mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e6e53f17 | ||
|
|
deaa867498 | ||
|
|
627a8498d0 | ||
|
|
4a7b8f0f27 | ||
|
|
72f8dfeb7c | ||
|
|
2b26c16683 | ||
|
|
b4aba36dc9 | ||
|
|
e6edd02bbe | ||
|
|
1b6017b9c5 | ||
|
|
e092e5bae9 | ||
|
|
b3f78b5871 | ||
|
|
b715ce1fdf | ||
|
|
94d9c2d026 | ||
|
|
49b6347c4a | ||
|
|
6ee12ab0a5 | ||
|
|
26138a6236 | ||
|
|
9962796a04 | ||
|
|
997d217cb2 | ||
|
|
720597235c | ||
|
|
f0a628843b | ||
|
|
35e2f83ff9 | ||
|
|
422908e78f | ||
|
|
0ea6598c90 | ||
|
|
749d72d874 | ||
|
|
5a272565bb | ||
|
|
0a4e46a25f | ||
|
|
3c46f8d440 | ||
|
|
daebae9b50 | ||
|
|
ed863e9e59 | ||
|
|
b4f503e1f1 | ||
|
|
94b5856e8d | ||
|
|
59d136fcf2 | ||
|
|
5c0ce7e4dc | ||
|
|
dd82d948a9 | ||
|
|
21a47de988 | ||
|
|
4425fb72c6 | ||
|
|
c5e1cc35d2 | ||
|
|
572757b933 | ||
|
|
3829513cfb | ||
|
|
33cac93254 | ||
|
|
a8006a1fdd | ||
|
|
2b38509768 | ||
|
|
ec38b8c371 | ||
|
|
8b3843dfd8 | ||
|
|
8a385c3348 | ||
|
|
dcd16e0d0a | ||
|
|
72f32d630f | ||
|
|
7a7edfb2b8 | ||
|
|
d1d02547fd | ||
|
|
2851da891e | ||
|
|
79c69e5097 | ||
|
|
9dc7e1982c | ||
|
|
4ac59ddf3c | ||
|
|
347eea66b7 | ||
|
|
d2df8d1633 | ||
|
|
66bfc86c78 | ||
|
|
2024810250 | ||
|
|
6b67957ecf | ||
|
|
99d80f7a83 | ||
|
|
b89b9b8cac | ||
|
|
2d3f3a5b73 | ||
|
|
4ad9c5f38f | ||
|
|
b504b3e8a9 | ||
|
|
ce13d6b568 | ||
|
|
2ad6f4c98e | ||
|
|
fe8737550c | ||
|
|
20577142fa | ||
|
|
8da0c59f42 | ||
|
|
ec9935d3ea | ||
|
|
1364293202 | ||
|
|
8be259be5f | ||
|
|
88dccd7cde | ||
|
|
6d28365c93 | ||
|
|
1efd41cfaa | ||
|
|
e6106072d5 | ||
|
|
8e0b2ff496 | ||
|
|
81b1a1e88e | ||
|
|
6e3709c73b | ||
|
|
cc456c3d4f | ||
|
|
ea3cb4f38a | ||
|
|
55a4b9f450 | ||
|
|
a2cd9fb5da | ||
|
|
08773d93f7 | ||
|
|
c224b3c972 | ||
|
|
64ba6a537b | ||
|
|
29cd60be4f | ||
|
|
05958bd21a | ||
|
|
e98c7792e7 | ||
|
|
de827a922e | ||
|
|
e0121fd649 | ||
|
|
d6c803b17b | ||
|
|
40bfe02074 | ||
|
|
fc0d576f6d | ||
|
|
705ea7464d | ||
|
|
4baec21fb7 | ||
|
|
7255a194cb | ||
|
|
e91340ee15 | ||
|
|
6db2589e37 | ||
|
|
070e09c615 | ||
|
|
a71e4534ae | ||
|
|
ff07fe0d02 | ||
|
|
8444fdbf09 | ||
|
|
bfc834c779 | ||
|
|
c29ec0d58e | ||
|
|
7265e03817 | ||
|
|
135e5a91a7 | ||
|
|
02886a4682 | ||
|
|
67fef902f4 | ||
|
|
a3038e25ea | ||
|
|
0fbdd86ac3 | ||
|
|
94a8fd6790 | ||
|
|
577154cf5a | ||
|
|
ca583c849d | ||
|
|
79d172fcff | ||
|
|
e0da2d09d3 | ||
|
|
b99c60aff6 | ||
|
|
cb16b0f8be | ||
|
|
b8274363b8 | ||
|
|
3144762194 | ||
|
|
3818692ce2 | ||
|
|
6bb7105b4c | ||
|
|
c43ca13ae3 | ||
|
|
27b76680ad | ||
|
|
2b8329f0f2 | ||
|
|
06b09768c2 | ||
|
|
eac2f14bfc | ||
|
|
ade47c9f3e | ||
|
|
c4e17f7eb7 | ||
|
|
ec697a438c | ||
|
|
986faa7584 | ||
|
|
66682b12a6 | ||
|
|
70569e556f | ||
|
|
ba422f96f8 | ||
|
|
a77a92a80c | ||
|
|
b3e243d516 | ||
|
|
3411e5c864 | ||
|
|
48d66d04d0 | ||
|
|
55c07f5852 | ||
|
|
7b825db71b | ||
|
|
26a015cd86 | ||
|
|
1bc6f70cd8 | ||
|
|
c4a82a1e7a | ||
|
|
86a0cd67ef | ||
|
|
ca9bfae5b9 | ||
|
|
3640f6827e | ||
|
|
37fc2f8a9b | ||
|
|
3266c014e5 | ||
|
|
ab468350ec | ||
|
|
ccfb108aa1 | ||
|
|
46cc9be7d9 | ||
|
|
cd24f69daa | ||
|
|
3294ade2cc | ||
|
|
97eef1e725 | ||
|
|
007c23b4e7 | ||
|
|
c4754dc49f | ||
|
|
4a67591641 | ||
|
|
a6696f07ed | ||
|
|
5c66bf5e09 | ||
|
|
b140f84ac4 | ||
|
|
2d4ee24660 | ||
|
|
0f708c2913 | ||
|
|
900f99797f | ||
|
|
a6f841293b | ||
|
|
1eb429e137 | ||
|
|
d0497e0ca6 | ||
|
|
4b9a1c4d5c | ||
|
|
4c1eaa6b16 | ||
|
|
05a72de255 | ||
|
|
124d7ae6cc | ||
|
|
c5b9b9ab5d | ||
|
|
f87b5d1568 | ||
|
|
bf3e356540 | ||
|
|
dc22e8c974 | ||
|
|
b03d51db31 | ||
|
|
55d4b0b613 | ||
|
|
41ec53442c | ||
|
|
78e717bea6 | ||
|
|
c5eb0a7a4a | ||
|
|
a593a2b200 | ||
|
|
d11d5acdfa | ||
|
|
ad18b8b4ea | ||
|
|
4b5d4e6aa7 | ||
|
|
92f92f6cdb | ||
|
|
1335ec032c | ||
|
|
a73bc4df69 | ||
|
|
53184faf87 | ||
|
|
9db4615c6c | ||
|
|
80141e3531 | ||
|
|
d58b3c6a2d |
@@ -38,3 +38,9 @@ packages/**/*.test.ts linguist-vendored
|
||||
# bytes and fail the guard for a contributor who touched neither file.
|
||||
crates/dbx-core/assets/docs-export.js -text -diff linguist-generated
|
||||
crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
|
||||
|
||||
# The pnpm launcher fixtures are captured from real installations and baked into
|
||||
# the Rust unit tests with include_str!, so the bytes those tests hand to the shim
|
||||
# parser must not depend on the contributor's core.autocrlf: a CRLF checkout
|
||||
# silently rewrites all three files.
|
||||
src-tauri/tests/fixtures/** text eol=lf
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const rustGroups = {
|
||||
foundation: ["dbx-types", "dbx-platform", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"],
|
||||
drivers: ["dbx-drivers", "dbx-sqlite-worker"],
|
||||
foundation: [
|
||||
"dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql",
|
||||
"dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime",
|
||||
],
|
||||
drivers: [
|
||||
"dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
|
||||
"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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { appendFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -12,7 +12,21 @@ export function changedPaths(base, root) {
|
||||
}).split("\0").filter(Boolean);
|
||||
}
|
||||
|
||||
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false }) {
|
||||
export function knownJavaDrivers(root, nativeDrivers) {
|
||||
try {
|
||||
const driversRoot = path.join(root, "agents/drivers");
|
||||
return readdirSync(driversRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory()
|
||||
&& existsSync(path.join(driversRoot, entry.name, "build.gradle"))
|
||||
&& !nativeDrivers.includes(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) {
|
||||
const unknownDiff = files === null;
|
||||
files ??= [];
|
||||
const packages = metadata.packages.filter((pkg) => metadata.workspace_members.includes(pkg.id));
|
||||
@@ -28,7 +42,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
if (owner) affected.add(owner[0]);
|
||||
else if (/^(?:crates|src-tauri)\//.test(file)) unknownRust = true;
|
||||
if (file.startsWith("plugins/connection-types/")) affected.add("dbx-types");
|
||||
if (file.startsWith("plugins/dialects/")) affected.add("dbx-sql");
|
||||
if (file.startsWith("plugins/dialects/")) affected.add("dbx-sql-dialect");
|
||||
}
|
||||
const knownGroups = new Set(Object.values(rustGroups).flat());
|
||||
const unknownMember = packages.some((pkg) => !knownGroups.has(pkg.name));
|
||||
@@ -49,12 +63,17 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
.filter(([, names]) => names.some((name) => affected.has(name)))
|
||||
.map(([group]) => ({ group }));
|
||||
const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents];
|
||||
const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers);
|
||||
const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))));
|
||||
const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)));
|
||||
// Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown
|
||||
// agents/drivers/<name>/ paths still fail open to full coverage below.
|
||||
const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/")
|
||||
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|
||||
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))
|
||||
&& !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|
||||
|| files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml"
|
||||
|| file === "crates/dbx-drivers/assets/agent-protocol-v2.json");
|
||||
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0);
|
||||
|| file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
|
||||
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges);
|
||||
if (allAgents) {
|
||||
for (const driver of nativeDrivers) nativeChanges.add(driver);
|
||||
}
|
||||
@@ -62,8 +81,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
const goMatrix = goAgents.filter((agent) => nativeChanges.has(agent.driver));
|
||||
const nativeRustMatrix = rustAgents.filter((driver) => nativeChanges.has(driver)).map((driver) => ({ driver }));
|
||||
const liveMatrix = integrationCases.filter((entry) => nativeChanges.has(entry.driver));
|
||||
const java = allAgents;
|
||||
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0;
|
||||
const java = allAgents || javaDriverChanges;
|
||||
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
|
||||
return {
|
||||
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
|
||||
affected_packages: [...affected].sort(),
|
||||
|
||||
@@ -12,9 +12,22 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs";
|
||||
|
||||
const root = "/fixture";
|
||||
const graph = {
|
||||
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql": ["dbx-types"],
|
||||
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"],
|
||||
"dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"],
|
||||
"dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
|
||||
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-platform", "dbx-sql", "dbx-types", "dbx-sqlite-worker"],
|
||||
"dbx-driver-support": ["dbx-platform", "dbx-types"],
|
||||
"dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"],
|
||||
"dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"],
|
||||
"dbx-driver-mongodb": ["dbx-driver-support", "dbx-types"],
|
||||
"dbx-driver-mysql": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"],
|
||||
"dbx-driver-redis": ["dbx-driver-support", "dbx-types"],
|
||||
"dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"],
|
||||
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
|
||||
"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"],
|
||||
};
|
||||
@@ -51,7 +64,7 @@ test("core and consumer changes do not pull unrelated foundation test groups", (
|
||||
const cli = plan(["crates/dbx-cli/src/main.rs"]);
|
||||
assert.deepEqual(groups(cli), ["application"]);
|
||||
assert.equal(cli.agents, false);
|
||||
assert.deepEqual(groups(plan(["crates/dbx-drivers/src/db/postgres.rs"])), ["drivers", "application"]);
|
||||
assert.deepEqual(groups(plan(["crates/dbx-driver-postgres/src/postgres.rs"])), ["drivers", "application"]);
|
||||
});
|
||||
|
||||
test("code generation and test-only dependencies participate in impact analysis", () => {
|
||||
@@ -106,9 +119,24 @@ for (const driver of ["duckdb", "tdengine", ...goAgents.map((entry) => entry.dri
|
||||
});
|
||||
}
|
||||
|
||||
test("known JDBC driver changes only select Java agent tests", () => {
|
||||
const result = plan(["agents/drivers/oceanbase-oracle/src/main/java/Agent.java"], {
|
||||
agentsChanged: true,
|
||||
javaDrivers: ["oceanbase-oracle", "dameng"],
|
||||
});
|
||||
assert.equal(result.rust, false);
|
||||
assert.equal(result.agents, true);
|
||||
assert.equal(result.agent_java, true);
|
||||
assert.equal(result.agent_go.include.length, 0);
|
||||
assert.equal(result.agent_rust.include.length, 0);
|
||||
assert.equal(result.agent_integration.include.length, 0);
|
||||
assert.equal(result.duckdb_changed, false);
|
||||
assert.equal(result.fast, true);
|
||||
});
|
||||
|
||||
test("shared Agent inputs and unknown native modules never silently lose coverage", () => {
|
||||
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle",
|
||||
"agents/drivers/new-driver/main.go", "crates/dbx-drivers/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
|
||||
"agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.agent_go.include.length, 10, file);
|
||||
assert.equal(result.agent_integration.include.length, 16, file);
|
||||
|
||||
@@ -12,10 +12,13 @@ export function rustCommand(action, group, mode) {
|
||||
if (mode === "full") capabilityFeatures.push("system-fonts");
|
||||
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
|
||||
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
|
||||
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
|
||||
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
|
||||
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql/openapi",
|
||||
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
|
||||
"dbx-driver-support/test-support",
|
||||
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
|
||||
] : appFeatures;
|
||||
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
|
||||
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class DbxMcp < Formula
|
||||
desc "Native MCP server for DBX database connections, schema, and safe queries"
|
||||
homepage "https://github.com/t8y2/dbx"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-x64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-arm64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_ARM64__"
|
||||
end
|
||||
end
|
||||
on_linux do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-x64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-arm64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_ARM64__"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "dbx-mcp"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dbx-mcp --version")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../docs/public/install-mcp.ps1') -Raw
|
||||
. ([scriptblock]::Create(($source -replace '(?m)^Install-DbxMcp\s*$', '')))
|
||||
$originalHome = $HOME
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('dbx-mcp-ps-test-' + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($testRoot) | Out-Null
|
||||
$realTar = (Get-Command tar).Source
|
||||
$script:count = 0
|
||||
|
||||
function Assert-True($Condition, [string]$Message) { if (!$Condition) { throw $Message } }
|
||||
function Get-McpPlatform { return $script:platform }
|
||||
function Set-InstallPath([string]$Directory) { $script:configuredPath = $Directory }
|
||||
function tar.exe { & $realTar @args }
|
||||
function Get-Download([string]$Url, [string]$Destination) {
|
||||
$script:requests += $Url
|
||||
if ($script:mode -eq 'offline') { return $false }
|
||||
if ($script:mode -in 'mirror', 'github' -and $Url.Contains('registry.npmjs.org')) { return $false }
|
||||
if ($script:mode -eq 'github' -and $Url.Contains('registry.npmmirror.com')) { return $false }
|
||||
$file = switch -Regex ($Url) {
|
||||
'/latest$' { 'latest.json'; break }
|
||||
'/matching-refs/' { 'refs.json'; break }
|
||||
'/0\.4\.96$' { 'metadata.json'; break }
|
||||
'\.tgz$' { 'npm.tgz'; break }
|
||||
'/SHA256SUMS$' { 'SHA256SUMS'; break }
|
||||
'\.zip$' { 'github.zip'; break }
|
||||
default { throw "Unexpected URL: $Url" }
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $script:fixture $file) -Destination $Destination -Force
|
||||
return $true
|
||||
}
|
||||
function New-Fixture([string]$Platform, [string]$Mode) {
|
||||
$script:platform = $Platform
|
||||
$script:mode = $Mode
|
||||
$script:requests = @()
|
||||
$script:fixture = Join-Path $testRoot ([Guid]::NewGuid().ToString('N'))
|
||||
$homePath = Join-Path $script:fixture "DBX User's home"
|
||||
[IO.Directory]::CreateDirectory($homePath) | Out-Null
|
||||
Set-Variable HOME -Scope Global -Force -Value $homePath
|
||||
$packageBin = Join-Path $script:fixture 'source/package/bin'
|
||||
[IO.Directory]::CreateDirectory($packageBin) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $packageBin 'dbx-mcp.exe'), 'fixture binary')
|
||||
& $realTar -czf (Join-Path $script:fixture 'npm.tgz') -C (Join-Path $script:fixture 'source') 'package/bin/dbx-mcp.exe'
|
||||
if ($LASTEXITCODE) { throw 'Fixture tar failed' }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packageBin, (Join-Path $script:fixture 'github.zip'))
|
||||
$stream = [IO.File]::OpenRead((Join-Path $script:fixture 'npm.tgz'))
|
||||
$hasher = [Security.Cryptography.SHA512]::Create()
|
||||
try { $integrity = 'sha512-' + [Convert]::ToBase64String($hasher.ComputeHash($stream)) }
|
||||
finally { $stream.Dispose(); $hasher.Dispose() }
|
||||
@{ version = '0.4.96' } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'latest.json')
|
||||
@{ version = '0.4.96'; dist = @{ integrity = $integrity } } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'metadata.json')
|
||||
@(@{ ref = 'refs/tags/packages-v0.4.9' }, @{ ref = 'refs/tags/packages-v0.4.96' }, @{ ref = 'refs/tags/packages-v9.0.0-beta' }) | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'refs.json')
|
||||
$hash = (Get-FileHash (Join-Path $script:fixture 'github.zip') -Algorithm SHA256).Hash
|
||||
"$hash dbx-mcp-$Platform.zip" | Set-Content (Join-Path $script:fixture 'SHA256SUMS')
|
||||
$script:binary = Join-Path $HOME '.dbx/bin/dbx-mcp.exe'
|
||||
$script:marker = Join-Path $HOME '.dbx/bin/.dbx-mcp-version'
|
||||
}
|
||||
function Expect-Failure([string]$Pattern) {
|
||||
try { Install-DbxMcp | Out-Null } catch {
|
||||
Assert-True ($_.Exception.Message -match $Pattern) "Unexpected error: $_"
|
||||
return
|
||||
}
|
||||
throw 'Expected installation to fail'
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($platformName in @('win32-arm64', 'win32-x64')) {
|
||||
foreach ($sourceMode in @('npm', 'mirror', 'github')) {
|
||||
New-Fixture $platformName $sourceMode
|
||||
$output = Install-DbxMcp | Out-String
|
||||
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'First install binary differs'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.96') 'Marker differs'
|
||||
Assert-True ($output.Contains((ConvertTo-Json -InputObject $binary -Compress))) 'Config is not the escaped absolute path'
|
||||
Assert-True ($configuredPath -eq (Split-Path $binary)) 'PATH target differs'
|
||||
Assert-True (!(Get-ChildItem -LiteralPath (Split-Path $binary) -Directory -Force)) 'Temporary install directory leaked'
|
||||
$rerun = Install-DbxMcp | Out-String
|
||||
Assert-True ($rerun.Contains('dbx-mcp 0.4.96 already up to date')) 'Rerun not idempotent'
|
||||
'0.4.95' | Set-Content -LiteralPath $marker
|
||||
$updated = Install-DbxMcp | Out-String
|
||||
Assert-True ($updated.Contains('Installed dbx-mcp 0.4.96')) 'Marker-based upgrade failed'
|
||||
Remove-Item -LiteralPath $binary
|
||||
$repaired = Install-DbxMcp | Out-String
|
||||
Assert-True ($repaired.Contains('Installed dbx-mcp 0.4.96')) 'Orphan marker prevented installation'
|
||||
$script:count++
|
||||
Write-Output "PASS $platformName $sourceMode install/rerun/update/configs"
|
||||
}
|
||||
}
|
||||
foreach ($sourceMode in @('npm', 'github', 'offline')) {
|
||||
New-Fixture 'win32-x64' 'npm'
|
||||
Install-DbxMcp | Out-Null
|
||||
'0.4.95' | Set-Content -LiteralPath $marker
|
||||
$script:mode = $sourceMode
|
||||
if ($sourceMode -eq 'npm') { 'corrupted' | Set-Content (Join-Path $fixture 'npm.tgz') }
|
||||
if ($sourceMode -eq 'github') { 'corrupted' | Set-Content (Join-Path $fixture 'github.zip') }
|
||||
Expect-Failure $(if ($sourceMode -eq 'offline') { 'Unable to resolve' } else { 'verification failed' })
|
||||
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'Failure replaced binary'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.95') 'Failure replaced marker'
|
||||
$script:count++
|
||||
Write-Output "PASS $sourceMode failure preserves installation"
|
||||
}
|
||||
New-Fixture 'win32-x64' 'npm'
|
||||
Install-DbxMcp | Out-Null
|
||||
'0.4.97' | Set-Content -LiteralPath $marker
|
||||
$script:mode = 'mirror'
|
||||
$newer = Install-DbxMcp | Out-String
|
||||
Assert-True ($newer.Contains('keeping current installation')) 'Older mirror downgraded installation'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.97') 'Downgrade changed marker'
|
||||
$script:count++
|
||||
Write-Output 'PASS older mirror cannot downgrade installation'
|
||||
Write-Output "PowerShell installer scenarios passed: $script:count"
|
||||
} finally {
|
||||
Set-Variable HOME -Scope Global -Force -Value $originalHome
|
||||
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const installer = resolve("docs/public/install-mcp.sh");
|
||||
const platforms = ["darwin-arm64", "darwin-x64", "linux-arm64-gnu", "linux-x64-gnu"];
|
||||
|
||||
function fixture(context, platform = "darwin-arm64") {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "dbx-mcp-test-")));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const home = join(root, "DBX User's 中文");
|
||||
const mocks = join(root, "mocks");
|
||||
const source = join(root, "source");
|
||||
mkdirSync(home);
|
||||
mkdirSync(mocks);
|
||||
mkdirSync(join(source, "package/bin"), { recursive: true });
|
||||
const binary = join(source, "package/bin/dbx-mcp");
|
||||
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
|
||||
copyFileSync(binary, join(source, "dbx-mcp"));
|
||||
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
|
||||
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
}
|
||||
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
|
||||
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
|
||||
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
|
||||
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
|
||||
writeFileSync(join(root, "refs.json"), JSON.stringify([
|
||||
{ ref: "refs/tags/packages-v0.4.9", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.96", object: {} },
|
||||
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.10", object: {} },
|
||||
]));
|
||||
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
|
||||
const routes = {};
|
||||
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
|
||||
routes[`${registry}/@dbx-app/mcp-server/latest`] = "latest.json";
|
||||
routes[`${registry}/@dbx-app/mcp-${platform}/0.4.96`] = "metadata.json";
|
||||
routes[`${registry}/@dbx-app/mcp-${platform}/-/mcp-${platform}-0.4.96.tgz`] = "npm.tgz";
|
||||
}
|
||||
routes["https://api.github.com/repos/t8y2/dbx/git/matching-refs/tags/packages-v"] = "refs.json";
|
||||
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
|
||||
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
|
||||
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
|
||||
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const url = args.find(arg => arg.startsWith('https://'));
|
||||
const root = process.env.FIXTURE;
|
||||
fs.appendFileSync(path.join(root, 'requests.log'), url + '\\n');
|
||||
if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.exit(22);
|
||||
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
|
||||
if (!routes[url]) process.exit(22);
|
||||
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
|
||||
`, { mode: 0o755 });
|
||||
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
|
||||
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
|
||||
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
|
||||
const run = (extra = {}) => spawnSync("sh", [installer], {
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, ...extra },
|
||||
encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
return { root, home, mocks, binary: installedBinary, marker, run };
|
||||
}
|
||||
|
||||
for (const platform of platforms) {
|
||||
test(`native install and absolute configs: ${platform}`, (context) => {
|
||||
const setup = fixture(context, platform);
|
||||
const result = setup.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(spawnSync(setup.binary, ["--version"], { encoding: "utf8" }).stdout.trim(), "dbx-mcp 0.4.96");
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
|
||||
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
|
||||
assert.equal(json.mcpServers.dbx.command, setup.binary);
|
||||
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
|
||||
assert.equal(JSON.parse(tomlCommand), setup.binary);
|
||||
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("rerun is idempotent, binary version precedes marker, legacy marker enables update", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
writeFileSync(setup.marker, "0.4.95\n");
|
||||
const unchanged = setup.run();
|
||||
assert.equal(unchanged.status, 0, unchanged.stderr);
|
||||
assert.match(unchanged.stdout, /dbx-mcp 0.4.96 already up to date/);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\nexit 1\n");
|
||||
const updated = setup.run();
|
||||
assert.equal(updated.status, 0, updated.stderr);
|
||||
assert.match(updated.stdout, /Installed dbx-mcp 0.4.96/);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n");
|
||||
assert.match(setup.run().stdout, /already up to date/);
|
||||
assert.equal(readFileSync(join(setup.home, ".zshrc"), "utf8").match(/# added by dbx installer/g).length, 1);
|
||||
});
|
||||
|
||||
for (const [name, failUrl, expected] of [
|
||||
["npmmirror", "registry\\.npmjs\\.org", "registry.npmmirror.com"],
|
||||
["GitHub", "registry\\.", "github.com/t8y2/dbx/releases/download/packages-v0.4.96"],
|
||||
]) {
|
||||
test(`falls back to ${name} for metadata and archive`, (context) => {
|
||||
const setup = fixture(context);
|
||||
const result = setup.run({ FAIL_URL: failUrl });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(readFileSync(join(setup.root, "requests.log"), "utf8").includes(expected));
|
||||
});
|
||||
}
|
||||
|
||||
test("download fallback retains the version selected by npmjs", (context) => {
|
||||
const setup = fixture(context);
|
||||
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org/.*mcp-darwin" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(readFileSync(join(setup.root, "requests.log"), "utf8"), /npmmirror.com\/.*0.4.96.tgz/);
|
||||
});
|
||||
|
||||
test("an older mirror version cannot downgrade an existing native installation", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
const newer = "#!/bin/sh\nprintf 'dbx-mcp 0.4.97\\n'\n";
|
||||
writeFileSync(setup.binary, newer);
|
||||
writeFileSync(setup.marker, "0.4.97\n");
|
||||
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /keeping current installation/);
|
||||
assert.equal(readFileSync(setup.binary, "utf8"), newer);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.97\n");
|
||||
});
|
||||
|
||||
test("offline exits with one concise error and preserves the existing installation", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
const original = readFileSync(setup.binary);
|
||||
const result = setup.run({ FAIL_URL: ".*" });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(result.stderr.trim().split("\n").length, 1);
|
||||
assert.match(result.stderr, /existing installation unchanged/);
|
||||
assert.deepEqual(readFileSync(setup.binary), original);
|
||||
});
|
||||
|
||||
for (const source of ["npm", "github"]) {
|
||||
test(`rejects ${source} checksum mismatch without replacing anything`, (context) => {
|
||||
const setup = fixture(context);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
writeFileSync(setup.marker, "0.4.95\n");
|
||||
const original = readFileSync(setup.binary);
|
||||
writeFileSync(join(setup.root, source === "npm" ? "npm.tgz" : "github.tar.gz"), "corrupted");
|
||||
const result = setup.run(source === "github" ? { FAIL_URL: "registry\\." } : {});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /verification failed/);
|
||||
assert.deepEqual(readFileSync(setup.binary), original);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
|
||||
assert.ok(!existsSync(join(setup.home, ".zshrc")));
|
||||
});
|
||||
}
|
||||
|
||||
test("stale marker without binary reinstalls, and bash users get migration guidance", (context) => {
|
||||
const setup = fixture(context);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
writeFileSync(setup.marker, "0.4.96\n");
|
||||
writeFileSync(join(setup.mocks, "dbx-mcp-server"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
const result = setup.run({ SHELL: "/bin/bash" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(existsSync(setup.binary));
|
||||
assert.ok(existsSync(join(setup.home, ".bash_profile")));
|
||||
assert.match(result.stdout, /npm rm -g @dbx-app\/mcp-server/);
|
||||
});
|
||||
|
||||
test("formula generator validates all hashes and both publishing workflows invoke it", (context) => {
|
||||
const setup = fixture(context);
|
||||
const sums = join(setup.root, "formula-sums");
|
||||
const output = join(setup.root, "dbx-mcp.rb");
|
||||
writeFileSync(sums, platforms.map((platform) => `${"a".repeat(64)} dbx-mcp-${platform}.tar.gz`).join("\n"));
|
||||
const generate = () => spawnSync(process.execPath, [".github/scripts/render-mcp-formula.mjs", "0.4.96", sums, output], { encoding: "utf8" });
|
||||
assert.equal(generate().status, 0);
|
||||
const formula = readFileSync(output, "utf8");
|
||||
assert.match(formula, /bin.install "dbx-mcp"/);
|
||||
assert.doesNotMatch(formula, /__[A-Z_]+__/);
|
||||
assert.equal((formula.match(/sha256 /g) ?? []).length, 4);
|
||||
writeFileSync(sums, "invalid");
|
||||
assert.notEqual(generate().status, 0);
|
||||
assert.equal(readFileSync(output, "utf8"), formula);
|
||||
for (const workflow of ["mcp-release", "publish-packages"]) {
|
||||
const text = readFileSync(`.github/workflows/${workflow}.yml`, "utf8");
|
||||
const checkoutIndex = text.indexOf("actions/checkout@");
|
||||
const renderIndex = text.indexOf("render-mcp-formula.mjs");
|
||||
assert.notEqual(checkoutIndex, -1);
|
||||
assert.notEqual(renderIndex, -1);
|
||||
assert.ok(checkoutIndex < renderIndex);
|
||||
assert.match(text, /render-mcp-formula.mjs/);
|
||||
assert.match(text, /git add .*Formula\/dbx-mcp.rb/);
|
||||
}
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export const STATIC_LABEL_SPECS = {
|
||||
"tests-only": { color: "bfd4f2", description: "Changes only tests, fixtures, or snapshots" },
|
||||
};
|
||||
|
||||
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|drivers|sql|formats|types|platform|ai-provider|plugin-runtime)\//;
|
||||
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//;
|
||||
|
||||
const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]);
|
||||
const LABEL_PALETTE = [
|
||||
|
||||
@@ -108,8 +108,8 @@ test("recognizes extracted core crates and their database sources", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "fix(sql): preserve PostgreSQL query contracts",
|
||||
changedFiles: [
|
||||
"crates/dbx-drivers/src/db/postgres.rs",
|
||||
"crates/dbx-sql/src/sql.rs",
|
||||
"crates/dbx-driver-postgres/src/postgres.rs",
|
||||
"crates/dbx-sql-core/src/sql.rs",
|
||||
"crates/dbx-types/src/types.rs",
|
||||
"crates/dbx-platform/src/process.rs",
|
||||
"crates/dbx-ai-provider/src/ai.rs",
|
||||
@@ -129,7 +129,7 @@ test("collapses broad area and database changes", () => {
|
||||
changedFiles: [
|
||||
"agents/drivers/mysql/build.gradle",
|
||||
"apps/desktop/src/components/grid/GeometryViewer.vue",
|
||||
"crates/dbx-drivers/src/db/postgres.rs",
|
||||
"crates/dbx-driver-postgres/src/postgres.rs",
|
||||
"crates/dbx-mcp/src/main.rs",
|
||||
"crates/dbx-web/src/main.rs",
|
||||
"docs/content/docs/geometry.mdx",
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
upstream_plugin="$script_dir/linuxdeploy-plugin-gtk-upstream.sh"
|
||||
[[ -x "$upstream_plugin" ]] || {
|
||||
echo "Missing executable upstream GTK plugin: $upstream_plugin" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# linuxdeploy queries this before invoking the plugin. Preserve the upstream
|
||||
# protocol response without requiring an AppDir.
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--plugin-api-version | --help)
|
||||
exec "$upstream_plugin" "$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
appdir=''
|
||||
arguments=("$@")
|
||||
for ((index = 0; index < ${#arguments[@]}; index += 1)); do
|
||||
case "${arguments[index]}" in
|
||||
--appdir)
|
||||
index=$((index + 1))
|
||||
appdir=${arguments[index]:-}
|
||||
;;
|
||||
--appdir=*)
|
||||
appdir=${arguments[index]#--appdir=}
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$appdir" ]] || {
|
||||
echo "GTK plugin wrapper did not receive --appdir" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$upstream_plugin" "$@"
|
||||
|
||||
# AppImages must use the target system's display stack. Bundling Ubuntu's
|
||||
# Wayland/XKB/XCB libraries ahead of a newer host Mesa stack can abort before
|
||||
# GTK creates a window (tauri-apps/tauri#15976).
|
||||
display_library_patterns=(
|
||||
'libwayland-*.so*'
|
||||
'libxkbcommon.so*'
|
||||
'libxcb-randr.so*'
|
||||
'libxcb-render.so*'
|
||||
'libxcb-shm.so*'
|
||||
'libXau.so*'
|
||||
'libXdmcp.so*'
|
||||
)
|
||||
|
||||
for pattern in "${display_library_patterns[@]}"; do
|
||||
find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -delete
|
||||
done
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
echo "AppImage bundler setup failed: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
die "usage: $0 <Linux target triple>"
|
||||
fi
|
||||
|
||||
: "${XDG_CACHE_HOME:?XDG_CACHE_HOME must point to the isolated release cache}"
|
||||
|
||||
target=$1
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
case "$target" in
|
||||
x86_64-unknown-linux-gnu)
|
||||
tools_arch=x86_64
|
||||
linuxdeploy_sha256=36a2d7e274d12e1050d0e9ecfe11d339ed54720b2bec464c286d53f8b07f5c62
|
||||
;;
|
||||
aarch64-unknown-linux-gnu)
|
||||
tools_arch=aarch64
|
||||
linuxdeploy_sha256=556ab80baa98e600aa80f0dcedfb70bca0e1ce7e9f147fb345be3fcc3e91b2b1
|
||||
;;
|
||||
*)
|
||||
die "unsupported Linux target: $target"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Tauri CLI 2.11.4 only downloads these tools when their cache entries are
|
||||
# absent, so an older runner cache can keep generating an AppRun hook that
|
||||
# unconditionally forces X11. Pin the tools from the accepted upstream fix
|
||||
# until a stable Tauri CLI release includes tauri-apps/tauri#16062.
|
||||
tauri_fix_commit=8e7028331ad37ac2db74d4ec20e66be5cacf2c40
|
||||
linuxdeploy_revision=07333c6
|
||||
gtk_plugin_sha256=ef6b9a980417243bc62e0241b51dc49876032afd1bab9b4762389f961b406d9b
|
||||
tools_dir="$XDG_CACHE_HOME/tauri"
|
||||
mkdir -p "$tools_dir"
|
||||
|
||||
download_path=''
|
||||
cleanup() {
|
||||
if [[ -n "$download_path" ]]; then
|
||||
rm -f -- "$download_path"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
install_verified_tool() {
|
||||
local name=$1
|
||||
local url=$2
|
||||
local expected_sha256=$3
|
||||
local destination="$tools_dir/$name"
|
||||
local actual_sha256
|
||||
|
||||
if [[ -f "$destination" ]]; then
|
||||
actual_sha256=$(sha256sum "$destination")
|
||||
actual_sha256=${actual_sha256%% *}
|
||||
if [[ "$actual_sha256" == "$expected_sha256" ]]; then
|
||||
chmod 0755 "$destination"
|
||||
echo "Using verified cached AppImage tool: $name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
download_path=$(mktemp "$tools_dir/.${name}.XXXXXX")
|
||||
curl --fail --location --retry 3 --retry-all-errors --silent --show-error \
|
||||
"$url" --output "$download_path"
|
||||
actual_sha256=$(sha256sum "$download_path")
|
||||
actual_sha256=${actual_sha256%% *}
|
||||
[[ "$actual_sha256" == "$expected_sha256" ]] \
|
||||
|| die "$name SHA-256 mismatch: expected $expected_sha256, got $actual_sha256"
|
||||
chmod 0755 "$download_path"
|
||||
mv -f -- "$download_path" "$destination"
|
||||
download_path=''
|
||||
echo "Installed verified AppImage tool: $name"
|
||||
}
|
||||
|
||||
linuxdeploy_name="linuxdeploy-${tools_arch}.AppImage"
|
||||
install_verified_tool \
|
||||
"$linuxdeploy_name" \
|
||||
"https://github.com/tauri-apps/binary-releases/releases/download/linuxdeploy-${linuxdeploy_revision}/${linuxdeploy_name}" \
|
||||
"$linuxdeploy_sha256"
|
||||
|
||||
gtk_plugin_name=linuxdeploy-plugin-gtk-upstream.sh
|
||||
install_verified_tool \
|
||||
"$gtk_plugin_name" \
|
||||
"https://raw.githubusercontent.com/tauri-apps/tauri/${tauri_fix_commit}/crates/tauri-bundler/src/bundle/linux/appimage/linuxdeploy-plugin-gtk.sh" \
|
||||
"$gtk_plugin_sha256"
|
||||
|
||||
upstream_gtk_plugin="$tools_dir/$gtk_plugin_name"
|
||||
gtk_plugin="$tools_dir/linuxdeploy-plugin-gtk.sh"
|
||||
install -m 0755 "$script_dir/linuxdeploy-plugin-gtk-wrapper.sh" "$gtk_plugin"
|
||||
bash -n "$upstream_gtk_plugin" "$gtk_plugin"
|
||||
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$upstream_gtk_plugin"; then
|
||||
die "pinned GTK plugin still contains an active GDK_BACKEND assignment"
|
||||
fi
|
||||
|
||||
echo "Prepared Wayland-capable AppImage tools for $target in $tools_dir"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const [version, sumsPath, outputPath] = process.argv.slice(2);
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version ?? "") || !sumsPath || !outputPath) {
|
||||
throw new Error("Usage: render-mcp-formula.mjs <version> <SHA256SUMS> <output.rb>");
|
||||
}
|
||||
const sums = readFileSync(sumsPath, "utf8").trim().split(/\r?\n/);
|
||||
let formula = readFileSync(new URL("./dbx-mcp.rb.template", import.meta.url), "utf8").replaceAll("__VERSION__", version);
|
||||
for (const [platform, placeholder] of [
|
||||
["darwin-arm64", "__SHA_DARWIN_ARM64__"],
|
||||
["darwin-x64", "__SHA_DARWIN_X64__"],
|
||||
["linux-arm64-gnu", "__SHA_LINUX_ARM64__"],
|
||||
["linux-x64-gnu", "__SHA_LINUX_X64__"],
|
||||
]) {
|
||||
const matches = sums.map((line) => line.trim().split(/\s+/)).filter(([, name]) => name === `dbx-mcp-${platform}.tar.gz`);
|
||||
if (matches.length !== 1 || !/^[a-fA-F0-9]{64}$/.test(matches[0][0])) {
|
||||
throw new Error(`Missing, duplicate, or invalid SHA256 for ${platform}`);
|
||||
}
|
||||
formula = formula.replaceAll(placeholder, matches[0][0].toLowerCase());
|
||||
}
|
||||
writeFileSync(outputPath, formula);
|
||||
@@ -0,0 +1,41 @@
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
:: 脚本所在目录(等价于 ROOT)
|
||||
set ROOT=%~dp0
|
||||
:: 去掉末尾反斜杠
|
||||
set ROOT=%ROOT:~0,-1%
|
||||
|
||||
set DBX_PACKAGE_ROOT=%ROOT%
|
||||
|
||||
:: static dir
|
||||
if "%DBX_STATIC_DIR%"=="" (
|
||||
:: dist静态资源文件已直接嵌入到二进制文件中,环境变量中没有设置的话,直接用二进制中的静态文件
|
||||
rem set DBX_STATIC_DIR=%ROOT%\dist
|
||||
)
|
||||
:: data dir
|
||||
if "%DBX_DATA_DIR%"=="" (
|
||||
set DBX_DATA_DIR=%ROOT%\data
|
||||
)
|
||||
|
||||
:: port
|
||||
if "%DBX_PORT%"=="" (
|
||||
set DBX_PORT=4224
|
||||
)
|
||||
|
||||
:: base path
|
||||
set BASE_PATH=%DBX_PUBLIC_BASE_PATH%
|
||||
if "%BASE_PATH%"=="" set BASE_PATH=/
|
||||
|
||||
:: 如果不是以 / 开头,补上 /
|
||||
echo %BASE_PATH% | findstr /r "^/" >nul || (
|
||||
set BASE_PATH=/!BASE_PATH!
|
||||
)
|
||||
|
||||
echo DBX_DATA_DIR=%DBX_DATA_DIR%
|
||||
echo DBX browser UI: http://127.0.0.1:%DBX_PORT%%BASE_PATH%
|
||||
|
||||
cd /d "%ROOT%"
|
||||
|
||||
:: 执行程序
|
||||
dbx-web.exe %*
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
echo "AppImage input-method verification failed: $*" >&2
|
||||
echo "AppImage runtime verification failed: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -37,6 +37,38 @@ appdir="$work_dir/squashfs-root"
|
||||
[[ -d "$appdir" ]] || die "AppImage extraction did not create squashfs-root"
|
||||
appdir=$(realpath "$appdir")
|
||||
|
||||
gtk_hook="$appdir/apprun-hooks/linuxdeploy-plugin-gtk.sh"
|
||||
[[ -f "$gtk_hook" ]] || die "missing GTK AppRun hook"
|
||||
if grep -Eq '^[[:space:]]*(export[[:space:]]+)?GDK_BACKEND[[:space:]]*=' "$gtk_hook"; then
|
||||
die "GTK AppRun hook overrides GDK_BACKEND"
|
||||
fi
|
||||
|
||||
for requested_backend in wayland x11; do
|
||||
preserved_backend=$(
|
||||
APPDIR="$appdir" GDK_BACKEND="$requested_backend" XDG_DATA_DIRS='' \
|
||||
bash --noprofile --norc -c 'source "$APPDIR/apprun-hooks/linuxdeploy-plugin-gtk.sh"; printf "%s" "$GDK_BACKEND"'
|
||||
)
|
||||
[[ "$preserved_backend" == "$requested_backend" ]] \
|
||||
|| die "GTK AppRun hook changed explicit GDK_BACKEND=$requested_backend to '$preserved_backend'"
|
||||
done
|
||||
echo "Verified GTK AppRun hook preserves explicit Wayland and X11 backends"
|
||||
|
||||
display_library_patterns=(
|
||||
'libwayland-*.so*'
|
||||
'libxkbcommon.so*'
|
||||
'libxcb-randr.so*'
|
||||
'libxcb-render.so*'
|
||||
'libxcb-shm.so*'
|
||||
'libXau.so*'
|
||||
'libXdmcp.so*'
|
||||
)
|
||||
for pattern in "${display_library_patterns[@]}"; do
|
||||
bundled_library=$(find "$appdir/usr" \( -type f -o -type l \) -name "$pattern" -print -quit)
|
||||
[[ -z "$bundled_library" ]] \
|
||||
|| die "bundled display-stack library must come from the host: ${bundled_library#"$appdir"/}"
|
||||
done
|
||||
echo "Verified AppImage relies on the host Wayland/XKB/XCB display stack"
|
||||
|
||||
require_artifact() {
|
||||
local basename=$1
|
||||
local match
|
||||
|
||||
@@ -160,6 +160,10 @@ jobs:
|
||||
- name: GitHub script tests
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/install-mcp-powershell.test.ps1
|
||||
|
||||
packages:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.packages == 'true'
|
||||
@@ -956,7 +960,8 @@ jobs:
|
||||
- 'scripts/run-check.mjs'
|
||||
- 'crates/dbx-core/src/**'
|
||||
- 'crates/dbx-drivers/src/**'
|
||||
- 'crates/dbx-sql/src/**'
|
||||
- 'crates/dbx-driver-*/src/**'
|
||||
- 'crates/dbx-sql*/src/**'
|
||||
- 'crates/dbx-formats/src/**'
|
||||
- 'crates/dbx-types/src/**'
|
||||
- 'crates/dbx-plugin-runtime/src/**'
|
||||
@@ -998,7 +1003,7 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
agents:
|
||||
- 'agents/**'
|
||||
- 'crates/dbx-drivers/assets/agent-protocol-v2.json'
|
||||
- 'crates/dbx-driver-agent/assets/agent-protocol-v2.json'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
@@ -1041,6 +1046,7 @@ jobs:
|
||||
- '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'
|
||||
@@ -1052,6 +1058,9 @@ jobs:
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- 'docs/public/install-mcp*'
|
||||
- '.github/workflows/mcp-release.yml'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
- '.github/workflows/plugin-release-reusable.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'apps/desktop/src/types/database.ts'
|
||||
|
||||
@@ -931,8 +931,19 @@ jobs:
|
||||
publish-homebrew-formula:
|
||||
name: Publish Homebrew formula
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, publish-cli-github-release]
|
||||
needs: [prepare, publish-cli-github-release, publish-mcp-github-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download MCP release checksums
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern SHA256SUMS --dir release-assets --clobber
|
||||
|
||||
- name: Download native CLI archives and compute SHA256
|
||||
id: cli-hash
|
||||
env:
|
||||
@@ -1045,14 +1056,17 @@ jobs:
|
||||
sed -i "s/__SHA_LINUX_ARM64__/${SHA_LINUX_ARM64}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_LINUX_X64__/${SHA_LINUX_X64}/g" Formula/dbx-cli.rb
|
||||
|
||||
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
|
||||
"${CLI_VERSION}" "$GITHUB_WORKSPACE/release-assets/SHA256SUMS" Formula/dbx-mcp.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/dbx-cli.rb
|
||||
git add Formula/dbx-cli.rb Formula/dbx-mcp.rb
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "CLI Homebrew formula is already up to date."
|
||||
else
|
||||
git commit -m "dbx-cli ${CLI_VERSION}"
|
||||
git commit -m "dbx-cli and dbx-mcp ${CLI_VERSION}"
|
||||
git push
|
||||
echo "::notice::CLI Homebrew formula updated to ${CLI_VERSION}"
|
||||
fi
|
||||
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
name: Publish to Homebrew & Scoop
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -91,11 +94,17 @@ jobs:
|
||||
- name: Publish Homebrew Cask
|
||||
env:
|
||||
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
SHA_ARM64="${{ steps.hashes.outputs.sha_dmg_arm64 }}"
|
||||
SHA_X64="${{ steps.hashes.outputs.sha_dmg_x64 }}"
|
||||
|
||||
MCP_VERSION="$(curl -fsSL https://registry.npmjs.org/@dbx-app/mcp-server/latest | jq -er '.version')"
|
||||
[[ "$MCP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
|
||||
gh release download "packages-v${MCP_VERSION}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern SHA256SUMS --dir artifacts --clobber
|
||||
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/homebrew-tap.git" \
|
||||
homebrew-tap
|
||||
@@ -137,9 +146,13 @@ jobs:
|
||||
sed -i "s/__SHA_ARM64__/${SHA_ARM64}/g" Casks/dbx.rb
|
||||
sed -i "s/__SHA_X64__/${SHA_X64}/g" Casks/dbx.rb
|
||||
|
||||
mkdir -p Formula
|
||||
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
|
||||
"${MCP_VERSION}" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" Formula/dbx-mcp.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Casks/dbx.rb
|
||||
git add Casks/dbx.rb Formula/dbx-mcp.rb
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "Homebrew Cask is already up to date."
|
||||
|
||||
@@ -144,6 +144,9 @@ jobs:
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
# The GTK plugin wrapper removes these after its own deployment pass,
|
||||
# while linuxdeploy must also exclude them from its later dependency pass.
|
||||
LINUXDEPLOY_EXCLUDED_LIBRARIES: "libwayland-*.so*;libxkbcommon.so*;libxcb-randr.so*;libxcb-render.so*;libxcb-shm.so*;libXau.so*;libXdmcp.so*"
|
||||
# Keep releases working until the shared S3 secrets are configured.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
@@ -256,6 +259,15 @@ jobs:
|
||||
# instead of loading potentially incompatible modules from the host.
|
||||
sudo apt-get install -y fcitx5-frontend-gtk3 ibus-gtk3 libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev xdg-utils
|
||||
|
||||
- name: Prepare Wayland-capable AppImage bundler
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
shell: bash
|
||||
env:
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/dbx-tauri-cache
|
||||
run: |
|
||||
echo "XDG_CACHE_HOME=${XDG_CACHE_HOME}" >> "$GITHUB_ENV"
|
||||
./.github/scripts/prepare-appimage-bundler.sh "${{ matrix.target }}"
|
||||
|
||||
- name: Setup Tauri signing key
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -281,7 +293,7 @@ jobs:
|
||||
assetNamePattern: ${{ matrix.asset_pattern }}
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
- name: Verify AppImage input methods
|
||||
- name: Verify AppImage runtime bundle
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
shell: bash
|
||||
run: ./.github/scripts/verify-appimage-input-methods.sh "target/${{ matrix.target }}/release/bundle/appimage"
|
||||
@@ -703,6 +715,89 @@ jobs:
|
||||
Remove-Item -LiteralPath $workDir -Recurse -Force
|
||||
}
|
||||
|
||||
build-windows-7-static-browser:
|
||||
needs: release-ready
|
||||
runs-on: windows-2022
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-win7-windows-msvc
|
||||
arch: x86_64
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Setup Rust for Windows 7
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
- name: Install NASM
|
||||
run: choco install nasm -y --no-progress
|
||||
|
||||
- name: Add NASM to PATH
|
||||
shell: pwsh
|
||||
run: Add-Content $env:GITHUB_PATH "C:\Program Files\NASM"
|
||||
|
||||
- name: Build DBX for Windows 7 ${{ matrix.arch }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
cargo build --locked --package dbx-web --release --no-default-features --features embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled --target ${{ matrix.target }} -Z build-std=std,panic_abort
|
||||
|
||||
- name: Audit Windows 7 PE imports
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/${{ matrix.target }}/release/dbx-web.exe
|
||||
|
||||
- name: Upload Windows portable ${{ matrix.arch }} ZIP
|
||||
timeout-minutes: 20
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$portableRoot = "portable"
|
||||
$portableDir = Join-Path $portableRoot "DBX_${version}_${arch}"
|
||||
$zipName = "dbx-web_${version}_${arch}-browser-static.zip"
|
||||
$zipSha256 = "dbx-web_${version}_${arch}-browser-static.zip.sha256"
|
||||
$exePath = "target/${{ matrix.target }}/release/dbx-web.exe"
|
||||
|
||||
if (!(Test-Path $exePath)) {
|
||||
Write-Error "Missing Windows executable: $exePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath (Join-Path $portableDir "dbx-web.exe") -Force
|
||||
Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force
|
||||
Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force
|
||||
Copy-Item ".github\scripts\start-dbx-web.bat" (Join-Path $portableDir "start.bat") -Force
|
||||
|
||||
Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force
|
||||
|
||||
$zip_sha256 = (Get-FileHash -LiteralPath $zipName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -Path $zipSha256 -Value $zip_sha256 -Encoding utf8NoBOM
|
||||
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $zipName $zipSha256 --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
static-browser:
|
||||
# Fully static musl builds of the browser (dbx-web) variant. No glibc
|
||||
# dependency, so the tarball runs on any Linux distribution (verified in
|
||||
@@ -799,7 +894,7 @@ jobs:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: |
|
||||
cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \
|
||||
--no-default-features --features "duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
|
||||
--no-default-features --features "embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled"
|
||||
|
||||
- name: Package static browser runtime
|
||||
env:
|
||||
@@ -938,7 +1033,8 @@ jobs:
|
||||
|
||||
publish:
|
||||
# Keep the release as a draft until every release-blocking platform job passes.
|
||||
# Nix packaging is advisory and runs independently from publication.
|
||||
# Nix packaging and the Windows 7 static-browser build are advisory and
|
||||
# upload their release assets independently of publication.
|
||||
needs: [cleanup-release-signatures, docker-manifest, jdbc-plugin, static-browser]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -44,6 +44,23 @@ make docs # preview the documentation site
|
||||
make cargo-check-fast # fast Rust checks
|
||||
```
|
||||
|
||||
### macOS Development Signing
|
||||
|
||||
Use `make dev`, `make dev-fast`, or `pnpm dev:tauri`. These entry points sign each rebuilt debug executable with a stable, local-only development identity before launching it. The first launch may still ask you to select **Always Allow** for DBX's existing Keychain item; subsequent rebuilds keep the same code identity. Direct `pnpm tauri dev` bypasses this setup.
|
||||
|
||||
The first run creates a dedicated signing keychain and a self-signed certificate under `~/Library/Application Support/DBX/development-signing/`. It adds only this keychain to your user search list, without changing the default keychain or system trust settings. The directory is owner-only (`0700`); its files, including the generated password used to unlock this development-only signing keychain, are owner-only (`0600`). No release private key or DBX connection-encryption key is exported or replaced. Keep this local identity across rebuilds; do not commit or share it. An incomplete, corrupt, or expired identity fails explicitly rather than silently rotating or falling back to ad-hoc signing.
|
||||
|
||||
The runner is restricted to `debug/dbx`, preserves Cargo feature and application arguments, and does not run for Linux, Windows, or release packaging. Custom `CARGO_TARGET_*_RUNNER` variables must be unset for these macOS development entry points.
|
||||
|
||||
Core, desktop and Web storage test fixtures use `dbx_core::persistence::test_storage` (the `test-support` dev-dependency feature). They resolve their own data-directory keys before migration preflight, without accessing the user's Keychain or inheriting `DBX_SECRET_KEY` / `DBX_SECRET_KEY_FILE`. Keep the fixture directory and its key together when testing database copies.
|
||||
|
||||
```bash
|
||||
node --test scripts/dev-tauri.test.mjs
|
||||
DBX_TEST_MACOS_KEYCHAIN=1 node --test scripts/dev-tauri.test.mjs
|
||||
```
|
||||
|
||||
The opt-in macOS integration test creates and removes a temporary signing keychain. It verifies that an ad-hoc rebuild is denied and that two different builds signed with the same identity can read the same test item with system interaction disabled.
|
||||
|
||||
### JDBC Agent Drivers
|
||||
|
||||
Agent driver projects live under `agents/`. Java/JDBC driver builds and tests require JDK 21; Gradle can auto-download the toolchain when available.
|
||||
|
||||
+30
-11
@@ -42,6 +42,23 @@ make docs # 本地预览文档站
|
||||
make cargo-check-fast # 快速 Rust 检查
|
||||
```
|
||||
|
||||
### macOS 开发签名
|
||||
|
||||
请使用 `make dev`、`make dev-fast` 或 `pnpm dev:tauri`。这些入口会在每次重新编译后、启动前,用固定的本地开发身份签名。首次启动仍可能需要对 DBX 原有钥匙串条目选择一次“始终允许”,之后重编译保持相同代码身份。直接执行 `pnpm tauri dev` 会绕过此流程。
|
||||
|
||||
首次运行会在 `~/Library/Application Support/DBX/development-signing/` 创建独立签名钥匙串和自签开发证书,仅将该钥匙串加入当前用户的搜索列表,不更改默认钥匙串或系统信任设置。目录权限为 `0700`,文件权限为 `0600`;其中保存的随机密码只用于解锁这个开发签名钥匙串。不使用发布私钥,也不导出或更换已有连接的加密密钥。请保留这套本地身份,不要提交或分享;配置不完整、损坏或证书过期时会明确报错,不会自动换证书或降级为临时签名。
|
||||
|
||||
签名 runner 仅接受 `debug/dbx`,保留 Cargo feature 和应用参数,不影响 Linux、Windows 或正式打包。使用这些 macOS 开发入口时,需要取消自定义的 `CARGO_TARGET_*_RUNNER` 环境变量。
|
||||
|
||||
Core、桌面端和 Web 的 Storage 测试夹具使用 `dbx_core::persistence::test_storage`(仅由开发依赖启用 `test-support` feature),在迁移预检查前选择测试目录自己的密钥,不访问用户钥匙串,也不继承 `DBX_SECRET_KEY` / `DBX_SECRET_KEY_FILE`。测试数据库复制时,应将夹具目录及其密钥一起保留。
|
||||
|
||||
```bash
|
||||
node --test scripts/dev-tauri.test.mjs
|
||||
DBX_TEST_MACOS_KEYCHAIN=1 node --test scripts/dev-tauri.test.mjs
|
||||
```
|
||||
|
||||
第二条为 macOS 集成验证:创建并清理临时签名钥匙串,禁止系统交互,证明临时签名重编译会被拒绝,而采用同一稳定身份的两个不同构建仍能读取同一测试条目。
|
||||
|
||||
### JDBC Agent 驱动
|
||||
|
||||
Agent 驱动工程在 `agents/` 目录。Java/JDBC 驱动构建和测试需要 JDK 21;环境允许时 Gradle 可以自动下载对应 toolchain。
|
||||
@@ -57,18 +74,18 @@ cd agents
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `apps/desktop/src/` | Vue 前端 |
|
||||
| `src-tauri/` | Tauri 桌面端壳层与命令层 |
|
||||
| `crates/dbx-core/` | 共享 Rust 数据库逻辑 |
|
||||
| `crates/dbx-web/` | Docker / Web HTTP 后端 |
|
||||
| `packages/cli/` | `@dbx-app/cli` |
|
||||
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
|
||||
| 路径 | 说明 |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `apps/desktop/src/` | Vue 前端 |
|
||||
| `src-tauri/` | Tauri 桌面端壳层与命令层 |
|
||||
| `crates/dbx-core/` | 共享 Rust 数据库逻辑 |
|
||||
| `crates/dbx-web/` | Docker / Web HTTP 后端 |
|
||||
| `packages/cli/` | `@dbx-app/cli` |
|
||||
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
|
||||
| `packages/mongo-shell/` | 桌面端内部 MongoDB 编辑器解析工具 |
|
||||
| `docs/` | 官方文档站 |
|
||||
| `examples/` | 配置与自动化示例 |
|
||||
| `agents/` | JDBC Agent 驱动工程 |
|
||||
| `docs/` | 官方文档站 |
|
||||
| `examples/` | 配置与自动化示例 |
|
||||
| `agents/` | JDBC Agent 驱动工程 |
|
||||
|
||||
## 开发约定
|
||||
|
||||
@@ -84,6 +101,8 @@ cd agents
|
||||
|
||||
一个 PR 只做一类事。文档 PR 不要夹带无关代码;修 Bug 时也不要顺手大重构,除非重构是修复所必需的。
|
||||
|
||||
提交前和推送后请按 [PR 提交与 CI 核查](docs/testing/pr-preflight.md) 检查多语言完整性、相关工作流及远端状态。
|
||||
|
||||
### 提交说明
|
||||
|
||||
提交信息用自然语言写清楚即可:
|
||||
|
||||
Generated
+275
-13
@@ -2047,7 +2047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.21"
|
||||
version = "0.6.25"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2128,7 +2128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.96"
|
||||
version = "0.4.98"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2158,6 +2158,7 @@ dependencies = [
|
||||
"crc",
|
||||
"csv",
|
||||
"dbx-ai-provider",
|
||||
"dbx-core",
|
||||
"dbx-drivers",
|
||||
"dbx-formats",
|
||||
"dbx-platform",
|
||||
@@ -2211,6 +2212,173 @@ dependencies = [
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc",
|
||||
"dbx-platform",
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"flate2",
|
||||
"futures",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-elasticsearch"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-mongodb"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"json5",
|
||||
"log",
|
||||
"mongodb",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-mysql"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"log",
|
||||
"mysql_async",
|
||||
"mysql_common",
|
||||
"percent-encoding",
|
||||
"rust_decimal",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-postgres"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
"percent-encoding",
|
||||
"portpicker",
|
||||
"postgres-openssl",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-openssl",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-redis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-sqlserver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-support"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dbx-platform",
|
||||
"dbx-types",
|
||||
"portpicker",
|
||||
"reqwest 0.12.28",
|
||||
"russh",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-drivers"
|
||||
version = "0.1.0"
|
||||
@@ -2221,8 +2389,18 @@ dependencies = [
|
||||
"chrono",
|
||||
"crc",
|
||||
"csv",
|
||||
"dbx-driver-agent",
|
||||
"dbx-driver-elasticsearch",
|
||||
"dbx-driver-mongodb",
|
||||
"dbx-driver-mysql",
|
||||
"dbx-driver-postgres",
|
||||
"dbx-driver-redis",
|
||||
"dbx-driver-sqlserver",
|
||||
"dbx-driver-support",
|
||||
"dbx-platform",
|
||||
"dbx-sql",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-sqlite-worker",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
@@ -2231,7 +2409,6 @@ dependencies = [
|
||||
"json5",
|
||||
"libsqlite3-hotbundle",
|
||||
"log",
|
||||
"mongodb",
|
||||
"mysql_async",
|
||||
"mysql_common",
|
||||
"nix 0.31.3",
|
||||
@@ -2240,7 +2417,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"portpicker",
|
||||
"postgres-openssl",
|
||||
"redis",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
@@ -2284,7 +2460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.96"
|
||||
version = "0.4.98"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2349,14 +2525,69 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "dbx-sql"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-sql-schema",
|
||||
"dbx-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-types",
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"unicode-ident",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-data"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"unicode-width",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-dialect"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-types",
|
||||
"insta",
|
||||
"log",
|
||||
"notify",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-schema"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"log",
|
||||
"rayon",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
@@ -2364,12 +2595,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sqlparser",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"unicode-ident",
|
||||
"unicode-width",
|
||||
"utoipa",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2396,7 +2621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.21"
|
||||
version = "0.6.25"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -2408,9 +2633,11 @@ dependencies = [
|
||||
"dbx-mcp",
|
||||
"futures",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"pbkdf2 0.12.2",
|
||||
"redis",
|
||||
"reqwest 0.12.28",
|
||||
"rust-embed",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7654,6 +7881,41 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
|
||||
dependencies = [
|
||||
"mime_guess",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.119",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
|
||||
dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
|
||||
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/webview2-com-sys", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
|
||||
@@ -82,7 +82,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
|
||||
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
|
||||
# os-keyring must stay in sync with src-tauri defaults: without it the keychain
|
||||
# key is unreadable and the secret-store migration wizard reappears on every launch.
|
||||
RUST_MIN_STACK=16777216 $(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
|
||||
RUST_MIN_STACK=16777216 $(PNPM) dev:tauri -- --no-default-features --features duckdb-sidecar,dynamodb,sqlite-bundled,os-keyring
|
||||
|
||||
dev-web: node_modules/.modules.yaml
|
||||
$(PNPM) dev:web
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>90+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>100+ databases in 25 MB. Desktop, Docker, CLI, built-in AI assistant, and MCP Server.</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX screenshot" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260925.png" alt="DBX screenshot" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -103,13 +103,13 @@
|
||||
</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">
|
||||
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
|
||||
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
UCloud is the first public cloud provider listed on China's STAR Market, with 28 global regions for cloud hosting, databases, and CDN; its AstraFlow platform offers one-click access to 200+ mainstream LLMs.
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
Special thanks to AICodeMirror for sponsoring this project! AICodeMirror provides a high-stability official relay service for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 7×24 dedicated support. Official-channel pricing for Claude Code / Codex / Gemini is as low as 38% / 2% / 9% of list price, with extra discounts on top-ups! AICodeMirror offers a special benefit for DBX users: register through this link to enjoy 20% off your first top-up, and enterprise customers up to 25% off.
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">Visit AICodeMirror</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -125,13 +125,13 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
|
||||
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Special thanks to AICodeMirror for sponsoring this project! AICodeMirror provides a high-stability official relay service for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 7×24 dedicated support. Official-channel pricing for Claude Code / Codex / Gemini is as low as 38% / 2% / 9% of list price, with extra discounts on top-ups! AICodeMirror offers a special benefit for DBX users: register through this link to enjoy 20% off your first top-up, and enterprise customers up to 25% off.
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">Visit AICodeMirror</a>
|
||||
UCloud is the first public cloud provider listed on China's STAR Market, with 28 global regions for cloud hosting, databases, and CDN; its AstraFlow platform offers one-click access to 200+ mainstream LLMs.
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -208,13 +208,23 @@
|
||||
<p>Native app on macOS, Windows, and Linux. Self-host via Docker for team access. Web version for browser-only environments. Same feature set. Same connections.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<h3>📨 Not just databases</h3>
|
||||
<p>Message queues and middleware get first-class consoles: Kafka, RocketMQ, RabbitMQ, Pulsar, and MQTT, plus Nacos, Consul, ZooKeeper, and etcd. Inspect topics and messages next to your databases — no extra tool.</p>
|
||||
</td>
|
||||
<td>
|
||||
<h3>🧩 Plugin ecosystem</h3>
|
||||
<p>Extend DBX with signed, sandboxed plugins from the built-in store — S3, Kubernetes, LDAP, and more. Build your own with the Go / TypeScript SDK.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
### 90+ Databases, One Tool
|
||||
### 100+ Databases, One Tool
|
||||
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~25 MB app. No bundled Chromium.
|
||||
MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Meilisearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingbaseES, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent-based profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Cloud Spanner, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, Consul KV, IRIS, and more. All in a single ~25 MB app. No bundled Chromium.
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -254,6 +264,18 @@ Virtual-scrolled table that handles large result sets. Inline editing, SQL previ
|
||||
- **Redis** — key pattern search, batch key operations, command runner, TTL editing, and all data types (String, Hash, List, Set, ZSet, Stream)
|
||||
- **MongoDB** — document CRUD with pagination, Atlas & replica set URL connection
|
||||
|
||||
### Message Queue & Middleware Consoles
|
||||
|
||||
- **Kafka / RocketMQ / RabbitMQ / Pulsar** — topics, consumer groups, message browsing, query and trace, broker monitoring, permissions and policies
|
||||
- **MQTT** — topic tree navigation, subscribe, and publish
|
||||
- **Nacos / Consul / ZooKeeper / etcd** — service discovery, KV / config browsing, health, and ACL
|
||||
|
||||
### Plugin System
|
||||
|
||||
- **Extensible by design** — new connection types and tools arrive as plugins: S3 browsing, Kubernetes, LDAP, and more from the built-in store
|
||||
- **Signed & sandboxed** — every plugin package is signature-verified before install; plugin UI runs sandboxed with its own sidecar process
|
||||
- **Build your own** — Go / TypeScript SDKs with `npx @dbx-app/plugin-cli` scaffolding; publish to the Marketplace via [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store)
|
||||
|
||||
### Safety & Connectivity
|
||||
|
||||
SSH tunnel (key & password) · database and AI proxy settings · auto-reconnect on connection loss · confirmation dialogs for destructive operations · encrypted config export/import · color-coded connections · driver store and optional JDBC plugin
|
||||
|
||||
+35
-13
@@ -1,8 +1,8 @@
|
||||
<div align="center">
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 90+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
<p style="font-size: 18px; white-space: nowrap;"><strong>25 MB 驾驭 100+ 种数据库。桌面端、Docker、CLI、内置 AI 助手与 MCP Server。</strong></p>
|
||||
|
||||
<p>
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260820.png" alt="DBX 截图" width="820" />
|
||||
<img src="https://dl.dbxio.com/assets/readme-hero-20260925.png" alt="DBX 截图" width="820" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -103,13 +103,13 @@
|
||||
</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">
|
||||
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
|
||||
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
UCloud 优刻得是国内首家公有云科创板上市公司,覆盖国内、亚洲、欧洲、北美等 28 个地域的云主机、数据库、CDN 等服务,注册享新客优惠 0.9 折起;星图 AstraFlow 大模型平台支持主流 200+ 大模型一键调用。
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 DBX 用户提供了特别福利,通过此链接注册的用户,可享受首充 8 折,企业客户最高可享 7.5 折!
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">访问 AICodeMirror</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -125,13 +125,13 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">
|
||||
<img src="docs/public/sponsors/aicodemirror-card.png" alt="AICodeMirror" width="175" />
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
<img src="docs/public/sponsors/astraflow-card.png" alt="AstraFlow" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 DBX 用户提供了特别福利,通过此链接注册的用户,可享受首充 8 折,企业客户最高可享 7.5 折!
|
||||
<a href="https://www.aicodemirror.ai/register?invitecode=9A50BU" target="_blank">访问 AICodeMirror</a>
|
||||
UCloud 优刻得是国内首家公有云科创板上市公司,覆盖国内、亚洲、欧洲、北美等 28 个地域的云主机、数据库、CDN 等服务,注册享新客优惠 0.9 折起;星图 AstraFlow 大模型平台支持主流 200+ 大模型一键调用。
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -208,11 +208,21 @@
|
||||
<p>macOS、Windows、Linux 原生应用。通过 Docker 自托管供团队访问。Web 版本适配纯浏览器环境。同样的功能,同样的连接配置。</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<h3>📨 不止数据库</h3>
|
||||
<p>消息队列与中间件同样是一等公民:Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT 控制台,外加 Nacos、Consul、ZooKeeper、etcd。在数据库旁边直接查看主题与消息,无需再开一个工具。</p>
|
||||
</td>
|
||||
<td>
|
||||
<h3>🧩 插件生态</h3>
|
||||
<p>从内置商店安装经过签名校验的沙箱插件扩展 DBX——S3、Kubernetes、LDAP 等。也可以用 Go / TypeScript SDK 开发自己的插件。</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 90+ 种数据库,一个工具搞定
|
||||
### 100+ 种数据库,一个工具搞定
|
||||
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent 配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 等。全部装进约 25 MB 的应用里,不内嵌 Chromium。
|
||||
|
||||
@@ -254,6 +264,18 @@ CodeMirror 6 语法高亮、元数据感知自动补全、`Cmd+Enter` 执行、
|
||||
- **Redis** — 模式匹配搜索、批量键操作、命令执行器、TTL 编辑,全数据类型支持(String、Hash、List、Set、ZSet、Stream)
|
||||
- **MongoDB** — 文档增删改查、分页浏览,支持 Atlas 和副本集 URL 直连
|
||||
|
||||
### 消息队列与中间件控制台
|
||||
|
||||
- **Kafka / RocketMQ / RabbitMQ / Pulsar** — 主题、消费组、消息浏览 / 查询 / 追踪、Broker 监控、权限与策略
|
||||
- **MQTT** — 主题树导航、订阅与发布
|
||||
- **Nacos / Consul / ZooKeeper / etcd** — 服务发现、KV / 配置浏览、健康状态与 ACL
|
||||
|
||||
### 插件系统
|
||||
|
||||
- **为扩展而生** — 新的连接类型与工具以插件形式接入:S3 浏览、Kubernetes、LDAP 等,内置商店一键安装
|
||||
- **签名与沙箱** — 插件包安装前签名校验,插件 UI 运行在沙箱中并拥有独立 sidecar 进程
|
||||
- **开发你的插件** — Go / TypeScript SDK,`npx @dbx-app/plugin-cli` 一行起步,通过 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) 发布到商店
|
||||
|
||||
### 安全与连接
|
||||
|
||||
SSH 隧道(密钥和密码认证)· 数据库和 AI 代理设置 · 断线自动重连 · 危险操作确认对话框 · 加密导出/导入连接配置 · 连接颜色标记 · 驱动商店与可选 JDBC 插件
|
||||
@@ -568,12 +590,12 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 和 DBeaver / TablePlus / Beekeeper Studio 有什么区别?</strong></summary>
|
||||
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 90+ 数据库、桌面端、Docker 和 Web。
|
||||
DBX 仅 25 MB,无需运行时依赖(无需 Java、无需 Python)。AI 和 MCP 是原生内置功能,不是插件。单一代码库同时支持 100+ 数据库、桌面端、Docker 和 Web。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>支持哪些数据库?</strong></summary>
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Pulsar、Kafka、RocketMQ)。
|
||||
MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、Meilisearch、Qdrant、Milvus、Weaviate、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingbaseES、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等。Agent 配置可扩展到 H2、Snowflake、Trino、PrestoSQL、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Cloud Spanner、Kylin、SunDB、JDBCX、Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase 8a/8s、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、ZooKeeper、Nacos、Consul KV、IRIS 及自定义 JDBC 连接,并支持消息队列管理(Kafka、RocketMQ、RabbitMQ、Pulsar、MQTT)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -32,6 +32,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
private boolean sessionAffinity;
|
||||
private boolean pooledConnectionPoisoned;
|
||||
|
||||
@Override
|
||||
public boolean supportsQueryTiming() { return true; }
|
||||
|
||||
@Override
|
||||
public final Connection getConnection() {
|
||||
return connection;
|
||||
@@ -185,8 +188,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
long prepareStarted = System.nanoTime();
|
||||
Connection conn = requireConnected();
|
||||
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
|
||||
QueryTiming.record("session_prepare", prepareStarted);
|
||||
return JdbcExecutor.current().execute(
|
||||
conn,
|
||||
sql,
|
||||
@@ -204,8 +209,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
|
||||
@Override
|
||||
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
|
||||
long prepareStarted = System.nanoTime();
|
||||
Connection conn = requireConnected();
|
||||
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
|
||||
QueryTiming.record("session_prepare", prepareStarted);
|
||||
return JdbcExecutor.current().executePage(
|
||||
conn,
|
||||
sql,
|
||||
|
||||
@@ -11,6 +11,9 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface DatabaseAgent {
|
||||
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
|
||||
default boolean supportsQueryTiming() { return false; }
|
||||
|
||||
void connect(ConnectParams params);
|
||||
|
||||
boolean testConnection(ConnectParams params);
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
/**
|
||||
* Decode PostGIS EWKB / WKB byte streams (and PGgeometry / hex strings) into
|
||||
* OGC Well-Known Text. Behaves identically to the Rust implementation in
|
||||
* {@code crates/dbx-drivers/src/db/postgres.rs::ewkb_to_wkt}, including the
|
||||
* {@code crates/dbx-driver-support/src/wkb.rs::wkb_to_wkt}, including the
|
||||
* {@code Z} / {@code M} / {@code ZM} dimension suffixes and {@code EMPTY}
|
||||
* geometries. Used by the JDBC agent layer so that PostgresLike databases
|
||||
* such as HighGo / KingBase / Vastbase / openGauss / GaussDB return WKT for
|
||||
|
||||
@@ -140,7 +140,10 @@ public final class JdbcExecutor {
|
||||
String trimmedSql = trimSql(sql);
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
long phaseStarted = System.nanoTime();
|
||||
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
QueryTiming.record("schema", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
activeStatements.add(stmt);
|
||||
@@ -153,7 +156,10 @@ public final class JdbcExecutor {
|
||||
}
|
||||
// SQL dumps often contain BEGIN/COMMIT/ROLLBACK as executable statements.
|
||||
// Do not translate them to Connection.commit(), which requires autoCommit=false.
|
||||
QueryTiming.record("statement_prepare", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
boolean hasResultSet = stmt.execute(trimmedSql);
|
||||
QueryTiming.record("jdbc_execute", phaseStarted);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
long affectedRows;
|
||||
if (advancePastUpdateCounts) {
|
||||
@@ -210,6 +216,7 @@ public final class JdbcExecutor {
|
||||
ResultValueReader valueReader
|
||||
) {
|
||||
return unchecked(() -> {
|
||||
long phaseStarted = System.nanoTime();
|
||||
ResultSetMetaData meta = rs.getMetaData();
|
||||
int colCount = meta.getColumnCount();
|
||||
List<String> columns = new ArrayList<>(colCount);
|
||||
@@ -226,6 +233,8 @@ public final class JdbcExecutor {
|
||||
typeNameByIndex[i - 1] = typeName;
|
||||
}
|
||||
|
||||
QueryTiming.record("metadata", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
List<List<Object>> rows = new ArrayList<>(initialRowCapacity(maxRows));
|
||||
boolean truncated = false;
|
||||
while (rs.next()) {
|
||||
@@ -236,6 +245,7 @@ public final class JdbcExecutor {
|
||||
rows.add(rowValues(rs, valueReader, sqlTypeByIndex, typeNameByIndex));
|
||||
}
|
||||
|
||||
QueryTiming.record("fetch", phaseStarted);
|
||||
return new QueryResult(columns, columnTypes, rows, 0L, executionTimeMs, truncated);
|
||||
});
|
||||
}
|
||||
@@ -383,7 +393,10 @@ public final class JdbcExecutor {
|
||||
String trimmedSql = trimSql(sql);
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
long phaseStarted = System.nanoTime();
|
||||
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
QueryTiming.record("schema", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
|
||||
Statement stmt = conn.createStatement();
|
||||
QuerySession createdSession = null;
|
||||
@@ -395,7 +408,10 @@ public final class JdbcExecutor {
|
||||
}
|
||||
// Keep script transaction-control statements in the SQL stream.
|
||||
// JDBC transaction APIs are reserved for executeTransaction.
|
||||
QueryTiming.record("statement_prepare", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
boolean hasResultSet = stmt.execute(trimmedSql);
|
||||
QueryTiming.record("jdbc_execute", phaseStarted);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
long affectedRows;
|
||||
if (advancePastUpdateCounts) {
|
||||
@@ -442,6 +458,7 @@ public final class JdbcExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
phaseStarted = System.nanoTime();
|
||||
ResultSet rs = stmt.getResultSet();
|
||||
ResultSetMetaData meta = rs.getMetaData();
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
@@ -471,7 +488,10 @@ public final class JdbcExecutor {
|
||||
);
|
||||
createdSession = session;
|
||||
targetSessions.put(sessionId, session);
|
||||
QueryTiming.record("metadata", phaseStarted);
|
||||
phaseStarted = System.nanoTime();
|
||||
QueryPageResult page = readSessionPage(targetSessions, session, options.getPageSize(), elapsed);
|
||||
QueryTiming.record("fetch", phaseStarted);
|
||||
// Same reason as `execute`: pulling the first page's rows is
|
||||
// part of the duration the caller waited for.
|
||||
page.setExecution_time_ms(System.currentTimeMillis() - start);
|
||||
@@ -724,7 +744,9 @@ public final class JdbcExecutor {
|
||||
synchronized (session) {
|
||||
long fetchStart = System.currentTimeMillis();
|
||||
try {
|
||||
long phaseStarted = System.nanoTime();
|
||||
QueryPageResult page = readSessionPage(targetSessions, session, pageSize, 0L);
|
||||
QueryTiming.record("fetch", phaseStarted);
|
||||
// A later page reads its rows after `stmt.execute()` has long
|
||||
// returned, so it has to measure the fetch itself instead of
|
||||
// reporting 0ms while the caller waits.
|
||||
|
||||
@@ -89,6 +89,26 @@ public final class JsonRpcServer {
|
||||
}
|
||||
|
||||
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
|
||||
boolean timedQuery = agent.supportsQueryTiming()
|
||||
&& (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)
|
||||
|| AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method)
|
||||
|| AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method));
|
||||
try (QueryTiming timing = timedQuery ? QueryTiming.begin() : null) {
|
||||
Object result = dispatchWithConnection(method, params);
|
||||
// Capture after connection return/reset, which runs in the finally
|
||||
// block below. Driver-only snapshots omit that lifecycle work.
|
||||
if (timing != null) {
|
||||
if (result instanceof QueryResult) {
|
||||
((QueryResult) result).setQuery_timings_ms(timing.finish());
|
||||
} else if (result instanceof QueryPageResult) {
|
||||
((QueryPageResult) result).setQuery_timings_ms(timing.finish());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private Object dispatchWithConnection(String method, JsonObject params) throws Exception {
|
||||
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> {
|
||||
AbstractJdbcAgent jdbcAgent = pooledJdbcAgent();
|
||||
if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method)
|
||||
@@ -98,7 +118,12 @@ public final class JsonRpcServer {
|
||||
}
|
||||
boolean manageConnection = jdbcAgent != null && requiresConnectedConnection(method);
|
||||
if (manageConnection) {
|
||||
jdbcAgent.beginPooledRequest();
|
||||
long acquireStarted = System.nanoTime();
|
||||
try {
|
||||
jdbcAgent.beginPooledRequest();
|
||||
} finally {
|
||||
QueryTiming.record("pool_acquire", acquireStarted);
|
||||
}
|
||||
}
|
||||
boolean succeeded = false;
|
||||
try {
|
||||
@@ -107,14 +132,19 @@ public final class JsonRpcServer {
|
||||
return result;
|
||||
} finally {
|
||||
if (manageConnection) {
|
||||
jdbcAgent.finishPooledRequest(
|
||||
jdbcExecutor,
|
||||
succeeded,
|
||||
requiresSessionAffinity(method, params),
|
||||
evictAfterRequest(method),
|
||||
endsSessionAffinity(method),
|
||||
preservesSchemaContext()
|
||||
);
|
||||
long releaseStarted = System.nanoTime();
|
||||
try {
|
||||
jdbcAgent.finishPooledRequest(
|
||||
jdbcExecutor,
|
||||
succeeded,
|
||||
requiresSessionAffinity(method, params),
|
||||
evictAfterRequest(method),
|
||||
endsSessionAffinity(method),
|
||||
preservesSchemaContext()
|
||||
);
|
||||
} finally {
|
||||
QueryTiming.record("pool_release", releaseStarted);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
|
||||
/**
|
||||
* Wrap {@link AbstractJdbcAgent#resultValue} so PostGIS-style {@code geometry}
|
||||
* and {@code geography} columns are decoded into WKT (matching the native
|
||||
* tokio_postgres path in {@code crates/dbx-drivers/src/db/postgres.rs}).
|
||||
* tokio_postgres path in {@code crates/dbx-driver-postgres/src/postgres.rs}).
|
||||
*/
|
||||
private JdbcExecutor.ColumnAwareResultValueReader geometryAwareResolver() {
|
||||
return (rs, index, sqlType, columnTypeName) -> {
|
||||
|
||||
@@ -13,6 +13,7 @@ public final class QueryPageResult {
|
||||
private long affected_rows;
|
||||
private long execution_time_ms;
|
||||
private Long server_execute_time_us;
|
||||
private java.util.Map<String, Double> query_timings_ms;
|
||||
/** Number of rows read by this JDBC cursor across all pages; never sent over JSON RPC. */
|
||||
private transient long cursor_rows_read;
|
||||
private boolean truncated;
|
||||
@@ -100,6 +101,10 @@ public final class QueryPageResult {
|
||||
return execution_time_ms;
|
||||
}
|
||||
|
||||
public java.util.Map<String, Double> getQuery_timings_ms() { return query_timings_ms; }
|
||||
|
||||
public void setQuery_timings_ms(java.util.Map<String, Double> value) { query_timings_ms = value; }
|
||||
|
||||
public Long getServer_execute_time_us() {
|
||||
return server_execute_time_us;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class QueryResult {
|
||||
private long affected_rows;
|
||||
private long execution_time_ms;
|
||||
private Long server_execute_time_us;
|
||||
private java.util.Map<String, Double> query_timings_ms;
|
||||
private boolean truncated;
|
||||
|
||||
public QueryResult() {
|
||||
@@ -85,6 +86,10 @@ public final class QueryResult {
|
||||
return execution_time_ms;
|
||||
}
|
||||
|
||||
public java.util.Map<String, Double> getQuery_timings_ms() { return query_timings_ms; }
|
||||
|
||||
public void setQuery_timings_ms(java.util.Map<String, Double> value) { query_timings_ms = value; }
|
||||
|
||||
public Long getServer_execute_time_us() {
|
||||
return server_execute_time_us;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Request-scoped, monotonic timings. Disabled unless a driver explicitly opens a scope. */
|
||||
public final class QueryTiming implements AutoCloseable {
|
||||
private static final ThreadLocal<QueryTiming> CURRENT = new ThreadLocal<>();
|
||||
private final Map<String, Double> stages = new LinkedHashMap<>();
|
||||
private final long started = System.nanoTime();
|
||||
private final QueryTiming previous;
|
||||
|
||||
private QueryTiming() {
|
||||
previous = CURRENT.get();
|
||||
CURRENT.set(this);
|
||||
}
|
||||
|
||||
public static QueryTiming begin() { return new QueryTiming(); }
|
||||
|
||||
public static void record(String stage, long started) {
|
||||
QueryTiming timing = CURRENT.get();
|
||||
if (timing != null) timing.stages.merge(stage, (System.nanoTime() - started) / 1_000_000.0, Double::sum);
|
||||
}
|
||||
|
||||
public Map<String, Double> finish() {
|
||||
Map<String, Double> result = new LinkedHashMap<>(stages);
|
||||
result.put("agent_total", (System.nanoTime() - started) / 1_000_000.0);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
// A driver scope also works for direct calls. When invoked through the
|
||||
// RPC boundary, carry its non-overlapping stages into the outer request.
|
||||
// agent_total is a snapshot only; never merge nested totals.
|
||||
if (previous != null) stages.forEach((key, value) -> previous.stages.merge(key, value, Double::sum));
|
||||
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class EwkbWktDecoderTest {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Cross-checked against the Rust ewkb_to_wkt tests in
|
||||
// crates/dbx-drivers/src/db/postgres.rs (same hex inputs, same expected WKT).
|
||||
// crates/dbx-driver-support/src/wkb.rs (same hex inputs, same expected WKT).
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1112,6 +1112,12 @@ class JdbcConnectionPoolingTest {
|
||||
pageParams
|
||||
));
|
||||
assertTrue(firstPage.get("has_more").getAsBoolean());
|
||||
JsonObject timings = firstPage.getAsJsonObject("query_timings_ms");
|
||||
assertNotNull(timings, "all JDBC agents expose timings through JSON RPC");
|
||||
assertTrue(timings.get("pool_acquire").getAsDouble() >= 0);
|
||||
assertTrue(timings.get("jdbc_execute").getAsDouble() >= 0);
|
||||
assertTrue(timings.get("fetch").getAsDouble() >= 0);
|
||||
assertTrue(timings.get("pool_release").getAsDouble() >= 0);
|
||||
String querySessionId = firstPage.get("session_id").getAsString();
|
||||
|
||||
Future<JsonObject> waiting = worker.submit(
|
||||
|
||||
@@ -53,6 +53,15 @@ public final class CacheAgent extends ConfiguredJdbcAgent {
|
||||
Object value = rs.getBigDecimal(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.BIT:
|
||||
case Types.BOOLEAN:
|
||||
// Caché has no boolean type: %Boolean is stored as 1/0 and only
|
||||
// the JDBC driver turns it into a Java boolean, which would
|
||||
// surface as true/false in the grid. Report the stored 1/0.
|
||||
return unchecked(() -> {
|
||||
int value = rs.getInt(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.OTHER:
|
||||
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
|
||||
default:
|
||||
|
||||
@@ -159,6 +159,47 @@ class CacheAgentTest {
|
||||
assertEquals(Collections.singletonList("getObject"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBitColumnsAsStoredNumbers() {
|
||||
for (int sqlType : new int[] {Types.BIT, Types.BOOLEAN}) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getInt".equals(method.getName())) {
|
||||
calls.add("getInt");
|
||||
return 1;
|
||||
}
|
||||
if (method.getName().startsWith("get")) {
|
||||
calls.add(method.getName());
|
||||
throw new AssertionError("Caché %Boolean must not be read as a Java boolean");
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new CacheAgent().resultValue(resultSet, 1, sqlType);
|
||||
|
||||
assertEquals(1, value);
|
||||
assertEquals(Collections.singletonList("getInt"), calls);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesNullForBitColumns() {
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getInt".equals(method.getName())) {
|
||||
return 0;
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return true;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
assertNull(new CacheAgent().resultValue(resultSet, 1, Types.BIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsColumnsAndUsesInlinePrimaryKey() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
|
||||
@@ -50,6 +50,7 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -68,6 +69,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
private static final int DBMS_OUTPUT_ENABLE_TIMEOUT_SECS = 5;
|
||||
private static final int DBMS_OUTPUT_ENABLE_NETWORK_TIMEOUT_MILLIS = 5_000;
|
||||
private static final int VIEW_VALIDITY_BATCH_SIZE = 500;
|
||||
// Dameng encodes the declared length unit of a character column in SCALE: see
|
||||
// declaredWithCharacterLength.
|
||||
private static final int CHARACTER_SCALE_VARIABLE = 7;
|
||||
private static final int CHARACTER_SCALE_FIXED = 8;
|
||||
private static final Pattern DATABASE_VERSION_MAJOR_PATTERN = Pattern.compile("(\\d+)\\.");
|
||||
// Word-boundary match so a type or default merely containing the letters is not mistaken
|
||||
// for the IDENTITY keyword.
|
||||
@@ -569,7 +574,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return queryConstrainedTables(schema, MetadataListConstraints.orNone(constraints));
|
||||
}
|
||||
|
||||
private List<TableInfo> queryConstrainedTables(String schema, MetadataListConstraints constraints) {
|
||||
private List<TableInfo> queryConstrainedTables(String rawSchema, MetadataListConstraints constraints) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema);
|
||||
}
|
||||
@@ -905,6 +911,43 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
&& schema.equalsIgnoreCase(connectedUsername);
|
||||
}
|
||||
|
||||
/**
|
||||
* DM resolves an unqualified object name in the session's current schema, and dbx sends an
|
||||
* unqualified metadata request whenever the editor's source table has no schema selected
|
||||
* (the same Oracle-like contract `oracle` and `oceanbase-oracle` follow). Looking up
|
||||
* `OWNER = ''` instead reported "no columns" for tables that exist, so column comments never
|
||||
* reached the result-column tooltip (issue #10221).
|
||||
*/
|
||||
private String normalizeSchema(String schema) {
|
||||
if (schema != null && !schema.isBlank()) {
|
||||
return schema;
|
||||
}
|
||||
return effectiveMetadataSchema(currentSchemaOrBlank(), connectedUsername);
|
||||
}
|
||||
|
||||
static String effectiveMetadataSchema(String currentSchema, String connectedUsername) {
|
||||
if (currentSchema != null && !currentSchema.isBlank()) {
|
||||
return currentSchema;
|
||||
}
|
||||
return connectedUsername == null ? "" : connectedUsername;
|
||||
}
|
||||
|
||||
private String currentSchemaOrBlank() {
|
||||
try (Statement stmt = requireConnected().createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL")) {
|
||||
if (rs.next()) {
|
||||
String current = rs.getString(1);
|
||||
if (current != null && !current.isBlank()) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
} catch (SQLException error) {
|
||||
// A server without SYS_CONTEXT support still resolves an unqualified name to the
|
||||
// connected user, which is what the caller falls back to.
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean includesSupportedObjectTypes(MetadataListConstraints constraints) {
|
||||
return constraints.includesTableLikeTypes()
|
||||
|| constraints.objectTypeAllowed("PROCEDURE")
|
||||
@@ -1095,7 +1138,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return queryConstrainedObjects(schema, MetadataListConstraints.orNone(constraints));
|
||||
}
|
||||
|
||||
private List<ObjectInfo> queryConstrainedObjects(String schema, MetadataListConstraints constraints) {
|
||||
private List<ObjectInfo> queryConstrainedObjects(String rawSchema, MetadataListConstraints constraints) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
List<ObjectInfo> objects = queryConstrainedObjectsWithoutValidity(schema, constraints);
|
||||
applyViewValidity(objects, schema);
|
||||
return objects;
|
||||
@@ -1349,7 +1393,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
public ObjectSource getObjectSource(String rawSchema, String name, String objectType) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
return unchecked(() -> {
|
||||
String dbmsType = damengDdlObjectType(objectType);
|
||||
RuntimeException dbmsError;
|
||||
@@ -1775,7 +1820,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTableDdl(String schema, String table) {
|
||||
public String getTableDdl(String rawSchema, String table) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return super.getTableDdl(schema, table);
|
||||
}
|
||||
@@ -1869,7 +1915,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ColumnInfo> getColumns(String schema, String table) {
|
||||
public List<ColumnInfo> getColumns(String rawSchema, String table) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.getColumns(
|
||||
requireConnected(),
|
||||
@@ -1904,6 +1951,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
Set<String> identityColumns = identityColumns(schema, table);
|
||||
// Lazily filled in only when a column's declared length is not visible in
|
||||
// ALL_TAB_COLUMNS; the dictionary lookup costs an extra round trip.
|
||||
Map<String, Integer> declaredColumnLengths = null;
|
||||
List<ColumnInfo> result = new ArrayList<>();
|
||||
// DATA_DEFAULT is a LONG column — it must be selected first and read first
|
||||
// in JDBC, otherwise the data is truncated.
|
||||
@@ -1940,7 +1990,15 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
Integer dataLen = intObject(rs, "DATA_LENGTH");
|
||||
Integer charLen = intObject(rs, "CHAR_LENGTH");
|
||||
String charUsed = rs.getString("CHAR_USED");
|
||||
String dataType = formatDataType(baseType, numPrec, numScale, dataLen, charLen, charUsed);
|
||||
Integer declaredCharLen = null;
|
||||
if (needsDeclaredCharacterLength(baseType, numScale, charLen)) {
|
||||
if (declaredColumnLengths == null) {
|
||||
declaredColumnLengths = declaredColumnLengths(schema, table);
|
||||
}
|
||||
declaredCharLen = declaredColumnLengths.get(name);
|
||||
}
|
||||
String dataType =
|
||||
formatDataType(baseType, numPrec, numScale, dataLen, charLen, charUsed, declaredCharLen);
|
||||
|
||||
result.add(new ColumnInfo(
|
||||
name,
|
||||
@@ -2175,7 +2233,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IndexInfo> listIndexes(String schema, String table) {
|
||||
public List<IndexInfo> listIndexes(String rawSchema, String table) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listIndexes(
|
||||
requireConnected(),
|
||||
@@ -2234,7 +2293,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForeignKeyInfo> listForeignKeys(String schema, String table) {
|
||||
public List<ForeignKeyInfo> listForeignKeys(String rawSchema, String table) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
|
||||
}
|
||||
@@ -2268,7 +2328,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerInfo> listTriggers(String schema, String table) {
|
||||
public List<TriggerInfo> listTriggers(String rawSchema, String table) {
|
||||
final String schema = normalizeSchema(rawSchema);
|
||||
if (legacyJdbcMetadata) {
|
||||
return StandardJdbcMetadata.INSTANCE.listTriggers(schema, table);
|
||||
}
|
||||
@@ -2558,15 +2619,17 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
Integer numScale,
|
||||
Integer dataLen,
|
||||
Integer charLen,
|
||||
String charUsed
|
||||
String charUsed,
|
||||
Integer declaredCharLen
|
||||
) {
|
||||
return switch (base.toUpperCase(Locale.ROOT)) {
|
||||
case "VARCHAR2", "VARCHAR", "CHAR" -> {
|
||||
Integer length = characterLength(dataLen, charLen, charUsed);
|
||||
yield length != null ? base + "(" + length + characterLengthUnit(charUsed) + ")" : base;
|
||||
String unit = characterLengthUnit(base, numScale, charUsed);
|
||||
Integer length = characterLength(dataLen, charLen, unit, declaredCharLen);
|
||||
yield length != null ? base + "(" + length + unit + ")" : base;
|
||||
}
|
||||
case "NVARCHAR2", "NCHAR" -> {
|
||||
Integer length = charLen != null ? charLen : dataLen;
|
||||
Integer length = firstPositive(charLen, declaredCharLen, dataLen);
|
||||
yield length != null ? base + "(" + length + ")" : base;
|
||||
}
|
||||
case "NUMBER", "NUMERIC", "DECIMAL" -> {
|
||||
@@ -2580,16 +2643,18 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private static Integer characterLength(Integer dataLen, Integer charLen, String charUsed) {
|
||||
String normalized = charUsed == null ? "" : charUsed.trim().toUpperCase(Locale.ROOT);
|
||||
if ("B".equals(normalized) || "BYTE".equals(normalized)) {
|
||||
return dataLen != null ? dataLen : charLen;
|
||||
private static Integer characterLength(Integer dataLen, Integer charLen, String unit, Integer declaredCharLen) {
|
||||
if (" BYTE".equals(unit)) {
|
||||
return firstPositive(dataLen, declaredCharLen, charLen);
|
||||
}
|
||||
return charLen != null ? charLen : dataLen;
|
||||
return firstPositive(charLen, declaredCharLen, dataLen);
|
||||
}
|
||||
|
||||
private static String characterLengthUnit(String charUsed) {
|
||||
if (charUsed == null) {
|
||||
private static String characterLengthUnit(String base, Integer numScale, String charUsed) {
|
||||
if (declaredWithCharacterLength(base, numScale)) {
|
||||
return " CHAR";
|
||||
}
|
||||
if (charUsed == null || charUsed.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return switch (charUsed.trim().toUpperCase(Locale.ROOT)) {
|
||||
@@ -2599,6 +2664,83 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private static Integer firstPositive(Integer... values) {
|
||||
for (Integer value : values) {
|
||||
if (value != null && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dameng keeps a character column's declared length unit in its SCALE field instead of in
|
||||
* CHAR_USED, which reads 'B' for byte- and character-length columns alike: SCALE is 7 for
|
||||
* VARCHAR/VARCHAR2/NVARCHAR2 and 8 for CHAR/NCHAR columns declared with character
|
||||
* semantics, and 0 for byte semantics. Reading CHAR_USED alone rendered a column created as
|
||||
* <code>VARCHAR2(100 CHAR)</code> as <code>VARCHAR2(400 BYTE)</code> on a UTF-8 server.
|
||||
*/
|
||||
private static boolean declaredWithCharacterLength(String base, Integer numScale) {
|
||||
if (numScale == null) {
|
||||
return false;
|
||||
}
|
||||
return switch (base.toUpperCase(Locale.ROOT)) {
|
||||
case "VARCHAR2", "VARCHAR", "NVARCHAR2" -> numScale == CHARACTER_SCALE_VARIABLE;
|
||||
case "CHAR", "NCHAR" -> numScale == CHARACTER_SCALE_FIXED;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NVARCHAR2/NCHAR columns leave CHAR_LENGTH at 0 in ALL_TAB_COLUMNS, and a character-length
|
||||
* column always reports DATA_LENGTH in bytes, so the declared length has to come from
|
||||
* elsewhere. Only ask when the cheaper columns cannot answer.
|
||||
*/
|
||||
private static boolean needsDeclaredCharacterLength(String base, Integer numScale, Integer charLen) {
|
||||
if (charLen != null && charLen > 0) {
|
||||
return false;
|
||||
}
|
||||
String type = base.toUpperCase(Locale.ROOT);
|
||||
return switch (type) {
|
||||
case "NVARCHAR2", "NCHAR" -> true;
|
||||
case "VARCHAR2", "VARCHAR", "CHAR" -> declaredWithCharacterLength(type, numScale);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SYS.SYSCOLUMNS.LENGTH$ keeps the length exactly as declared, which ALL_TAB_COLUMNS cannot
|
||||
* report for NVARCHAR2/NCHAR. The view is not granted to PUBLIC, so a permission error just
|
||||
* means the caller keeps using the ALL_TAB_COLUMNS values instead of failing the request.
|
||||
*/
|
||||
private Map<String, Integer> declaredColumnLengths(String schema, String table) {
|
||||
Map<String, Integer> result = new HashMap<>();
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ c.NAME, c.LENGTH$
|
||||
FROM SYS.SYSCOLUMNS c
|
||||
JOIN SYS.SYSOBJECTS t ON c.ID = t.ID
|
||||
JOIN SYS.SYSOBJECTS s ON t.SCHID = s.ID
|
||||
WHERE s.NAME = ? AND t.NAME = ?
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String name = rs.getString(1);
|
||||
int length = rs.getInt(2);
|
||||
if (notBlank(name) && length > 0) {
|
||||
result.put(name, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception error) {
|
||||
LOGGER.log(Level.FINE, "Unable to read declared column lengths for " + schema + "." + table, error);
|
||||
return Map.of();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Integer intObject(ResultSet rs, String column) throws Exception {
|
||||
Object value = rs.getObject(column);
|
||||
return value == null ? null : ((Number) value).intValue();
|
||||
@@ -2704,15 +2846,39 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
if (notBlank(tableComment) && !containsCommentOnTable(result.toString(), schema, table)) {
|
||||
appendCommentStatement(result, "COMMENT ON TABLE " + tableRef + " IS '" + sqlStringBody(tableComment) + "';");
|
||||
}
|
||||
for (ColumnInfo column : getColumns(schema, table)) {
|
||||
if (!notBlank(column.getComment()) || containsCommentOnColumn(result.toString(), schema, table, column.getName())) {
|
||||
for (Map.Entry<String, String> column : tableColumnComments(schema, table).entrySet()) {
|
||||
if (containsCommentOnColumn(result.toString(), schema, table, column.getKey())) {
|
||||
continue;
|
||||
}
|
||||
appendCommentStatement(result, "COMMENT ON COLUMN " + tableRef + "." + JdbcIdentifiers.INSTANCE.doubleQuote(column.getName()) + " IS '" + sqlStringBody(column.getComment()) + "';");
|
||||
appendCommentStatement(result, "COMMENT ON COLUMN " + tableRef + "." + JdbcIdentifiers.INSTANCE.doubleQuote(column.getKey()) + " IS '" + sqlStringBody(column.getValue()) + "';");
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private Map<String, String> tableColumnComments(String schema, String table) throws Exception {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ COLUMN_NAME, COMMENTS
|
||||
FROM ALL_COL_COMMENTS
|
||||
WHERE OWNER = ? AND TABLE_NAME = ? AND COMMENTS IS NOT NULL
|
||||
ORDER BY COLUMN_NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String column = rs.getString(1);
|
||||
String comment = rs.getString(2);
|
||||
if (notBlank(column) && notBlank(comment)) {
|
||||
result.put(column, comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String appendIndependentIndexDdl(String ddl, String schema, String table) throws Exception {
|
||||
StringBuilder result = new StringBuilder(ddl == null ? "" : ddl.trim());
|
||||
for (IndexInfo index : independentIndexes(schema, table)) {
|
||||
@@ -2729,6 +2895,84 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexes(String schema, String table) throws Exception {
|
||||
try {
|
||||
return independentIndexesFromSystemCatalog(schema, table);
|
||||
} catch (Exception systemCatalogError) {
|
||||
if (isDamengConnectionError(systemCatalogError)) {
|
||||
throw systemCatalogError;
|
||||
}
|
||||
try {
|
||||
return independentIndexesFromDictionaryViews(schema, table);
|
||||
} catch (Exception dictionaryViewError) {
|
||||
dictionaryViewError.addSuppressed(systemCatalogError);
|
||||
throw dictionaryViewError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexesFromSystemCatalog(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// DM's ALL_INDEXES view performs privilege filtering across the complete catalog and can
|
||||
// take tens of seconds in installations with very large index dictionaries (#10075).
|
||||
// The DM JDBC driver and DBeaver instead resolve the requested table id first and read its
|
||||
// SYS index rows directly. Keep the ALL_* implementation below as a compatibility and
|
||||
// permission fallback for deployments that do not expose these catalog tables.
|
||||
String sql = """
|
||||
SELECT /*+ PARALLEL(1) */ index_object.NAME,
|
||||
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
|
||||
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
|
||||
) AS COLUMNS,
|
||||
index_metadata.ISUNIQUE,
|
||||
index_metadata.TYPE$,
|
||||
index_metadata.FLAG
|
||||
FROM SYS.SYSOBJECTS schema_object
|
||||
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
|
||||
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
|
||||
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
|
||||
AND index_object.SUBTYPE$ = 'INDEX'
|
||||
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
|
||||
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
|
||||
AND SF_COL_IS_IDX_KEY(
|
||||
index_metadata.KEYNUM,
|
||||
index_metadata.KEYINFO,
|
||||
column_object.COLID
|
||||
) = 1
|
||||
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM SYS.SYSCONS constraint_metadata
|
||||
WHERE constraint_metadata.TABLEID = table_object.ID
|
||||
AND constraint_metadata.INDEXID = index_metadata.ID
|
||||
AND constraint_metadata.TYPE$ IN ('P', 'U')
|
||||
)
|
||||
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
|
||||
ORDER BY index_object.NAME
|
||||
""".stripIndent().trim();
|
||||
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
|
||||
stmt.setString(1, schema);
|
||||
stmt.setString(2, table);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String indexName = rs.getString(1);
|
||||
if (notBlank(indexName)) {
|
||||
result.add(new IndexInfo(
|
||||
indexName,
|
||||
splitNonEmpty(coalesce(rs.getString(2)), ","),
|
||||
"Y".equalsIgnoreCase(rs.getString(3)),
|
||||
false,
|
||||
null,
|
||||
damengSystemIndexType(rs.getString(4), rs.getInt(5)),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<IndexInfo> independentIndexesFromDictionaryViews(String schema, String table) throws Exception {
|
||||
List<IndexInfo> result = new ArrayList<>();
|
||||
// Primary-key and unique-constraint backing indexes are already represented in table DDL.
|
||||
String sql = """
|
||||
@@ -2774,6 +3018,13 @@ public final class DamengAgent extends AbstractJdbcAgent {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String damengSystemIndexType(String catalogType, int flags) {
|
||||
if ((flags & 3) != 0) {
|
||||
return "INTERNAL";
|
||||
}
|
||||
return "ST".equalsIgnoreCase(coalesce(catalogType).trim()) ? "SPATIAL" : catalogType;
|
||||
}
|
||||
|
||||
static String indexDdl(String schema, String table, IndexInfo index) {
|
||||
StringBuilder ddl = new StringBuilder("CREATE ");
|
||||
if (index.getIs_unique()) {
|
||||
|
||||
+349
-13
@@ -21,7 +21,9 @@ import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLNonTransientConnectionException;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
@@ -111,6 +113,80 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertTrue(columnsSql.startsWith("SELECT /*+ PARALLEL(1) */"), columnsSql);
|
||||
}
|
||||
|
||||
// #10221:dbx 在编辑器里的表没有选 schema 时,会以「不带 schema」的形态请求元数据
|
||||
// (oracle / oceanbase-oracle 同款约定)。DM 会按会话当前 schema 解析未限定名,所以
|
||||
// 这里必须先把空 schema 解析成当前 schema,否则会按 OWNER = '' 去查,列注释永远拿不到。
|
||||
@Test
|
||||
void resolvesBlankColumnSchemaFromTheSessionBeforeReadingComments() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sessionQueries = new ArrayList<>();
|
||||
List<String> boundOwners = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("", "USERS");
|
||||
|
||||
Assertions.assertTrue(
|
||||
sessionQueries.stream().anyMatch(sql -> sql.contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")),
|
||||
sessionQueries.toString()
|
||||
);
|
||||
Assertions.assertTrue(boundOwners.contains("SYSDBA"), boundOwners.toString());
|
||||
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
|
||||
Assertions.assertEquals(1, columns.size());
|
||||
Assertions.assertEquals("id comment", columns.get(0).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesBlankTableSchemaFromTheSessionBeforeListingTables() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sessionQueries = new ArrayList<>();
|
||||
List<String> boundOwners = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
|
||||
|
||||
List<TableInfo> tables = agent.listTables("");
|
||||
|
||||
Assertions.assertTrue(boundOwners.contains("SYSDBA"), boundOwners.toString());
|
||||
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
|
||||
Assertions.assertEquals(List.of("USERS"), tables.stream().map(TableInfo::getName).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesBlankSchemaToTheConnectedUserWhenTheSessionCannotReportIt() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
setConnectedUsername(agent, "DBXUSER");
|
||||
List<String> sessionQueries = new ArrayList<>();
|
||||
List<String> boundOwners = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, sessionSchemaConnection(null, sessionQueries, boundOwners));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("", "USERS");
|
||||
|
||||
Assertions.assertTrue(boundOwners.contains("DBXUSER"), boundOwners.toString());
|
||||
Assertions.assertFalse(boundOwners.contains(""), boundOwners.toString());
|
||||
Assertions.assertEquals(1, columns.size());
|
||||
Assertions.assertEquals("id comment", columns.get(0).getComment());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAnExplicitSchemaWithoutConsultingTheSession() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sessionQueries = new ArrayList<>();
|
||||
List<String> boundOwners = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, sessionSchemaConnection("SYSDBA", sessionQueries, boundOwners));
|
||||
|
||||
agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(boundOwners.contains("APP"), boundOwners.toString());
|
||||
Assertions.assertTrue(sessionQueries.isEmpty(), sessionQueries.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveMetadataSchemaPrefersTheSessionSchemaThenTheConnectedUser() {
|
||||
Assertions.assertEquals("CURRENT_SCH", DamengAgent.effectiveMetadataSchema("CURRENT_SCH", "CONNECTED"));
|
||||
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema("", "CONNECTED"));
|
||||
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema(" ", "CONNECTED"));
|
||||
Assertions.assertEquals("CONNECTED", DamengAgent.effectiveMetadataSchema(null, "CONNECTED"));
|
||||
Assertions.assertEquals("", DamengAgent.effectiveMetadataSchema(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disablesParallelExecutionForIndexMetadataQuery() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -1651,7 +1727,12 @@ class DamengAgentMetadataTest {
|
||||
Arrays.asList("DEFAULT_VALUE", "VARCHAR2", "Y", null, null, 80, 20, null, "default length"),
|
||||
Arrays.asList("WORD_VALUE", "VARCHAR2", "Y", null, null, 128, 32, "BYTE", "word byte length"),
|
||||
Arrays.asList("NATIONAL_VALUE", "NVARCHAR2", "Y", null, null, 80, 20, "C", "national length"),
|
||||
Arrays.asList("NATIONAL_FIXED", "NCHAR", "Y", null, null, 40, 10, "B", "national fixed length")
|
||||
Arrays.asList("NATIONAL_FIXED", "NCHAR", "Y", null, null, 40, 10, "B", "national fixed length"),
|
||||
// DM8 reports CHAR_USED as 'B' for byte- and character-length columns alike and
|
||||
// records the declared unit in SCALE instead.
|
||||
Arrays.asList("SCALE_CHAR_VAR", "VARCHAR2", "Y", null, 7, 40, 10, "B", "dameng char unit"),
|
||||
Arrays.asList("SCALE_CHAR_FIXED", "CHAR", "Y", null, 8, 10, 10, "B", "dameng fixed char unit"),
|
||||
Arrays.asList("SCALE_BYTE_VAR", "VARCHAR2", "Y", null, 0, 10, 10, "B", "dameng byte unit")
|
||||
)));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
@@ -1663,18 +1744,49 @@ class DamengAgentMetadataTest {
|
||||
Assertions.assertEquals("VARCHAR2(128 BYTE)", columns.get(4).getData_type());
|
||||
Assertions.assertEquals("NVARCHAR2(20)", columns.get(5).getData_type());
|
||||
Assertions.assertEquals("NCHAR(10)", columns.get(6).getData_type());
|
||||
Assertions.assertEquals("VARCHAR2(10 CHAR)", columns.get(7).getData_type());
|
||||
Assertions.assertEquals("CHAR(10 CHAR)", columns.get(8).getData_type());
|
||||
Assertions.assertEquals("VARCHAR2(10 BYTE)", columns.get(9).getData_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsDeclaredLengthWhenAllTabColumnsHasNoCharacterLength() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnectionForColumns(
|
||||
List.of(
|
||||
// DM8 leaves CHAR_LENGTH at 0 for NVARCHAR2 and reports the byte length in
|
||||
// DATA_LENGTH, so the declared character length has to come from SYSCOLUMNS.
|
||||
Arrays.asList("NATIONAL_VALUE", "NVARCHAR2", "Y", null, 7, 400, 0, null, "national length"),
|
||||
Arrays.asList("BIG_CHAR_VALUE", "VARCHAR2", "Y", null, 7, 800, 0, "B", "char unit from scale")
|
||||
),
|
||||
List.of(Arrays.asList("NATIONAL_VALUE", 100), Arrays.asList("BIG_CHAR_VALUE", 200))
|
||||
));
|
||||
|
||||
List<ColumnInfo> columns = agent.getColumns("APP", "USERS");
|
||||
|
||||
Assertions.assertEquals("NVARCHAR2(100)", columns.get(0).getData_type());
|
||||
Assertions.assertEquals("VARCHAR2(200 CHAR)", columns.get(1).getData_type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsTableAndColumnCommentsToTableDdl() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnection());
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnection(
|
||||
"id comment",
|
||||
null,
|
||||
false,
|
||||
List.of(),
|
||||
sqls
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(ddl.contains("CREATE TABLE \"APP\".\"USERS\""), ddl);
|
||||
Assertions.assertTrue(ddl.contains("COMMENT ON TABLE \"APP\".\"USERS\" IS '用户示例表';"), ddl);
|
||||
Assertions.assertTrue(ddl.contains("COMMENT ON COLUMN \"APP\".\"USERS\".\"ID\" IS 'id comment';"), ddl);
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_COL_COMMENTS")), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONS_COLUMNS")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1786,13 +1898,74 @@ class DamengAgentMetadataTest {
|
||||
ddl.contains("CREATE UNIQUE INDEX \"APP\".\"UX_USERS_EMAIL\" ON \"APP\".\"USERS\" (\"EMAIL\");"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("CREATE SPATIAL INDEX \"APP\".\"IDX_USERS_GEO\" ON \"APP\".\"USERS\" (\"GEO\");"),
|
||||
ddl
|
||||
);
|
||||
Assertions.assertFalse(ddl.contains("PK_USERS"), ddl);
|
||||
String indexSql = sqls.stream().filter(sql -> sql.contains("ALL_INDEXES")).findFirst().orElseThrow();
|
||||
Assertions.assertTrue(indexSql.contains("CONSTRAINT_TYPE IN ('P', 'U')"), indexSql);
|
||||
String indexSql = sqls.stream().filter(sql -> sql.contains("SYS.SYSINDEXES")).findFirst().orElseThrow();
|
||||
Assertions.assertTrue(indexSql.contains("constraint_metadata.TYPE$ IN ('P', 'U')"), indexSql);
|
||||
Assertions.assertTrue(indexSql.contains("schema_object.NAME = ?"), indexSql);
|
||||
Assertions.assertTrue(indexSql.contains("table_object.NAME = ?"), indexSql);
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
|
||||
long dbmsMetadataCalls = sqls.stream().filter(sql -> sql.contains("DBMS_METADATA.GET_DDL")).count();
|
||||
Assertions.assertEquals(1, dbmsMetadataCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDictionaryViewsWhenSystemIndexCatalogIsUnavailable() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnection(
|
||||
"id comment",
|
||||
null,
|
||||
false,
|
||||
List.of(indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0)),
|
||||
sqls,
|
||||
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
|
||||
defaultColumnMetadataRows("id comment"),
|
||||
null,
|
||||
new SQLException("no SYS index catalog privilege")
|
||||
));
|
||||
|
||||
String ddl = agent.getTableDdl("APP", "USERS");
|
||||
|
||||
Assertions.assertTrue(
|
||||
ddl.contains("CREATE INDEX \"APP\".\"IDX_USERS_NAME\" ON \"APP\".\"USERS\" (\"NAME\");"),
|
||||
ddl
|
||||
);
|
||||
int systemQuery = indexOfSql(sqls, "SYS.SYSINDEXES");
|
||||
int fallbackQuery = indexOfSql(sqls, "ALL_INDEXES");
|
||||
Assertions.assertTrue(systemQuery >= 0, String.join("\n", sqls));
|
||||
Assertions.assertTrue(fallbackQuery > systemQuery, String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRetryIndexCatalogAfterConnectionFailure() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
List<String> sqls = new ArrayList<>();
|
||||
TestSupport.setPrivateConnection(agent, metadataConnection(
|
||||
"id comment",
|
||||
null,
|
||||
false,
|
||||
List.of(),
|
||||
sqls,
|
||||
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
|
||||
defaultColumnMetadataRows("id comment"),
|
||||
null,
|
||||
new SQLNonTransientConnectionException("connection lost")
|
||||
));
|
||||
|
||||
RuntimeException error = Assertions.assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> agent.getTableDdl("APP", "USERS")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("connection lost", error.getCause().getMessage());
|
||||
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSINDEXES")), String.join("\n", sqls));
|
||||
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsDamengInternalIndexesWhenAppendingTableDdl() {
|
||||
DamengAgent agent = new DamengAgent();
|
||||
@@ -1802,8 +1975,8 @@ class DamengAgentMetadataTest {
|
||||
null,
|
||||
false,
|
||||
List.of(
|
||||
indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"),
|
||||
indexRow("SYS_INTERNAL_DDL", "ID", "NONUNIQUE", "INNER CLUSTER INDEX")
|
||||
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
|
||||
indexRow("SYS_INTERNAL_DDL", "ID", "N", "BT", 1)
|
||||
),
|
||||
sqls
|
||||
));
|
||||
@@ -2003,8 +2176,9 @@ class DamengAgentMetadataTest {
|
||||
null,
|
||||
false,
|
||||
List.of(
|
||||
indexRow("IDX_USERS_NAME", "NAME", "NONUNIQUE", "NORMAL"),
|
||||
indexRow("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "NORMAL")
|
||||
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
|
||||
indexRow("UX_USERS_EMAIL", "EMAIL", "Y", "BT", 0),
|
||||
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0)
|
||||
),
|
||||
sqls
|
||||
);
|
||||
@@ -2053,6 +2227,13 @@ class DamengAgentMetadataTest {
|
||||
}
|
||||
|
||||
private static Connection metadataConnectionForColumns(List<List<Object>> columnRows) {
|
||||
return metadataConnectionForColumns(columnRows, null);
|
||||
}
|
||||
|
||||
private static Connection metadataConnectionForColumns(
|
||||
List<List<Object>> columnRows,
|
||||
List<List<Object>> declaredColumnLengths
|
||||
) {
|
||||
return metadataConnection(
|
||||
"id comment",
|
||||
null,
|
||||
@@ -2060,7 +2241,10 @@ class DamengAgentMetadataTest {
|
||||
List.of(),
|
||||
null,
|
||||
"CREATE TABLE \"APP\".\"USERS\" (\n \"ID\" NUMBER\n);",
|
||||
columnRows
|
||||
columnRows,
|
||||
null,
|
||||
null,
|
||||
declaredColumnLengths
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2094,6 +2278,56 @@ class DamengAgentMetadataTest {
|
||||
String dbmsMetadataDdl,
|
||||
List<List<Object>> columnRows,
|
||||
String dbmsMetadataError
|
||||
) {
|
||||
return metadataConnection(
|
||||
allColumnComment,
|
||||
fallbackColumnComment,
|
||||
includeMaterializedView,
|
||||
independentIndexes,
|
||||
sqls,
|
||||
dbmsMetadataDdl,
|
||||
columnRows,
|
||||
dbmsMetadataError,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection metadataConnection(
|
||||
String allColumnComment,
|
||||
String fallbackColumnComment,
|
||||
boolean includeMaterializedView,
|
||||
List<List<Object>> independentIndexes,
|
||||
List<String> sqls,
|
||||
String dbmsMetadataDdl,
|
||||
List<List<Object>> columnRows,
|
||||
String dbmsMetadataError,
|
||||
SQLException systemIndexError
|
||||
) {
|
||||
return metadataConnection(
|
||||
allColumnComment,
|
||||
fallbackColumnComment,
|
||||
includeMaterializedView,
|
||||
independentIndexes,
|
||||
sqls,
|
||||
dbmsMetadataDdl,
|
||||
columnRows,
|
||||
dbmsMetadataError,
|
||||
systemIndexError,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private static Connection metadataConnection(
|
||||
String allColumnComment,
|
||||
String fallbackColumnComment,
|
||||
boolean includeMaterializedView,
|
||||
List<List<Object>> independentIndexes,
|
||||
List<String> sqls,
|
||||
String dbmsMetadataDdl,
|
||||
List<List<Object>> columnRows,
|
||||
String dbmsMetadataError,
|
||||
SQLException systemIndexError,
|
||||
List<List<Object>> declaredColumnLengths
|
||||
) {
|
||||
boolean[] dbmsMetadataResultOpen = {false};
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
@@ -2115,9 +2349,18 @@ class DamengAgentMetadataTest {
|
||||
if (sql.startsWith("SELECT NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH'")) {
|
||||
return metadataStatement(List.of(List.of("APP"), List.of("EMPTY_SCHEMA"), List.of("SYSDBA")));
|
||||
}
|
||||
if (sql.contains("SYS.SYSINDEXES")) {
|
||||
if (systemIndexError != null) {
|
||||
return failingMetadataStatement(systemIndexError);
|
||||
}
|
||||
return metadataStatement(independentIndexes);
|
||||
}
|
||||
if (sql.contains("ALL_CONS_COLUMNS")) {
|
||||
return metadataStatement(List.of(List.of("ID")));
|
||||
}
|
||||
if (sql.contains("SYS.SYSCOLUMNS") && sql.contains("LENGTH$")) {
|
||||
return metadataStatement(declaredColumnLengths == null ? List.of() : declaredColumnLengths);
|
||||
}
|
||||
if (sql.contains("SYS.SYSCOLUMNS")) {
|
||||
return metadataStatement(List.of(List.of("ID")));
|
||||
}
|
||||
@@ -2158,7 +2401,7 @@ class DamengAgentMetadataTest {
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
if (sql.contains("ALL_INDEXES")) {
|
||||
return metadataStatement(independentIndexes);
|
||||
return metadataStatement(dictionaryIndexRows(independentIndexes));
|
||||
}
|
||||
if (sql.contains("ALL_TAB_COMMENTS")) {
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
@@ -2186,7 +2429,9 @@ class DamengAgentMetadataTest {
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
if (sql.contains("ALL_COL_COMMENTS") && !sql.contains("ALL_TAB_COLUMNS")) {
|
||||
return metadataStatement(List.of());
|
||||
return metadataStatement(
|
||||
allColumnComment == null ? List.of() : List.of(List.of("ID", allColumnComment))
|
||||
);
|
||||
}
|
||||
if (sql.contains("ALL_TAB_COLUMNS")) {
|
||||
return metadataStatement(columnRows);
|
||||
@@ -2234,8 +2479,26 @@ class DamengAgentMetadataTest {
|
||||
});
|
||||
}
|
||||
|
||||
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType) {
|
||||
return List.of(name, columns, uniqueness, indexType);
|
||||
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType, int flags) {
|
||||
return List.of(name, columns, uniqueness, indexType, flags);
|
||||
}
|
||||
|
||||
private static List<List<Object>> dictionaryIndexRows(List<List<Object>> systemRows) {
|
||||
return systemRows.stream().map(row -> List.of(
|
||||
row.get(0),
|
||||
row.get(1),
|
||||
"Y".equals(row.get(2)) ? "UNIQUE" : "NONUNIQUE",
|
||||
"ST".equals(row.get(3)) ? "SPATIAL" : "NORMAL"
|
||||
)).toList();
|
||||
}
|
||||
|
||||
private static int indexOfSql(List<String> sqls, String fragment) {
|
||||
for (int i = 0; i < sqls.size(); i++) {
|
||||
if (sqls.get(i).contains(fragment)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static PreparedStatement dbmsMetadataStatement(String ddl, boolean[] resultOpen) {
|
||||
@@ -2699,6 +2962,75 @@ class DamengAgentMetadataTest {
|
||||
});
|
||||
}
|
||||
|
||||
// Serves the session-schema probe, records every first bound parameter (the OWNER of the
|
||||
// metadata queries under test) and answers the column/table queries these tests assert on.
|
||||
private static Connection sessionSchemaConnection(
|
||||
String currentSchema,
|
||||
List<String> sessionQueries,
|
||||
List<String> boundOwners
|
||||
) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("createStatement".equals(name)) {
|
||||
return proxy(Statement.class, (statementMethod, statementArgs) -> {
|
||||
String statementName = statementMethod.getName();
|
||||
if ("executeQuery".equals(statementName)) {
|
||||
String sql = (String) statementArgs[0];
|
||||
sessionQueries.add(sql);
|
||||
if (sql.contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")) {
|
||||
if (currentSchema == null) {
|
||||
throw new SQLException("SYS_CONTEXT is not supported");
|
||||
}
|
||||
return metadataResultSet(List.of(List.of(currentSchema)));
|
||||
}
|
||||
return metadataResultSet(List.of());
|
||||
}
|
||||
if ("close".equals(statementName)) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
}
|
||||
if ("prepareStatement".equals(name)) {
|
||||
String sql = (String) args[0];
|
||||
if (sql.contains("ALL_TAB_COLUMNS")) {
|
||||
return ownerRecordingStatement(defaultColumnMetadataRows("id comment"), boundOwners);
|
||||
}
|
||||
if (sql.contains("ALL_OBJECTS") && sql.contains("ALL_TAB_COMMENTS")) {
|
||||
return ownerRecordingStatement(List.of(Arrays.asList("USERS", "TABLE", "用户示例表")), boundOwners);
|
||||
}
|
||||
return metadataStatement(List.of());
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("isClosed".equals(name)) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement ownerRecordingStatement(List<List<Object>> rows, List<String> owners) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("executeQuery".equals(name)) {
|
||||
return metadataResultSet(rows);
|
||||
}
|
||||
if (("setString".equals(name) || "setObject".equals(name)) && Integer.valueOf(1).equals(args[0])) {
|
||||
owners.add(args[1] == null ? null : String.valueOf(args[1]));
|
||||
return null;
|
||||
}
|
||||
if ("setString".equals(name) || "setObject".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement metadataStatement(List<List<Object>> rows) {
|
||||
return metadataStatement(rows, null);
|
||||
}
|
||||
@@ -2761,6 +3093,10 @@ class DamengAgentMetadataTest {
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
if ("getInt".equals(name) && args[0] instanceof Integer columnIndex) {
|
||||
Object value = rows.get(index[0]).get(columnIndex - 1);
|
||||
return value instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
if ("getCharacterStream".equals(name) && args[0] instanceof Integer columnIndex) {
|
||||
Object value = rows.get(index[0]).get(columnIndex - 1);
|
||||
if (value instanceof LongText longText) {
|
||||
|
||||
Generated
+227
-7
@@ -1316,6 +1316,168 @@ dependencies = [
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc",
|
||||
"dbx-platform",
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"flate2",
|
||||
"futures",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"zip 4.6.1",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-elasticsearch"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-mongodb"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"json5",
|
||||
"log",
|
||||
"mongodb",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-mysql"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"log",
|
||||
"mysql_async",
|
||||
"percent-encoding",
|
||||
"rust_decimal",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-postgres"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
"percent-encoding",
|
||||
"postgres-openssl",
|
||||
"rustls 0.23.42",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-redis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-sqlserver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-driver-support",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-types",
|
||||
"futures",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-driver-support"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dbx-platform",
|
||||
"dbx-types",
|
||||
"portpicker",
|
||||
"reqwest",
|
||||
"russh",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-drivers"
|
||||
version = "0.1.0"
|
||||
@@ -1325,8 +1487,18 @@ dependencies = [
|
||||
"chrono",
|
||||
"crc",
|
||||
"csv",
|
||||
"dbx-driver-agent",
|
||||
"dbx-driver-elasticsearch",
|
||||
"dbx-driver-mongodb",
|
||||
"dbx-driver-mysql",
|
||||
"dbx-driver-postgres",
|
||||
"dbx-driver-redis",
|
||||
"dbx-driver-sqlserver",
|
||||
"dbx-driver-support",
|
||||
"dbx-platform",
|
||||
"dbx-sql",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-sqlite-worker",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
@@ -1334,14 +1506,12 @@ dependencies = [
|
||||
"futures",
|
||||
"json5",
|
||||
"log",
|
||||
"mongodb",
|
||||
"mysql_async",
|
||||
"openssl",
|
||||
"pageant",
|
||||
"percent-encoding",
|
||||
"portpicker",
|
||||
"postgres-openssl",
|
||||
"redis",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
@@ -1374,6 +1544,7 @@ name = "dbx-duckdb-driver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"dbx-core",
|
||||
"duckdb",
|
||||
"log",
|
||||
@@ -1434,23 +1605,72 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "dbx-sql"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-data",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-sql-schema",
|
||||
"dbx-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-types",
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"unicode-ident",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-data"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlparser",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-dialect"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dbx-types",
|
||||
"log",
|
||||
"notify",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbx-sql-schema"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-sql-dialect",
|
||||
"dbx-types",
|
||||
"log",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sqlparser",
|
||||
"tokio",
|
||||
"unicode-ident",
|
||||
"unicode-width",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -24,6 +24,7 @@ test = false
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
duckdb = { version = "1.3.2", features = ["bundled", "parquet"] }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(clippy::items_after_test_module)]
|
||||
|
||||
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Offset, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use duckdb::core::LogicalTypeId;
|
||||
use duckdb::types::{TimeUnit, Value, ValueRef};
|
||||
|
||||
use crate::wire as db;
|
||||
@@ -179,6 +181,53 @@ mod tests {
|
||||
]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_preserves_timezone_marker_only_for_timestamptz() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
con.execute_batch("SET TimeZone = 'Asia/Shanghai'").expect("set session timezone");
|
||||
con.execute_batch("CREATE VIEW events AS SELECT TIMESTAMPTZ '2025-02-10 20:37:56+00' AS occurred_at")
|
||||
.expect("create view");
|
||||
|
||||
let result = duckdb_execute(
|
||||
&con,
|
||||
"SELECT occurred_at, occurred_at::VARCHAR AS session_value, \
|
||||
TIMESTAMP '2025-02-10 20:37:56' AS naive_value, \
|
||||
NULL::TIMESTAMPTZ AS missing_value, \
|
||||
TIMESTAMPTZ '2025-02-10 20:37:56.123456+00' AS precise_value FROM events",
|
||||
)
|
||||
.expect("query view with temporal values");
|
||||
|
||||
assert_eq!(result.rows[0][0], serde_json::json!("2025-02-11 04:37:56+08"));
|
||||
assert_eq!(result.rows[0][1], serde_json::json!("2025-02-11 04:37:56+08"));
|
||||
assert_eq!(result.rows[0][2], serde_json::json!("2025-02-10 20:37:56"));
|
||||
assert_eq!(result.rows[0][3], serde_json::Value::Null);
|
||||
assert_eq!(result.rows[0][4], serde_json::json!("2025-02-11 04:37:56.123456+08"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_execute_uses_dst_offset_for_each_instant() {
|
||||
let con = duckdb::Connection::open_in_memory().expect("connect in-memory DuckDB");
|
||||
con.execute_batch("SET TimeZone = 'America/New_York'").expect("set session timezone");
|
||||
let result = duckdb_execute(
|
||||
&con,
|
||||
"SELECT TIMESTAMPTZ '2025-01-15 12:00:00+00' AS winter, TIMESTAMPTZ '2025-07-15 12:00:00+00' AS summer",
|
||||
)
|
||||
.expect("execute timestamptz query");
|
||||
|
||||
assert_eq!(result.rows[0][0], serde_json::json!("2025-01-15 07:00:00-05"));
|
||||
assert_eq!(result.rows[0][1], serde_json::json!("2025-07-15 08:00:00-04"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duckdb_session_timezone_accepts_fixed_offsets() {
|
||||
assert!(DuckDbSessionTimezone::parse("+08").is_some());
|
||||
assert!(DuckDbSessionTimezone::parse("-05:30").is_some());
|
||||
assert!(DuckDbSessionTimezone::parse("+24").is_none());
|
||||
assert_eq!(format_utc_offset(0), "+00");
|
||||
assert_eq!(format_utc_offset(8 * 3600), "+08");
|
||||
assert_eq!(format_utc_offset(-(5 * 3600 + 30 * 60)), "-05:30");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -186,7 +235,12 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryRe
|
||||
duckdb_execute_with_max_rows(con, sql, None)
|
||||
}
|
||||
|
||||
fn duckdb_value_to_json(row: &duckdb::Row<'_>, idx: usize) -> serde_json::Value {
|
||||
fn duckdb_value_to_json(
|
||||
row: &duckdb::Row<'_>,
|
||||
idx: usize,
|
||||
timestamptz: bool,
|
||||
session_timezone: Option<DuckDbSessionTimezone>,
|
||||
) -> serde_json::Value {
|
||||
let Ok(value_ref) = row.get_ref(idx) else {
|
||||
return serde_json::Value::Null;
|
||||
};
|
||||
@@ -216,7 +270,12 @@ fn duckdb_value_to_json(row: &duckdb::Row<'_>, idx: usize) -> serde_json::Value
|
||||
duckdb_time64_to_string(unit, value).map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
ValueRef::Timestamp(unit, value) => {
|
||||
duckdb_timestamp_to_string(unit, value).map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
|
||||
let formatted = if timestamptz {
|
||||
duckdb_timestamptz_to_string(unit, value, session_timezone)
|
||||
} else {
|
||||
duckdb_timestamp_to_string(unit, value)
|
||||
};
|
||||
formatted.map(serde_json::Value::String).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
ValueRef::Text(bytes) => std::str::from_utf8(bytes)
|
||||
.map(|s| serde_json::Value::String(s.to_string()))
|
||||
@@ -353,6 +412,87 @@ fn duckdb_time64_to_string(unit: TimeUnit, value: i64) -> Option<String> {
|
||||
Some(format_temporal_without_empty_fraction(time.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum DuckDbSessionTimezone {
|
||||
Zone(Tz),
|
||||
Fixed(FixedOffset),
|
||||
}
|
||||
|
||||
impl DuckDbSessionTimezone {
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
let name = name.trim();
|
||||
if let Ok(zone) = name.parse::<Tz>() {
|
||||
return Some(Self::Zone(zone));
|
||||
}
|
||||
parse_fixed_utc_offset(name).map(Self::Fixed)
|
||||
}
|
||||
|
||||
fn to_local(self, instant: DateTime<Utc>) -> (NaiveDateTime, i32) {
|
||||
match self {
|
||||
Self::Zone(zone) => {
|
||||
let local = instant.with_timezone(&zone);
|
||||
(local.naive_local(), local.offset().fix().local_minus_utc())
|
||||
}
|
||||
Self::Fixed(offset) => {
|
||||
let local = instant.with_timezone(&offset);
|
||||
(local.naive_local(), local.offset().fix().local_minus_utc())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_fixed_utc_offset(value: &str) -> Option<FixedOffset> {
|
||||
let (sign, rest) = match value.as_bytes().first()? {
|
||||
b'+' => (1, &value[1..]),
|
||||
b'-' => (-1, &value[1..]),
|
||||
_ => return None,
|
||||
};
|
||||
let (hours, minutes) = match rest.split_once(':') {
|
||||
Some((hours, minutes)) => (hours.parse::<i32>().ok()?, minutes.parse::<i32>().ok()?),
|
||||
None if rest.len() == 4 => (rest[..2].parse::<i32>().ok()?, rest[2..].parse::<i32>().ok()?),
|
||||
None if rest.len() <= 2 => (rest.parse::<i32>().ok()?, 0),
|
||||
None => return None,
|
||||
};
|
||||
if !(0..=23).contains(&hours) || !(0..=59).contains(&minutes) {
|
||||
return None;
|
||||
}
|
||||
FixedOffset::east_opt(sign * (hours * 3600 + minutes * 60))
|
||||
}
|
||||
|
||||
fn duckdb_session_timezone(con: &duckdb::Connection) -> Option<DuckDbSessionTimezone> {
|
||||
let name: String = con.query_row("SELECT current_setting('TimeZone')", [], |row| row.get(0)).ok()?;
|
||||
DuckDbSessionTimezone::parse(&name)
|
||||
}
|
||||
|
||||
fn duckdb_timestamptz_to_string(
|
||||
unit: TimeUnit,
|
||||
value: i64,
|
||||
session_timezone: Option<DuckDbSessionTimezone>,
|
||||
) -> Option<String> {
|
||||
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
|
||||
let seconds = nanos.div_euclid(1_000_000_000);
|
||||
let nanos_remainder = nanos.rem_euclid(1_000_000_000) as u32;
|
||||
let instant: DateTime<Utc> = DateTime::from_timestamp(seconds, nanos_remainder)?;
|
||||
Some(match session_timezone {
|
||||
Some(session_timezone) => {
|
||||
let (local, offset_seconds) = session_timezone.to_local(instant);
|
||||
format!("{}{}", format_naive_datetime(local), format_utc_offset(offset_seconds))
|
||||
}
|
||||
None => format!("{}+00", format_naive_datetime(instant.naive_utc())),
|
||||
})
|
||||
}
|
||||
|
||||
fn format_utc_offset(offset_seconds: i32) -> String {
|
||||
let sign = if offset_seconds < 0 { '-' } else { '+' };
|
||||
let total_minutes = offset_seconds.unsigned_abs() / 60;
|
||||
let (hours, minutes) = (total_minutes / 60, total_minutes % 60);
|
||||
if minutes == 0 {
|
||||
format!("{sign}{hours:02}")
|
||||
} else {
|
||||
format!("{sign}{hours:02}:{minutes:02}")
|
||||
}
|
||||
}
|
||||
|
||||
fn duckdb_timestamp_to_string(unit: TimeUnit, value: i64) -> Option<String> {
|
||||
let nanos = duckdb_time_unit_to_nanos(unit, value)?;
|
||||
let seconds = nanos.div_euclid(1_000_000_000);
|
||||
@@ -397,6 +537,8 @@ pub fn duckdb_execute_with_max_rows(
|
||||
let sql = sql.as_ref();
|
||||
|
||||
if crate::sql::starts_with_duckdb_result_sql_keyword(sql) {
|
||||
// DuckDB cannot answer another query while a result set is being streamed.
|
||||
let session_timezone = duckdb_session_timezone(con);
|
||||
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
|
||||
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||
let stmt_ref = rows.as_ref().ok_or("DuckDB statement unavailable")?;
|
||||
@@ -404,10 +546,14 @@ pub fn duckdb_execute_with_max_rows(
|
||||
let columns: Vec<String> = (0..col_count)
|
||||
.map(|i| stmt_ref.column_name(i).map(|s| s.to_string()).unwrap_or_else(|_| "?".to_string()))
|
||||
.collect();
|
||||
let timestamptz_columns: Vec<bool> =
|
||||
(0..col_count).map(|i| stmt_ref.column_logical_type(i).id() == LogicalTypeId::TimestampTZ).collect();
|
||||
|
||||
let mut result_rows = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
let vals: Vec<serde_json::Value> = (0..col_count).map(|i| duckdb_value_to_json(row, i)).collect();
|
||||
let vals: Vec<serde_json::Value> = (0..col_count)
|
||||
.map(|i| duckdb_value_to_json(row, i, timestamptz_columns[i], session_timezone))
|
||||
.collect();
|
||||
result_rows.push(vals);
|
||||
if result_rows.len() > row_limit {
|
||||
break;
|
||||
|
||||
@@ -35,7 +35,9 @@ The following URL parameters are supported:
|
||||
- `time_zone`
|
||||
- `connect_retry_max`
|
||||
- `connect_timeout_ms`
|
||||
- `enable_compression=true`
|
||||
- `enable_compression=true` (also accepts `rpc_compression` and the IoTDB JDBC
|
||||
alias `rpc_compress`; when conflicting aliases are present, they take
|
||||
precedence in that order)
|
||||
- `node_urls=host1:6667,host2:6667` for cluster sessions
|
||||
- `ssl=true` and `insecure_skip_verify=true`
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ func parseConnectionConfig(params connectParams) (connectionConfig, error) {
|
||||
}
|
||||
config.ConnectTimeoutMS = parsed
|
||||
}
|
||||
config.EnableCompression = queryBool(query, "enable_compression", "rpc_compression")
|
||||
config.EnableCompression = queryBool(query, "enable_compression", "rpc_compression", "rpc_compress")
|
||||
config.NodeURLs = parseNodeURLs(firstQueryValue(query, "node_urls", "nodes"))
|
||||
if len(config.NodeURLs) == 0 {
|
||||
config.NodeURLs = []string{net.JoinHostPort(config.Host, strconv.Itoa(config.Port))}
|
||||
|
||||
@@ -70,6 +70,56 @@ func TestParseConnectionConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConnectionConfigCompressionAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connectionString string
|
||||
urlParams string
|
||||
want bool
|
||||
}{
|
||||
{name: "default disabled"},
|
||||
{name: "documented enabled", urlParams: "enable_compression=true", want: true},
|
||||
{name: "documented disabled", urlParams: "enable_compression=false"},
|
||||
{name: "existing alias enabled", urlParams: "rpc_compression=true", want: true},
|
||||
{name: "existing alias disabled", urlParams: "rpc_compression=false"},
|
||||
{name: "JDBC alias enabled in URL parameters", urlParams: "rpc_compress=true", want: true},
|
||||
{name: "JDBC alias false", urlParams: "rpc_compress=false"},
|
||||
{
|
||||
name: "JDBC alias enabled in connection string",
|
||||
connectionString: "jdbc:iotdb://localhost:6667/?rpc_compress=true",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "URL parameters override same connection string key",
|
||||
connectionString: "jdbc:iotdb://localhost:6667/?rpc_compress=true",
|
||||
urlParams: "rpc_compress=false",
|
||||
},
|
||||
{
|
||||
name: "documented option takes precedence",
|
||||
urlParams: "enable_compression=false&rpc_compression=true&rpc_compress=true",
|
||||
},
|
||||
{
|
||||
name: "existing alias takes precedence over JDBC alias",
|
||||
urlParams: "rpc_compression=false&rpc_compress=true",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config, err := parseConnectionConfig(connectParams{
|
||||
ConnectionString: test.connectionString,
|
||||
URLParams: test.urlParams,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.EnableCompression != test.want {
|
||||
t.Fatalf("EnableCompression = %t, want %t", config.EnableCompression, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundledTimeZoneDatabase(t *testing.T) {
|
||||
dataSet, err := client.NewIoTDBRpcDataSet(
|
||||
"", nil, nil, nil, true, false, 0, 0, nil, 0, nil, 0, nil,
|
||||
|
||||
@@ -78,6 +78,15 @@ public final class IrisAgent extends ConfiguredJdbcAgent {
|
||||
Object value = rs.getBigDecimal(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.BIT:
|
||||
case Types.BOOLEAN:
|
||||
// IRIS has no boolean type: %Boolean is stored as 1/0 and only
|
||||
// the JDBC driver turns it into a Java boolean, which would
|
||||
// surface as true/false in the grid. Report the stored 1/0.
|
||||
return unchecked(() -> {
|
||||
int value = rs.getInt(index);
|
||||
return rs.wasNull() ? null : value;
|
||||
});
|
||||
case Types.OTHER:
|
||||
return unchecked(() -> JdbcExecutor.normalizeResultValue(rs.getObject(index)));
|
||||
default:
|
||||
|
||||
@@ -147,6 +147,47 @@ class IrisAgentTest {
|
||||
assertEquals(Collections.singletonList("getObject"), calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBitColumnsAsStoredNumbers() {
|
||||
for (int sqlType : new int[] {Types.BIT, Types.BOOLEAN}) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getInt".equals(method.getName())) {
|
||||
calls.add("getInt");
|
||||
return 1;
|
||||
}
|
||||
if (method.getName().startsWith("get")) {
|
||||
calls.add(method.getName());
|
||||
throw new AssertionError("IRIS %Boolean must not be read as a Java boolean");
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return false;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
Object value = new IrisAgent().resultValue(resultSet, 1, sqlType);
|
||||
|
||||
assertEquals(1, value);
|
||||
assertEquals(Collections.singletonList("getInt"), calls);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesNullForBitColumns() {
|
||||
ResultSet resultSet = proxy(ResultSet.class, (method, args) -> {
|
||||
if ("getInt".equals(method.getName())) {
|
||||
return 0;
|
||||
}
|
||||
if ("wasNull".equals(method.getName())) {
|
||||
return true;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
|
||||
assertNull(new IrisAgent().resultValue(resultSet, 1, Types.BIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsColumnsAndUsesInlinePrimaryKey() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.dbx.agent.kafka;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.spi.InetAddressResolver;
|
||||
import java.net.spi.InetAddressResolverProvider;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class DbxInetAddressResolverProvider extends InetAddressResolverProvider {
|
||||
private static volatile boolean avoidReverseDns;
|
||||
|
||||
@Override
|
||||
public InetAddressResolver get(Configuration configuration) {
|
||||
return wrapping(configuration.builtinResolver());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "dbx-kafka";
|
||||
}
|
||||
|
||||
static boolean setAvoidReverseDns(boolean avoid) {
|
||||
boolean previous = avoidReverseDns;
|
||||
avoidReverseDns = avoid;
|
||||
return previous;
|
||||
}
|
||||
|
||||
static InetAddressResolver wrapping(InetAddressResolver delegate) {
|
||||
Objects.requireNonNull(delegate, "delegate");
|
||||
return new InetAddressResolver() {
|
||||
@Override
|
||||
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
|
||||
throws UnknownHostException {
|
||||
return delegate.lookupByName(host, lookupPolicy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lookupByAddress(byte[] address) throws UnknownHostException {
|
||||
if (!avoidReverseDns) {
|
||||
return delegate.lookupByAddress(address);
|
||||
}
|
||||
Objects.requireNonNull(address, "address");
|
||||
if (address.length != 4 && address.length != 16) {
|
||||
throw new IllegalArgumentException("Invalid address length");
|
||||
}
|
||||
return InetAddress.getByAddress(address).getHostAddress();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ public final class KafkaAgent {
|
||||
private static final int DEFAULT_SESSION_TIMEOUT_MS = 30_000;
|
||||
private static final int DEFAULT_ZOOKEEPER_CONNECTION_TIMEOUT_MS = 10_000;
|
||||
private static final String ZOOKEEPER_PROPERTY_PREFIX = "zookeeper.";
|
||||
private static final Set<String> SASL_MECHANISMS_WITHOUT_SERVER_NAME = Set.of(
|
||||
"PLAIN",
|
||||
"SCRAM-SHA-256",
|
||||
"SCRAM-SHA-512",
|
||||
"OAUTHBEARER"
|
||||
);
|
||||
private static final Set<String> KERBEROS_SYSTEM_PROPERTY_KEYS = Set.of(
|
||||
"java.security.krb5.conf",
|
||||
"sun.security.krb5.debug",
|
||||
@@ -134,8 +140,17 @@ public final class KafkaAgent {
|
||||
case "handshake" -> handshakeResult();
|
||||
case "connect" -> connect(params);
|
||||
case "test_connection" -> testConnection(params);
|
||||
case "disconnect" -> { closeClients(); yield Collections.singletonMap("ok", true); }
|
||||
case "shutdown" -> { closeClients(); shutdownRequested = true; yield Collections.singletonMap("ok", true); }
|
||||
case "disconnect" -> {
|
||||
closeClients();
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(false);
|
||||
yield Collections.singletonMap("ok", true);
|
||||
}
|
||||
case "shutdown" -> {
|
||||
closeClients();
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(false);
|
||||
shutdownRequested = true;
|
||||
yield Collections.singletonMap("ok", true);
|
||||
}
|
||||
// Topic management
|
||||
case "mq_list_topics" -> listTopics(params);
|
||||
case "mq_create_topic" -> createTopic(params);
|
||||
@@ -175,6 +190,9 @@ public final class KafkaAgent {
|
||||
|
||||
private static Object connect(JsonObject params) throws Exception {
|
||||
JsonObject conn = resolveBrokerConnection(connectionObject(params));
|
||||
boolean previousReverseDnsMode = DbxInetAddressResolverProvider.setAvoidReverseDns(
|
||||
shouldAvoidSaslReverseDns(conn)
|
||||
);
|
||||
Map<String, String> previousKerberosSystemProperties = applyKerberosSystemProperties(conn);
|
||||
AdminClient nextAdmin = null;
|
||||
KafkaProducer<String, byte[]> nextProducer = null;
|
||||
@@ -198,12 +216,16 @@ public final class KafkaAgent {
|
||||
nextProducer.close(Duration.ofSeconds(5));
|
||||
}
|
||||
restoreKerberosSystemProperties(previousKerberosSystemProperties);
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previousReverseDnsMode);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object testConnection(JsonObject params) throws Exception {
|
||||
JsonObject conn = resolveBrokerConnection(connectionObject(params));
|
||||
boolean previousReverseDnsMode = DbxInetAddressResolverProvider.setAvoidReverseDns(
|
||||
shouldAvoidSaslReverseDns(conn)
|
||||
);
|
||||
Map<String, String> previousKerberosSystemProperties = applyKerberosSystemProperties(conn);
|
||||
AdminClient probe = null;
|
||||
try {
|
||||
@@ -244,6 +266,7 @@ public final class KafkaAgent {
|
||||
probe.close(Duration.ofSeconds(5));
|
||||
}
|
||||
restoreKerberosSystemProperties(previousKerberosSystemProperties);
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previousReverseDnsMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +493,25 @@ public final class KafkaAgent {
|
||||
return protocol.isBlank() ? "PLAINTEXT" : protocol;
|
||||
}
|
||||
|
||||
static boolean shouldAvoidSaslReverseDns(JsonObject conn) {
|
||||
JsonObject properties = connectionProperties(conn);
|
||||
String configuredProtocol = stringProperty(properties, "security.protocol");
|
||||
String protocol = configuredProtocol == null || configuredProtocol.isBlank()
|
||||
? securityProtocol(conn)
|
||||
: configuredProtocol;
|
||||
if (!protocol.trim().toUpperCase(Locale.ROOT).startsWith("SASL_")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String mechanism = stringOrEmpty(conn, "sasl_mechanism");
|
||||
if (mechanism.isBlank()) mechanism = stringOrEmpty(conn, "saslMechanism");
|
||||
String configuredMechanism = stringProperty(properties, "sasl.mechanism");
|
||||
if (configuredMechanism != null && !configuredMechanism.isBlank()) {
|
||||
mechanism = configuredMechanism;
|
||||
}
|
||||
return SASL_MECHANISMS_WITHOUT_SERVER_NAME.contains(mechanism.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
static void applySecurityProperties(JsonObject conn, Properties props) {
|
||||
String securityProtocol = stringOrEmpty(conn, "security_protocol");
|
||||
if (securityProtocol.isBlank()) {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.dbx.agent.kafka.DbxInetAddressResolverProvider
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.dbx.agent.kafka;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.spi.InetAddressResolver;
|
||||
import java.net.spi.InetAddressResolverProvider;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DbxInetAddressResolverProviderTest {
|
||||
@Test
|
||||
void providerIsPublishedAsTheKafkaAgentResolverService() {
|
||||
assertTrue(ServiceLoader.load(InetAddressResolverProvider.class).stream()
|
||||
.anyMatch(provider -> provider.type() == DbxInetAddressResolverProvider.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void installedProviderShortCircuitsTheInetAddressCallUsedByKafka() throws Exception {
|
||||
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
|
||||
try {
|
||||
InetAddress numericAddress = InetAddress.getByAddress(new byte[] { 127, 0, 0, 1 });
|
||||
|
||||
assertEquals("127.0.0.1", numericAddress.getHostName());
|
||||
} finally {
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonKerberosSaslReturnsNumericAddressWithoutCallingReverseDns() throws Exception {
|
||||
AtomicInteger reverseLookups = new AtomicInteger();
|
||||
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(
|
||||
resolverReturning("broker.example.test", reverseLookups)
|
||||
);
|
||||
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
|
||||
try {
|
||||
assertEquals("192.0.2.25", resolver.lookupByAddress(new byte[] {
|
||||
(byte) 192, 0, 2, 25
|
||||
}));
|
||||
assertEquals(0, reverseLookups.get());
|
||||
} finally {
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void kerberosModeKeepsPlatformReverseDnsBehavior() throws Exception {
|
||||
AtomicInteger reverseLookups = new AtomicInteger();
|
||||
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(
|
||||
resolverReturning("broker.example.test", reverseLookups)
|
||||
);
|
||||
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(false);
|
||||
try {
|
||||
assertEquals("broker.example.test", resolver.lookupByAddress(new byte[] {
|
||||
(byte) 192, 0, 2, 25
|
||||
}));
|
||||
assertEquals(1, reverseLookups.get());
|
||||
} finally {
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformReverseDnsFailuresRemainVisibleWhenBypassIsDisabled() {
|
||||
InetAddressResolver delegate = new InetAddressResolver() {
|
||||
@Override
|
||||
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy) {
|
||||
throw new AssertionError("forward lookup was not expected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lookupByAddress(byte[] address) throws UnknownHostException {
|
||||
throw new UnknownHostException("PTR lookup failed");
|
||||
}
|
||||
};
|
||||
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(delegate);
|
||||
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(false);
|
||||
try {
|
||||
UnknownHostException error = assertThrows(UnknownHostException.class,
|
||||
() -> resolver.lookupByAddress(new byte[] { (byte) 192, 0, 2, 25 }));
|
||||
assertEquals("PTR lookup failed", error.getMessage());
|
||||
} finally {
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hostnameLookupAlwaysUsesThePlatformResolver() throws Exception {
|
||||
AtomicInteger forwardLookups = new AtomicInteger();
|
||||
InetAddressResolver delegate = new InetAddressResolver() {
|
||||
@Override
|
||||
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy)
|
||||
throws UnknownHostException {
|
||||
forwardLookups.incrementAndGet();
|
||||
return Stream.of(InetAddress.getByAddress(host, new byte[] { 127, 0, 0, 1 }));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lookupByAddress(byte[] address) {
|
||||
throw new AssertionError("reverse lookup was not expected");
|
||||
}
|
||||
};
|
||||
InetAddressResolver resolver = DbxInetAddressResolverProvider.wrapping(delegate);
|
||||
boolean previous = DbxInetAddressResolverProvider.setAvoidReverseDns(true);
|
||||
try {
|
||||
InetAddress resolved = resolver
|
||||
.lookupByName("broker.example.test", InetAddressResolver.LookupPolicy.of(
|
||||
InetAddressResolver.LookupPolicy.IPV4
|
||||
))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals("broker.example.test", resolved.getHostName());
|
||||
assertEquals(1, forwardLookups.get());
|
||||
} finally {
|
||||
DbxInetAddressResolverProvider.setAvoidReverseDns(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private static InetAddressResolver resolverReturning(String reverseResult, AtomicInteger reverseLookups) {
|
||||
return new InetAddressResolver() {
|
||||
@Override
|
||||
public Stream<InetAddress> lookupByName(String host, LookupPolicy lookupPolicy) {
|
||||
throw new AssertionError("forward lookup was not expected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String lookupByAddress(byte[] address) {
|
||||
reverseLookups.incrementAndGet();
|
||||
return reverseResult;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -581,6 +581,35 @@ class KafkaAgentTest {
|
||||
assertNull(kafkaProperties.getProperty("zookeeper.ssl.trustStore.password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reverseDnsIsSkippedOnlyForSupportedNonKerberosSaslMechanisms() {
|
||||
assertTrue(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SASL_PLAINTEXT", "PLAIN")));
|
||||
assertTrue(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SASL_SSL", "SCRAM-SHA-512")));
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SASL_PLAINTEXT", "GSSAPI")));
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"PLAINTEXT", "PLAIN")));
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SSL", "PLAIN")));
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SASL_PLAINTEXT", "CUSTOM")));
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connectionWithSecurity(
|
||||
"SASL_PLAINTEXT", "")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitKafkaPropertiesControlReverseDnsMode() {
|
||||
JsonObject connection = connectionWithSecurity("SASL_PLAINTEXT", "PLAIN");
|
||||
JsonObject properties = new JsonObject();
|
||||
properties.addProperty("security.protocol", "SASL_SSL");
|
||||
properties.addProperty("sasl.mechanism", "GSSAPI");
|
||||
connection.add("properties", properties);
|
||||
|
||||
assertFalse(KafkaAgent.shouldAvoidSaslReverseDns(connection));
|
||||
}
|
||||
|
||||
@Test
|
||||
void producerDefaultsToLegacyCompatibleNonIdempotentDelivery() {
|
||||
JsonObject connection = new JsonObject();
|
||||
@@ -592,6 +621,13 @@ class KafkaAgentTest {
|
||||
assertEquals("false", properties.getProperty(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG));
|
||||
}
|
||||
|
||||
private static JsonObject connectionWithSecurity(String protocol, String mechanism) {
|
||||
JsonObject connection = new JsonObject();
|
||||
connection.addProperty("security_protocol", protocol);
|
||||
connection.addProperty("sasl_mechanism", mechanism);
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Test
|
||||
void producerAllowsExplicitIdempotenceForModernBrokers() {
|
||||
JsonObject extraProperties = new JsonObject();
|
||||
|
||||
+382
-82
@@ -2,6 +2,12 @@ package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.AgentProtocol;
|
||||
import com.dbx.agent.CompletionAssistantCandidate;
|
||||
import com.dbx.agent.CompletionAssistantCandidateKind;
|
||||
import com.dbx.agent.CompletionAssistantMatchMode;
|
||||
import com.dbx.agent.CompletionAssistantObjectKind;
|
||||
import com.dbx.agent.CompletionAssistantRequest;
|
||||
import com.dbx.agent.CompletionAssistantResponse;
|
||||
import com.dbx.agent.ConfiguredJdbcAgent;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.DatabaseInfo;
|
||||
@@ -21,6 +27,7 @@ import com.dbx.agent.PartitionInfo;
|
||||
import com.dbx.agent.QueryPageOptions;
|
||||
import com.dbx.agent.QueryPageResult;
|
||||
import com.dbx.agent.QueryResult;
|
||||
import com.dbx.agent.QueryTiming;
|
||||
import com.dbx.agent.TableInfo;
|
||||
import com.dbx.agent.TriggerInfo;
|
||||
|
||||
@@ -28,14 +35,15 @@ import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -58,9 +66,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
"GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC"
|
||||
);
|
||||
private boolean queryTimeoutChanged;
|
||||
private String auditEligibleCursorId;
|
||||
private static final String SQL_AUDIT_BY_TRACE = "SELECT EXECUTE_TIME, RETURN_ROWS FROM SYS.GV$OB_SQL_AUDIT "
|
||||
+ "WHERE TRACE_ID = ? AND IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0 AND ROWNUM <= 2";
|
||||
|
||||
public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile(
|
||||
"com.oceanbase.jdbc.Driver",
|
||||
@@ -80,97 +85,33 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
super(OCEANBASE_ORACLE_PROFILE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsQueryTiming() { return true; }
|
||||
|
||||
@Override
|
||||
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
|
||||
auditEligibleCursorId = null;
|
||||
QueryResult result = super.executeQuery(sql, schema, options);
|
||||
if (!result.getTruncated() && !result.getColumns().isEmpty()) {
|
||||
result.setServer_execute_time_us(availableServerExecuteTimeUs(
|
||||
result.getRows().size(), JdbcExecutor.statementMaxRows(options.getMaxRows())
|
||||
));
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
QueryResult result = super.executeQuery(sql, schema, options);
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
|
||||
auditEligibleCursorId = null;
|
||||
QueryPageResult result = super.executeQueryPage(sql, schema, options);
|
||||
if (result.getHas_more()) {
|
||||
auditEligibleCursorId = result.getSession_id();
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
QueryPageResult result = super.executeQueryPage(sql, schema, options);
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
}
|
||||
return withCompletedCursorServerTiming(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
|
||||
boolean auditEligible = sessionId != null && sessionId.equals(auditEligibleCursorId);
|
||||
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
|
||||
if (!result.getHas_more()) auditEligibleCursorId = null;
|
||||
return auditEligible ? withCompletedCursorServerTiming(result) : result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void beforeAgentMethod(String method, String querySessionId) {
|
||||
if (!AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)
|
||||
|| auditEligibleCursorId == null
|
||||
|| !auditEligibleCursorId.equals(querySessionId)) {
|
||||
auditEligibleCursorId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private QueryPageResult withCompletedCursorServerTiming(QueryPageResult result) {
|
||||
// JdbcExecutor closes the ResultSet and Statement before returning a terminal
|
||||
// page. Never issue audit SQL while Connector/J still owns an open cursor.
|
||||
// executePage limits rows while reading, without Statement.setMaxRows.
|
||||
// Preserve its JDBC default of 0 here; QueryPageOptions.maxRows is a
|
||||
// client-side cap, not the statement limit used by Connector/J.
|
||||
if (!result.getHas_more() && !result.getTruncated() && !result.getColumns().isEmpty()) {
|
||||
result.setServer_execute_time_us(availableServerExecuteTimeUs(result.getCursor_rows_read(), 0));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Long availableServerExecuteTimeUs(long expectedRows, int statementMaxRows) {
|
||||
try {
|
||||
return serverExecuteTimeUs(requireConnected(), expectedRows, statementMaxRows);
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Long serverExecuteTimeUs(Connection connection, long expectedRows, int statementMaxRows) {
|
||||
try {
|
||||
String traceId;
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
// Connector/J resets the session's SQL_SELECT_LIMIT in execute prolog
|
||||
// when this differs from the previous statement. That SET would
|
||||
// replace the query's LAST_TRACE_ID before we can read it.
|
||||
statement.setMaxRows(statementMaxRows);
|
||||
statement.setQueryTimeout(1);
|
||||
try (ResultSet traces = statement.executeQuery("SELECT LAST_TRACE_ID() FROM DUAL")) {
|
||||
traceId = traces.next() ? traces.getString(1) : null;
|
||||
}
|
||||
}
|
||||
if (traceId == null || traceId.isBlank()) return null;
|
||||
try (PreparedStatement statement = connection.prepareStatement(SQL_AUDIT_BY_TRACE)) {
|
||||
statement.setMaxRows(statementMaxRows);
|
||||
statement.setQueryTimeout(1);
|
||||
statement.setString(1, traceId);
|
||||
try (ResultSet audit = statement.executeQuery()) {
|
||||
if (!audit.next()) return null;
|
||||
long executionUs = audit.getLong(1);
|
||||
if (audit.wasNull() || executionUs < 0) return null;
|
||||
long returnedRows = audit.getLong(2);
|
||||
if (audit.wasNull() || returnedRows != expectedRows || audit.next()) return null;
|
||||
return executionUs;
|
||||
}
|
||||
}
|
||||
} catch (SQLException | RuntimeException ignored) {
|
||||
// SQL Audit can be disabled, unavailable to this account, or evicted.
|
||||
// The user's query result remains valid; no wall-clock substitute is used.
|
||||
return null;
|
||||
try (QueryTiming timing = QueryTiming.begin()) {
|
||||
QueryPageResult result = super.fetchQueryPage(sessionId, pageSize);
|
||||
result.setQuery_timings_ms(timing.finish());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +329,330 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionAssistantResponse completionAssistantSearch(CompletionAssistantRequest request) {
|
||||
if (hasTableLikeCompletionKind(request.getObject_kinds())) {
|
||||
return unchecked(() -> completionAssistantTables(request));
|
||||
}
|
||||
return super.completionAssistantSearch(request);
|
||||
}
|
||||
|
||||
private CompletionAssistantResponse completionAssistantTables(CompletionAssistantRequest request) throws SQLException {
|
||||
int limit = boundedCompletionLimit(request.getMax_results());
|
||||
int scanLimit = Math.min(1000, Math.max(limit * 3, limit + 1));
|
||||
String preferredSchema = preferredCompletionSchema(request);
|
||||
CompletionTablesQuery query = buildCompletionTablesQuery(request, preferredSchema, scanLimit + 1);
|
||||
List<CompletionTableRow> rows = new ArrayList<>();
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(query.sql)) {
|
||||
bindCompletionArgs(stmt, query.args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
rows.add(new CompletionTableRow(
|
||||
rs.getString(1),
|
||||
rs.getString(2),
|
||||
rs.getString(3),
|
||||
rs.getString(4),
|
||||
rs.getString(5)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<CompletionSynonymTarget> validTargets = validCompletionSynonymTargets(
|
||||
rows,
|
||||
completionTableObjectTypes(request.getObject_kinds())
|
||||
);
|
||||
List<CompletionAssistantCandidate> candidates = new ArrayList<>();
|
||||
for (CompletionTableRow row : rows) {
|
||||
if (row.name == null || row.name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if ("SYNONYM".equalsIgnoreCase(row.objectType)
|
||||
&& (row.targetOwner == null || row.targetName == null
|
||||
|| !validTargets.contains(new CompletionSynonymTarget(row.targetOwner, row.targetName)))) {
|
||||
continue;
|
||||
}
|
||||
CompletionAssistantCandidateKind kind = "VIEW".equalsIgnoreCase(row.objectType)
|
||||
? CompletionAssistantCandidateKind.VIEW
|
||||
: CompletionAssistantCandidateKind.TABLE;
|
||||
candidates.add(new CompletionAssistantCandidate(
|
||||
row.name,
|
||||
kind,
|
||||
blankToNull(request.getDatabase()),
|
||||
row.owner,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
row.objectType
|
||||
));
|
||||
}
|
||||
boolean incomplete = candidates.size() > limit;
|
||||
if (incomplete) {
|
||||
candidates = new ArrayList<>(candidates.subList(0, limit));
|
||||
}
|
||||
return new CompletionAssistantResponse(candidates, incomplete, false);
|
||||
}
|
||||
|
||||
static CompletionTablesQuery buildCompletionTablesQuery(
|
||||
CompletionAssistantRequest request,
|
||||
String preferredSchema,
|
||||
int limit
|
||||
) {
|
||||
List<String> objectTypes = completionTableObjectTypes(request.getObject_kinds());
|
||||
boolean caseSensitive = request.getCase_sensitive();
|
||||
String pattern = completionLikePattern(request.getMask(), request.getMatch_mode());
|
||||
// OceanBase Oracle matches metadata the same way as listTables: fold the
|
||||
// bind value in Java and compare UPPER(column) LIKE ?. UPPER(?) on binds
|
||||
// is unreliable for fuzzy/lowercase masks in this driver.
|
||||
if (!caseSensitive) {
|
||||
pattern = pattern.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
List<Object> args = new ArrayList<>();
|
||||
String namePredicate = completionNamePredicate("o.OBJECT_NAME", caseSensitive);
|
||||
String synonymNamePredicate = completionNamePredicate("s.SYNONYM_NAME", caseSensitive);
|
||||
args.add(pattern);
|
||||
|
||||
String ownerPredicate = "";
|
||||
String synonymOwnerPredicate = "";
|
||||
String owner = "";
|
||||
if (!request.getGlobal_search()) {
|
||||
owner = firstNonBlank(request.getParent_schema(), request.getSchema(), preferredSchema);
|
||||
if (owner == null) {
|
||||
owner = "";
|
||||
}
|
||||
owner = owner.toUpperCase(Locale.ROOT);
|
||||
args.add(owner);
|
||||
ownerPredicate = " AND UPPER(o.OWNER) = ?";
|
||||
}
|
||||
|
||||
args.add(pattern);
|
||||
if (!owner.isEmpty()) {
|
||||
args.add(owner);
|
||||
synonymOwnerPredicate = " AND UPPER(s.OWNER) = ?";
|
||||
}
|
||||
|
||||
String preferred = preferredSchema == null ? "" : preferredSchema.trim();
|
||||
String preferredFolded = caseSensitive ? preferred : preferred.toUpperCase(Locale.ROOT);
|
||||
String exactMask = request.getMask() == null ? "" : request.getMask().trim();
|
||||
String exactFolded = caseSensitive ? exactMask : exactMask.toUpperCase(Locale.ROOT);
|
||||
args.add(preferredFolded);
|
||||
args.add(exactFolded);
|
||||
args.add(limit);
|
||||
|
||||
String typeList = String.join(", ", objectTypes);
|
||||
String baseSql = """
|
||||
SELECT o.OWNER,
|
||||
o.OBJECT_NAME,
|
||||
o.OBJECT_TYPE,
|
||||
CAST(NULL AS VARCHAR2(128)) AS TARGET_OWNER,
|
||||
CAST(NULL AS VARCHAR2(128)) AS TARGET_NAME
|
||||
FROM ALL_OBJECTS o
|
||||
WHERE o.OBJECT_TYPE IN (%s)
|
||||
AND %s%s
|
||||
UNION ALL
|
||||
SELECT s.OWNER,
|
||||
s.SYNONYM_NAME AS OBJECT_NAME,
|
||||
'SYNONYM' AS OBJECT_TYPE,
|
||||
s.TABLE_OWNER AS TARGET_OWNER,
|
||||
s.TABLE_NAME AS TARGET_NAME
|
||||
FROM ALL_SYNONYMS s
|
||||
WHERE s.DB_LINK IS NULL
|
||||
AND %s%s
|
||||
""".formatted(typeList, namePredicate, ownerPredicate, synonymNamePredicate, synonymOwnerPredicate)
|
||||
.stripIndent()
|
||||
.trim();
|
||||
|
||||
String preferredOwnerPredicate = caseSensitive ? "OWNER = ?" : "UPPER(OWNER) = ?";
|
||||
String exactNamePredicate = caseSensitive
|
||||
? "OBJECT_NAME = ?"
|
||||
: "UPPER(OBJECT_NAME) = ?";
|
||||
String orderedSql = """
|
||||
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME
|
||||
FROM (
|
||||
%s
|
||||
)
|
||||
ORDER BY CASE
|
||||
WHEN %s THEN 0
|
||||
WHEN OWNER = 'PUBLIC' THEN 1
|
||||
WHEN OWNER IN ('SYS','SYSTEM','SYSMAN','DBSNMP','OUTLN','XDB','MDSYS','CTXSYS','WMSYS') THEN 3
|
||||
ELSE 2
|
||||
END,
|
||||
CASE WHEN %s THEN 0 ELSE 1 END,
|
||||
CASE OBJECT_TYPE WHEN 'TABLE' THEN 0 WHEN 'VIEW' THEN 1 WHEN 'SYNONYM' THEN 3 ELSE 4 END,
|
||||
OBJECT_NAME,
|
||||
OWNER
|
||||
""".formatted(baseSql, preferredOwnerPredicate, exactNamePredicate).stripIndent().trim();
|
||||
|
||||
String sql = """
|
||||
SELECT OWNER, OBJECT_NAME, OBJECT_TYPE, TARGET_OWNER, TARGET_NAME
|
||||
FROM (
|
||||
%s
|
||||
)
|
||||
WHERE ROWNUM <= ?
|
||||
""".formatted(orderedSql).stripIndent().trim();
|
||||
|
||||
return new CompletionTablesQuery(sql, args);
|
||||
}
|
||||
|
||||
private static List<String> completionTableObjectTypes(List<CompletionAssistantObjectKind> kinds) {
|
||||
List<String> objectTypes = new ArrayList<>();
|
||||
boolean any = kinds == null || kinds.isEmpty();
|
||||
if (any || kinds.contains(CompletionAssistantObjectKind.TABLE)) {
|
||||
objectTypes.add("'TABLE'");
|
||||
}
|
||||
if (any || kinds.contains(CompletionAssistantObjectKind.VIEW)) {
|
||||
objectTypes.add("'VIEW'");
|
||||
}
|
||||
if (objectTypes.isEmpty()) {
|
||||
objectTypes.add("'TABLE'");
|
||||
objectTypes.add("'VIEW'");
|
||||
}
|
||||
return objectTypes;
|
||||
}
|
||||
|
||||
private static boolean hasTableLikeCompletionKind(List<CompletionAssistantObjectKind> kinds) {
|
||||
if (kinds == null || kinds.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (CompletionAssistantObjectKind kind : kinds) {
|
||||
if (kind == CompletionAssistantObjectKind.TABLE || kind == CompletionAssistantObjectKind.VIEW) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String preferredCompletionSchema(CompletionAssistantRequest request) throws SQLException {
|
||||
String preferred = firstNonBlank(request.getSchema());
|
||||
if (preferred != null && !preferred.isBlank()) {
|
||||
return preferred;
|
||||
}
|
||||
String current = currentSchema();
|
||||
return current == null ? "" : current;
|
||||
}
|
||||
|
||||
private static String completionLikePattern(String mask, CompletionAssistantMatchMode matchMode) {
|
||||
String escaped = escapeLikePattern(mask == null ? "" : mask.trim());
|
||||
if (matchMode == CompletionAssistantMatchMode.CONTAINS) {
|
||||
return "%" + escaped + "%";
|
||||
}
|
||||
return escaped + "%";
|
||||
}
|
||||
|
||||
private static String escapeLikePattern(String mask) {
|
||||
return mask.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
|
||||
}
|
||||
|
||||
private static String completionNamePredicate(String column, boolean caseSensitive) {
|
||||
if (caseSensitive) {
|
||||
return column + " LIKE ? ESCAPE '\\'";
|
||||
}
|
||||
return "UPPER(" + column + ") LIKE ? ESCAPE '\\'";
|
||||
}
|
||||
|
||||
private static int boundedCompletionLimit(Integer requested) {
|
||||
if (requested == null || requested <= 0) {
|
||||
return 100;
|
||||
}
|
||||
return Math.min(requested, 1000);
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
|
||||
private static void bindCompletionArgs(PreparedStatement stmt, List<Object> args) throws SQLException {
|
||||
for (int i = 0; i < args.size(); i++) {
|
||||
Object arg = args.get(i);
|
||||
if (arg instanceof Integer integer) {
|
||||
stmt.setInt(i + 1, integer);
|
||||
} else {
|
||||
stmt.setString(i + 1, arg == null ? null : String.valueOf(arg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<CompletionSynonymTarget> validCompletionSynonymTargets(
|
||||
List<CompletionTableRow> rows,
|
||||
List<String> objectTypes
|
||||
) throws SQLException {
|
||||
Set<CompletionSynonymTarget> targets = new LinkedHashSet<>();
|
||||
for (CompletionTableRow row : rows) {
|
||||
if ("SYNONYM".equalsIgnoreCase(row.objectType) && row.targetOwner != null && row.targetName != null) {
|
||||
targets.add(new CompletionSynonymTarget(row.targetOwner, row.targetName));
|
||||
}
|
||||
}
|
||||
Set<CompletionSynonymTarget> valid = new HashSet<>();
|
||||
if (targets.isEmpty()) {
|
||||
return valid;
|
||||
}
|
||||
String typeList = String.join(", ", objectTypes);
|
||||
List<CompletionSynonymTarget> ordered = new ArrayList<>(targets);
|
||||
for (int start = 0; start < ordered.size(); start += 100) {
|
||||
List<CompletionSynonymTarget> batch = ordered.subList(start, Math.min(start + 100, ordered.size()));
|
||||
List<Object> args = new ArrayList<>();
|
||||
List<String> predicates = new ArrayList<>();
|
||||
for (CompletionSynonymTarget target : batch) {
|
||||
args.add(target.owner());
|
||||
args.add(target.name());
|
||||
predicates.add("(o.OWNER = ? AND o.OBJECT_NAME = ?)");
|
||||
}
|
||||
String sql = "SELECT DISTINCT o.OWNER, o.OBJECT_NAME"
|
||||
+ " FROM ALL_OBJECTS o"
|
||||
+ " WHERE o.OBJECT_TYPE IN (" + typeList + ")"
|
||||
+ " AND (" + String.join(" OR ", predicates) + ")";
|
||||
try (PreparedStatement stmt = requireConnection().prepareStatement(sql)) {
|
||||
bindCompletionArgs(stmt, args);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
valid.add(new CompletionSynonymTarget(rs.getString(1), rs.getString(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
record CompletionSynonymTarget(String owner, String name) {
|
||||
}
|
||||
|
||||
static final class CompletionTableRow {
|
||||
final String owner;
|
||||
final String name;
|
||||
final String objectType;
|
||||
final String targetOwner;
|
||||
final String targetName;
|
||||
|
||||
CompletionTableRow(String owner, String name, String objectType, String targetOwner, String targetName) {
|
||||
this.owner = owner;
|
||||
this.name = name;
|
||||
this.objectType = objectType;
|
||||
this.targetOwner = targetOwner;
|
||||
this.targetName = targetName;
|
||||
}
|
||||
}
|
||||
|
||||
static final class CompletionTablesQuery {
|
||||
final String sql;
|
||||
final List<Object> args;
|
||||
|
||||
CompletionTablesQuery(String sql, List<Object> args) {
|
||||
this.sql = sql;
|
||||
this.args = List.copyOf(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static MetadataSql oceanBaseMetadataSql(
|
||||
String baseSql,
|
||||
String selectList,
|
||||
@@ -457,6 +722,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
String owner = normalizeSchema(schema);
|
||||
String objectName = normalizeObjectName(name);
|
||||
String normalizedType = normalizeObjectSourceType(objectType);
|
||||
if (prefersDictionarySource(normalizedType)) {
|
||||
return getDictionaryFirstObjectSource(owner, objectName, normalizedType);
|
||||
}
|
||||
String source;
|
||||
SQLException metadataError = null;
|
||||
try {
|
||||
@@ -486,6 +754,30 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
});
|
||||
}
|
||||
|
||||
private ObjectSource getDictionaryFirstObjectSource(String owner, String name, String objectType) throws SQLException {
|
||||
String source;
|
||||
SQLException dictionaryError = null;
|
||||
try {
|
||||
source = queryDictionarySource(owner, name, objectType);
|
||||
} catch (SQLException e) {
|
||||
dictionaryError = e;
|
||||
source = null;
|
||||
}
|
||||
|
||||
if (source == null || source.trim().isEmpty()) {
|
||||
try {
|
||||
source = queryDbmsMetadataSource(owner, name, objectType);
|
||||
} catch (SQLException metadataError) {
|
||||
if (dictionaryError != null) {
|
||||
dictionaryError.addSuppressed(metadataError);
|
||||
throw dictionaryError;
|
||||
}
|
||||
throw metadataError;
|
||||
}
|
||||
}
|
||||
return new ObjectSource(name, objectType, owner, source == null ? "" : source);
|
||||
}
|
||||
|
||||
private String queryDbmsMetadataSource(String owner, String name, String objectType) throws SQLException {
|
||||
if ("SYNONYM".equals(objectType)) {
|
||||
// GET_DDL does not cover synonyms on every supported OB version.
|
||||
@@ -529,6 +821,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
stmt.setString(1, owner);
|
||||
stmt.setString(2, name);
|
||||
stmt.setString(3, sourceType);
|
||||
stmt.setFetchSize(256);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String line = rs.getString(1);
|
||||
@@ -559,6 +852,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean prefersDictionarySource(String objectType) {
|
||||
return switch (objectType) {
|
||||
case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "TRIGGER", "TYPE", "TYPE_BODY" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static String editableOracleSource(String source) {
|
||||
String trimmed = source.trim();
|
||||
if (trimmed.isEmpty() || trimmed.regionMatches(true, 0, "CREATE ", 0, "CREATE ".length())) {
|
||||
|
||||
+124
-31
@@ -1,7 +1,10 @@
|
||||
package com.dbx.agent.oceanbaseoracle;
|
||||
|
||||
import com.dbx.agent.AgentProtocol;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.CompletionAssistantMatchMode;
|
||||
import com.dbx.agent.CompletionAssistantObjectKind;
|
||||
import com.dbx.agent.CompletionAssistantRequest;
|
||||
import com.dbx.agent.CompletionAssistantResponse;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.ExecuteQueryOptions;
|
||||
import com.dbx.agent.MetadataListConstraints;
|
||||
@@ -137,28 +140,9 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertThrows(IllegalArgumentException.class, () -> OceanBaseOracleAgent.queryTimeoutSql(-1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsServerExecutionOnlyFromOneMatchingAuditRow() {
|
||||
List<String> statements = new ArrayList<>();
|
||||
List<String> parameters = new ArrayList<>();
|
||||
List<Integer> maxRows = new ArrayList<>();
|
||||
Assertions.assertEquals(370L, OceanBaseOracleAgent.serverExecuteTimeUs(
|
||||
auditTimingConnection(1, 500, false, statements, parameters, maxRows), 500, 1001
|
||||
));
|
||||
Assertions.assertEquals(List.of(1001, 1001), maxRows,
|
||||
"trace and audit statements must preserve the target statement's session row limit");
|
||||
Assertions.assertEquals(List.of("trace-1"), parameters);
|
||||
Assertions.assertTrue(statements.get(1).contains("IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0"));
|
||||
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(0, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
|
||||
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(2, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
|
||||
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 1, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
|
||||
Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 500, true, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0));
|
||||
}
|
||||
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"10, 1000, false", "1, 1000, false", "1, 1, true", "2, 2, false"})
|
||||
void preservesTheCursorStatementLimitWhenSamplingItsTerminalPage(int pageSize, int maxRows, boolean truncated) {
|
||||
void returnsCursorRowsWithoutAdditionalAuditQueries(int pageSize, int maxRows, boolean truncated) {
|
||||
List<Integer> auditLimits = new ArrayList<>();
|
||||
List<String> auditSql = new ArrayList<>();
|
||||
Connection auditConnection = auditTimingConnection(1, 2, false, auditSql, new ArrayList<>(), auditLimits);
|
||||
@@ -215,12 +199,13 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertEquals(truncated, result.getTruncated());
|
||||
Assertions.assertEquals(truncated ? List.of(List.of(1)) : List.of(List.of(1), List.of(2)), rows);
|
||||
Assertions.assertEquals(0, queryLimit[0], "the paging cap must not change the JDBC statement limit");
|
||||
Assertions.assertEquals(truncated ? List.of() : List.of(0, 0), auditLimits);
|
||||
Assertions.assertEquals(truncated ? null : Long.valueOf(370), result.getServer_execute_time_us());
|
||||
Assertions.assertTrue(auditSql.isEmpty(), "completed queries must not read trace or audit records");
|
||||
Assertions.assertTrue(auditLimits.isEmpty());
|
||||
Assertions.assertNull(result.getServer_execute_time_us());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAuditAnOpenCursorAfterAnotherSessionMethodCanReplaceItsTrace() {
|
||||
void finishesCursorWithoutCreatingDiagnosticStatements() {
|
||||
int[] row = {-1};
|
||||
ResultSetMetaData meta = proxy(ResultSetMetaData.class, (method, args) -> {
|
||||
if ("getColumnCount".equals(method.getName())) return 1;
|
||||
@@ -255,11 +240,11 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertTrue(first.getHas_more());
|
||||
Assertions.assertEquals(2, statementsCreated[0]);
|
||||
|
||||
agent.beforeAgentMethod(AgentProtocol.METHOD_LIST_TABLES, null);
|
||||
|
||||
QueryPageResult last = agent.fetchQueryPage(first.getSession_id(), 1);
|
||||
Assertions.assertFalse(last.getHas_more());
|
||||
Assertions.assertNull(last.getServer_execute_time_us());
|
||||
Assertions.assertEquals(2, statementsCreated[0], "no LAST_TRACE_ID query may run for an invalidated cursor");
|
||||
Assertions.assertEquals(2, statementsCreated[0], "no diagnostic query may run when the cursor finishes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -460,6 +445,64 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalCompletionTableSearchOmitsOwnerFilter() {
|
||||
CompletionAssistantRequest request = completionRequest("DWD", null, "STAG", true);
|
||||
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "DWD", 21);
|
||||
|
||||
Assertions.assertTrue(query.sql.contains("FROM ALL_OBJECTS"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("FROM ALL_SYNONYMS"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql);
|
||||
Assertions.assertFalse(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql);
|
||||
Assertions.assertFalse(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("ROWNUM <= ?"), query.sql);
|
||||
Assertions.assertEquals(List.of("STAG%", "STAG%", "DWD", "STAG", 21), query.args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionTableSearchFoldsLowercaseMasksForFuzzyMatch() {
|
||||
CompletionAssistantRequest request = completionRequest("dwd", null, "ord", true);
|
||||
setField(request, "match_mode", CompletionAssistantMatchMode.CONTAINS);
|
||||
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21);
|
||||
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(o.OBJECT_NAME) LIKE ?"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(OWNER) = ?"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(OBJECT_NAME) = ?"), query.sql);
|
||||
Assertions.assertFalse(query.sql.contains("LIKE UPPER(?)"), query.sql);
|
||||
Assertions.assertEquals(List.of("%ORD%", "%ORD%", "DWD", "ORD", 21), query.args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopedCompletionTableSearchFiltersOwnerCaseInsensitively() {
|
||||
CompletionAssistantRequest request = completionRequest("dwd", "staging", "ord", false);
|
||||
OceanBaseOracleAgent.CompletionTablesQuery query = OceanBaseOracleAgent.buildCompletionTablesQuery(request, "dwd", 21);
|
||||
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(o.OWNER) = ?"), query.sql);
|
||||
Assertions.assertTrue(query.sql.contains("UPPER(s.OWNER) = ?"), query.sql);
|
||||
Assertions.assertEquals(List.of("ORD%", "STAGING", "ORD%", "STAGING", "DWD", "ORD", 21), query.args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionAssistantSearchReturnsGlobalTableCandidates() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet(
|
||||
new String[]{"OWNER", "OBJECT_NAME", "OBJECT_TYPE", "TARGET_OWNER", "TARGET_NAME"},
|
||||
new Object[][]{
|
||||
{"DWD", "ORDERS", "TABLE", null, null},
|
||||
{"STAGING", "ORDERS", "TABLE", null, null}
|
||||
}
|
||||
)));
|
||||
|
||||
CompletionAssistantResponse response = agent.completionAssistantSearch(completionRequest("DWD", null, "ORD", true));
|
||||
|
||||
Assertions.assertEquals(2, response.getCandidates().size());
|
||||
Assertions.assertEquals("ORDERS", response.getCandidates().get(0).getName());
|
||||
Assertions.assertEquals("DWD", response.getCandidates().get(0).getSchema());
|
||||
Assertions.assertEquals("STAGING", response.getCandidates().get(1).getSchema());
|
||||
Assertions.assertFalse(sql.get(0).contains("UPPER(o.OWNER) = ?"), sql.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsViewDdlWithDbmsMetadataForSchemaCompare() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
@@ -578,7 +621,7 @@ class OceanBaseOracleAgentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAllSourceForOracleRoutineAndPackageTypes() {
|
||||
void readsOracleRoutineAndPackageTypesFromAllSourceFirst() {
|
||||
for (String[] object : new String[][]{
|
||||
{"PROCEDURE", "PROCEDURE"},
|
||||
{"FUNCTION", "FUNCTION"},
|
||||
@@ -588,7 +631,7 @@ class OceanBaseOracleAgentTest {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> params = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(
|
||||
TestSupport.setPrivateConnection(agent, objectSourceConnection(
|
||||
sql,
|
||||
params,
|
||||
resultSet(
|
||||
@@ -602,14 +645,37 @@ class OceanBaseOracleAgentTest {
|
||||
Assertions.assertEquals(object[0], source.getObject_type());
|
||||
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + object[1]), source.getSource());
|
||||
Assertions.assertEquals(
|
||||
List.of(object[0], "MixedRoutine", "MixedOwner", "MixedOwner", "MixedRoutine", object[1]),
|
||||
List.of("MixedOwner", "MixedRoutine", object[1]),
|
||||
params
|
||||
);
|
||||
Assertions.assertTrue(sql.get(1).contains("ALL_SOURCE"), sql.get(1));
|
||||
Assertions.assertTrue(sql.get(1).contains("ORDER BY LINE"), sql.get(1));
|
||||
Assertions.assertEquals(1, sql.size());
|
||||
Assertions.assertTrue(sql.get(0).contains("ALL_SOURCE"), sql.get(0));
|
||||
Assertions.assertTrue(sql.get(0).contains("ORDER BY LINE"), sql.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDbmsMetadataWhenAllSourceIsUnavailable() {
|
||||
List<String> sql = new ArrayList<>();
|
||||
List<String> params = new ArrayList<>();
|
||||
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
|
||||
TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(
|
||||
sql,
|
||||
params,
|
||||
resultSet(
|
||||
new String[]{"DDL"},
|
||||
new Object[][]{{"CREATE OR REPLACE PROCEDURE APP.P1 AS BEGIN NULL; END;"}}
|
||||
)
|
||||
));
|
||||
|
||||
ObjectSource source = agent.getObjectSource("APP", "P1", "PROCEDURE");
|
||||
|
||||
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE PROCEDURE"), source.getSource());
|
||||
Assertions.assertTrue(sql.get(0).contains("ALL_SOURCE"), sql.get(0));
|
||||
Assertions.assertTrue(sql.get(1).contains("DBMS_METADATA.GET_DDL"), sql.get(1));
|
||||
Assertions.assertEquals(List.of("APP", "P1", "PROCEDURE", "PROCEDURE", "P1", "APP"), params);
|
||||
}
|
||||
|
||||
@Test
|
||||
void synonymSourcePreservesOwnersQuotedNamesAndRemoteLinkDomains() {
|
||||
for (String owner : List.of("Mixed.Owner", "PUBLIC")) {
|
||||
@@ -1467,6 +1533,33 @@ class OceanBaseOracleAgentTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CompletionAssistantRequest completionRequest(
|
||||
String schema,
|
||||
String parentSchema,
|
||||
String mask,
|
||||
boolean globalSearch
|
||||
) {
|
||||
CompletionAssistantRequest request = new CompletionAssistantRequest();
|
||||
setField(request, "database", "OBORCL");
|
||||
setField(request, "schema", schema);
|
||||
setField(request, "parent_schema", parentSchema);
|
||||
setField(request, "mask", mask);
|
||||
setField(request, "global_search", globalSearch);
|
||||
setField(request, "max_results", 20);
|
||||
setField(request, "object_kinds", List.of(CompletionAssistantObjectKind.TABLE, CompletionAssistantObjectKind.VIEW));
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void setField(Object target, String name, Object value) {
|
||||
try {
|
||||
var field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T proxy(Class<T> type, MethodHandler handler) {
|
||||
InvocationHandler invocationHandler = new InvocationHandler() {
|
||||
@Override
|
||||
|
||||
@@ -3866,7 +3866,7 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
|
||||
if err != nil {
|
||||
s.closeRows(rows)
|
||||
var panicErr oracleDriverPanicError
|
||||
if attempt == 0 && errors.As(err, &panicErr) {
|
||||
if attempt == 0 && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) {
|
||||
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
|
||||
sqlText = placeholder
|
||||
continue
|
||||
@@ -4211,10 +4211,32 @@ func columnTypeNames(rows *sql.Rows) []string {
|
||||
}
|
||||
|
||||
func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (*sql.Rows, error) {
|
||||
// A statement that keeps row locks while it runs is rewritten before the
|
||||
// first attempt: when such a statement panics after the rows were locked,
|
||||
// the locks stay behind and the retry below blocks on them until the query
|
||||
// times out (issues #9344 / #9180).
|
||||
if oracleSQLLocksRows(sqlText) {
|
||||
rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, deferLOBs)
|
||||
if err == nil && rewritten != sqlText {
|
||||
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
}
|
||||
return s.queryRowsWithTimeout(sqlText, nil, timeoutSecs)
|
||||
}
|
||||
if deferLOBs {
|
||||
rewritten, err := rewriteOracleSelectSQL(sqlText, s.loadOracleColumnMeta, true)
|
||||
if err == nil && rewritten != sqlText {
|
||||
return s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
rows, rewrittenErr := s.queryRowsWithTimeout(rewritten, nil, timeoutSecs)
|
||||
if rewrittenErr == nil {
|
||||
return rows, nil
|
||||
}
|
||||
// A deferred projection can still read a value the driver cannot
|
||||
// decode (user-defined object/collection columns). Only a panic is
|
||||
// worth retrying through the value-rewrite fallback below; other
|
||||
// failures keep their own error.
|
||||
var panicErr oracleDriverPanicError
|
||||
if !errors.As(rewrittenErr, &panicErr) {
|
||||
return nil, rewrittenErr
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, err := s.queryRowsWithTimeout(sqlText, nil, timeoutSecs)
|
||||
@@ -4502,6 +4524,7 @@ func parseSingleOracleTableRef(fromSQL string) (oracleTableRef, bool) {
|
||||
ref.Table = second.Name
|
||||
pos = skipSQLWhitespace(fromSQL, afterSecond)
|
||||
}
|
||||
pos = parseOracleDatabaseLinkSuffix(fromSQL, pos)
|
||||
if pos < len(fromSQL) {
|
||||
if strings.HasPrefix(strings.TrimLeft(fromSQL[pos:], " \t\r\n"), ",") {
|
||||
return oracleTableRef{}, false
|
||||
@@ -4536,6 +4559,31 @@ func parseSingleOracleTableRef(fromSQL string) (oracleTableRef, bool) {
|
||||
return ref, true
|
||||
}
|
||||
|
||||
// parseOracleDatabaseLinkSuffix consumes the `@link` (or `@!link`) suffix of a
|
||||
// remote table reference so that the alias written after the link is still
|
||||
// recognized (issues #9171 / #9344). Without it `schema.table@link t` parsed as
|
||||
// an unaliased reference, so a `t.*` / `t.column` projection no longer matched
|
||||
// the table and go-ora panicked on the user-defined type columns the value
|
||||
// rewrite is meant to substitute.
|
||||
//
|
||||
// A suffix that cannot be read as a link name is left in place, which keeps the
|
||||
// previous shape for connect-string links and other unparsable spellings.
|
||||
func parseOracleDatabaseLinkSuffix(fromSQL string, pos int) int {
|
||||
if pos >= len(fromSQL) || fromSQL[pos] != '@' {
|
||||
return pos
|
||||
}
|
||||
linkStart := pos + 1
|
||||
if linkStart < len(fromSQL) && fromSQL[linkStart] == '!' {
|
||||
// `table@!link` keeps the same link name; the marker only changes where
|
||||
// the link itself is resolved.
|
||||
linkStart++
|
||||
}
|
||||
if _, afterLink, ok := readOracleIdentifierToken(fromSQL, linkStart); ok {
|
||||
return skipSQLWhitespace(fromSQL, afterLink)
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func splitOracleSelectListModifier(selectList string) (string, string) {
|
||||
trimmedLeft := strings.TrimLeft(selectList, " \t\r\n")
|
||||
prefixLen := len(selectList) - len(trimmedLeft)
|
||||
@@ -4589,6 +4637,8 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
|
||||
rewritten = append(rewritten, oracleGeometryExpression(column, columnRef, outputAlias))
|
||||
} else if isOracleXMLType(column.DataType) && !deferLOBs {
|
||||
rewritten = append(rewritten, oracleXMLSerializeExpression(columnRef, outputAlias))
|
||||
} else if isOracleOpaqueObjectType(column) {
|
||||
rewritten = append(rewritten, oracleOpaqueObjectExpression(column, columnRef, outputAlias))
|
||||
} else if deferLOBs {
|
||||
if expressions, ok := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, column); ok {
|
||||
rewritten = append(rewritten, expressions...)
|
||||
@@ -4623,6 +4673,12 @@ func rewriteOracleSelectItems(items []string, columns []oracleColumnMeta, tableR
|
||||
sourceIndex++
|
||||
continue
|
||||
}
|
||||
if isOracleOpaqueObjectType(meta) {
|
||||
rewritten = append(rewritten, oracleOpaqueObjectExpression(meta, columnRef, outputAlias))
|
||||
changed = true
|
||||
sourceIndex++
|
||||
continue
|
||||
}
|
||||
if deferLOBs {
|
||||
if expressions, isLOB := oracleDeferredLOBExpressions(columnRef, outputAlias, sourceIndex, meta); isLOB {
|
||||
rewritten = append(rewritten, expressions...)
|
||||
@@ -4676,6 +4732,41 @@ func oracleGeometryPlaceholder(column oracleColumnMeta) string {
|
||||
return "<ST_GEOMETRY>"
|
||||
}
|
||||
|
||||
// oracleOpaqueObjectExpression projects a placeholder instead of the value of a
|
||||
// user-defined type (object, collection, ANYDATA, ...). go-ora cannot decode
|
||||
// those values over the wire and panics while scanning them, which fails the
|
||||
// whole statement and rolls the transaction back (issues #9344 / #9180). The
|
||||
// placeholder keeps the statement and the remaining columns readable.
|
||||
func oracleOpaqueObjectExpression(column oracleColumnMeta, columnRef, alias string) string {
|
||||
return fmt.Sprintf("CASE WHEN %s IS NULL THEN NULL ELSE '%s' END AS %s", columnRef, oracleOpaqueObjectPlaceholder(column), alias)
|
||||
}
|
||||
|
||||
func oracleOpaqueObjectPlaceholder(column oracleColumnMeta) string {
|
||||
name := strings.ToUpper(strings.TrimSpace(column.DataType))
|
||||
if name == "" {
|
||||
name = "OBJECT"
|
||||
}
|
||||
return "<" + name + ">"
|
||||
}
|
||||
|
||||
// oracleSQLLocksRows reports whether a statement holds row locks while it runs
|
||||
// (FOR UPDATE, FOR UPDATE OF ..., NOWAIT / WAIT n / SKIP LOCKED). Such a
|
||||
// statement must not be retried after a driver panic: the failed attempt keeps
|
||||
// its locks, so the retry would block on them instead of returning.
|
||||
func oracleSQLLocksRows(sqlText string) bool {
|
||||
for searchFrom := 0; searchFrom < len(sqlText); {
|
||||
pos := findTopLevelSQLKeyword(sqlText, searchFrom, "for")
|
||||
if pos < 0 {
|
||||
return false
|
||||
}
|
||||
if sqlKeywordAt(sqlText, skipSQLWhitespace(sqlText, pos+len("for")), "update") {
|
||||
return true
|
||||
}
|
||||
searchFrom = pos + len("for")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func oracleDeferredLOBKind(dataType string) (kind, placeholder string, ok bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(dataType)) {
|
||||
case "CLOB":
|
||||
@@ -4773,7 +4864,15 @@ func oracleQualifierMatchesTable(qualifier string, tableRef oracleTableRef) bool
|
||||
|
||||
func oracleColumnsNeedValueRewrite(columns []oracleColumnMeta, deferLOBs bool) bool {
|
||||
for _, column := range columns {
|
||||
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || (deferLOBs && isOracleDeferredLOBType(column.DataType)) {
|
||||
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) {
|
||||
return true
|
||||
}
|
||||
if deferLOBs && isOracleDeferredLOBType(column.DataType) {
|
||||
return true
|
||||
}
|
||||
// Opaque user-defined types are substituted in both the plain and the
|
||||
// deferred preview projection, so a preview never panics on them.
|
||||
if isOracleOpaqueObjectType(column) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -4822,6 +4921,18 @@ func isOracleGeometryType(column oracleColumnMeta) bool {
|
||||
return isOracleSTGeometry(column) || isOracleSDOGeometry(column)
|
||||
}
|
||||
|
||||
// isOracleOpaqueObjectType reports whether a column's declared type is a named
|
||||
// user-defined type that the driver cannot decode. Built-in scalar types report
|
||||
// no DATA_TYPE_OWNER; named types (object, collection, ANYDATA, ...) do. Types
|
||||
// DBX is able to render — SDE/SDO geometry, XMLType and deferred LOBs — are
|
||||
// matched earlier, so they are never treated as opaque here.
|
||||
func isOracleOpaqueObjectType(column oracleColumnMeta) bool {
|
||||
if isOracleGeometryType(column) || isOracleXMLType(column.DataType) || isOracleDeferredLOBType(column.DataType) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(column.DataTypeOwner) != ""
|
||||
}
|
||||
|
||||
func leadingSQLSelectListStart(sqlText string) int {
|
||||
trimmed := trimLeadingSQLComments(sqlText)
|
||||
prefixLen := len(sqlText) - len(trimmed)
|
||||
|
||||
@@ -4095,3 +4095,253 @@ func (r *oracleManualTxRows) Next(dest []driver.Value) error {
|
||||
r.next++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOracleOpaqueObjectTypeDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
column oracleColumnMeta
|
||||
want bool
|
||||
}{
|
||||
{"object type", oracleColumnMeta{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"}, true},
|
||||
{"collection type", oracleColumnMeta{Name: "V", DataType: "DBX9180_VARRAY", DataTypeOwner: "DBX_TEST"}, true},
|
||||
{"anydata", oracleColumnMeta{Name: "V", DataType: "ANYDATA", DataTypeOwner: "SYS"}, true},
|
||||
{"number", oracleColumnMeta{Name: "ID", DataType: "NUMBER"}, false},
|
||||
{"varchar2", oracleColumnMeta{Name: "NOTE", DataType: "VARCHAR2"}, false},
|
||||
{"clob", oracleColumnMeta{Name: "PAYLOAD", DataType: "CLOB"}, false},
|
||||
{"xmltype", oracleColumnMeta{Name: "DOC", DataType: "XMLTYPE", DataTypeOwner: "SYS"}, false},
|
||||
{"sdo geometry", oracleColumnMeta{Name: "SHAPE", DataType: "SDO_GEOMETRY", DataTypeOwner: "MDSYS"}, false},
|
||||
{"sde geometry", oracleColumnMeta{Name: "SHAPE", DataType: "ST_GEOMETRY", DataTypeOwner: "SDE"}, false},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := isOracleOpaqueObjectType(testCase.column); got != testCase.want {
|
||||
t.Fatalf("%s: isOracleOpaqueObjectType() = %v, want %v", testCase.name, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleOpaqueObjectSelectStar(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT * FROM TEST_UDT`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
|
||||
{Name: "NOTE", DataType: "VARCHAR2"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT "ID", CASE WHEN "G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G", "NOTE" FROM TEST_UDT`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleOpaqueObjectExplicitColumn(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT t.G AS shape, t.ID FROM TEST_UDT t`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS shape, t.ID FROM TEST_UDT t`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleKeepsBuiltinColumnsUntouched(t *testing.T) {
|
||||
input := `SELECT ID, NOTE FROM TEST_PLAIN`
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
input,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "NOTE", DataType: "VARCHAR2"},
|
||||
{Name: "PAYLOAD", DataType: "CLOB"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sqlText != input {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOracleSQLLocksRowsDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sql string
|
||||
want bool
|
||||
}{
|
||||
{"for update", "SELECT * FROM T FOR UPDATE", true},
|
||||
{"for update skip locked", "SELECT * FROM T FOR UPDATE SKIP LOCKED", true},
|
||||
{"for update of column", "SELECT ID FROM T FOR UPDATE OF ID NOWAIT", true},
|
||||
{"lowercase", "select id from t for update", true},
|
||||
{"plain select", "SELECT * FROM T", false},
|
||||
{"string literal", "SELECT 'FOR UPDATE' FROM T", false},
|
||||
{"comment", "SELECT * FROM T -- FOR UPDATE", false},
|
||||
{"subquery for update", "SELECT * FROM (SELECT ID FROM T FOR UPDATE) X", false},
|
||||
{"column named update", "SELECT FOR_UPDATE FROM T", false},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := oracleSQLLocksRows(testCase.sql); got != testCase.want {
|
||||
t.Fatalf("%s: oracleSQLLocksRows(%q) = %v, want %v", testCase.name, testCase.sql, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleOpaqueObjectInDeferredProjection(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT * FROM TEST_UDT_LOB`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "DOC", DataType: "CLOB"},
|
||||
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
|
||||
}, nil
|
||||
},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(sqlText, `CASE WHEN "G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G"`) {
|
||||
t.Fatalf("deferred rewrite did not substitute the opaque column: %s", sqlText)
|
||||
}
|
||||
if !strings.Contains(sqlText, `"DOC"`) {
|
||||
t.Fatalf("deferred rewrite dropped the LOB column: %s", sqlText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSingleOracleTableRefReadsDatabaseLinkAndAlias(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
from string
|
||||
want oracleTableRef
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "unqualified link with alias",
|
||||
from: `T_UDT@DBX_LOOP t`,
|
||||
want: oracleTableRef{Table: "T_UDT", Alias: "T", AliasText: "t"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "schema qualified link with alias",
|
||||
from: `DBX_TEST.T_UDT@DBX_LOOP t`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "T", AliasText: "t"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "remote link marker",
|
||||
from: `DBX_TEST.T_UDT@!DBX_LOOP x`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "X", AliasText: "x"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "quoted link followed by a clause",
|
||||
from: `DBX_TEST.T_UDT@"Loop Link" WHERE ID > 0`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "link without alias",
|
||||
from: `DBX_TEST.T_UDT@DBX_LOOP`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "local table is unchanged",
|
||||
from: `DBX_TEST.T_UDT t`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT", Alias: "T", AliasText: "t"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "link followed by another table is not a single reference",
|
||||
from: `DBX_TEST.T_UDT@DBX_LOOP, DBX_TEST.OTHER`,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "link followed by a join is not a single reference",
|
||||
from: `DBX_TEST.T_UDT@DBX_LOOP JOIN DBX_TEST.OTHER ON 1 = 1`,
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "connect string link keeps the previous shape",
|
||||
from: `DBX_TEST.T_UDT@'XE'`,
|
||||
want: oracleTableRef{Schema: "DBX_TEST", Table: "T_UDT"},
|
||||
ok: true,
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got, ok := parseSingleOracleTableRef(testCase.from)
|
||||
if ok != testCase.ok {
|
||||
t.Fatalf("%s: parseSingleOracleTableRef(%q) ok = %v, want %v", testCase.name, testCase.from, ok, testCase.ok)
|
||||
}
|
||||
if !testCase.ok {
|
||||
continue
|
||||
}
|
||||
if got != testCase.want {
|
||||
t.Fatalf("%s: parseSingleOracleTableRef(%q) = %+v, want %+v", testCase.name, testCase.from, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleOpaqueObjectProjectionKeepsLinkedAlias(t *testing.T) {
|
||||
var gotSchema, gotTable string
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT t.*, ROWIDTOCHAR(t.ROWID) AS "__DBX_PK_0" FROM DBX_TEST.T_UDT@DBX_LOOP t`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
gotSchema, gotTable = schema, table
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotSchema != "DBX_TEST" || gotTable != "T_UDT" {
|
||||
t.Fatalf("column metadata loaded for (%q, %q), want (DBX_TEST, T_UDT)", gotSchema, gotTable)
|
||||
}
|
||||
want := `SELECT t."ID", CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G", ROWIDTOCHAR(t.ROWID) AS "__DBX_PK_0" FROM DBX_TEST.T_UDT@DBX_LOOP t`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteOracleOpaqueObjectExplicitColumnKeepsLinkedAlias(t *testing.T) {
|
||||
sqlText, err := rewriteOracleSelectSQL(
|
||||
`SELECT t.ID, t.G FROM T_UDT@DBX_LOOP t`,
|
||||
func(schema, table string) ([]oracleColumnMeta, error) {
|
||||
return []oracleColumnMeta{
|
||||
{Name: "ID", DataType: "NUMBER"},
|
||||
{Name: "G", DataType: "GEOM_T", DataTypeOwner: "DBX_TEST"},
|
||||
}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `SELECT t.ID, CASE WHEN t."G" IS NULL THEN NULL ELSE '<GEOM_T>' END AS "G" FROM T_UDT@DBX_LOOP t`
|
||||
if sqlText != want {
|
||||
t.Fatalf("rewriteOracleSelectSQL() = %s, want %s", sqlText, want)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -519,10 +519,17 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
+ "ORDER BY c.colid";
|
||||
}
|
||||
|
||||
// sysobjects.xtype codes for the object kinds the object browser and the
|
||||
// tree both ask object source for: P = stored procedure, FN = scalar
|
||||
// function, V = view, TR = trigger. Views were missing, so "view DDL" on a
|
||||
// SQL Server legacy connection failed with "Unsupported object type: VIEW"
|
||||
// instead of returning the definition stored in syscomments (#10162).
|
||||
private static String sqlServer2000ObjectXtype(String objectType) {
|
||||
return switch (objectType) {
|
||||
case "PROCEDURE" -> "P";
|
||||
case "FUNCTION" -> "FN";
|
||||
case "VIEW" -> "V";
|
||||
case "TRIGGER" -> "TR";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
+79
@@ -3,6 +3,7 @@ package com.dbx.agent.sqlserverlegacy;
|
||||
import com.dbx.agent.ConnectParams;
|
||||
import com.dbx.agent.ColumnInfo;
|
||||
import com.dbx.agent.IndexInfo;
|
||||
import com.dbx.agent.ObjectSource;
|
||||
import com.dbx.agent.test.TestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -126,6 +127,48 @@ class SqlServerLegacyAgentTest {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The object browser asks for object source with the object's kind, so a
|
||||
* view must map to sysobjects.xtype 'V' (and a trigger to 'TR') instead of
|
||||
* being rejected as an unsupported object type (#10162).
|
||||
*/
|
||||
@Test
|
||||
void sqlServer2000ObjectSourceResolvesViewsAndTriggers() {
|
||||
List<String> boundXtypes = new java.util.ArrayList<>();
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, objectSourceConnection(boundXtypes));
|
||||
setSqlServer2000Mode(agent, true);
|
||||
|
||||
ObjectSource view = agent.getObjectSource("dbo", "V_ORDERS", "VIEW");
|
||||
ObjectSource trigger = agent.getObjectSource("dbo", "TR_ORDERS", "TRIGGER");
|
||||
ObjectSource procedure = agent.getObjectSource("dbo", "P_ORDERS", "PROCEDURE");
|
||||
|
||||
Assertions.assertEquals(List.of("V", "TR", "P"), boundXtypes);
|
||||
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", view.getSource());
|
||||
Assertions.assertEquals("VIEW", view.getObject_type());
|
||||
Assertions.assertEquals("dbo", view.getSchema());
|
||||
// Legacy catalogs stay read-only in the editor.
|
||||
Assertions.assertFalse(view.isEditable());
|
||||
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", trigger.getSource());
|
||||
Assertions.assertEquals("CREATE VIEW dbo.V_ORDERS AS SELECT 1", procedure.getSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ObjectSourceStillRejectsKindsWithoutAnXtype() {
|
||||
List<String> boundXtypes = new java.util.ArrayList<>();
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
TestSupport.setPrivateConnection(agent, objectSourceConnection(boundXtypes));
|
||||
setSqlServer2000Mode(agent, true);
|
||||
|
||||
IllegalArgumentException error = Assertions.assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> agent.getObjectSource("dbo", "PKG_ORDERS", "PACKAGE")
|
||||
);
|
||||
|
||||
Assertions.assertEquals("Unsupported object type: PACKAGE", error.getMessage());
|
||||
Assertions.assertTrue(boundXtypes.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServer2000ColumnCommentsEnrichJdbcColumnRemarks() {
|
||||
SqlServerLegacyAgent agent = new SqlServerLegacyAgent();
|
||||
@@ -495,6 +538,42 @@ class SqlServerLegacyAgentTest {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection whose object-source query returns the ordered syscomments
|
||||
* chunks and records the xtype the agent bound, so tests can assert the
|
||||
* sysobjects.xtype mapping without a live legacy server.
|
||||
*/
|
||||
private static Connection objectSourceConnection(List<String> boundXtypes) {
|
||||
return proxy(Connection.class, (method, args) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
|
||||
String name = statementMethod.getName();
|
||||
if ("setString".equals(name) && statementArgs != null && Integer.valueOf(3).equals(statementArgs[0])) {
|
||||
boundXtypes.add((String) statementArgs[1]);
|
||||
return null;
|
||||
}
|
||||
if ("executeQuery".equals(name)) {
|
||||
return metadataResultSet(
|
||||
Arrays.asList(
|
||||
Arrays.asList("CREATE VIEW dbo.V_ORDERS AS "),
|
||||
Arrays.asList("SELECT 1")
|
||||
),
|
||||
Map.of("SOURCE_TEXT", 0)
|
||||
);
|
||||
}
|
||||
if ("close".equals(name)) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(statementMethod.getReturnType());
|
||||
});
|
||||
}
|
||||
if ("close".equals(method.getName()) || "isClosed".equals(method.getName())) {
|
||||
return "isClosed".equals(method.getName()) ? Boolean.FALSE : null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement commentStatement(List<List<Object>> rows) {
|
||||
return proxy(PreparedStatement.class, (method, args) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
|
||||
@@ -60,6 +60,15 @@ for platform in "${PLATFORMS[@]}"; do
|
||||
cp "$native_file" "$WORK/drivers/"
|
||||
done
|
||||
|
||||
# The SQLite SSH worker executes on the remote SSH host, so its Linux binaries
|
||||
# must ship in every bundle: the desktop platform never matches them, which
|
||||
# otherwise leaves the worker missing from offline packages (#8987).
|
||||
for worker_file in "$RELEASE_DIR"/dbx-agent-sqlite-worker-*-linux-x64 \
|
||||
"$RELEASE_DIR"/dbx-agent-sqlite-worker-*-linux-aarch64; do
|
||||
[ -f "$worker_file" ] || continue
|
||||
cp "$worker_file" "$WORK/drivers/"
|
||||
done
|
||||
|
||||
ZIP_NAME="dbx-agents-offline-${platform}.zip"
|
||||
ZIP_ENTRIES=(agent-registry.json jre/ drivers/)
|
||||
[ -d "$WORK/jdbc" ] && ZIP_ENTRIES+=(jdbc/)
|
||||
|
||||
+38
-38
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"access": "0.1.63",
|
||||
"bigquery": "0.1.67",
|
||||
"cache": "0.1.11",
|
||||
"access": "0.1.65",
|
||||
"bigquery": "0.1.69",
|
||||
"cache": "0.1.13",
|
||||
"cassandra": "0.1.50",
|
||||
"dameng": "0.1.75",
|
||||
"databend": "0.1.54",
|
||||
"databricks": "0.1.61",
|
||||
"db2": "0.1.67",
|
||||
"duckdb": "0.1.21",
|
||||
"exasol": "0.1.61",
|
||||
"firebird": "0.1.62",
|
||||
"gbase8a": "0.1.61",
|
||||
"gbase8s": "0.1.56",
|
||||
"goldendb": "0.1.66",
|
||||
"h2": "0.1.67",
|
||||
"h2-legacy": "0.1.36",
|
||||
"dameng": "0.1.77",
|
||||
"databend": "0.1.56",
|
||||
"databricks": "0.1.63",
|
||||
"db2": "0.1.69",
|
||||
"duckdb": "0.1.23",
|
||||
"exasol": "0.1.63",
|
||||
"firebird": "0.1.64",
|
||||
"gbase8a": "0.1.63",
|
||||
"gbase8s": "0.1.58",
|
||||
"goldendb": "0.1.68",
|
||||
"h2": "0.1.69",
|
||||
"h2-legacy": "0.1.38",
|
||||
"hive": "0.1.55",
|
||||
"argo": "0.1.0",
|
||||
"ignite": "0.1.19",
|
||||
"ignite3": "0.1.19",
|
||||
"spark": "0.1.38",
|
||||
"informix": "0.1.67",
|
||||
"ignite": "0.1.21",
|
||||
"ignite3": "0.1.21",
|
||||
"spark": "0.1.40",
|
||||
"informix": "0.1.69",
|
||||
"kingbase": "0.1.59",
|
||||
"kylin": "0.1.66",
|
||||
"kylin": "0.1.68",
|
||||
"neo4j": "0.1.48",
|
||||
"oceanbase-oracle": "0.1.63",
|
||||
"oracle": "0.1.63",
|
||||
"snowflake": "0.1.66",
|
||||
"sqlite-worker": "0.1.3",
|
||||
"spanner": "0.1.19",
|
||||
"saphana": "0.1.62",
|
||||
"sundb": "0.1.66",
|
||||
"teradata": "0.1.61",
|
||||
"tdengine": "0.1.49",
|
||||
"trino": "0.1.66",
|
||||
"oceanbase-oracle": "0.1.65",
|
||||
"oracle": "0.1.64",
|
||||
"snowflake": "0.1.68",
|
||||
"sqlite-worker": "0.1.4",
|
||||
"spanner": "0.1.21",
|
||||
"saphana": "0.1.64",
|
||||
"sundb": "0.1.68",
|
||||
"teradata": "0.1.63",
|
||||
"tdengine": "0.1.50",
|
||||
"trino": "0.1.68",
|
||||
"vastbase": "0.1.51",
|
||||
"vertica": "0.1.61",
|
||||
"mongodb": "0.1.69",
|
||||
"highgo": "0.1.64",
|
||||
"uxdb": "0.1.33",
|
||||
"yashandb": "0.1.62",
|
||||
"oscar": "0.1.41",
|
||||
"vertica": "0.1.63",
|
||||
"mongodb": "0.1.71",
|
||||
"highgo": "0.1.66",
|
||||
"uxdb": "0.1.35",
|
||||
"yashandb": "0.1.64",
|
||||
"oscar": "0.1.43",
|
||||
"xugu": "0.1.47",
|
||||
"iris": "0.1.60",
|
||||
"iris": "0.1.62",
|
||||
"iotdb": "0.1.41",
|
||||
"etcd": "0.1.43",
|
||||
"etcd2": "0.1.1",
|
||||
@@ -50,5 +50,5 @@
|
||||
"kafka": "0.1.14",
|
||||
"rocketmq": "0.1.13",
|
||||
"rabbitmq": "0.1.11",
|
||||
"sqlserver-legacy": "0.1.39"
|
||||
"sqlserver-legacy": "0.1.41"
|
||||
}
|
||||
|
||||
+32
-7
@@ -13,6 +13,10 @@
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html.dark,
|
||||
html.dark body,
|
||||
html.dark #root {
|
||||
@@ -22,30 +26,47 @@
|
||||
#startup-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font: 13px system-ui, sans-serif;
|
||||
font:
|
||||
14px system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
#startup-loading::before {
|
||||
#startup-loading .startup-brand {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
html.dark #startup-loading .startup-brand {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
#startup-loading .startup-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(107, 114, 128, 0.22);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 999px;
|
||||
content: "";
|
||||
animation: dbx-startup-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#startup-loading::before { animation: none; }
|
||||
#startup-loading .startup-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dbx-startup-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script data-dbx-startup-theme>
|
||||
@@ -98,7 +119,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
|
||||
<div id="startup-loading" role="status" aria-live="polite" aria-busy="true">
|
||||
<span class="startup-brand" aria-hidden="true">DBX</span>
|
||||
<span class="startup-spinner" aria-hidden="true"></span>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Salesforce">
|
||||
<path fill="#00A1E0" d="M82 60c4.5-11 15.5-19 28-19 10 0 19 5 24 13 3-1 6-2 9-2 13 0 23 10 23 23 0 2 0 3-.5 5 9 4 15 13 15 23 0 15-12 27-27 27H58c-18 0-33-15-33-33 0-16 12-30 27-33 3-12 15-21 28-21 1 0 2 0 2 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
+96
-41
@@ -2,12 +2,17 @@
|
||||
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
|
||||
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FileText } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "@/components/layout/groupTabBarPortal";
|
||||
import PluginShortcutBar from "@/components/plugins/PluginShortcutBar.vue";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
|
||||
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
|
||||
@@ -144,7 +149,7 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
|
||||
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
|
||||
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
@@ -179,6 +184,7 @@ import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChan
|
||||
import { resolveWindowContext } from "@/lib/app/windowContext";
|
||||
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
|
||||
import { OPEN_PLUGIN_AI_CONVERSATION, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
|
||||
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
|
||||
@@ -214,6 +220,7 @@ type AiAssistantHandle = {
|
||||
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -361,9 +368,9 @@ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, appl
|
||||
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const needsAuth = ref(!isDesktop);
|
||||
const authenticated = ref(isDesktop);
|
||||
const setupRequired = ref(false);
|
||||
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 showConnectionDialog = ref(false);
|
||||
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
|
||||
@@ -396,6 +403,7 @@ const agentDriverUpdateCount = ref(0);
|
||||
const showHistory = ref(false);
|
||||
const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true");
|
||||
const isAiPanelMaximized = ref(false);
|
||||
const isHistoryPanelMaximized = ref(false);
|
||||
const isZenMode = ref(false);
|
||||
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
|
||||
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
|
||||
@@ -484,6 +492,20 @@ const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQ
|
||||
let aiRunsQuitConfirmed = false;
|
||||
|
||||
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
|
||||
const pluginAiRecommendationsByTab = ref<Record<string, PluginAiRecommendationHostUpdate>>({});
|
||||
const activePluginAiRecommendations = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || tab.mode !== "plugin-workbench" || !tab.pluginWorkbench) return undefined;
|
||||
return pluginAiRecommendationsByTab.value[tab.id];
|
||||
});
|
||||
|
||||
function updatePluginAiRecommendations(tabId: string, update: PluginAiRecommendationHostUpdate): void {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab?.pluginWorkbench || tab.pluginWorkbench.pluginId !== update.pluginId || tab.pluginWorkbench.contributionId !== update.contributionId) return;
|
||||
const expectedWorkbenchId = typeof tab.pluginWorkbench.context?.workbenchId === "string" ? tab.pluginWorkbench.context.workbenchId : undefined;
|
||||
if (expectedWorkbenchId && update.workbenchId && expectedWorkbenchId !== update.workbenchId) return;
|
||||
pluginAiRecommendationsByTab.value = { ...pluginAiRecommendationsByTab.value, [tabId]: update };
|
||||
}
|
||||
// Plugin workbench tabs stay mounted once opened (hidden via v-show): an
|
||||
// iframe moved out of the DOM reloads from scratch, so KeepAlive/ContentArea
|
||||
// remounts flash the whole webview and drop its live session state.
|
||||
@@ -937,9 +959,16 @@ function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: str
|
||||
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
|
||||
}
|
||||
|
||||
function requestActiveEditorExecuteInNewResultTab() {
|
||||
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.()) return;
|
||||
void tryExecuteInNewResultTab();
|
||||
function requestActiveEditorExecuteInNewResultTab(source?: "pointer" | "keyboard", tabId?: string) {
|
||||
const snapshot = pendingToolbarExecutionSnapshot.value;
|
||||
pendingToolbarExecutionSnapshot.value = undefined;
|
||||
const targetTabId = tabId ?? (source === "pointer" ? snapshot?.tabId : undefined);
|
||||
if (source === "pointer" && snapshot && snapshot.tabId === targetTabId) {
|
||||
void tryExecuteInNewResultTab(snapshot, { tabId: targetTabId });
|
||||
return;
|
||||
}
|
||||
if (contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab?.(targetTabId)) return;
|
||||
void tryExecuteInNewResultTab(undefined, targetTabId ? { tabId: targetTabId } : undefined);
|
||||
}
|
||||
|
||||
const toolbarAgentDriverUpdateCount = computed(() => Math.max(agentDriverUpdateCount.value, componentUpdates.driverUpdateCount.value));
|
||||
@@ -983,6 +1012,7 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
databaseRequiredSignalFor: (tabId: string) => (databaseRequiredTabId.value === tabId ? databaseRequiredSignal.value : 0),
|
||||
captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,
|
||||
toolbarExecute: requestActiveEditorExecute,
|
||||
toolbarExecuteInNewResultTab: requestActiveEditorExecuteInNewResultTab,
|
||||
cancelExecution: (tabId: string) => cancelActiveExecution(tabId),
|
||||
explain: (tabId: string) => tryExplain(undefined, { tabId }),
|
||||
formatSql: formatActiveSql,
|
||||
@@ -1263,11 +1293,12 @@ function openPluginConnectionDialog(pluginId: string, providerId: string) {
|
||||
showConnectionDialog.value = true;
|
||||
}
|
||||
async function checkAllUpdates() {
|
||||
if (manualCheckingAllUpdates.value) return;
|
||||
if (manualCheckingAllUpdates.value || componentUpdates.updating.value) return;
|
||||
manualCheckingAllUpdates.value = true;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const [, componentRefresh] = await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh()]);
|
||||
// A user-triggered check must replace any in-flight background snapshot that may predate the update.
|
||||
const [, componentRefresh] = await Promise.allSettled([checkUpdates({ silent: true }), componentUpdates.refresh({ force: true })]);
|
||||
if (componentRefresh.status === "fulfilled" && componentRefresh.value) syncToolbarComponentUpdateState();
|
||||
const remaining = 500 - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||
@@ -1283,7 +1314,7 @@ function openDriverStoreFromUpdate(target?: DriverStoreTab) {
|
||||
|
||||
function handleToolbarUpdateClick() {
|
||||
showUpdateDialog.value = true;
|
||||
if (!checkingAllUpdates.value) void checkAllUpdates();
|
||||
void checkAllUpdates();
|
||||
}
|
||||
|
||||
function syncToolbarComponentUpdateState() {
|
||||
@@ -1674,6 +1705,7 @@ function applyRightSidebarPanelState(next: RightSidebarPanelState) {
|
||||
}
|
||||
|
||||
function setRightSidebarPanelOpen(panelId: RightSidebarPanelId, open: boolean) {
|
||||
if ((panelId === "history" && !open) || (panelId !== "history" && open)) isHistoryPanelMaximized.value = false;
|
||||
if (panelId === "ai" && !open) {
|
||||
isAiPanelMaximized.value = false;
|
||||
} else if (open && panelId !== "ai" && isAiPanelMaximized.value) {
|
||||
@@ -3863,12 +3895,24 @@ function onLoginSuccess() {
|
||||
async function initApp() {
|
||||
const t0 = performance.now();
|
||||
console.log("[STARTUP] initApp begin");
|
||||
void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()])
|
||||
.then(() => {
|
||||
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
void queryStore.hydrateSavedSqlTabs();
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
|
||||
const restoreOpenTabs = async () => {
|
||||
await settingsStore.initEditorSettings();
|
||||
markStartupPhase("settings-ready");
|
||||
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
await connectionStore.initFromDisk();
|
||||
markStartupPhase("connections-ready");
|
||||
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) });
|
||||
markStartupPhase("tabs-restored");
|
||||
console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
};
|
||||
|
||||
@@ -3906,15 +3950,6 @@ async function initApp() {
|
||||
|
||||
void promptTemplateStore.init();
|
||||
|
||||
void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()])
|
||||
.then(() => {
|
||||
console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
void queryStore.hydrateSavedSqlTabs();
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
|
||||
restoreActiveConnectionContext();
|
||||
} catch (e: any) {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -3973,6 +4008,8 @@ function runUpdateNotificationChecks() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
clearStartupPreloadRetry();
|
||||
markStartupPhase("app-mounted");
|
||||
console.log("[STARTUP] onMounted begin");
|
||||
const mountStart = performance.now();
|
||||
if (isDetachedWindowContext) {
|
||||
@@ -4022,14 +4059,15 @@ onMounted(async () => {
|
||||
true,
|
||||
);
|
||||
if (!isDesktop) {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/api/auth/check"));
|
||||
const data = await res.json();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
setupRequired.value = data.setup_required;
|
||||
} catch {
|
||||
/* server unreachable */
|
||||
if (!startupProps.startupAuthentication) {
|
||||
try {
|
||||
const data = await checkStartupAuthentication();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
setupRequired.value = data.setup_required;
|
||||
} catch {
|
||||
/* server unreachable */
|
||||
}
|
||||
}
|
||||
if (needsAuth.value && !authenticated.value) {
|
||||
history.replaceState(null, "", webPath("/login"));
|
||||
@@ -4058,14 +4096,15 @@ onMounted(async () => {
|
||||
appVersion.value = v;
|
||||
})
|
||||
.catch(() => {});
|
||||
setupTauriListeners();
|
||||
void setupTauriListeners().then(() => {
|
||||
void openPendingSqlFiles();
|
||||
void openPendingDbFiles();
|
||||
void openPendingConnectionLinks();
|
||||
void openPendingAiConfigLinks();
|
||||
void openPendingPluginInstallLinks();
|
||||
});
|
||||
setupCloseActionPromptListener();
|
||||
void setupDetachedWindowEvents();
|
||||
void openPendingSqlFiles();
|
||||
void openPendingDbFiles();
|
||||
void openPendingConnectionLinks();
|
||||
void openPendingAiConfigLinks();
|
||||
void openPendingPluginInstallLinks();
|
||||
console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`);
|
||||
});
|
||||
|
||||
@@ -4154,6 +4193,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<div :class="isDetachedWindowContext ? 'flex-1 flex min-h-0' : isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
|
||||
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('left-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
|
||||
<AppSidebar
|
||||
v-if="!isDetachedWindowContext"
|
||||
v-show="sidebarOpen && !isZenMode"
|
||||
@@ -4169,7 +4209,11 @@ onUnmounted(() => {
|
||||
@mousedown="rememberSidebarSearchSurface"
|
||||
/>
|
||||
|
||||
<div v-show="!isAiPanelMaximized || isZenMode" :class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div
|
||||
data-editor-content
|
||||
v-show="(!isAiPanelMaximized && !isHistoryPanelMaximized) || isZenMode"
|
||||
:class="isDetachedWindowContext ? 'flex-1 min-w-0 overflow-hidden bg-background' : isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'"
|
||||
>
|
||||
<div class="h-full flex min-h-0 min-w-0 flex-col">
|
||||
<AppTabBar
|
||||
v-if="!isDetachedWindowContext"
|
||||
@@ -4432,6 +4476,7 @@ onUnmounted(() => {
|
||||
:contribution-id="workbenchTab.pluginWorkbench!.contributionId"
|
||||
:context="workbenchTab.pluginWorkbench!.context"
|
||||
@close-tab="queryStore.closeTab(workbenchTab.id)"
|
||||
@recommendations="updatePluginAiRecommendations(workbenchTab.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4441,7 +4486,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showAiPanel"
|
||||
v-show="!isZenMode"
|
||||
v-show="!isHistoryPanelMaximized && !isZenMode"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isAiPanelMaximized ? 'min-w-0 flex-1' : 'min-w-[240px] max-w-full']"
|
||||
:style="isAiPanelMaximized ? {} : { width: aiPanelWidth + 'px' }"
|
||||
>
|
||||
@@ -4453,6 +4498,7 @@ onUnmounted(() => {
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
:maximized="isAiPanelMaximized"
|
||||
:plugin-recommendations="activePluginAiRecommendations"
|
||||
@append-sql="onAiAppendSql"
|
||||
@execute-sql="onAiExecuteSql"
|
||||
@temp-run-sql="onAiTempRunSql"
|
||||
@@ -4470,20 +4516,28 @@ onUnmounted(() => {
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showHistory"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: historyWidth + 'px' }"
|
||||
:class="[isClassicLayout ? 'h-full relative z-30 isolate bg-background' : 'h-full relative z-30 isolate rounded-md border border-border/80 bg-background', isHistoryPanelMaximized ? 'min-w-0 flex-1' : 'shrink-0 max-w-full']"
|
||||
:style="isHistoryPanelMaximized ? {} : { width: historyWidth + 'px' }"
|
||||
>
|
||||
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
|
||||
<div v-if="!isHistoryPanelMaximized" class="panel-resize-handle panel-resize-handle--left" @pointerdown="startHistoryResize" />
|
||||
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]" @mousedown="rememberAuxiliarySearchSurface('history')">
|
||||
<div data-history-panel class="h-full min-h-0">
|
||||
<QueryHistory :current-connection-id="activeTab?.connectionId" :current-database="activeTab?.database" @restore="restoreHistorySql" @analyze-ai="analyzeHistoryWithAi" @close="closeRightSidebarPanel('history')" />
|
||||
<QueryHistory
|
||||
:maximized="isHistoryPanelMaximized"
|
||||
@toggle-maximize="isHistoryPanelMaximized = !isHistoryPanelMaximized"
|
||||
:current-connection-id="activeTab?.connectionId"
|
||||
:current-database="activeTab?.database"
|
||||
@restore="restoreHistorySql"
|
||||
@analyze-ai="analyzeHistoryWithAi"
|
||||
@close="closeRightSidebarPanel('history')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlLibraryPanel"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: sqlLibraryWidth + 'px' }"
|
||||
>
|
||||
@@ -4497,7 +4551,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="!isDetachedWindowContext && showSqlFilePanel"
|
||||
v-show="!isAiPanelMaximized && !isZenMode"
|
||||
v-show="!isAiPanelMaximized && !isHistoryPanelMaximized && !isZenMode"
|
||||
:class="isClassicLayout ? 'h-full shrink-0 relative z-30 isolate bg-background' : 'h-full shrink-0 relative z-30 isolate rounded-md border border-border/80 bg-background'"
|
||||
:style="{ width: sqlFilePanelWidth + 'px' }"
|
||||
>
|
||||
@@ -4506,6 +4560,7 @@ onUnmounted(() => {
|
||||
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
|
||||
</div>
|
||||
</div>
|
||||
<PluginShortcutBar v-if="!isDetachedWindowContext && !isZenMode && settingsStore.editorSettings.pluginShortcuts.enabled && settingsStore.editorSettings.pluginShortcuts.position.startsWith('right-')" :position="settingsStore.editorSettings.pluginShortcuts.position" />
|
||||
</div>
|
||||
|
||||
<AppDialogs
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LoginPage from "@/components/auth/LoginPage.vue";
|
||||
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
|
||||
import StartupLoading from "@/components/layout/StartupLoading.vue";
|
||||
import { useMigrationStore } from "@/stores/migrationStore";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
import { webPath } from "@/lib/common/webPath";
|
||||
import { loadSavedLocale } from "@/i18n";
|
||||
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
|
||||
import { markStartupPhase } from "@/lib/startup/startupTiming";
|
||||
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
|
||||
|
||||
// App setup installs listeners and instantiates business stores, so even its import
|
||||
// is deferred until authentication and the security migration have completed.
|
||||
const App = defineAsyncComponent(() => import("./App.vue"));
|
||||
const startupAsyncOptions = {
|
||||
loadingComponent: StartupLoading,
|
||||
errorComponent: StartupLoading,
|
||||
delay: 0,
|
||||
suspensible: false,
|
||||
onError(error: Error, _retry: () => void, fail: () => void) {
|
||||
retryStartupAfterPreloadFailure(error);
|
||||
fail();
|
||||
},
|
||||
};
|
||||
const App = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("./App.vue") });
|
||||
const LoginPage = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/auth/LoginPage.vue") });
|
||||
const SecurityMigrationWizard = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/migration/SecurityMigrationWizard.vue") });
|
||||
const props = defineProps<{ localeReady?: Promise<void> }>();
|
||||
const { t } = useI18n();
|
||||
const migration = useMigrationStore();
|
||||
const { blocking } = migration;
|
||||
@@ -17,33 +33,42 @@ const checkingAuth = ref(true);
|
||||
const loginRequired = ref(false);
|
||||
const setupRequired = ref(false);
|
||||
const authFailed = ref(false);
|
||||
const enteringApp = ref(false);
|
||||
let launchTransitionTimer: number | undefined;
|
||||
const startupAuthentication = shallowRef<StartupAuthentication>();
|
||||
const checkingLocale = ref(Boolean(props.localeReady));
|
||||
const localeFailed = ref(false);
|
||||
let initialLocaleReady = props.localeReady;
|
||||
let authRequest: AbortController | undefined;
|
||||
|
||||
watch(
|
||||
() => migration.state.entered,
|
||||
(entered) => {
|
||||
if (entered) {
|
||||
enteringApp.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
watch(migration.blocking, (blocked) => {
|
||||
if (!blocked) markStartupPhase("migration-ready");
|
||||
});
|
||||
|
||||
function appReady() {
|
||||
window.clearTimeout(launchTransitionTimer);
|
||||
launchTransitionTimer = window.setTimeout(() => {
|
||||
enteringApp.value = false;
|
||||
}, 650);
|
||||
async function initializeLocale() {
|
||||
if (!initialLocaleReady && !localeFailed.value) return;
|
||||
checkingLocale.value = true;
|
||||
localeFailed.value = false;
|
||||
try {
|
||||
await (initialLocaleReady ?? loadSavedLocale());
|
||||
markStartupPhase("locale-ready");
|
||||
window.dispatchEvent(new Event("dbx:startup-ready"));
|
||||
} catch {
|
||||
localeFailed.value = true;
|
||||
} finally {
|
||||
initialLocaleReady = undefined;
|
||||
checkingLocale.value = false;
|
||||
}
|
||||
}
|
||||
async function initialize() {
|
||||
authRequest?.abort();
|
||||
const request = new AbortController();
|
||||
authRequest = request;
|
||||
checkingAuth.value = true;
|
||||
authFailed.value = false;
|
||||
try {
|
||||
if (!isTauriRuntime()) {
|
||||
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
|
||||
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
|
||||
const result = await response.json();
|
||||
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
|
||||
const result = await checkStartupAuthentication(request.signal);
|
||||
if (request.signal.aborted) return;
|
||||
startupAuthentication.value = result;
|
||||
setupRequired.value = result.setup_required === true;
|
||||
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
|
||||
if (loginRequired.value) {
|
||||
@@ -51,74 +76,27 @@ async function initialize() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
markStartupPhase("auth-ready");
|
||||
await migration.initialize();
|
||||
} catch {
|
||||
authFailed.value = true;
|
||||
if (!request.signal.aborted) authFailed.value = true;
|
||||
} finally {
|
||||
checkingAuth.value = false;
|
||||
if (!request.signal.aborted) checkingAuth.value = false;
|
||||
}
|
||||
}
|
||||
async function authenticated() {
|
||||
history.replaceState(null, "", webPath("/"));
|
||||
await initialize();
|
||||
}
|
||||
onMounted(initialize);
|
||||
onMounted(() => {
|
||||
void initializeLocale();
|
||||
void initialize();
|
||||
});
|
||||
onUnmounted(() => authRequest?.abort());
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
|
||||
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
|
||||
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
|
||||
</div>
|
||||
<StartupLoading v-if="checkingLocale || localeFailed || checkingAuth || authFailed" :label="authFailed ? t('migration.authFailed') : !checkingLocale && !localeFailed ? t('migration.checking') : undefined" :error="localeFailed || authFailed" :retry="localeFailed ? initializeLocale : initialize" />
|
||||
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
|
||||
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
|
||||
<div v-else class="relative min-h-screen">
|
||||
<Suspense @resolve="appReady">
|
||||
<App />
|
||||
</Suspense>
|
||||
<Transition name="startup-fade">
|
||||
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
|
||||
<div class="flex flex-col items-center gap-5 text-center">
|
||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
|
||||
</div>
|
||||
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<App v-else :startup-authentication="startupAuthentication" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.startup-fade-enter-active,
|
||||
.startup-fade-leave-active {
|
||||
transition: opacity 280ms ease;
|
||||
}
|
||||
|
||||
.startup-fade-enter-from,
|
||||
.startup-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.startup-progress {
|
||||
animation: startup-progress 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes startup-progress {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.startup-progress {
|
||||
animation: none;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: vi.fn(),
|
||||
appImported: vi.fn(),
|
||||
reloadLocale: vi.fn(),
|
||||
migrationStatus: vi.fn(),
|
||||
migrationStart: vi.fn(),
|
||||
migrationRetry: vi.fn(),
|
||||
migrationCleanupBackups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.desktop }));
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("@/i18n", () => ({ loadSavedLocale: mocks.reloadLocale }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const readyStatus = { state: "not_required", needsMigration: false };
|
||||
let businessReady: ReturnType<typeof deferred<void>>;
|
||||
let mountedApp: App | undefined;
|
||||
let root: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
sessionStorage.clear();
|
||||
mocks.desktop.mockReturnValue(true);
|
||||
mocks.migrationStatus.mockResolvedValue(readyStatus);
|
||||
mocks.reloadLocale.mockResolvedValue(undefined);
|
||||
businessReady = deferred<void>();
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
vi.doMock("../App.vue", async () => {
|
||||
mocks.appImported();
|
||||
await businessReady.promise;
|
||||
return {
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["startupAuthentication"],
|
||||
setup: (props) => () => h("main", { "data-business-app": "" }, JSON.stringify(props.startupAuthentication ?? null)),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("@/components/auth/LoginPage.vue", () => ({
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["setupMode"],
|
||||
emits: ["authenticated"],
|
||||
setup:
|
||||
(props, { emit }) =>
|
||||
() =>
|
||||
h("button", { "data-login": "", onClick: () => emit("authenticated") }, props.setupMode ? "setup" : "login"),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/components/migration/SecurityMigrationWizard.vue", () => ({
|
||||
__esModule: true,
|
||||
default: defineComponent({
|
||||
props: ["store"],
|
||||
setup: (props) => () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
"data-migration": "",
|
||||
onClick: async () => {
|
||||
await props.store.start();
|
||||
props.store.enter();
|
||||
},
|
||||
},
|
||||
"migrate",
|
||||
),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount();
|
||||
mountedApp = undefined;
|
||||
root.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function mountGate(localeReady?: Promise<void>) {
|
||||
const { default: Gate } = await import("../StartupGate.vue");
|
||||
mountedApp = createApp(Gate, { localeReady });
|
||||
mountedApp.config.errorHandler = vi.fn();
|
||||
mountedApp.mount(root);
|
||||
}
|
||||
|
||||
describe("startup boundary", () => {
|
||||
it("checks migration while the locale loads and keeps feedback until the app module resolves", async () => {
|
||||
const locale = deferred<void>();
|
||||
await mountGate(locale.promise);
|
||||
await vi.waitFor(() => expect(mocks.migrationStatus).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
|
||||
locale.resolve();
|
||||
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
|
||||
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
|
||||
expect(root.querySelector("main")).toBeNull();
|
||||
businessReady.resolve();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-business-app]")).not.toBeNull());
|
||||
expect(root.querySelector("[data-startup-loading]")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the checked Web authentication state without an extra request", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const authentication = { required: true, authenticated: true, setup_required: false };
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => authentication });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(JSON.parse(root.querySelector("main")!.textContent!)).toEqual(authentication);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("does not inspect protected data until login/setup completes (setup=%s)", async (setup) => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: false, setup_required: setup }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: true }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-login]")).not.toBeNull());
|
||||
expect(root.querySelector("[data-login]")!.textContent).toBe(setup ? "setup" : "login");
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
(root.querySelector("[data-login]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not load the business app until a required migration is completed and entered", async () => {
|
||||
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
|
||||
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector("[data-migration]")).not.toBeNull());
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
(root.querySelector("[data-migration]") as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.migrationStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed on malformed auth and allows retry", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => null })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: false, authenticated: false }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
businessReady.resolve();
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
root.querySelector("button")!.click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
});
|
||||
|
||||
it("retries a failed locale load without rerunning successful security checks", async () => {
|
||||
const locale = deferred<void>();
|
||||
businessReady.resolve();
|
||||
await mountGate(locale.promise);
|
||||
locale.reject(new Error("locale download failed"));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(mocks.appImported).not.toHaveBeenCalled();
|
||||
root.querySelector("button")!.click();
|
||||
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
|
||||
expect(mocks.reloadLocale).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a safe retry state when the app module fails to load", async () => {
|
||||
await mountGate();
|
||||
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
|
||||
businessReady.reject(new Error("private module URL"));
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
|
||||
expect(root.textContent).toContain("startup.loadFailed");
|
||||
expect(root.textContent).not.toContain("private module URL");
|
||||
expect(root.querySelector("button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("cancels pending authentication when the gate unmounts", async () => {
|
||||
mocks.desktop.mockReturnValue(false);
|
||||
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await mountGate();
|
||||
await nextTick();
|
||||
const signal = fetchMock.mock.calls[0]?.[1].signal as AbortSignal;
|
||||
mountedApp!.unmount();
|
||||
mountedApp = undefined;
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(mocks.migrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { authorizationPrivileges, type AuthorizationPreset, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
|
||||
import { authorizationPresetPrivileges, authorizationPrivileges, type AuthorizationDatabaseOption, type AuthorizationPreset, type AuthorizationTableSelection, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
|
||||
import type { DatabaseUserAdminProvider } from "@/lib/database/databaseUserAdmin";
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
/** 当前连接的权限提供者,用于判断是否支持表级授权以及表级可用权限 */
|
||||
provider: DatabaseUserAdminProvider | null;
|
||||
/** 当前连接下的数据库名列表 */
|
||||
databases: string[];
|
||||
databases: AuthorizationDatabaseOption[];
|
||||
/** 数据库列表是否正在加载 */
|
||||
databasesLoading?: boolean;
|
||||
/** 连接 ID,用于按库懒加载表列表 */
|
||||
@@ -37,9 +37,10 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
// 全局作用域标识:MySQL 的 GRANT/REVOKE 支持 ON *.*,权限编辑面板需要保留这一能力
|
||||
const GLOBAL_SCOPE = "*";
|
||||
const GLOBAL_OPTION: AuthorizationDatabaseOption = { database: GLOBAL_SCOPE };
|
||||
const search = ref("");
|
||||
// 表列表按库懒加载并缓存,避免下拉展开时重复请求
|
||||
const tables = ref<Record<string, string[]>>({});
|
||||
const tables = ref<Record<string, AuthorizationTableSelection[]>>({});
|
||||
const tablesLoading = ref<Record<string, boolean>>({});
|
||||
const tableErrors = ref<Record<string, string>>({});
|
||||
const tableSearch = ref<Record<string, string>>({});
|
||||
@@ -47,12 +48,12 @@ const tableSearch = ref<Record<string, string>>({});
|
||||
const supportsTableGrants = computed(() => !!props.provider?.supportsTableGrantsOnCreate);
|
||||
const filteredDatabases = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
return query ? props.databases.filter((database) => database.toLowerCase().includes(query)) : props.databases;
|
||||
return query ? props.databases.filter((database) => databaseLabel(database).toLowerCase().includes(query)) : props.databases;
|
||||
});
|
||||
// 紧凑模式(权限编辑面板)在列表顶部提供固定的「全局」入口,且不受库搜索影响;
|
||||
// 新增用户弹窗保持只勾选真实数据库,不改变原有产品行为。
|
||||
const visibleDatabases = computed(() => (props.compact ? [GLOBAL_SCOPE, ...filteredDatabases.value] : filteredDatabases.value));
|
||||
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map((selection) => selection.database)));
|
||||
const visibleDatabases = computed(() => (props.compact && props.provider?.authorizationModel === "mysql" ? [GLOBAL_OPTION, ...filteredDatabases.value] : filteredDatabases.value));
|
||||
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map(authorizationSelectionKey)));
|
||||
|
||||
// 同一 tick 内连续触发多次选择变更时(例如连续点击多张表),父组件回流的 props 尚未更新,
|
||||
// 这里用最近一次发出的值作为基准,避免后一次变更覆盖前一次导致选择丢失。
|
||||
@@ -61,6 +62,19 @@ watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
pendingSelections = value;
|
||||
for (const selection of value) {
|
||||
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.databases,
|
||||
() => {
|
||||
for (const selection of props.modelValue) {
|
||||
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -73,33 +87,34 @@ function commitSelections(next: DatabaseAuthorizationSelection[]) {
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
function selectionFor(database: string): DatabaseAuthorizationSelection | undefined {
|
||||
return props.modelValue.find((selection) => selection.database === database);
|
||||
function selectionFor(database: AuthorizationDatabaseOption): DatabaseAuthorizationSelection | undefined {
|
||||
const key = authorizationOptionKey(database);
|
||||
return props.modelValue.find((selection) => authorizationSelectionKey(selection) === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一以“不可变更新”方式回写选中项:始终替换为新的数组与对象,
|
||||
* 避免直接修改 props 中的对象引用(v-model 单向数据流的约束)。
|
||||
*/
|
||||
function patchSelection(database: string, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
|
||||
function patchSelection(database: AuthorizationDatabaseOption, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
|
||||
const next: DatabaseAuthorizationSelection[] = [];
|
||||
for (const selection of currentSelections()) {
|
||||
if (selection.database !== database) {
|
||||
if (authorizationSelectionKey(selection) !== authorizationOptionKey(database)) {
|
||||
next.push(selection);
|
||||
continue;
|
||||
}
|
||||
const updated = patch({ ...selection });
|
||||
if (updated) next.push({ ...updated, tables: updated.tables ? [...updated.tables] : undefined, privileges: updated.privileges ? [...updated.privileges] : undefined });
|
||||
if (updated) next.push({ ...updated, tables: updated.tables?.map((table) => ({ ...table })), privileges: updated.privileges ? [...updated.privileges] : undefined });
|
||||
}
|
||||
commitSelections(next);
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string) {
|
||||
function toggleDatabase(database: AuthorizationDatabaseOption) {
|
||||
if (selectionFor(database)) {
|
||||
commitSelections(currentSelections().filter((selection) => selection.database !== database));
|
||||
commitSelections(currentSelections().filter((selection) => authorizationSelectionKey(selection) !== authorizationOptionKey(database)));
|
||||
return;
|
||||
}
|
||||
commitSelections([...currentSelections(), { database, preset: "readOnly", privileges: ["SELECT"] }]);
|
||||
commitSelections([...currentSelections(), { database: database.database, ...(database.catalog ? { catalog: database.catalog } : {}), preset: "readOnly", privileges: ["SELECT"] }]);
|
||||
}
|
||||
|
||||
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
|
||||
@@ -109,43 +124,63 @@ function privilegesForSelection(selection?: DatabaseAuthorizationSelection): str
|
||||
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
|
||||
}
|
||||
|
||||
function usesSelectedTables(database: string): boolean {
|
||||
function usesSelectedTables(database: AuthorizationDatabaseOption): boolean {
|
||||
return selectionFor(database)?.tables !== undefined;
|
||||
}
|
||||
|
||||
function isTableSelected(database: string, table: string): boolean {
|
||||
return selectionFor(database)?.tables?.includes(table) ?? false;
|
||||
function isTableSelected(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection): boolean {
|
||||
return selectionFor(database)?.tables?.some((selected) => authorizationTableKey(selected) === authorizationTableKey(table)) ?? false;
|
||||
}
|
||||
|
||||
function allTablesSelected(database: string): boolean {
|
||||
const available = tables.value[database] ?? [];
|
||||
return available.length > 0 && selectionFor(database)?.tables?.length === available.length;
|
||||
function allTablesSelected(database: AuthorizationDatabaseOption): boolean {
|
||||
const key = authorizationOptionKey(database);
|
||||
const available = tables.value[key] ?? [];
|
||||
const selected = new Set(selectionFor(database)?.tables?.map(authorizationTableKey) ?? []);
|
||||
return available.length > 0 && available.every((table) => selected.has(authorizationTableKey(table)));
|
||||
}
|
||||
|
||||
function filteredTables(database: string): string[] {
|
||||
const query = (tableSearch.value[database] ?? "").trim().toLowerCase();
|
||||
const available = tables.value[database] ?? [];
|
||||
return query ? available.filter((table) => table.toLowerCase().includes(query)) : available;
|
||||
function filteredTables(database: AuthorizationDatabaseOption): AuthorizationTableSelection[] {
|
||||
const key = authorizationOptionKey(database);
|
||||
const query = (tableSearch.value[key] ?? "").trim().toLowerCase();
|
||||
const available = tables.value[key] ?? [];
|
||||
return query ? available.filter((table) => tableLabel(table).toLowerCase().includes(query)) : available;
|
||||
}
|
||||
|
||||
async function loadTables(database: string) {
|
||||
if (tables.value[database] || tablesLoading.value[database]) return;
|
||||
tablesLoading.value = { ...tablesLoading.value, [database]: true };
|
||||
tableErrors.value = { ...tableErrors.value, [database]: "" };
|
||||
async function loadTables(database: AuthorizationDatabaseOption) {
|
||||
const key = authorizationOptionKey(database);
|
||||
if (tables.value[key] || tablesLoading.value[key]) return;
|
||||
tablesLoading.value = { ...tablesLoading.value, [key]: true };
|
||||
tableErrors.value = { ...tableErrors.value, [key]: "" };
|
||||
try {
|
||||
const result = await api.listTables(props.connectionId, database, "");
|
||||
let result: AuthorizationTableSelection[];
|
||||
if (props.provider?.authorizationModel === "postgres") {
|
||||
const schemas = (await api.listSchemas(props.connectionId, database.database)).filter(isPostgresGrantSchema);
|
||||
const schemaTables = await Promise.all(
|
||||
schemas.map(async (schema) => {
|
||||
const items = await api.listTables(props.connectionId, database.database, schema, undefined, undefined, undefined, ["TABLE"]);
|
||||
return items.map((table) => ({ name: table.name, schema }));
|
||||
}),
|
||||
);
|
||||
result = schemaTables.flat();
|
||||
} else if (props.provider?.authorizationModel === "starrocks") {
|
||||
const items = await api.listTables(props.connectionId, database.database, "", undefined, undefined, undefined, ["TABLE"], database.catalog);
|
||||
result = items.map((table) => ({ name: table.name }));
|
||||
} else {
|
||||
const items = await api.listTables(props.connectionId, database.database, "");
|
||||
result = items.map((table) => ({ name: table.name }));
|
||||
}
|
||||
tables.value = {
|
||||
...tables.value,
|
||||
[database]: Array.from(new Set(result.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
|
||||
[key]: uniqueTableOptions(result).sort((left, right) => tableLabel(left).localeCompare(tableLabel(right))),
|
||||
};
|
||||
} catch (error: any) {
|
||||
tableErrors.value = { ...tableErrors.value, [database]: error?.message || String(error) };
|
||||
tableErrors.value = { ...tableErrors.value, [key]: error?.message || String(error) };
|
||||
} finally {
|
||||
tablesLoading.value = { ...tablesLoading.value, [database]: false };
|
||||
tablesLoading.value = { ...tablesLoading.value, [key]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTableScope(database: string, selectTables: boolean) {
|
||||
async function updateTableScope(database: AuthorizationDatabaseOption, selectTables: boolean) {
|
||||
if (!selectTables) {
|
||||
patchSelection(database, (selection) => {
|
||||
delete selection.tables;
|
||||
@@ -155,6 +190,7 @@ async function updateTableScope(database: string, selectTables: boolean) {
|
||||
}
|
||||
patchSelection(database, (selection) => {
|
||||
selection.tables = selection.tables ?? [];
|
||||
if (authorizationPresetPrivileges(props.provider!, selection.preset, selection.privileges, "table").length === 0) selection.preset = "readOnly";
|
||||
if (selection.preset === "custom") {
|
||||
const allowed = new Set(privilegesForSelection(selection));
|
||||
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
|
||||
@@ -164,27 +200,25 @@ async function updateTableScope(database: string, selectTables: boolean) {
|
||||
await loadTables(database);
|
||||
}
|
||||
|
||||
function toggleTable(database: string, table: string) {
|
||||
function toggleTable(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection) {
|
||||
patchSelection(database, (selection) => {
|
||||
if (!selection.tables) return selection;
|
||||
const next = new Set(selection.tables);
|
||||
if (next.has(table)) next.delete(table);
|
||||
else next.add(table);
|
||||
selection.tables = Array.from(next);
|
||||
const key = authorizationTableKey(table);
|
||||
selection.tables = selection.tables.some((selected) => authorizationTableKey(selected) === key) ? selection.tables.filter((selected) => authorizationTableKey(selected) !== key) : [...selection.tables, table];
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllTables(database: string) {
|
||||
function toggleAllTables(database: AuthorizationDatabaseOption) {
|
||||
patchSelection(database, (selection) => {
|
||||
if (!selection.tables) return selection;
|
||||
const available = tables.value[database] ?? [];
|
||||
selection.tables = selection.tables.length === available.length ? [] : [...available];
|
||||
const available = tables.value[authorizationOptionKey(database)] ?? [];
|
||||
selection.tables = allTablesSelected(database) ? [] : [...available];
|
||||
return selection;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreset(database: string, preset: unknown) {
|
||||
function updatePreset(database: AuthorizationDatabaseOption, preset: unknown) {
|
||||
if (typeof preset !== "string") return;
|
||||
patchSelection(database, (selection) => {
|
||||
selection.preset = preset as AuthorizationPreset;
|
||||
@@ -192,7 +226,7 @@ function updatePreset(database: string, preset: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function togglePrivilege(database: string, privilege: string) {
|
||||
function togglePrivilege(database: AuthorizationDatabaseOption, privilege: string) {
|
||||
patchSelection(database, (selection) => {
|
||||
const next = new Set(selection.privileges ?? []);
|
||||
if (next.has(privilege)) next.delete(privilege);
|
||||
@@ -202,9 +236,45 @@ function togglePrivilege(database: string, privilege: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
function isPrivilegeSelected(database: AuthorizationDatabaseOption, privilege: string): boolean {
|
||||
return selectionFor(database)?.privileges?.includes(privilege) ?? false;
|
||||
}
|
||||
|
||||
function authorizationOptionKey(option: AuthorizationDatabaseOption): string {
|
||||
return JSON.stringify([option.catalog ?? "", option.database]);
|
||||
}
|
||||
|
||||
function authorizationSelectionKey(selection: Pick<DatabaseAuthorizationSelection, "catalog" | "database">): string {
|
||||
return authorizationOptionKey(selection);
|
||||
}
|
||||
|
||||
function databaseLabel(option: AuthorizationDatabaseOption): string {
|
||||
return option.catalog ? `${option.catalog} / ${option.database}` : option.database;
|
||||
}
|
||||
|
||||
function authorizationTableKey(table: AuthorizationTableSelection): string {
|
||||
return JSON.stringify([table.schema ?? "", table.name]);
|
||||
}
|
||||
|
||||
function tableLabel(table: AuthorizationTableSelection): string {
|
||||
return table.schema ? `${table.schema}.${table.name}` : table.name;
|
||||
}
|
||||
|
||||
function uniqueTableOptions(options: AuthorizationTableSelection[]): AuthorizationTableSelection[] {
|
||||
const seen = new Set<string>();
|
||||
return options.filter((option) => {
|
||||
if (!option.name.trim()) return false;
|
||||
const key = authorizationTableKey(option);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isPostgresGrantSchema(schema: string): boolean {
|
||||
const normalized = schema.toLowerCase();
|
||||
return !!schema.trim() && normalized !== "information_schema" && normalized !== "sys_catalog" && !normalized.startsWith("pg_");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -227,13 +297,13 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
{{ t("userAdmin.loadingDatabases") }}
|
||||
</div>
|
||||
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
|
||||
<div v-for="database in visibleDatabases" :key="database" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
|
||||
<div v-for="database in visibleDatabases" :key="authorizationOptionKey(database)" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
|
||||
<div class="flex items-center gap-2">
|
||||
<input :checked="selectedDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
|
||||
<input :checked="selectedDatabaseSet.has(authorizationOptionKey(database))" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleDatabase(database)">
|
||||
{{ database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : database }}
|
||||
{{ database.database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : databaseLabel(database) }}
|
||||
</button>
|
||||
<Select v-if="selectedDatabaseSet.has(database)" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
|
||||
<Select v-if="selectedDatabaseSet.has(authorizationOptionKey(database))" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
|
||||
<SelectTrigger class="h-7 text-xs" :class="compact ? 'w-28' : 'w-32'"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
|
||||
@@ -244,7 +314,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="supportsTableGrants && database !== GLOBAL_SCOPE && selectedDatabaseSet.has(database)" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
|
||||
<div v-if="supportsTableGrants && database.database !== GLOBAL_SCOPE && selectedDatabaseSet.has(authorizationOptionKey(database))" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="!compact" class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
|
||||
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
|
||||
@@ -263,7 +333,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
|
||||
</span>
|
||||
<Loader2 v-if="tablesLoading[database]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<Loader2 v-if="tablesLoading[authorizationOptionKey(database)]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -271,29 +341,29 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
<div class="flex items-center gap-2 border-b p-2">
|
||||
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="tableSearch[database]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
|
||||
<input v-model="tableSearch[authorizationOptionKey(database)]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
|
||||
</div>
|
||||
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[database]?.length > 0)" @click="toggleAllTables(database)">
|
||||
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[authorizationOptionKey(database)]?.length > 0)" @click="toggleAllTables(database)">
|
||||
{{ t(allTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-auto p-1">
|
||||
<div v-if="tablesLoading[database]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<div v-if="tablesLoading[authorizationOptionKey(database)]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingTables") }}
|
||||
</div>
|
||||
<div v-else-if="tableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
|
||||
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[database] }) }}</p>
|
||||
<div v-else-if="tableErrors[authorizationOptionKey(database)]" class="px-3 py-4 text-center text-xs text-destructive">
|
||||
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[authorizationOptionKey(database)] }) }}</p>
|
||||
<button type="button" class="mt-2 text-primary" @click="loadTables(database)">{{ t("userAdmin.retry") }}</button>
|
||||
</div>
|
||||
<div v-else-if="filteredTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("userAdmin.emptyTables") }}
|
||||
</div>
|
||||
<button v-for="table in filteredTables(database)" :key="table" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
|
||||
<button v-for="table in filteredTables(database)" :key="authorizationTableKey(table)" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
|
||||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="isTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
|
||||
<Check v-if="isTableSelected(database, table)" class="h-2.5 w-2.5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
|
||||
<span class="min-w-0 flex-1 truncate">{{ tableLabel(table) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -319,7 +389,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
|
||||
{{ t("userAdmin.customPrivilegeRequired") }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(database) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
|
||||
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(authorizationOptionKey(database)) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
|
||||
{{ t("userAdmin.postgresDdlHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,15 +16,18 @@ import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { grantsFromQueryResult, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
|
||||
import { grantsFromQueryResult, normalizeStarrocksCatalog, resolveDatabaseUserAdminProviderForConnection, type DatabaseTablePrivilegeGrant, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
|
||||
import {
|
||||
authorizationPlanSql,
|
||||
authorizationPlanStatus,
|
||||
authorizationPresetPrivileges,
|
||||
authorizationPrivileges,
|
||||
buildCreateUserAuthorizationPlan,
|
||||
buildGrantAuthorizationPlan,
|
||||
databaseAuthorizationsFromTableGrants,
|
||||
executeAuthorizationPlan,
|
||||
type AuthorizationAccountType,
|
||||
type AuthorizationDatabaseOption,
|
||||
type AuthorizationPlan,
|
||||
type AuthorizationStepResult,
|
||||
type DatabaseAuthorizationSelection,
|
||||
@@ -88,12 +91,19 @@ const grantOption = ref(restoredUiState.grantOption ?? false);
|
||||
const selectedPrivileges = ref<string[]>(restoredUiState.selectedPrivileges ?? ["SELECT"]);
|
||||
const createCanLogin = ref(true);
|
||||
const createAccountType = ref<AuthorizationAccountType>("standard");
|
||||
const createDatabases = ref<string[]>([]);
|
||||
const createDatabases = ref<AuthorizationDatabaseOption[]>([]);
|
||||
const createDatabasesLoading = ref(false);
|
||||
const createDatabasesLoadError = ref("");
|
||||
const createDatabaseAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
|
||||
// 权限编辑面板的授权范围选择(MySQL 表级授权场景),与新增用户弹窗复用同一个范围编辑器
|
||||
// 权限编辑面板的授权范围选择,与新增用户弹窗复用同一个范围编辑器
|
||||
const privilegeAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
|
||||
const currentTableGrants = ref<DatabaseTablePrivilegeGrant[]>([]);
|
||||
const currentTableGrantsLoaded = ref(false);
|
||||
const currentTableGrantsLoading = ref(false);
|
||||
const tableGrantLoadError = ref("");
|
||||
let createPlanRequestId = 0;
|
||||
let tableGrantRequestId = 0;
|
||||
let databaseLoadPromise: Promise<void> | undefined;
|
||||
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
|
||||
|
||||
trackUiState(() => ({
|
||||
@@ -139,9 +149,9 @@ const grantsSqlText = computed(() => grants.value.join("\n") || t("userAdmin.noG
|
||||
const highlightedGrantsSql = computed(() => highlight(grantsSqlText.value));
|
||||
const highlightedPendingSql = computed(() => highlight(pendingSql.value));
|
||||
const createDatabaseAuthorizationsValid = computed(() => authorizationSelectionsValid(createDatabaseAuthorizations.value));
|
||||
// MySQL 表级授权场景下,权限编辑面板复用与新增用户一致的授权范围编辑器
|
||||
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role");
|
||||
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role" && (provider.value?.authorizationModel !== "postgres" || privilegeScope.value === "table"));
|
||||
const privilegeAuthorizationsValid = computed(() => authorizationSelectionsValid(privilegeAuthorizations.value));
|
||||
const tableGrantDiffReady = computed(() => provider.value?.authorizationModel === "mysql" || currentTableGrantsLoaded.value);
|
||||
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
|
||||
const canPreviewHostChange = computed(() => {
|
||||
const host = newHost.value.trim();
|
||||
@@ -221,23 +231,94 @@ async function loadGrants() {
|
||||
loadingGrants.value = true;
|
||||
grantsLoaded.value = false;
|
||||
grantError.value = "";
|
||||
if (userProvider.authorizationModel !== "mysql") {
|
||||
currentTableGrants.value = [];
|
||||
currentTableGrantsLoaded.value = false;
|
||||
currentTableGrantsLoading.value = false;
|
||||
tableGrantLoadError.value = "";
|
||||
}
|
||||
try {
|
||||
const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, {
|
||||
maxRows: 1000,
|
||||
});
|
||||
grants.value = (userProvider.parseGrants ?? grantsFromQueryResult)(result);
|
||||
grantsLoaded.value = true;
|
||||
if (userProvider.tableGrantsFromShowGrants && userProvider.parseTableGrants) {
|
||||
currentTableGrants.value = userProvider.parseTableGrants(result);
|
||||
currentTableGrantsLoaded.value = true;
|
||||
tableGrantLoadError.value = "";
|
||||
syncAuthorizationSelectionsFromTableGrants();
|
||||
}
|
||||
// Existing-user editing reflects the exact SHOW GRANTS scope; create-user defaults stay independent.
|
||||
syncPrivilegeSelectionFromGrants();
|
||||
if (userProvider.authorizationModel === "postgres" && usePrivilegeScopeEditor.value) await loadCurrentTableGrants();
|
||||
} catch (error: any) {
|
||||
grantError.value = error?.message || String(error);
|
||||
grants.value = [];
|
||||
grantsLoaded.value = false;
|
||||
currentTableGrants.value = [];
|
||||
currentTableGrantsLoaded.value = false;
|
||||
} finally {
|
||||
loadingGrants.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentTableGrants() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
if (!user || !userProvider?.tableGrantsSql || !userProvider.parseTableGrants || currentTableGrantsLoading.value) return;
|
||||
const requestId = ++tableGrantRequestId;
|
||||
const selectedKey = userKey(user);
|
||||
currentTableGrantsLoading.value = true;
|
||||
currentTableGrantsLoaded.value = false;
|
||||
tableGrantLoadError.value = "";
|
||||
try {
|
||||
if (createDatabases.value.length === 0) await loadDatabases();
|
||||
if (createDatabasesLoadError.value) throw new Error(createDatabasesLoadError.value);
|
||||
const grantsByDatabase = await Promise.all(
|
||||
createDatabases.value.map(async ({ database }) => {
|
||||
const result = await api.executeQuery(props.connection.id, database, userProvider.tableGrantsSql!(user), undefined, undefined, { maxRows: 10000 });
|
||||
return userProvider.parseTableGrants!(result, { database });
|
||||
}),
|
||||
);
|
||||
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
|
||||
currentTableGrants.value = grantsByDatabase.flat();
|
||||
currentTableGrantsLoaded.value = true;
|
||||
syncAuthorizationSelectionsFromTableGrants();
|
||||
} catch (error: any) {
|
||||
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
|
||||
currentTableGrants.value = [];
|
||||
currentTableGrantsLoaded.value = false;
|
||||
tableGrantLoadError.value = error?.message || String(error);
|
||||
} finally {
|
||||
if (requestId === tableGrantRequestId) currentTableGrantsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuthorizationSelectionsFromTableGrants() {
|
||||
const userProvider = provider.value;
|
||||
if (!userProvider || createDatabases.value.length === 0 || privilegeAuthorizations.value.length > 0) return;
|
||||
const loaded = databaseAuthorizationsFromTableGrants(userProvider, currentTableGrants.value);
|
||||
const available = new Map(createDatabases.value.map((option) => [authorizationDatabaseOptionKey(option), option]));
|
||||
const selections = loaded.selections.flatMap((selection) => {
|
||||
const option = available.get(authorizationDatabaseOptionKey(selection));
|
||||
if (!option) return [];
|
||||
return [{ ...selection, catalog: option.catalog }];
|
||||
});
|
||||
privilegeAuthorizations.value = selections;
|
||||
if (selections.length > 0) grantOption.value = loaded.grantOption;
|
||||
}
|
||||
|
||||
function authorizationDatabaseOptionKey(option: AuthorizationDatabaseOption): string {
|
||||
const catalog = provider.value?.authorizationModel === "starrocks" ? (normalizeStarrocksCatalog(option.catalog ?? "") ?? "default_catalog") : (option.catalog ?? "");
|
||||
return JSON.stringify([catalog, option.database]);
|
||||
}
|
||||
|
||||
async function preparePrivilegeScopeEditor() {
|
||||
if (createDatabases.value.length === 0) await loadDatabases();
|
||||
if (provider.value?.authorizationModel === "postgres" && !currentTableGrantsLoaded.value) await loadCurrentTableGrants();
|
||||
}
|
||||
|
||||
function selectUser(user: DatabaseUserIdentity) {
|
||||
selectedUserKey.value = userKey(user);
|
||||
}
|
||||
@@ -257,19 +338,39 @@ function togglePrivilege(privilege: string) {
|
||||
}
|
||||
|
||||
// 加载当前连接下的数据库列表:新增用户弹窗与权限编辑面板的授权范围编辑器共用
|
||||
async function loadDatabases() {
|
||||
if (createDatabasesLoading.value) return;
|
||||
createDatabases.value = [];
|
||||
createDatabasesLoading.value = true;
|
||||
try {
|
||||
await ensureConnection();
|
||||
const config = props.connection;
|
||||
createDatabases.value = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
|
||||
} catch (error: any) {
|
||||
toast(t("userAdmin.loadDatabasesFailed", { message: error?.message || String(error) }), 5000);
|
||||
} finally {
|
||||
createDatabasesLoading.value = false;
|
||||
}
|
||||
function loadDatabases(): Promise<void> {
|
||||
if (databaseLoadPromise) return databaseLoadPromise;
|
||||
let request!: Promise<void>;
|
||||
request = (async () => {
|
||||
createDatabases.value = [];
|
||||
createDatabasesLoading.value = true;
|
||||
createDatabasesLoadError.value = "";
|
||||
try {
|
||||
await ensureConnection();
|
||||
const config = props.connection;
|
||||
if (provider.value?.authorizationModel === "starrocks") {
|
||||
const catalogs = await api.listDorisCatalogs(config.id);
|
||||
if (catalogs.length > 0) {
|
||||
const catalogDatabases = await Promise.all(catalogs.map(async (catalog) => (await api.listDorisCatalogDatabases(config.id, catalog.name)).map((database) => ({ catalog: catalog.name, database: database.name }))));
|
||||
createDatabases.value = catalogDatabases.flat();
|
||||
} else {
|
||||
createDatabases.value = (await api.listDatabases(config.id)).map((database) => ({ database: database.name }));
|
||||
}
|
||||
} else {
|
||||
const databases = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
|
||||
createDatabases.value = databases.map((database) => ({ database }));
|
||||
}
|
||||
if (currentTableGrantsLoaded.value) syncAuthorizationSelectionsFromTableGrants();
|
||||
} catch (error: any) {
|
||||
createDatabasesLoadError.value = error?.message || String(error);
|
||||
toast(t("userAdmin.loadDatabasesFailed", { message: createDatabasesLoadError.value }), 5000);
|
||||
} finally {
|
||||
createDatabasesLoading.value = false;
|
||||
if (databaseLoadPromise === request) databaseLoadPromise = undefined;
|
||||
}
|
||||
})();
|
||||
databaseLoadPromise = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
async function openCreateUserDialog() {
|
||||
@@ -290,11 +391,23 @@ function authorizationSelectionsValid(selections: DatabaseAuthorizationSelection
|
||||
if (!userProvider) return false;
|
||||
return selections.every((selection) => {
|
||||
if (selection.tables !== undefined && selection.tables.length === 0) return false;
|
||||
if (selection.preset !== "custom") return true;
|
||||
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table").some((privilege) => selection.privileges?.includes(privilege));
|
||||
const targetScope = selection.tables === undefined && userProvider.authorizationModel !== "starrocks" ? "database" : "table";
|
||||
const available = authorizationPrivileges(userProvider, targetScope);
|
||||
if (selection.preset !== "custom") return authorizationPresetPrivileges(userProvider, selection.preset, selection.privileges, targetScope).length > 0;
|
||||
return available.some((privilege) => selection.privileges?.includes(privilege));
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareAuthorizationSelections(selections: DatabaseAuthorizationSelection[]): Promise<DatabaseAuthorizationSelection[]> {
|
||||
if (provider.value?.authorizationModel !== "postgres") return selections;
|
||||
return Promise.all(
|
||||
selections.map(async (selection) => ({
|
||||
...selection,
|
||||
schemas: selection.tables === undefined ? await api.listSchemas(props.connection.id, selection.database) : Array.from(new Set(selection.tables.flatMap((table) => (table.schema ? [table.schema] : [])))),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void> } = {}) {
|
||||
pendingSql.value = sql;
|
||||
pendingPlan.value = undefined;
|
||||
@@ -315,7 +428,7 @@ async function applyPendingSql() {
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => {
|
||||
if (pendingPlan.value) {
|
||||
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, continueOnError: true }));
|
||||
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, catalog: step.targetCatalog, continueOnError: step.targetCatalog ? false : true }));
|
||||
}
|
||||
const queryResults = await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000, continueOnError: true });
|
||||
const failed = queryResults.find((item) => item.execution_error === true);
|
||||
@@ -392,12 +505,15 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
|
||||
if (step.operation === "createUser") return t("userAdmin.stepCreateUser");
|
||||
if (step.operation === "grantAdmin") return t("userAdmin.stepGrantAdmin", { user: step.subject });
|
||||
if (step.operation === "grantDatabase") {
|
||||
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
|
||||
const database = authorizationStepDatabase(step);
|
||||
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database });
|
||||
}
|
||||
if (step.operation === "revokePrivileges") {
|
||||
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database: step.targetDatabase });
|
||||
const database = authorizationStepDatabase(step);
|
||||
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database });
|
||||
}
|
||||
if (step.operation === "grantCurrentObjects") {
|
||||
if (step.targetTable) return t("userAdmin.stepGrantTable", { user: step.subject, database: authorizationStepDatabase(step), table: step.targetTable });
|
||||
return t("userAdmin.stepGrantCurrentObjects", {
|
||||
user: step.subject,
|
||||
database: step.targetDatabase,
|
||||
@@ -413,6 +529,10 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
|
||||
return step.label;
|
||||
}
|
||||
|
||||
function authorizationStepDatabase(step: AuthorizationStepResult["step"]): string {
|
||||
return [step.targetCatalog, step.targetDatabase, step.targetSchema].filter(Boolean).join(".");
|
||||
}
|
||||
|
||||
function authorizationObjectScopeLabel(scope: AuthorizationStepResult["step"]["objectScope"]): string {
|
||||
if (scope === "schemas") return t("userAdmin.scopeSchemas");
|
||||
if (scope === "tables") return t("userAdmin.scopeTables");
|
||||
@@ -480,15 +600,23 @@ function previewPlan(plan: AuthorizationPlan, options: { danger?: boolean } = {}
|
||||
sqlDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function previewGrant() {
|
||||
async function previewGrant() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
|
||||
if (!user || !userProvider || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (usePrivilegeScopeEditor.value) {
|
||||
// MySQL 表级授权:按所选库/表展开为多条 GRANT(仅追加,不回收该用户已有权限)
|
||||
if (!privilegeAuthorizationsValid.value) return;
|
||||
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, grantOption: grantOption.value }));
|
||||
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
|
||||
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
|
||||
previewPlan(
|
||||
buildGrantAuthorizationPlan({
|
||||
provider: userProvider,
|
||||
user,
|
||||
databases,
|
||||
grantOption: grantOption.value,
|
||||
currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
previewSql(
|
||||
@@ -504,15 +632,15 @@ function previewGrant() {
|
||||
);
|
||||
}
|
||||
|
||||
function previewRevoke() {
|
||||
async function previewRevoke() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
|
||||
if (!user || !userProvider || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
|
||||
if (usePrivilegeScopeEditor.value) {
|
||||
// 与授权保持同一套选择:按所选库/表展开为多条 REVOKE
|
||||
if (!privilegeAuthorizationsValid.value) return;
|
||||
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, revoke: true }), { danger: true });
|
||||
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
|
||||
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
|
||||
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases, revoke: true, currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value }), { danger: true });
|
||||
return;
|
||||
}
|
||||
previewSql(
|
||||
@@ -551,9 +679,14 @@ watch(
|
||||
watch(
|
||||
() => selectedUserKey.value,
|
||||
() => {
|
||||
tableGrantRequestId += 1;
|
||||
grantsLoaded.value = false;
|
||||
// 切换用户时清空上一位用户选择的授权范围,避免把权限误授给当前用户
|
||||
privilegeAuthorizations.value = [];
|
||||
currentTableGrants.value = [];
|
||||
currentTableGrantsLoaded.value = false;
|
||||
currentTableGrantsLoading.value = false;
|
||||
tableGrantLoadError.value = "";
|
||||
void loadGrants();
|
||||
},
|
||||
);
|
||||
@@ -561,17 +694,23 @@ watch(
|
||||
watch(
|
||||
() => props.connection.id,
|
||||
() => {
|
||||
tableGrantRequestId += 1;
|
||||
users.value = [];
|
||||
createDatabases.value = [];
|
||||
createDatabasesLoadError.value = "";
|
||||
createDatabaseAuthorizations.value = [];
|
||||
privilegeAuthorizations.value = [];
|
||||
currentTableGrants.value = [];
|
||||
currentTableGrantsLoaded.value = false;
|
||||
currentTableGrantsLoading.value = false;
|
||||
tableGrantLoadError.value = "";
|
||||
selectedUserKey.value = "";
|
||||
grants.value = [];
|
||||
grantsLoaded.value = false;
|
||||
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
|
||||
resetPrivilegeDefaults(privilegeScope.value);
|
||||
void loadUsers();
|
||||
if (usePrivilegeScopeEditor.value) void loadDatabases();
|
||||
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -599,10 +738,17 @@ watch(
|
||||
|
||||
watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants);
|
||||
|
||||
watch(
|
||||
() => usePrivilegeScopeEditor.value,
|
||||
async (enabled) => {
|
||||
if (!enabled) return;
|
||||
await preparePrivilegeScopeEditor();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadUsers();
|
||||
// 权限编辑面板的授权范围选择需要数据库列表,进入页面即预加载(仅 MySQL 表级授权场景)
|
||||
if (usePrivilegeScopeEditor.value) void loadDatabases();
|
||||
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -721,7 +867,7 @@ onMounted(() => {
|
||||
<aside v-if="canEditPrivileges" class="flex min-h-0 flex-col bg-muted/10">
|
||||
<div class="border-b p-3">
|
||||
<div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div>
|
||||
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
|
||||
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor && provider?.authorizationModel === "mysql" ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3">
|
||||
<template v-if="isPostgres">
|
||||
@@ -743,7 +889,14 @@ onMounted(() => {
|
||||
<label class="mb-2 block text-xs font-medium">{{ t("userAdmin.memberRole") }}</label>
|
||||
<Input v-model="privilegeRole" class="mb-3 h-8 text-xs" :placeholder="t('userAdmin.memberRole')" />
|
||||
</template>
|
||||
<AuthorizationScopeEditor v-else-if="usePrivilegeScopeEditor" v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
|
||||
<div v-else-if="usePrivilegeScopeEditor" class="flex h-full min-h-0 flex-col gap-2">
|
||||
<AuthorizationScopeEditor v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
|
||||
<div v-if="currentTableGrantsLoading" class="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("userAdmin.loadingGrants") }}
|
||||
</div>
|
||||
<p v-else-if="tableGrantLoadError" class="text-[11px] text-destructive">{{ tableGrantLoadError }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<label class="mb-2 block text-xs font-medium">
|
||||
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
|
||||
@@ -781,10 +934,10 @@ onMounted(() => {
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 border-t p-3">
|
||||
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewRevoke">
|
||||
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewRevoke">
|
||||
{{ t("userAdmin.revoke") }}
|
||||
</Button>
|
||||
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewGrant">
|
||||
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewGrant">
|
||||
{{ t("userAdmin.grant") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,7 @@ const previewVisible = computed(() => open.value && !!props.source);
|
||||
<Dialog v-model:open="open">
|
||||
<!-- 对话框整体限高:高度上限跟随 --dbx-viewport-height(兼容旧版 WebView 中 vh 不准的情况);
|
||||
内部采用纵向 flex 布局,header/footer 固定,只有中间预览区滚动,保证底部按钮始终可见 -->
|
||||
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
|
||||
<DialogContent class="flex max-h-[calc(var(--dbx-viewport-height)-2rem)] flex-col overflow-hidden border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[860px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Camera class="h-5 w-5 text-primary" />
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles";
|
||||
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
|
||||
import type { SalesforceAuthContext, SalesforceAuthMode, SalesforceEnvironment, SalesforceExternalConfig, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult } from "@/types/salesforce";
|
||||
import { SALESFORCE_OAUTH_CALLBACK_URL } from "@/types/salesforce";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { MqttConnectionConfig } from "@/types/mqtt";
|
||||
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
|
||||
@@ -99,6 +101,7 @@ import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage,
|
||||
import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget";
|
||||
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
|
||||
import { buildCassandraExternalConfig, cassandraTlsConfigFromExternalConfig, type CassandraTlsConfig } from "@/lib/connection/cassandraTlsOptions";
|
||||
import { savedMysqlTlsFormFields, supportsMysqlTlsOptions as mysqlTlsOptionsSupported, supportsMysqlTlsTab } from "@/lib/connection/mysqlTlsCapabilities";
|
||||
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
|
||||
import { postgresLegacyTlsEnabled, postgresTlsModeForForm, setPostgresLegacyTlsEnabled } from "@/lib/connection/postgresTlsMode";
|
||||
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
|
||||
@@ -1574,6 +1577,390 @@ function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig {
|
||||
return { apiPath, lookback };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Salesforce OAuth / device-code flow state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const salesforceAuthMode = ref<SalesforceAuthMode>("token");
|
||||
const salesforceEnvironment = ref<SalesforceEnvironment>("production");
|
||||
const salesforceLoginUrl = ref("");
|
||||
const salesforceClientId = ref("");
|
||||
const salesforceClientSecret = ref("");
|
||||
// Username-password (ROPC) mode credentials. The password is never
|
||||
// round-tripped from the backend; blank on edit unless re-typed.
|
||||
const salesforceUsername = ref("");
|
||||
const salesforceUserPassword = ref("");
|
||||
|
||||
const salesforceOauthRunning = ref(false);
|
||||
const salesforceOauthError = ref("");
|
||||
const salesforceOauthSuccess = ref("");
|
||||
// When editing an existing connection whose credentials live in the backend's
|
||||
// secret store, refreshToken/clientSecret are intentionally not sent back to
|
||||
// the UI. Track "we have previously authorized" separately from whether we
|
||||
// still hold the tokens in-memory so the form can show a status chip.
|
||||
const salesforceOauthPreviouslyAuthorized = ref(false);
|
||||
|
||||
type SalesforceDevicePhase = "idle" | "polling" | "success" | "expired" | "denied" | "error";
|
||||
const salesforceDevicePhase = ref<SalesforceDevicePhase>("idle");
|
||||
const salesforceDeviceUserCode = ref("");
|
||||
const salesforceDeviceVerificationUri = ref("");
|
||||
const salesforceDeviceCode = ref("");
|
||||
const salesforceDeviceIntervalSecs = ref(5);
|
||||
const salesforceDeviceSecondsLeft = ref(0);
|
||||
const salesforceDeviceError = ref("");
|
||||
const salesforceDeviceUserCodeCopied = ref(false);
|
||||
const salesforceDeviceVerificationCopied = ref(false);
|
||||
|
||||
let salesforceDevicePollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let salesforceDevicePollAbortId = 0;
|
||||
let salesforceDeviceExpiresTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function salesforceAuthModeFromConfig(externalConfig: unknown, fallbackPassword?: string): SalesforceAuthMode {
|
||||
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
|
||||
const mode = cfg?.auth?.mode;
|
||||
if (mode === "oauth" || mode === "device" || mode === "password") return mode;
|
||||
if (cfg?.auth && (cfg.auth.refreshToken || cfg.auth.clientId || cfg.auth.authorizedAt)) return "oauth";
|
||||
// No persisted auth context: if a password/token is present, treat as manual token.
|
||||
if (fallbackPassword?.trim()) return "token";
|
||||
return "token";
|
||||
}
|
||||
|
||||
function resetSalesforceOAuthFields(externalConfig?: unknown, fallbackPassword?: string) {
|
||||
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
|
||||
const auth = cfg?.auth;
|
||||
salesforceAuthMode.value = salesforceAuthModeFromConfig(externalConfig, fallbackPassword);
|
||||
salesforceEnvironment.value = (auth?.environment as SalesforceEnvironment) || "production";
|
||||
salesforceLoginUrl.value = auth?.loginUrl?.trim() || "";
|
||||
salesforceClientId.value = auth?.clientId?.trim() || "";
|
||||
// Client secret is never round-tripped from the backend for security; the
|
||||
// input is blank on edit and only sent when the user re-types it.
|
||||
salesforceClientSecret.value = "";
|
||||
salesforceUsername.value = auth?.username?.trim() || "";
|
||||
salesforceUserPassword.value = "";
|
||||
salesforceOauthRunning.value = false;
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
// A Client ID alone only means the form was filled in, not that anybody ever
|
||||
// signed in; claiming "authorized" from it showed a false reassurance banner.
|
||||
salesforceOauthPreviouslyAuthorized.value = !!(auth?.refreshToken || auth?.authorizedAt);
|
||||
salesforceDevicePhase.value = "idle";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceCode.value = "";
|
||||
salesforceDeviceIntervalSecs.value = 5;
|
||||
salesforceDeviceSecondsLeft.value = 0;
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
salesforceDeviceStopPolling();
|
||||
}
|
||||
|
||||
function hydrateSalesforceOAuthFields(value: unknown, fallbackPassword?: string) {
|
||||
resetSalesforceOAuthFields(value, fallbackPassword);
|
||||
}
|
||||
|
||||
function salesforceBuildAuthorizeParams(): SalesforceOAuthAuthorizeParams {
|
||||
const clientId = salesforceClientId.value.trim();
|
||||
if (!clientId) throw new Error(t("connection.salesforceOauthClientIdRequired"));
|
||||
const environment = salesforceEnvironment.value;
|
||||
const loginUrl = environment === "custom" ? salesforceLoginUrl.value.trim() : undefined;
|
||||
if (environment === "custom" && !loginUrl) {
|
||||
throw new Error(t("connection.salesforceOauthLoginUrlRequired"));
|
||||
}
|
||||
const clientSecret = salesforceClientSecret.value.trim() || undefined;
|
||||
return { environment, loginUrl, clientId, clientSecret };
|
||||
}
|
||||
|
||||
function salesforceApplyTokenToForm(token: { accessToken: string; instanceUrl: string }) {
|
||||
form.value.password = token.accessToken;
|
||||
// The backend normalizes bare hostnames; we mirror it so the card preview
|
||||
// shows the final instance URL even before the backend canonicalizes it.
|
||||
let instanceUrl = token.instanceUrl.trim();
|
||||
if (instanceUrl && !/^https?:\/\//i.test(instanceUrl)) {
|
||||
instanceUrl = `https://${instanceUrl.replace(/\/+$/, "")}`;
|
||||
}
|
||||
if (instanceUrl) {
|
||||
form.value.host = instanceUrl.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceBuildAuthContext(mode: SalesforceAuthMode, token?: { refreshToken?: string }): SalesforceAuthContext | undefined {
|
||||
if (mode === "token") return undefined;
|
||||
const context: SalesforceAuthContext = {
|
||||
mode,
|
||||
environment: salesforceEnvironment.value,
|
||||
clientId: salesforceClientId.value.trim() || undefined,
|
||||
authorizedAt: new Date().toISOString(),
|
||||
};
|
||||
if (salesforceEnvironment.value === "custom") {
|
||||
context.loginUrl = salesforceLoginUrl.value.trim() || undefined;
|
||||
}
|
||||
// Only forward the client secret when the user explicitly typed it in this
|
||||
// session — otherwise we would overwrite the backend-stored secret with "".
|
||||
if (salesforceClientSecret.value.trim()) {
|
||||
context.clientSecret = salesforceClientSecret.value.trim();
|
||||
}
|
||||
if (mode === "password") {
|
||||
context.username = salesforceUsername.value.trim() || undefined;
|
||||
if (salesforceUserPassword.value) {
|
||||
context.password = salesforceUserPassword.value;
|
||||
}
|
||||
}
|
||||
if (token?.refreshToken) {
|
||||
context.refreshToken = token.refreshToken;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function salesforceMergeAuthIntoExternalConfig(mode: SalesforceAuthMode, token?: { refreshToken?: string }) {
|
||||
const existing = (form.value.external_config && typeof form.value.external_config === "object" ? (form.value.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
|
||||
const auth = salesforceBuildAuthContext(mode, token);
|
||||
if (!auth) {
|
||||
const { auth: _dropped, ...rest } = existing;
|
||||
form.value.external_config = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
return;
|
||||
}
|
||||
// Preserve an existing refreshToken when the user re-authorizes without the
|
||||
// backend returning a new one (e.g. refresh flow that omits a rotation).
|
||||
if (!auth.refreshToken && existing.auth?.refreshToken) {
|
||||
auth.refreshToken = existing.auth.refreshToken;
|
||||
}
|
||||
// Same for the ROPC password: it is not round-tripped for display, so an
|
||||
// edit-without-retype must not clobber the stored credential.
|
||||
if (!auth.password && existing.auth?.password) {
|
||||
auth.password = existing.auth.password;
|
||||
}
|
||||
form.value.external_config = { ...existing, auth };
|
||||
}
|
||||
|
||||
async function salesforceStartBrowserAuthorize() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
try {
|
||||
const token = await api.salesforceOauthBrowserAuthorize(params);
|
||||
salesforceApplyTokenToForm(token);
|
||||
salesforceMergeAuthIntoExternalConfig("oauth", token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function salesforceStartPasswordLogin() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
return;
|
||||
}
|
||||
const username = salesforceUsername.value.trim();
|
||||
if (!username) {
|
||||
salesforceOauthError.value = t("connection.salesforceUsernameRequired");
|
||||
return;
|
||||
}
|
||||
if (!salesforceUserPassword.value) {
|
||||
salesforceOauthError.value = t("connection.salesforceUserPasswordRequired");
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
try {
|
||||
const token = await api.salesforceOauthPasswordLogin(params, username, salesforceUserPassword.value);
|
||||
salesforceApplyTokenToForm(token);
|
||||
salesforceMergeAuthIntoExternalConfig("password", token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceStopPolling() {
|
||||
salesforceDevicePollAbortId++;
|
||||
if (salesforceDevicePollTimer) {
|
||||
clearTimeout(salesforceDevicePollTimer);
|
||||
salesforceDevicePollTimer = null;
|
||||
}
|
||||
if (salesforceDeviceExpiresTimer) {
|
||||
clearInterval(salesforceDeviceExpiresTimer);
|
||||
salesforceDeviceExpiresTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceStartExpiryCountdown(expiresInSecs: number) {
|
||||
salesforceDeviceSecondsLeft.value = Math.max(0, Math.floor(expiresInSecs));
|
||||
if (salesforceDeviceExpiresTimer) clearInterval(salesforceDeviceExpiresTimer);
|
||||
salesforceDeviceExpiresTimer = setInterval(() => {
|
||||
salesforceDeviceSecondsLeft.value = Math.max(0, salesforceDeviceSecondsLeft.value - 1);
|
||||
if (salesforceDeviceSecondsLeft.value <= 0 && salesforceDevicePhase.value === "polling") {
|
||||
salesforceDeviceStopPolling();
|
||||
salesforceDevicePhase.value = "expired";
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function salesforceDeviceRequestCode() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
salesforceDevicePhase.value = "error";
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
salesforceDevicePhase.value = "idle";
|
||||
try {
|
||||
const start = await api.salesforceOauthDeviceStart(params);
|
||||
salesforceDeviceUserCode.value = start.userCode;
|
||||
salesforceDeviceVerificationUri.value = start.verificationUri;
|
||||
salesforceDeviceCode.value = start.deviceCode;
|
||||
salesforceDeviceIntervalSecs.value = Math.max(1, start.intervalSecs || 5);
|
||||
salesforceDevicePhase.value = "polling";
|
||||
salesforceDeviceStartExpiryCountdown(start.expiresInSecs);
|
||||
await salesforceDevicePollLoop(params, start.deviceCode, salesforceDeviceIntervalSecs.value, salesforceDevicePollAbortId);
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
salesforceDevicePhase.value = "error";
|
||||
salesforceDeviceStopPolling();
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function salesforceDevicePollLoop(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number, abortId: number): Promise<void> {
|
||||
while (salesforceDevicePhase.value === "polling" && abortId === salesforceDevicePollAbortId) {
|
||||
await new Promise<void>((resolve) => {
|
||||
salesforceDevicePollTimer = setTimeout(() => resolve(), intervalSecs * 1000);
|
||||
});
|
||||
if (abortId !== salesforceDevicePollAbortId) return;
|
||||
let result: SalesforceOAuthDevicePollResult;
|
||||
try {
|
||||
result = await api.salesforceOauthDevicePoll(params, deviceCode, intervalSecs);
|
||||
} catch (e) {
|
||||
// Transient network error: keep polling until the code expires.
|
||||
if (salesforceDeviceSecondsLeft.value <= 0) {
|
||||
salesforceDevicePhase.value = "expired";
|
||||
salesforceDeviceStopPolling();
|
||||
return;
|
||||
}
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
continue;
|
||||
}
|
||||
if (abortId !== salesforceDevicePollAbortId) return;
|
||||
if (result.status === "pending") {
|
||||
salesforceDeviceIntervalSecs.value = Math.max(1, result.intervalSecs || intervalSecs);
|
||||
continue;
|
||||
}
|
||||
salesforceDeviceStopPolling();
|
||||
if (result.status === "success") {
|
||||
salesforceApplyTokenToForm(result.token);
|
||||
salesforceMergeAuthIntoExternalConfig("device", result.token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: result.token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
salesforceDevicePhase.value = "success";
|
||||
return;
|
||||
}
|
||||
if (result.status === "expired") {
|
||||
salesforceDevicePhase.value = "expired";
|
||||
return;
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
salesforceDeviceError.value = result.reason || t("connection.salesforceDeviceDenied");
|
||||
salesforceDevicePhase.value = "denied";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceCancel() {
|
||||
salesforceDeviceStopPolling();
|
||||
salesforceDevicePhase.value = "idle";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceCode.value = "";
|
||||
}
|
||||
|
||||
function salesforceDeviceRestart() {
|
||||
salesforceDeviceCancel();
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceOauthError.value = "";
|
||||
}
|
||||
|
||||
async function salesforceCopyUserCode() {
|
||||
if (!salesforceDeviceUserCode.value) return;
|
||||
await copyToClipboard(salesforceDeviceUserCode.value);
|
||||
salesforceDeviceUserCodeCopied.value = true;
|
||||
setTimeout(() => {
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function salesforceCopyVerificationUri() {
|
||||
if (!salesforceDeviceVerificationUri.value) return;
|
||||
await copyToClipboard(salesforceDeviceVerificationUri.value);
|
||||
salesforceDeviceVerificationCopied.value = true;
|
||||
setTimeout(() => {
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function salesforceOpenVerificationPage() {
|
||||
if (!salesforceDeviceVerificationUri.value) return;
|
||||
// Same pattern as the update-flow "open release notes" action: defer to the
|
||||
// shell helper that picks the right opener for desktop vs. web.
|
||||
void (async () => {
|
||||
try {
|
||||
if (isDesktop) {
|
||||
const { open } = await import("@tauri-apps/plugin-shell");
|
||||
await open(salesforceDeviceVerificationUri.value);
|
||||
} else {
|
||||
globalThis.open(salesforceDeviceVerificationUri.value, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function salesforceSetAuthMode(mode: SalesforceAuthMode) {
|
||||
// Switching modes resets any in-flight state so stale tokens from one flow
|
||||
// never leak into another.
|
||||
if (salesforceOauthRunning.value && mode !== salesforceAuthMode.value) return;
|
||||
salesforceAuthMode.value = mode;
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
if (mode !== "device") {
|
||||
salesforceDeviceCancel();
|
||||
salesforceDeviceError.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch(influxDbVersion, (version, previousVersion) => {
|
||||
if (form.value.db_type !== "influxdb") return;
|
||||
if (version === "2" || version === "3") {
|
||||
@@ -2546,6 +2933,9 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.connection_string = undefined;
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "salesforce") {
|
||||
resetSalesforceOAuthFields(form.value.external_config, form.value.password);
|
||||
}
|
||||
resetHiveKerberosFields(profile.type === "hive" || profile.type === "argo" || profile.type === "kyuubi" || profile.type === "impala" ? form.value : undefined);
|
||||
}
|
||||
if (profile.type === "meilisearch") {
|
||||
@@ -2677,7 +3067,7 @@ watch(
|
||||
db_type: oceanbasePatch?.db_type || profileConfig?.type || config.db_type,
|
||||
driver_profile: config.db_type === "plugin" ? "plugin" : oceanbasePatch?.driver_profile || config.driver_profile || profile,
|
||||
driver_label: config.driver_label || oceanbasePatch?.driver_label || driverProfiles[profile]?.label || config.db_type,
|
||||
url_params: config.url_params || "",
|
||||
...savedMysqlTlsFormFields(config),
|
||||
agent_java_options: config.agent_java_options || [],
|
||||
host: config.db_type === "h2" && h2FilePathFromJdbcUrl(config.connection_string) ? h2FilePathFromJdbcUrl(config.connection_string) : config.host,
|
||||
port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port,
|
||||
@@ -2694,10 +3084,6 @@ watch(
|
||||
query_timeout_inherit: config.query_timeout_inherit === true,
|
||||
idle_timeout_secs: config.idle_timeout_secs ?? 60,
|
||||
keepalive_interval_secs: config.keepalive_interval_secs ?? 30,
|
||||
ssl: config.ssl || false,
|
||||
ca_cert_path: config.ca_cert_path || "",
|
||||
client_cert_path: config.client_cert_path || "",
|
||||
client_key_path: config.client_key_path || "",
|
||||
sysdba: config.sysdba || isOracleSysUser(config),
|
||||
oracle_connection_type: config.oracle_connection_type || "service_name",
|
||||
connection_string: config.connection_string,
|
||||
@@ -2777,6 +3163,11 @@ watch(
|
||||
} else {
|
||||
resetVictoriaMetricsFields();
|
||||
}
|
||||
if (config.db_type === "salesforce") {
|
||||
hydrateSalesforceOAuthFields(config.external_config, config.password);
|
||||
} else {
|
||||
resetSalesforceOAuthFields(undefined, undefined);
|
||||
}
|
||||
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
|
||||
resetHiveKerberosFields(config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala" ? config : undefined);
|
||||
resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined);
|
||||
@@ -3252,12 +3643,12 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
|
||||
"cassandra",
|
||||
"zookeeper",
|
||||
]);
|
||||
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
|
||||
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type) || supportsMysqlTlsTab(form.value.db_type, selectedType.value));
|
||||
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics");
|
||||
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
|
||||
const showGenericUrlParamsHint = computed(() => form.value.db_type === "mysql" || form.value.db_type === "doris" || form.value.db_type === "starrocks");
|
||||
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
|
||||
const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)));
|
||||
const supportsMysqlTlsOptions = computed(() => mysqlTlsOptionsSupported(form.value.db_type, selectedType.value));
|
||||
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
|
||||
const supportsDoltSystemTables = computed(() => isDoltDriverProfile(form.value.driver_profile));
|
||||
const showDoltSystemTables = computed({
|
||||
@@ -4426,6 +4817,51 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.connection_string = undefined;
|
||||
config.database = "metrics";
|
||||
config.username = config.username.trim();
|
||||
} else if (config.db_type === "salesforce") {
|
||||
// Token-mode connections never carry auth context (the access token itself is
|
||||
// the credential in `password`). OAuth/device modes merge the auth context
|
||||
// into `external_config.auth`; the backend handles refreshToken encryption.
|
||||
config.connection_string = undefined;
|
||||
config.database = undefined;
|
||||
config.username = config.username.trim();
|
||||
const existing = (config.external_config && typeof config.external_config === "object" ? (config.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
|
||||
if (salesforceAuthMode.value === "token") {
|
||||
const { auth: _dropped, ...rest } = existing;
|
||||
config.external_config = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
} else {
|
||||
// Preserve an existing refreshToken/clientSecret when the user saves the
|
||||
// form without re-authorizing or re-typing them (they're not round-tripped
|
||||
// from the backend). The hydrate step flagged this via
|
||||
// `salesforceOauthPreviouslyAuthorized`.
|
||||
const auth: SalesforceAuthContext = {
|
||||
mode: salesforceAuthMode.value,
|
||||
environment: salesforceEnvironment.value,
|
||||
clientId: salesforceClientId.value.trim() || existing.auth?.clientId,
|
||||
authorizedAt: existing.auth?.authorizedAt,
|
||||
};
|
||||
if (salesforceEnvironment.value === "custom") {
|
||||
auth.loginUrl = salesforceLoginUrl.value.trim() || existing.auth?.loginUrl;
|
||||
}
|
||||
if (salesforceClientSecret.value.trim()) {
|
||||
auth.clientSecret = salesforceClientSecret.value.trim();
|
||||
} else if (existing.auth?.clientSecret) {
|
||||
// Preserve secret indicator without leaking the value to the client.
|
||||
auth.clientSecret = existing.auth.clientSecret;
|
||||
}
|
||||
if (salesforceAuthMode.value === "password") {
|
||||
auth.username = salesforceUsername.value.trim() || existing.auth?.username;
|
||||
if (salesforceUserPassword.value) {
|
||||
auth.password = salesforceUserPassword.value;
|
||||
} else if (existing.auth?.password) {
|
||||
// Stored ROPC credential (scrubbed into the backend secret store);
|
||||
// keep it when the user edits the connection without retyping.
|
||||
auth.password = existing.auth.password;
|
||||
}
|
||||
} else if (existing.auth?.refreshToken) {
|
||||
auth.refreshToken = existing.auth.refreshToken;
|
||||
}
|
||||
config.external_config = { ...existing, auth };
|
||||
}
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value);
|
||||
} else if (config.db_type === "meilisearch") {
|
||||
@@ -4588,6 +5024,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.db_type !== "etcd" &&
|
||||
config.db_type !== "consul" &&
|
||||
config.db_type !== "starrocks" &&
|
||||
config.db_type !== "doris" &&
|
||||
config.db_type !== "mongodb" &&
|
||||
config.db_type !== "victoriametrics" &&
|
||||
config.db_type !== "zookeeper" &&
|
||||
@@ -5476,6 +5913,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
|
||||
appliedConnectionUrlInput.value = "";
|
||||
resetMeilisearchHostInput();
|
||||
oracleTnsAdminPath.value = "";
|
||||
resetSalesforceOAuthFields(undefined, undefined);
|
||||
if (!options.preservePickerState) {
|
||||
dialogStep.value = "select";
|
||||
dbSearchQuery.value = "";
|
||||
@@ -6366,6 +6804,9 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
unlistenAgentInstallProgress?.();
|
||||
unlistenAgentInstallProgress = null;
|
||||
// Stop any in-flight Salesforce device-code poll / expiry countdown so
|
||||
// closing the dialog does not leave orphan timers running.
|
||||
salesforceDeviceStopPolling();
|
||||
});
|
||||
|
||||
function openExternalUrl(url: string) {
|
||||
@@ -6521,7 +6962,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
|
||||
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq'" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq' && form.db_type !== 'salesforce'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
|
||||
@@ -7943,6 +8384,289 @@ function openExternalUrl(url: string) {
|
||||
<CloudflareD1ConnectionFields v-model:account-id="form.host" v-model:database-id="form.database" v-model:api-token="form.password" />
|
||||
</template>
|
||||
|
||||
<!-- Salesforce: instance URL + access token (SOQL) -->
|
||||
<template v-else-if="form.db_type === 'salesforce'">
|
||||
<!-- Instance URL (always visible so the user can confirm the
|
||||
target org; OAuth/device flows overwrite it after a
|
||||
successful authorize) -->
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceInstanceUrl") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="form.host" class="flex-1" :placeholder="t('connection.salesforceInstanceUrlPlaceholder')" />
|
||||
<Input v-model.number="form.port" type="number" class="w-24 shrink-0" />
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceInstanceUrlHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication mode picker -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceAuthMode") }}</Label>
|
||||
<div class="col-span-3 grid h-8 grid-cols-4 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'token' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'token'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('token')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeToken") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'oauth' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'"
|
||||
:aria-pressed="salesforceAuthMode === 'oauth'"
|
||||
:disabled="salesforceOauthRunning || !isDesktop"
|
||||
:title="!isDesktop ? t('connection.salesforceAuthModeOauthDesktopOnly') : undefined"
|
||||
@click="salesforceSetAuthMode('oauth')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeOauth") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'device' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'device'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('device')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeDevice") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'password' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'password'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('password')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModePassword") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isDesktop && salesforceAuthMode === 'oauth'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAuthModeOauthDesktopOnly") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual access token -->
|
||||
<template v-if="salesforceAuthMode === 'token'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceAccessToken") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.salesforceAccessTokenPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAccessTokenHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shared OAuth / device-code fields -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceEnvironment") }}</Label>
|
||||
<Select v-model="salesforceEnvironment" :disabled="salesforceOauthRunning">
|
||||
<SelectTrigger class="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">{{ t("connection.salesforceEnvironmentProduction") }}</SelectItem>
|
||||
<SelectItem value="sandbox">{{ t("connection.salesforceEnvironmentSandbox") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("connection.salesforceEnvironmentCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="salesforceEnvironment === 'custom'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceLoginUrl") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceLoginUrl" :placeholder="t('connection.salesforceLoginUrlPlaceholder')" :disabled="salesforceOauthRunning" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceLoginUrlHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">
|
||||
{{ t("connection.salesforceClientId") }}
|
||||
<span class="ml-1 text-destructive">*</span>
|
||||
</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceClientId" :placeholder="t('connection.salesforceClientIdPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
|
||||
<!-- The callback URL only matters for the browser redirect flow; the
|
||||
other two modes never see one, so keep it out of their way. -->
|
||||
<p v-if="salesforceAuthMode === 'oauth'" class="text-xs leading-5 text-muted-foreground">
|
||||
{{ t("connection.salesforceClientIdHint", { callbackUrl: SALESFORCE_OAUTH_CALLBACK_URL }) }}
|
||||
</p>
|
||||
<p v-else class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceClientIdHintShared") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceClientSecret") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<PasswordInput v-model="salesforceClientSecret" :placeholder="t('connection.salesforceClientSecretPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username & password (ROPC) credentials -->
|
||||
<template v-if="salesforceAuthMode === 'password'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceUsernameLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceUsername" :placeholder="t('connection.salesforceUsernamePlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUsernameHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceUserPasswordLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<PasswordInput v-model="salesforceUserPassword" :placeholder="t('connection.salesforceUserPasswordPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUserPasswordHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Previously-authorized banner (edit flow) -->
|
||||
<div v-if="salesforceOauthPreviouslyAuthorized && !salesforceOauthRunning && salesforceDevicePhase !== 'polling'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck class="h-4 w-4 shrink-0" />
|
||||
<span>{{ t("connection.salesforceOauthAuthorized") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser OAuth action -->
|
||||
<template v-if="salesforceAuthMode === 'oauth'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !isDesktop" @click="salesforceStartBrowserAuthorize">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforceOauthReauthorize") : t("connection.salesforceOauthAuthorize") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceOauthRunning" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceOauthAuthorizingHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Username-password sign in -->
|
||||
<template v-if="salesforceAuthMode === 'password'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !salesforceUsername.trim() || !salesforceUserPassword" @click="salesforceStartPasswordLogin">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforcePasswordSignInAgain") : t("connection.salesforcePasswordSignIn") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforcePasswordRopcNote") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Device-code flow -->
|
||||
<template v-if="salesforceAuthMode === 'device'">
|
||||
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim()" @click="salesforceDeviceRequestCode">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("connection.salesforceDeviceGetCode") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDeviceFlowNote") }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="salesforceDevicePhase === 'polling'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceDeviceUserCodeLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<div class="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2">
|
||||
<span class="font-mono text-lg font-semibold tracking-widest">{{ salesforceDeviceUserCode }}</span>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="salesforceCopyUserCode">
|
||||
<Check v-if="salesforceDeviceUserCodeCopied" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="salesforceDeviceVerificationUri" class="flex items-center gap-2 text-xs">
|
||||
<a class="text-primary underline underline-offset-2 hover:no-underline" href="#" @click.prevent="salesforceOpenVerificationPage">{{ t("connection.salesforceDeviceOpenVerification") }}</a>
|
||||
<code class="truncate rounded bg-muted px-1 py-0.5 text-[11px]">{{ salesforceDeviceVerificationUri }}</code>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-6 w-6" @click="salesforceCopyVerificationUri">
|
||||
<Check v-if="salesforceDeviceVerificationCopied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t("connection.salesforceDevicePolling") }}</span>
|
||||
<span v-if="salesforceDeviceSecondsLeft > 0">· {{ t("connection.salesforceDeviceSecondsLeft", { seconds: salesforceDeviceSecondsLeft }) }}</span>
|
||||
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="salesforceDeviceCancel">
|
||||
{{ t("connection.salesforceDeviceCancel") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDevicePollingHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="salesforceDevicePhase === 'expired'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<span class="text-amber-700 dark:text-amber-300">{{ t("connection.salesforceDeviceExpired") }}</span>
|
||||
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
|
||||
{{ t("connection.salesforceDeviceRestart") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="salesforceDevicePhase === 'denied'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
||||
<span class="text-destructive">{{ salesforceDeviceError || t("connection.salesforceDeviceDenied") }}</span>
|
||||
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
|
||||
{{ t("connection.salesforceDeviceRestart") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Shared status / error strip (oauth success + any error) -->
|
||||
<div v-if="salesforceOauthSuccess && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password' || salesforceDevicePhase === 'success')" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck class="h-4 w-4 shrink-0" />
|
||||
<span>{{ salesforceOauthSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceOauthError && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password')" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{{ salesforceOauthError }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceDeviceError && salesforceAuthMode === 'device' && salesforceDevicePhase !== 'polling' && salesforceDevicePhase !== 'denied'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{{ salesforceDeviceError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
|
||||
<template v-else>
|
||||
<div v-if="form.db_type === 'ignite' || form.db_type === 'ignite3'" class="grid grid-cols-4 items-start gap-4">
|
||||
|
||||
@@ -5,9 +5,8 @@ import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||
import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import { cn } from "@/lib/common/utils";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
@@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
@click="selectConnection(row.id)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', row.id === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionById.get(row.id))" class="h-3.5 w-3.5 shrink-0" />
|
||||
<ConnectionIcon :connection="connectionById.get(row.id)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
Vendored
+266
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const { store, settings, backend } = vi.hoisted(() => ({
|
||||
store: {
|
||||
connectionGroupOptions: [],
|
||||
newConnectionGroupId: null,
|
||||
selectedConnectionGroupId: null,
|
||||
addConnection: vi.fn(),
|
||||
updateConnection: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
addEphemeralConnection: vi.fn(),
|
||||
removeConnection: vi.fn(),
|
||||
startEditing: vi.fn(),
|
||||
stopEditing: vi.fn(),
|
||||
clearConnectionError: vi.fn(),
|
||||
updateConnectionDatabaseInfo: vi.fn(),
|
||||
applyGlobalTimeouts: vi.fn((config) => config),
|
||||
getConfig: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
|
||||
rememberedDatabaseForConnection: vi.fn(() => ""),
|
||||
persistEditorSettings: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
updateEditorSettingsAndPersist: vi.fn(),
|
||||
},
|
||||
backend: {
|
||||
connectDb: vi.fn(),
|
||||
disconnectDb: vi.fn(),
|
||||
testConnectionWithInfo: vi.fn(),
|
||||
listDatabases: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listJdbcDrivers: vi.fn(),
|
||||
listJdbcMavenBundles: vi.fn(),
|
||||
listJdbcLocalBundles: vi.fn(),
|
||||
listSshConfigHosts: vi.fn(),
|
||||
listInstalledAgentsLocal: vi.fn(),
|
||||
listenAgentInstallProgress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
|
||||
vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original<typeof import("@/stores/settingsStore")>()), useSettingsStore: () => settings }));
|
||||
vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) }));
|
||||
vi.mock("@/lib/backend/api", async (original) => ({ ...(await original<typeof import("@/lib/backend/api")>()), ...backend }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const Dialog = defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup:
|
||||
(props, { slots, emit }) =>
|
||||
() =>
|
||||
props.open ? h("section", [h("button", { "data-testid": "dismiss-dialog", onClick: () => emit("update:open", false) }, "Dismiss"), slots.default?.()]) : null,
|
||||
});
|
||||
return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup:
|
||||
(props, { attrs, emit }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/PasswordInput.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
setup:
|
||||
(props, { attrs }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, type: "password", value: props.modelValue }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
props: ["value"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
props.value === "connection" ? h("div", slots.default?.()) : null,
|
||||
});
|
||||
return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("span", slots.default?.()),
|
||||
});
|
||||
return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass };
|
||||
});
|
||||
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function salesforceConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "sfdc-sandbox",
|
||||
name: "Salesforce sandbox",
|
||||
db_type: "salesforce",
|
||||
driver_profile: "salesforce",
|
||||
driver_label: "Salesforce",
|
||||
host: "https://acme--qas1.sandbox.my.salesforce.com",
|
||||
port: 443,
|
||||
password: "saved-access-token",
|
||||
ssl: true,
|
||||
save_password: true,
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 8; i++) await nextTick();
|
||||
}
|
||||
|
||||
async function mountDialog(config: ConnectionConfig) {
|
||||
const props = reactive({ open: true, editConfig: config });
|
||||
store.getConfig.mockReturnValue(config);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ConnectionDialog, {
|
||||
...props,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
props.open = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(i18n);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await settle();
|
||||
return props;
|
||||
}
|
||||
|
||||
function findModeButton(label: string): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
i18n.global.locale.value = "en";
|
||||
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
|
||||
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
|
||||
store.updateConnection.mockResolvedValue(undefined);
|
||||
store.addConnection.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ConnectionDialog Salesforce username-password mode", () => {
|
||||
it("switches to password mode and reveals the credential inputs", async () => {
|
||||
await mountDialog(salesforceConnection());
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
expect(passwordTab, "password mode button should exist").toBeTruthy();
|
||||
expect(passwordTab!.disabled, "password mode button should be clickable").toBe(false);
|
||||
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
|
||||
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
|
||||
const username = [...document.querySelectorAll("input")].find((input) => input.getAttribute("placeholder") === "user@example.com");
|
||||
expect(username, "username input should render in password mode").toBeTruthy();
|
||||
expect((username as HTMLInputElement).disabled).toBe(false);
|
||||
const signIn = findModeButton("Sign In");
|
||||
expect(signIn, "sign-in button should render in password mode").toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the password mode button enabled while a browser OAuth authorize is not running", async () => {
|
||||
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key", authorizedAt: "2026-09-21T00:00:00.000Z" } } } as Partial<ConnectionConfig>));
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
expect(passwordTab).toBeTruthy();
|
||||
expect(passwordTab!.disabled).toBe(false);
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows each mode only the guidance that applies to it", async () => {
|
||||
const bodyText = () => document.body.textContent ?? "";
|
||||
|
||||
// Token mode carries no Connected App at all: neither client-ID hint renders,
|
||||
// and Salesforce has no TLS toggle (the driver always speaks HTTPS).
|
||||
await mountDialog(salesforceConnection());
|
||||
expect(bodyText()).not.toContain("The Connected App consumer key.");
|
||||
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
|
||||
expect(bodyText()).not.toContain("Enable encrypted connection");
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
expect(bodyText()).toContain("The Connected App consumer key.");
|
||||
expect(bodyText()).toContain("No callback URL needed");
|
||||
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
|
||||
|
||||
const deviceTab = findModeButton("Device Code");
|
||||
deviceTab!.click();
|
||||
await settle();
|
||||
expect(bodyText()).toContain("If requesting the code fails");
|
||||
expect(bodyText()).not.toContain("No callback URL needed");
|
||||
});
|
||||
|
||||
it("reserves the callback-URL guidance for browser OAuth", async () => {
|
||||
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key" } } } as Partial<ConnectionConfig>));
|
||||
await settle();
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Create a Connected App in Salesforce Setup");
|
||||
expect(text).not.toContain("The Connected App consumer key.");
|
||||
expect(text).not.toContain("If requesting the code fails");
|
||||
});
|
||||
});
|
||||
Vendored
+16
-2
@@ -26,6 +26,13 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/plugins/PluginIcon.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: ["pluginId", "contributionId"],
|
||||
setup: (props) => () => h("span", { "data-plugin-id": props.pluginId, "data-contribution-id": props.contributionId }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const connections: ConnectionConfig[] = [
|
||||
@@ -45,12 +52,12 @@ const layout: SidebarLayout = {
|
||||
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
|
||||
};
|
||||
|
||||
async function mountPicker() {
|
||||
async function mountPicker(extraConnections: ConnectionConfig[] = []) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(ConnectionTreeSelect, {
|
||||
modelValue: "",
|
||||
connections,
|
||||
connections: [...connections, ...extraConnections],
|
||||
layout,
|
||||
placeholder: "Select connection",
|
||||
searchPlaceholder: "Search connections",
|
||||
@@ -82,6 +89,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ConnectionTreeSelect search collapse behavior", () => {
|
||||
it("passes plugin identity and provider logo metadata to the shared icon", async () => {
|
||||
await mountPicker([{ ...connections[0], id: "plugin", name: "Plugin", db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" }]);
|
||||
const icon = document.body.querySelector('[data-picker-connection="plugin"] [data-plugin-id]');
|
||||
expect(icon?.getAttribute("data-plugin-id")).toBe("sample.plugin");
|
||||
expect(icon?.getAttribute("data-contribution-id")).toBe("sample.connection");
|
||||
});
|
||||
|
||||
it("ignores group clicks during search without changing the post-search state", async () => {
|
||||
await mountPicker();
|
||||
await setSearchText("Primary");
|
||||
|
||||
@@ -114,7 +114,7 @@ let initializingPrefillGeneration = 0;
|
||||
let componentUnmounted = false;
|
||||
let shownSessionError = "";
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
|
||||
@@ -1447,7 +1447,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<DialogFooter v-if="!hasResults">
|
||||
<Button variant="outline" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" size="sm" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{ compareProgressLabel }}</span>
|
||||
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
|
||||
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
|
||||
@@ -1457,7 +1457,7 @@ onBeforeUnmount(() => {
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else class="flex items-center gap-2">
|
||||
<Button variant="outline" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" size="sm" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" size="sm" :disabled="comparing || executionLocked || !canCompare" @click="startCompare">
|
||||
<Loader2 v-if="comparing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<RotateCcw v-else class="w-3 h-3 mr-1" />
|
||||
|
||||
@@ -84,7 +84,7 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DEFAULT_CUSTOM_THEME_DDL_COLORS } from "@/stores/settingsStore";
|
||||
import { useDiffScrollSync } from "@/composables/useDiffScrollSync";
|
||||
import { buildHunks, type DiffLine } from "@/components/diff/DiffHunkBuilder";
|
||||
import { buildSchemaDiffHighlightSegments, type SchemaDiffHighlightSegment } from "@/lib/schema/schemaDiffHighlight";
|
||||
import { findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
|
||||
import { findFirstSchemaDiffChangedLineNumber, findSchemaDiffDdlLineNumber } from "@/lib/schema/schemaDiffDdlLocate";
|
||||
import DiffSvgConnector from "@/components/diff/DiffSvgConnector.vue";
|
||||
import { FileCode, ScrollText, Copy, Play, FileDiff } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -206,14 +206,19 @@ function scrollPaneToLine(pane: HTMLDivElement | undefined, lineNumber: number |
|
||||
function locateFocusedObject() {
|
||||
const selectedObject = props.selectedObject;
|
||||
const focusedObject = props.focusedObject;
|
||||
if (!selectedObject || !focusedObject || focusedObject.id === selectedObject.id) {
|
||||
if (!selectedObject || !focusedObject) {
|
||||
focusedSourceLineNumber.value = null;
|
||||
focusedTargetLineNumber.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
|
||||
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
|
||||
if (focusedObject.id === selectedObject.id) {
|
||||
focusedSourceLineNumber.value = focusedObject.operationType === "modify" ? findFirstSchemaDiffChangedLineNumber(hunks.value, "source") : null;
|
||||
focusedTargetLineNumber.value = focusedObject.operationType === "modify" ? findFirstSchemaDiffChangedLineNumber(hunks.value, "target") : null;
|
||||
} else {
|
||||
focusedSourceLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.sourceDdl ?? "", focusedObject, "source");
|
||||
focusedTargetLineNumber.value = findSchemaDiffDdlLineNumber(selectedObject.targetDdl ?? "", focusedObject, "target");
|
||||
}
|
||||
scrollPaneToLine(leftPaneRef.value, focusedSourceLineNumber.value);
|
||||
scrollPaneToLine(rightPaneRef.value, focusedTargetLineNumber.value);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
schemaDiffDeployTargetSchema,
|
||||
normalizeDialectKind,
|
||||
databaseTypeToDialectKind,
|
||||
schemaDiffEngineDatabaseType,
|
||||
findSchemaDiffObject,
|
||||
flattenSchemaDiffObjects,
|
||||
schemaDiffSelectionTargets,
|
||||
@@ -107,6 +108,10 @@ const showFieldMappingDialog = ref(false);
|
||||
const showConfigSelector = ref(false);
|
||||
const sourceDbType = computed(() => store.getConfig(sourceConnectionId.value)?.db_type ?? "");
|
||||
const targetDbType = computed(() => store.getConfig(targetConnectionId.value)?.db_type ?? "");
|
||||
// A JDBC connection stores `jdbc` in db_type while its product lives in the driver
|
||||
// profile; the diff engine needs the product for clause shapes and view comparison.
|
||||
const sourceEngineDbType = computed(() => schemaDiffEngineDatabaseType(store.getConfig(sourceConnectionId.value)));
|
||||
const targetEngineDbType = computed(() => schemaDiffEngineDatabaseType(store.getConfig(targetConnectionId.value)));
|
||||
|
||||
// Clear stale field mappings when source and target are the same type
|
||||
watch([sourceDbType, targetDbType], ([src, tgt]) => {
|
||||
@@ -742,6 +747,8 @@ function handleCompare(): void {
|
||||
targetSchema: targetSchema.value,
|
||||
sourceDbType,
|
||||
targetDbType,
|
||||
sourceEngineDbType: sourceEngineDbType.value,
|
||||
targetEngineDbType: targetEngineDbType.value,
|
||||
options,
|
||||
ignoreComments: ignoreComments.value,
|
||||
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
|
||||
@@ -802,11 +809,12 @@ function rebuildDiffGroups() {
|
||||
}
|
||||
|
||||
function buildSchemaSyncPlanOptions(options: SchemaDiffCompareOptions) {
|
||||
const engineDbType = targetEngineDbType.value ?? getDbType();
|
||||
return {
|
||||
databaseType: getDbType(),
|
||||
databaseType: engineDbType,
|
||||
targetSchema: schemaDiffDeployTargetSchema(getDbType(), targetDatabase.value, targetSchema.value),
|
||||
cascadeDelete: options.cascadeDelete,
|
||||
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceDbType.value ? databaseTypeToDialectKind(sourceDbType.value) : undefined,
|
||||
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : sourceEngineDbType.value ? databaseTypeToDialectKind(sourceEngineDbType.value) : undefined,
|
||||
fieldMappings: options.fieldMappings,
|
||||
enableRollback: options.enableRollback,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { emptyAnnotations } from "@/docs/annotationEdits";
|
||||
import type { SchemaSnapshot, SnapshotWarning, TableKind } from "@/docs/types";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import {
|
||||
applyTemplate,
|
||||
dataDictionaryFileName,
|
||||
dictionaryCatalogNames,
|
||||
exportBlockedBySkippedTables,
|
||||
isDictionaryProfile,
|
||||
objectKey,
|
||||
orderDictionaryTables,
|
||||
resolveDictionaryExportPath,
|
||||
tableKindFrom,
|
||||
warningsForSelection,
|
||||
toggleOrderedKey,
|
||||
moveOrderedKey,
|
||||
type DataDictionaryLabels,
|
||||
type DictionaryLayout,
|
||||
type DictionaryObjectRef,
|
||||
type DictionaryProfile,
|
||||
type DictionaryTable,
|
||||
type DictionaryTemplateId,
|
||||
} from "@/lib/docs/dataDictionary";
|
||||
import { buildDataDictionaryPdf } from "@/lib/docs/dataDictionaryPdf";
|
||||
import { DictionaryFileExistsError, chooseDataDictionaryPath } from "@/lib/docs/saveDataDictionaryFile";
|
||||
import { hasActiveDictionaryExport, startDataDictionaryExport } from "@/lib/docs/dataDictionaryExportTask";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillTableNames?: string[];
|
||||
snapshot?: SchemaSnapshot | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const step = ref(0);
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
const exportError = ref<string | null>(null);
|
||||
const databases = ref<Array<{ name: string; checked: boolean }>>([]);
|
||||
const schemas = ref<Array<{ database: string; name: string; checked: boolean }>>([]);
|
||||
const objects = ref<DictionaryObjectRef[]>([]);
|
||||
const order = ref<string[]>([]);
|
||||
const objectSearch = ref("");
|
||||
const templateId = ref<DictionaryTemplateId>("standard");
|
||||
const layout = ref<DictionaryLayout>(blankLayout());
|
||||
const appendTimestamp = ref(false);
|
||||
const overwrite = ref(true);
|
||||
const continueOnError = ref(true);
|
||||
const outputPath = ref("");
|
||||
const previewUrl = ref("");
|
||||
let generation = 0;
|
||||
|
||||
const steps = computed(() => [t("dataDictionary.stepDatabases"), t("dataDictionary.stepObjects"), t("dataDictionary.stepTemplate"), t("dataDictionary.stepLayout"), t("dataDictionary.stepFile")]);
|
||||
const dbType = computed(() => connectionStore.getConfig(props.prefillConnectionId || "")?.db_type);
|
||||
const schemaAware = computed(() => isSchemaAware(dbType.value));
|
||||
const checkedDatabases = computed(() => databases.value.filter((item) => item.checked).map((item) => item.name));
|
||||
const checkedSchemas = computed(() => schemas.value.filter((item) => item.checked && checkedDatabases.value.includes(item.database)));
|
||||
const filteredObjects = computed(() => {
|
||||
const query = objectSearch.value.trim().toLowerCase();
|
||||
return objects.value.filter((item) => !query || `${item.database} ${item.schema} ${item.name}`.toLowerCase().includes(query));
|
||||
});
|
||||
const orderedObjects = computed(() => order.value.flatMap((key) => objects.value.filter((item) => objectKey(item) === key)));
|
||||
const canNext = computed(() => {
|
||||
if (loading.value) return false;
|
||||
if (step.value === 0) return checkedDatabases.value.length > 0 && (!schemaAware.value || checkedSchemas.value.length > 0 || schemas.value.length === 0);
|
||||
if (step.value === 1) return order.value.length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
function blankLayout(): DictionaryLayout {
|
||||
return applyTemplate("standard", { title: "", introduction: "", detailedIntroduction: "", leftFooter: "" });
|
||||
}
|
||||
|
||||
function labels(): DataDictionaryLabels {
|
||||
return {
|
||||
column: t("dataDictionary.column"),
|
||||
type: t("dataDictionary.type"),
|
||||
length: t("dataDictionary.length"),
|
||||
precision: t("dataDictionary.precision"),
|
||||
scale: t("dataDictionary.scale"),
|
||||
primaryKey: t("dataDictionary.primaryKey"),
|
||||
nullable: t("dataDictionary.nullable"),
|
||||
unique: t("dataDictionary.unique"),
|
||||
defaultValue: t("dataDictionary.defaultValue"),
|
||||
extra: t("dataDictionary.extra"),
|
||||
comment: t("dataDictionary.comment"),
|
||||
indexName: t("dataDictionary.indexName"),
|
||||
indexColumns: t("dataDictionary.indexColumns"),
|
||||
indexType: t("dataDictionary.indexType"),
|
||||
constraintName: t("dataDictionary.constraintName"),
|
||||
refSchema: t("dataDictionary.refSchema"),
|
||||
refTable: t("dataDictionary.refTable"),
|
||||
refColumn: t("dataDictionary.refColumn"),
|
||||
onUpdate: t("dataDictionary.onUpdate"),
|
||||
onDelete: t("dataDictionary.onDelete"),
|
||||
yes: t("dataDictionary.yes"),
|
||||
no: t("dataDictionary.no"),
|
||||
indexesHeading: t("dataDictionary.indexesHeading"),
|
||||
foreignKeysHeading: t("dataDictionary.foreignKeysHeading"),
|
||||
contentsHeading: t("dataDictionary.contentsHeading"),
|
||||
kindTable: t("dataDictionary.kindTable"),
|
||||
kindView: t("dataDictionary.kindView"),
|
||||
kindMaterializedView: t("dataDictionary.kindMaterializedView"),
|
||||
};
|
||||
}
|
||||
|
||||
function kindLabel(kind: TableKind): string {
|
||||
if (kind === "VIEW") return t("dataDictionary.kindView");
|
||||
if (kind === "MATERIALIZED_VIEW") return t("dataDictionary.kindMaterializedView");
|
||||
return t("dataDictionary.kindTable");
|
||||
}
|
||||
|
||||
function objectLabel(item: DictionaryObjectRef): string {
|
||||
const schema = item.schema ? `${item.schema}.` : "";
|
||||
return `${item.database}.${schema}${item.name}`;
|
||||
}
|
||||
|
||||
function warningText(warning: SnapshotWarning): string {
|
||||
switch (warning.kind) {
|
||||
case "tableSkipped":
|
||||
return t("dataDictionary.warningTableSkipped", { table: warning.table, reason: warning.reason });
|
||||
case "noForeignKeyMetadata":
|
||||
return t("dataDictionary.warningNoForeignKeys", { engine: warning.engine });
|
||||
case "commentsUnsupported":
|
||||
return t("dataDictionary.warningCommentsUnsupported", { engine: warning.engine });
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function chooseTemplate(id: DictionaryTemplateId): void {
|
||||
templateId.value = id;
|
||||
const title = checkedDatabases.value.join(", ") || props.prefillDatabase || "Data Dictionary";
|
||||
layout.value = applyTemplate(id, {
|
||||
title,
|
||||
introduction: t("dataDictionary.introductionDefault"),
|
||||
detailedIntroduction: `${t("dataDictionary.introductionDefault")}\n${t("dataDictionary.detailedIntroduction")}`,
|
||||
leftFooter: title,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDatabases(): Promise<void> {
|
||||
const mine = ++generation;
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
databases.value = [];
|
||||
schemas.value = [];
|
||||
try {
|
||||
const connectionId = props.prefillConnectionId;
|
||||
if (!connectionId) throw new Error(t("dataDictionary.empty"));
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
if (mine !== generation) return;
|
||||
const listed = await api.listDatabases(connectionId);
|
||||
if (mine !== generation) return;
|
||||
const names = dictionaryCatalogNames(
|
||||
listed.map((item) => item.name).filter((name) => name !== ""),
|
||||
dbType.value,
|
||||
props.prefillDatabase,
|
||||
);
|
||||
const preferred = props.prefillDatabase && names.includes(props.prefillDatabase) ? props.prefillDatabase : names[0];
|
||||
databases.value = (names.length ? names : [props.prefillDatabase || ""].filter(Boolean)).map((name) => ({ name, checked: name === preferred }));
|
||||
await loadSchemas();
|
||||
} catch (error) {
|
||||
if (mine === generation) loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
if (mine === generation) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDatabase(item: { name: string; checked: boolean }, checked: boolean): Promise<void> {
|
||||
item.checked = checked;
|
||||
await loadSchemas();
|
||||
}
|
||||
|
||||
async function loadSchemas(): Promise<void> {
|
||||
if (!schemaAware.value) {
|
||||
schemas.value = [];
|
||||
return;
|
||||
}
|
||||
const connectionId = props.prefillConnectionId;
|
||||
if (!connectionId) return;
|
||||
const previous = new Set(schemas.value.filter((item) => item.checked).map((item) => `${item.database}.${item.name}`));
|
||||
const firstLoad = schemas.value.length === 0;
|
||||
const next: Array<{ database: string; name: string; checked: boolean }> = [];
|
||||
for (const database of checkedDatabases.value) {
|
||||
const names = dictionaryCatalogNames(await api.listSchemas(connectionId, database), dbType.value, database === props.prefillDatabase ? props.prefillSchema : undefined);
|
||||
for (const name of names) {
|
||||
const key = `${database}.${name}`;
|
||||
const prefilled = !props.prefillSchema || (database === props.prefillDatabase && name === props.prefillSchema);
|
||||
next.push({ database, name, checked: firstLoad ? prefilled : previous.has(key) });
|
||||
}
|
||||
}
|
||||
if (firstLoad && props.prefillSchema && next.length > 0 && !next.some((item) => item.checked)) next.forEach((item) => (item.checked = true));
|
||||
schemas.value = next;
|
||||
}
|
||||
|
||||
async function loadObjects(): Promise<void> {
|
||||
const connectionId = props.prefillConnectionId;
|
||||
if (!connectionId) return;
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const targets = schemaAware.value ? checkedSchemas.value.map((item) => ({ database: item.database, schema: item.name })) : checkedDatabases.value.map((database) => ({ database, schema: "" }));
|
||||
const listed: DictionaryObjectRef[] = [];
|
||||
for (const target of targets) {
|
||||
const tables = await api.listTables(connectionId, target.database, target.schema, undefined, undefined, undefined, ["TABLE", "VIEW", "MATERIALIZED_VIEW"] satisfies SidebarObjectKind[]);
|
||||
for (const table of tables) {
|
||||
listed.push({ database: target.database, schema: target.schema || table.parent_schema || "", name: table.name, kind: tableKindFrom(table.table_type) });
|
||||
}
|
||||
}
|
||||
objects.value = listed;
|
||||
const wanted = new Set(props.prefillTableNames ?? []);
|
||||
const initial = listed.filter((item) => (wanted.size > 0 ? wanted.has(item.name) : item.kind === "TABLE"));
|
||||
order.value = (initial.length ? initial : listed).map(objectKey);
|
||||
} catch (error) {
|
||||
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
objects.value = [];
|
||||
order.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSelection(): Promise<{ tables: DictionaryTable[]; warnings: string[] }> {
|
||||
const connectionId = props.prefillConnectionId;
|
||||
if (!connectionId) throw new Error(t("dataDictionary.empty"));
|
||||
const warnings: string[] = [];
|
||||
const tables: DictionaryTable[] = [];
|
||||
if (props.snapshot) {
|
||||
const database = props.snapshot.project.database || props.prefillDatabase || "";
|
||||
const fromSnapshot = orderDictionaryTables(
|
||||
props.snapshot.tables.map((table) => ({ ...table, database })),
|
||||
order.value,
|
||||
);
|
||||
if (fromSnapshot.length === order.value.length) {
|
||||
const relevant = warningsForSelection(props.snapshot.warnings, orderedObjects.value);
|
||||
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) {
|
||||
throw new Error(relevant.map(warningText).find(Boolean) || database);
|
||||
}
|
||||
return { tables: fromSnapshot, warnings: relevant.map(warningText).filter(Boolean) };
|
||||
}
|
||||
}
|
||||
for (const database of checkedDatabases.value) {
|
||||
const schemaNames = schemaAware.value ? checkedSchemas.value.filter((item) => item.database === database).map((item) => item.name) : [];
|
||||
const tableNames = orderedObjects.value.filter((item) => item.database === database).map((item) => (item.schema ? `${item.schema}.${item.name}` : item.name));
|
||||
if (tableNames.length === 0) continue;
|
||||
try {
|
||||
const raw = await api.collectDocsSnapshot(connectionId, database, schemaNames, tableNames, database);
|
||||
const file = (await api.loadDocsAnnotations(connectionId)) ?? emptyAnnotations();
|
||||
const merged = await api.applyDocsAnnotations(connectionId, raw, file);
|
||||
const relevant = warningsForSelection(merged.warnings, orderedObjects.value);
|
||||
if (exportBlockedBySkippedTables(relevant, continueOnError.value)) throw new Error(relevant.map(warningText).find(Boolean) || database);
|
||||
warnings.push(...relevant.map(warningText).filter(Boolean));
|
||||
tables.push(...merged.tables.map((table) => ({ ...table, database })));
|
||||
} catch (error) {
|
||||
if (!continueOnError.value) throw error;
|
||||
warnings.push(t("dataDictionary.warningTableSkipped", { table: database, reason: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
}
|
||||
return { tables: orderDictionaryTables(tables, order.value), warnings };
|
||||
}
|
||||
|
||||
async function refreshPreview(): Promise<void> {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
|
||||
previewUrl.value = "";
|
||||
try {
|
||||
const document = await collectSelection();
|
||||
const bytes = buildDataDictionaryPdf(document.tables, labels(), layout.value, document.warnings);
|
||||
previewUrl.value = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer], { type: "application/pdf" }));
|
||||
} catch (error) {
|
||||
exportError.value = t("dataDictionary.previewFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function goNext(): Promise<void> {
|
||||
exportError.value = null;
|
||||
if (step.value === 0) {
|
||||
await loadObjects();
|
||||
if (loadError.value || order.value.length === 0) return;
|
||||
chooseTemplate(templateId.value);
|
||||
}
|
||||
if (step.value === 1 && order.value.length === 0) return;
|
||||
const enteringLayout = step.value === 2;
|
||||
if (step.value < 4) step.value += 1;
|
||||
if (enteringLayout) await refreshPreview();
|
||||
}
|
||||
|
||||
async function browse(): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", appendTimestamp.value), filters: [{ name: "PDF", extensions: ["pdf"] }] });
|
||||
if (path) outputPath.value = path;
|
||||
}
|
||||
|
||||
async function exportDictionary(): Promise<void> {
|
||||
const connectionId = props.prefillConnectionId;
|
||||
if (!connectionId) return;
|
||||
if (hasActiveDictionaryExport(connectionId)) {
|
||||
exportError.value = t("dataDictionary.alreadyRunning");
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
exportError.value = null;
|
||||
try {
|
||||
const fallback = dataDictionaryFileName(checkedDatabases.value[0] || "DataDictionary", false);
|
||||
const suggested = resolveDictionaryExportPath(outputPath.value, fallback, appendTimestamp.value);
|
||||
const path = await chooseDataDictionaryPath(suggested);
|
||||
if (!path) return;
|
||||
const started = startDataDictionaryExport({
|
||||
connectionId,
|
||||
databases: [...checkedDatabases.value],
|
||||
schemas: checkedSchemas.value.map((item) => ({ database: item.database, name: item.name })),
|
||||
objects: orderedObjects.value.map((item) => ({ ...item })),
|
||||
snapshot: props.snapshot,
|
||||
layout: { ...layout.value },
|
||||
labels: labels(),
|
||||
outputPath: path,
|
||||
overwrite: overwrite.value,
|
||||
continueOnError: continueOnError.value,
|
||||
warningText: (warning) => warningText(warning),
|
||||
skippedDatabase: (database, reason) => t("dataDictionary.warningTableSkipped", { table: database, reason }),
|
||||
missingObject: (object) => t("dataDictionary.warningTableSkipped", { table: objectLabel(object), reason: t("dataDictionary.objectUnavailable") }),
|
||||
onSuccess: (saved, warnings) => toast(t("dataDictionary.backgroundDone", { path: saved }) + (warnings ? ` · ${t("dataDictionary.warningCount", { count: warnings })}` : ""), 8000),
|
||||
onFailure: (error) => toast(t("dataDictionary.backgroundFailed", { error: error instanceof DictionaryFileExistsError ? t("dataDictionary.fileExists") : error instanceof Error ? error.message : String(error) }), 8000),
|
||||
});
|
||||
if (!started) throw new Error(t("dataDictionary.alreadyRunning"));
|
||||
toast(t("dataDictionary.backgroundStarted"));
|
||||
open.value = false;
|
||||
} catch (error) {
|
||||
exportError.value = error instanceof DictionaryFileExistsError ? t("dataDictionary.fileExists") : t("dataDictionary.exportFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function profilePayload(): DictionaryProfile {
|
||||
return {
|
||||
version: 1,
|
||||
databases: checkedDatabases.value,
|
||||
schemas: checkedSchemas.value.map((item) => ({ database: item.database, name: item.name })),
|
||||
objects: orderedObjects.value.map((item) => ({ database: item.database, schema: item.schema, name: item.name })),
|
||||
layout: layout.value,
|
||||
appendTimestamp: appendTimestamp.value,
|
||||
overwrite: overwrite.value,
|
||||
continueOnError: continueOnError.value,
|
||||
fileName: outputPath.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveProfile(): Promise<void> {
|
||||
const content = JSON.stringify(profilePayload(), null, 2);
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({ defaultPath: "data-dictionary.json", filters: [{ name: "JSON", extensions: ["json"] }] });
|
||||
if (path) await fs.writeTextFile(path, content);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "data-dictionary.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function loadProfile(): Promise<void> {
|
||||
try {
|
||||
const text = isTauriRuntime() ? await readProfileFromDialog() : await readProfileFromBrowser();
|
||||
if (!text) return;
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!isDictionaryProfile(parsed)) throw new Error(t("dataDictionary.profileInvalid"));
|
||||
layout.value = parsed.layout;
|
||||
appendTimestamp.value = parsed.appendTimestamp;
|
||||
overwrite.value = parsed.overwrite;
|
||||
continueOnError.value = parsed.continueOnError;
|
||||
outputPath.value = parsed.fileName;
|
||||
databases.value = databases.value.map((item) => ({ ...item, checked: parsed.databases.includes(item.name) }));
|
||||
await loadSchemas();
|
||||
schemas.value = schemas.value.map((item) => ({ ...item, checked: parsed.schemas.some((schema) => schema.database === item.database && schema.name === item.name) }));
|
||||
await loadObjects();
|
||||
order.value = parsed.objects.map((item) => objectKey(item)).filter((key) => objects.value.some((item) => objectKey(item) === key));
|
||||
} catch (error) {
|
||||
loadError.value = t("dataDictionary.loadFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function readProfileFromDialog(): Promise<string | null> {
|
||||
const [{ open: pick }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await pick({ filters: [{ name: "JSON", extensions: ["json"] }] });
|
||||
if (!path || Array.isArray(path)) return null;
|
||||
return fs.readTextFile(path);
|
||||
}
|
||||
|
||||
function readProfileFromBrowser(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json,application/json";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return resolve(null);
|
||||
void file.text().then(resolve);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function setFontSize(size: "small" | "medium" | "large"): void {
|
||||
const scale = size === "small" ? 0.85 : size === "large" ? 1.15 : 1;
|
||||
layout.value = { ...layout.value, headingSize: Math.round(16 * scale), bodySize: Math.round(9 * scale) };
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
generation += 1;
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
|
||||
previewUrl.value = "";
|
||||
return;
|
||||
}
|
||||
step.value = 0;
|
||||
exportError.value = null;
|
||||
outputPath.value = "";
|
||||
appendTimestamp.value = false;
|
||||
overwrite.value = true;
|
||||
continueOnError.value = true;
|
||||
templateId.value = "standard";
|
||||
void loadDatabases();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
generation += 1;
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="flex h-[min(760px,88vh)] w-[min(980px,94vw)] max-w-[94vw] flex-col gap-0 p-0 sm:max-w-[94vw]">
|
||||
<DialogHeader class="border-b px-4 py-3 pr-12">
|
||||
<DialogTitle>{{ t("dataDictionary.title") }}</DialogTitle>
|
||||
<DialogDescription>{{ steps[step] }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-2 border-b px-4 py-2 text-xs">
|
||||
<span v-for="(label, index) in steps" :key="label" :class="index === step ? 'font-medium text-foreground' : 'text-muted-foreground'">{{ index + 1 }}. {{ label }}</span>
|
||||
<span class="ml-auto flex gap-2">
|
||||
<Button type="button" size="sm" variant="outline" @click="loadProfile()">{{ t("dataDictionary.loadProfile") }}</Button>
|
||||
<Button type="button" size="sm" variant="outline" @click="saveProfile()">{{ t("dataDictionary.saveProfile") }}</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<p v-if="loading" class="text-sm text-muted-foreground">{{ t("dataDictionary.loading") }}</p>
|
||||
<p v-if="loadError" class="mb-2 text-sm text-destructive">{{ loadError }}</p>
|
||||
|
||||
<div v-if="step === 0" class="grid gap-4 md:grid-cols-2">
|
||||
<section>
|
||||
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.databases") }}</h3>
|
||||
<label v-for="item in databases" :key="item.name" class="flex items-center gap-2 py-1 text-sm">
|
||||
<input type="checkbox" :checked="item.checked" @change="toggleDatabase(item, ($event.target as HTMLInputElement).checked)" />
|
||||
<span>{{ item.name }}</span>
|
||||
</label>
|
||||
</section>
|
||||
<section v-if="schemaAware">
|
||||
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.schemasHeading") }}</h3>
|
||||
<p v-if="schemas.length === 0" class="text-sm text-muted-foreground">{{ t("dataDictionary.noSchemaLevel") }}</p>
|
||||
<label v-for="item in schemas" :key="`${item.database}.${item.name}`" class="flex items-center gap-2 py-1 text-sm">
|
||||
<input v-model="item.checked" type="checkbox" />
|
||||
<span>{{ item.database }}.{{ item.name }}</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 1" class="grid h-full gap-3 md:grid-cols-2">
|
||||
<section class="flex min-h-0 flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="objectSearch" :placeholder="t('dataDictionary.searchObjects')" />
|
||||
<Button type="button" size="sm" variant="outline" @click="order = filteredObjects.map(objectKey)">{{ t("dataDictionary.selectAll") }}</Button>
|
||||
<Button type="button" size="sm" variant="outline" @click="order = order.filter((key) => !filteredObjects.some((item) => objectKey(item) === key))">{{ t("dataDictionary.selectNone") }}</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded border">
|
||||
<label v-for="item in filteredObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
|
||||
<input type="checkbox" :checked="order.includes(objectKey(item))" @change="order = toggleOrderedKey(order, objectKey(item), ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ kindLabel(item.kind) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="mb-2 text-sm font-medium">{{ t("dataDictionary.selectedCount", { count: order.length }) }}</h3>
|
||||
<div class="max-h-[420px] overflow-auto rounded border">
|
||||
<div v-for="item in orderedObjects" :key="objectKey(item)" class="flex items-center gap-2 border-b px-2 py-1 text-sm">
|
||||
<span class="min-w-0 flex-1 truncate">{{ objectLabel(item) }}</span>
|
||||
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), -1)">{{ t("dataDictionary.moveUp") }}</Button>
|
||||
<Button type="button" size="sm" variant="outline" @click="order = moveOrderedKey(order, objectKey(item), 1)">{{ t("dataDictionary.moveDown") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 2" class="grid gap-2">
|
||||
<button v-for="id in ['standard', 'detailed', 'compact', 'catalog', 'reference']" :key="id" type="button" class="rounded border px-3 py-2 text-left" :class="templateId === id ? 'border-foreground bg-muted' : 'border-border'" @click="chooseTemplate(id as DictionaryTemplateId)">
|
||||
<span class="block text-sm font-medium">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}`) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ t(`dataDictionary.template${id[0]!.toUpperCase()}${id.slice(1)}Hint`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 3" class="grid h-full gap-3 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<div class="flex flex-col gap-3 overflow-auto pr-1">
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeCover") }}</span
|
||||
><input v-model="layout.includeCover" type="checkbox"
|
||||
/></label>
|
||||
<Label>{{ t("dataDictionary.header") }}</Label>
|
||||
<Input v-model="layout.header" />
|
||||
<Label>{{ t("dataDictionary.dictionaryTitle") }}</Label>
|
||||
<Input v-model="layout.title" />
|
||||
<Label>{{ t("dataDictionary.subtitle") }}</Label>
|
||||
<Input v-model="layout.subtitle" />
|
||||
<Label>{{ t("dataDictionary.remarks") }}</Label>
|
||||
<Input v-model="layout.remarks" />
|
||||
<Label>{{ t("dataDictionary.coverFooter") }}</Label>
|
||||
<Input v-model="layout.coverFooter" />
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeToc") }}</span
|
||||
><input v-model="layout.includeToc" type="checkbox"
|
||||
/></label>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeBreadcrumbs") }}</span
|
||||
><input v-model="layout.includeBreadcrumbs" type="checkbox"
|
||||
/></label>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeLeftFooter") }}</span
|
||||
><input v-model="layout.includeLeftFooter" type="checkbox"
|
||||
/></label>
|
||||
<Input v-model="layout.leftFooter" />
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includePageNumber") }}</span
|
||||
><input v-model="layout.includePageNumber" type="checkbox"
|
||||
/></label>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeIntroduction") }}</span
|
||||
><input v-model="layout.includeIntroduction" type="checkbox"
|
||||
/></label>
|
||||
<textarea v-model="layout.introduction" class="min-h-24 rounded border bg-transparent p-2 text-sm" />
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.includeIndexes") }}</span
|
||||
><input v-model="layout.includeIndexesAndForeignKeys" type="checkbox"
|
||||
/></label>
|
||||
<Label>{{ t("dataDictionary.paper") }}</Label>
|
||||
<select v-model="layout.paper" class="rounded border bg-transparent px-2 py-1 text-sm">
|
||||
<option value="A4">A4</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
</select>
|
||||
<Label>{{ t("dataDictionary.orientation") }}</Label>
|
||||
<select v-model="layout.orientation" class="rounded border bg-transparent px-2 py-1 text-sm">
|
||||
<option value="portrait">{{ t("dataDictionary.portrait") }}</option>
|
||||
<option value="landscape">{{ t("dataDictionary.landscape") }}</option>
|
||||
</select>
|
||||
<Label>{{ t("dataDictionary.margin") }}</Label>
|
||||
<Input v-model.number="layout.marginCm" type="number" min="0.5" max="4" step="0.1" />
|
||||
<Label>{{ t("dataDictionary.fontSize") }}</Label>
|
||||
<select class="rounded border bg-transparent px-2 py-1 text-sm" @change="setFontSize(($event.target as HTMLSelectElement).value as 'small' | 'medium' | 'large')">
|
||||
<option value="medium">{{ t("dataDictionary.fontMedium") }}</option>
|
||||
<option value="small">{{ t("dataDictionary.fontSmall") }}</option>
|
||||
<option value="large">{{ t("dataDictionary.fontLarge") }}</option>
|
||||
</select>
|
||||
<Button type="button" variant="outline" @click="refreshPreview()">{{ t("dataDictionary.preview") }}</Button>
|
||||
</div>
|
||||
<iframe v-if="previewUrl" :src="previewUrl" class="h-full min-h-80 w-full rounded border" :title="t('dataDictionary.preview')" />
|
||||
<p v-else class="text-sm text-muted-foreground">{{ t("dataDictionary.preview") }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex max-w-xl flex-col gap-3">
|
||||
<Label>{{ t("dataDictionary.exportToFile") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="outputPath" :placeholder="dataDictionaryFileName(checkedDatabases[0] || 'DataDictionary', appendTimestamp)" />
|
||||
<Button v-if="isTauriRuntime()" type="button" variant="outline" @click="browse()">{{ t("dataDictionary.browse") }}</Button>
|
||||
</div>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.appendTimestamp") }}</span
|
||||
><input v-model="appendTimestamp" type="checkbox"
|
||||
/></label>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.overwriteExisting") }}</span
|
||||
><input v-model="overwrite" type="checkbox"
|
||||
/></label>
|
||||
<label class="flex items-center justify-between gap-2 text-sm"
|
||||
><span>{{ t("dataDictionary.continueOnError") }}</span
|
||||
><input v-model="continueOnError" type="checkbox"
|
||||
/></label>
|
||||
<p v-if="exporting" class="text-sm text-muted-foreground">{{ t("dataDictionary.exporting") }}</p>
|
||||
<p v-if="exportError" class="text-sm text-destructive">{{ exportError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="border-t px-4 py-3">
|
||||
<Button v-if="step > 0" type="button" variant="outline" @click="step -= 1">{{ t("dataDictionary.back") }}</Button>
|
||||
<Button v-if="step < 4" type="button" :disabled="!canNext" @click="goNext()">{{ t("dataDictionary.next") }}</Button>
|
||||
<Button v-else type="button" :disabled="exporting || order.length === 0" @click="exportDictionary()">{{ exporting ? t("dataDictionary.exporting") : t("dataDictionary.start") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Download, Network } from "@lucide/vue";
|
||||
import { Download, FileText, Network } from "@lucide/vue";
|
||||
import DataDictionaryDialog from "@/components/docs/DataDictionaryDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import DocsApp from "@/docs/DocsApp.vue";
|
||||
@@ -77,6 +78,7 @@ const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" &
|
||||
|
||||
const exporting = ref(false);
|
||||
const exportError = ref<string | null>(null);
|
||||
const showDataDictionary = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const connectionId = props.prefillConnectionId;
|
||||
@@ -208,7 +210,7 @@ watch(
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
|
||||
<DialogHeader class="px-4 py-3 border-b pr-12">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<DialogTitle class="flex flex-wrap items-center gap-2">
|
||||
<span>{{ t("docs.title") }}</span>
|
||||
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ statusLabel }}
|
||||
@@ -218,6 +220,10 @@ watch(
|
||||
<Network class="w-4 h-4" />
|
||||
{{ t("docs.openDiagram") }}
|
||||
</Button>
|
||||
<Button v-if="snapshot" variant="outline" size="sm" @click="showDataDictionary = true">
|
||||
<FileText class="w-4 h-4" />
|
||||
{{ t("dataDictionary.title") }}
|
||||
</Button>
|
||||
<Button v-if="snapshot" variant="outline" size="sm" :disabled="exporting" @click="exportHtml()">
|
||||
<Download class="w-4 h-4" />
|
||||
{{ exporting ? t("docs.exporting") : t("docs.exportHtml") }}
|
||||
@@ -232,4 +238,5 @@ watch(
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DataDictionaryDialog v-if="showDataDictionary && snapshot" v-model:open="showDataDictionary" :prefill-connection-id="prefillConnectionId" :prefill-database="prefillDatabase" :prefill-schema="prefillSchema" :snapshot="snapshot" />
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
|
||||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||||
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
|
||||
import DataGridColumnWidthModeControl from "@/components/grid/DataGridColumnWidthModeControl.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
@@ -2833,6 +2834,7 @@ defineExpose({ focusSearch });
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGridColumnWidthModeControl />
|
||||
<DataGridFontFamilyControl />
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||||
|
||||
+4
@@ -1113,6 +1113,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
buttonWithTitle("grid.viewOptions").click();
|
||||
await flushUi();
|
||||
expect(document.body.textContent).toContain("grid.renderMode");
|
||||
expect(document.body.textContent).toContain("grid.columnWidthMode");
|
||||
expect(document.body.textContent).toContain("grid.tableFontFamily");
|
||||
expect(document.body.textContent).toContain("grid.tableFontSize");
|
||||
expect(document.body.textContent).toContain("grid.transposeMultiRowToggle");
|
||||
@@ -1121,6 +1122,9 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
buttonWithText("grid.columnWidthCompact").click();
|
||||
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ columnWidthDensity: "compact" });
|
||||
|
||||
buttonWithText("grid.columnWidthModeContent").click();
|
||||
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ dataGridColumnWidthMode: "content" });
|
||||
|
||||
buttonWithText("grid.domRenderMode").click();
|
||||
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ dataGridRenderMode: "dom" });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, h, nextTick, onMounted, onUnmounted, reactive, ref, toRaw, watch, type Component } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { deferUntilPanelResizeEnd } from "@/lib/app/panelResizeState";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import {
|
||||
@@ -66,8 +67,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
import { useUserSkillStore } from "@/stores/userSkillStore";
|
||||
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
||||
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
|
||||
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
type AiAction,
|
||||
type AiActionSelection,
|
||||
type AiAssistantMode,
|
||||
type AiContext,
|
||||
type AiContextTarget,
|
||||
type AiCsvFileContext,
|
||||
type AiTextAttachmentEncoding,
|
||||
@@ -154,11 +156,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
|
||||
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
|
||||
import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
|
||||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
||||
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
||||
import { aiStream, aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
||||
import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
||||
import type { AiMessage } from "@/lib/backend/api";
|
||||
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
|
||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||
@@ -177,15 +179,17 @@ import { DBX_TABLE_REFERENCE_DROP_EVENT, clearActiveTableReferencePayload } from
|
||||
import { canSubmitAiPrompt, isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
|
||||
import { isActionableWriteProposalMessage, isActionableWriteSqlProposal, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
|
||||
import { classifyIntentByLlm, routeIntentByRules, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
|
||||
import { retryableUserMessageIndex, visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
|
||||
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport";
|
||||
import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport";
|
||||
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
|
||||
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
|
||||
import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue";
|
||||
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
|
||||
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import { buildPluginAiRequest, createPluginAiConversation, pluginComposerConnectionLabel, pluginContextConnectionId, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const { t } = useI18n();
|
||||
const AiChartRenderer = defineAsyncComponent({
|
||||
@@ -289,6 +293,7 @@ const props = defineProps<{
|
||||
tab?: QueryTab;
|
||||
connection?: ConnectionConfig;
|
||||
maximized?: boolean;
|
||||
pluginRecommendations?: PluginAiRecommendationHostUpdate;
|
||||
}>();
|
||||
|
||||
// Every AI-initiated action carries the *target* it must run against: the
|
||||
@@ -327,19 +332,6 @@ let defaultModeInitialized = false;
|
||||
let initialConversationStateLoaded = false;
|
||||
let initialConversationRestored = false;
|
||||
let assistantViewMounted = false;
|
||||
watch(
|
||||
() => settings.isAiConfigLoaded,
|
||||
(loaded) => {
|
||||
if (loaded && !defaultModeInitialized) {
|
||||
assistantMode.value = settings.defaultAiMode;
|
||||
// Same apply-once rule as the mode: later setting changes must not
|
||||
// disturb an active conversation.
|
||||
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
|
||||
defaultModeInitialized = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const currentSessionId = ref("");
|
||||
const conversationId = ref("");
|
||||
const conversations = ref<AiConversation[]>([]);
|
||||
@@ -367,6 +359,46 @@ const boundConnectionId = computed(() => conversationBinding.value.connectionId)
|
||||
const boundConnection = computed(() => (boundConnectionId.value ? connectionStore.getConfig(boundConnectionId.value) : undefined));
|
||||
const boundDatabase = computed(() => conversationBinding.value.database);
|
||||
const boundSchema = computed(() => conversationBinding.value.schema);
|
||||
const pluginContextConnection = computed(() => {
|
||||
const contextConnectionId = pluginContext.value ? pluginContextConnectionId(pluginContext.value) : undefined;
|
||||
return boundConnection.value ?? (contextConnectionId ? connectionStore.getConfig(contextConnectionId) : undefined);
|
||||
});
|
||||
const pluginComposerConnectionName = computed(() => {
|
||||
const context = pluginContext.value;
|
||||
if (!context) return "";
|
||||
return pluginComposerConnectionLabel(context, pluginContextConnection.value?.name, activeConversation.value?.connectionName);
|
||||
});
|
||||
|
||||
function canUsePluginAgentMode(context = pluginContext.value, connection = boundConnection.value): boolean {
|
||||
return !context || !!connection;
|
||||
}
|
||||
|
||||
watch(
|
||||
[pluginContext, boundConnection],
|
||||
([context, connection]) => {
|
||||
if (context && !connection && assistantMode.value === "agent") assistantMode.value = "ask";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// `immediate` runs this during setup whenever the AI config finished loading
|
||||
// before the panel mounted (the usual case: the app loads it at startup), and
|
||||
// `resolveDefaultActionSelection` reads `boundConnection`. It must therefore
|
||||
// stay below the binding computeds, or setup throws a TDZ ReferenceError and
|
||||
// the panel never mounts.
|
||||
watch(
|
||||
() => settings.isAiConfigLoaded,
|
||||
(loaded) => {
|
||||
if (loaded && !defaultModeInitialized) {
|
||||
assistantMode.value = settings.defaultAiMode;
|
||||
// Same apply-once rule as the mode: later setting changes must not
|
||||
// disturb an active conversation.
|
||||
activeAction.value = resolveDefaultActionSelection(settings.defaultAiMode);
|
||||
defaultModeInitialized = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* Binding the visible conversation's *active run* is executing against.
|
||||
@@ -1193,10 +1225,15 @@ const AI_TEXTAREA_HEIGHT_STORAGE_KEY = "dbx-ai-textarea-height";
|
||||
const textareaHeight = ref<number>(AI_TEXTAREA_MIN_HEIGHT_PX);
|
||||
const assistantRootRef = ref<HTMLElement | null>(null);
|
||||
const promptPanelRef = ref<HTMLElement | null>(null);
|
||||
const compactContextControls = ref(false);
|
||||
const compactActionControls = ref(false);
|
||||
const isResizing = ref<boolean>(false);
|
||||
let resizeStartY = 0;
|
||||
let resizeStartHeight = 0;
|
||||
let promptPanelResizeObserver: ResizeObserver | undefined;
|
||||
let responsiveControlMeasureFrame: number | null = null;
|
||||
let responsiveControlMeasureForce = false;
|
||||
let lastResponsiveControlWidth: number | null = null;
|
||||
|
||||
interface AiTableMentionCandidate {
|
||||
kind: "table";
|
||||
@@ -1650,6 +1687,7 @@ function openCodeSnapshot(seg: { content: string; lang: string }) {
|
||||
}
|
||||
|
||||
const showActionButtons = computed(() => {
|
||||
if (pluginContext.value) return false;
|
||||
if (!boundConnection.value) return true;
|
||||
return !isVectorDbType(boundConnection.value.db_type);
|
||||
});
|
||||
@@ -1664,6 +1702,7 @@ const modeActionTriggerLabel = computed(() => {
|
||||
});
|
||||
|
||||
function switchModeActionTab(mode: "ask" | "agent") {
|
||||
if (mode === "agent" && !canUsePluginAgentMode()) return;
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
if (assistantMode.value !== mode) {
|
||||
// Set the mode after the action so the tab label and picker stay aligned.
|
||||
@@ -1774,9 +1813,11 @@ watch(
|
||||
);
|
||||
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
|
||||
|
||||
const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type));
|
||||
|
||||
const showAiSchemaSelector = computed(() => {
|
||||
const connection = boundConnection.value;
|
||||
return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
|
||||
return showAiDatabaseSelector.value && !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
|
||||
});
|
||||
|
||||
const aiSchemaDatabaseKey = computed(() => {
|
||||
@@ -1798,7 +1839,7 @@ async function loadAiSchemas() {
|
||||
}
|
||||
|
||||
async function loadDatabases(connection = boundConnection.value): Promise<string[]> {
|
||||
if (!connection) return [];
|
||||
if (!connection || !supportsAiAssistantContext(connection.db_type)) return [];
|
||||
if (connection.db_type !== "dameng") {
|
||||
await loadDatabaseOptions(connection.id);
|
||||
return databaseOptions.value[connection.id] || [];
|
||||
@@ -2097,6 +2138,21 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the user's answer for a pending plugin tool approval back to the run that asked. */
|
||||
async function resolveToolApproval(step: AiAgentStepItem, approved: boolean) {
|
||||
const approval = step.approval;
|
||||
if (!approval || approval.status !== "pending") return;
|
||||
approval.status = "submitting";
|
||||
try {
|
||||
// The final status arrives with `tool_approval_resolved`; `false` means
|
||||
// the run stopped waiting (timed out, cancelled, or app restarted).
|
||||
if (!(await resolveAiToolApproval(approval.sessionId, approval.approvalId, approved))) approval.status = "expired";
|
||||
} catch (error) {
|
||||
approval.status = "pending";
|
||||
toast(t("ai.toolApproval.answerFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined {
|
||||
if (event.type === "context_compacted") {
|
||||
return {
|
||||
@@ -2337,7 +2393,7 @@ function mentionTargetDatabase(): string {
|
||||
async function loadMentionCandidates(query: string) {
|
||||
const connection = boundConnection.value;
|
||||
const mentionDatabase = mentionTargetDatabase();
|
||||
if (pluginContext.value || !connection || !boundConnectionId.value || !mentionDatabase) return;
|
||||
if (pluginContext.value || !connection || !supportsAiAssistantContext(connection.db_type) || !boundConnectionId.value || !mentionDatabase) return;
|
||||
|
||||
const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query);
|
||||
if (mentionCache.value[key]) {
|
||||
@@ -3215,9 +3271,9 @@ async function send() {
|
||||
// database. When it still matches, dropping it would silently discard what
|
||||
// the user attached alongside the affirmative.
|
||||
const confirmationRetargets = !!confirmationTarget && !sameConversationBinding(runBinding, conversationBinding.value);
|
||||
const connection = runPluginContext ? undefined : runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
|
||||
const connection = runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
|
||||
const tab = runPluginContext ? undefined : aiContextTargetFor(runBinding, props.tab);
|
||||
const runSourceName = runPluginContext?.pluginName ?? connection?.name ?? "";
|
||||
const runSourceName = connection?.name ?? runPluginContext?.pluginName ?? "";
|
||||
if (!runPluginContext && (!connection || !tab)) {
|
||||
clearPendingWriteGrant();
|
||||
return;
|
||||
@@ -3437,7 +3493,7 @@ async function send() {
|
||||
if (autoSendVisible) scrollToBottom({ force: true });
|
||||
|
||||
const requestedSelection: AiActionSelection = auto ? auto.action : activeAction.value;
|
||||
const requestedMode: AiAssistantMode = runPluginContext ? "ask" : auto ? auto.mode : assistantMode.value;
|
||||
const requestedMode: AiAssistantMode = auto ? auto.mode : assistantMode.value;
|
||||
// A confirmed-write turn (the ✅ reply, or the segment that resumes an
|
||||
// `awaiting_write_confirmation` run) is a continuation of the pending proposal,
|
||||
// not a new user request: its reply text is component copy, so the Auto router
|
||||
@@ -3452,8 +3508,8 @@ async function send() {
|
||||
// router entirely, so their behavior is unchanged (#9118).
|
||||
let requestedAction: AiAction;
|
||||
if (runPluginContext) {
|
||||
// A plugin conversation carries its own data snapshot: the Auto router must
|
||||
// not classify the prompt, and the task contract stays host-owned.
|
||||
// Plugin requests use the general action; the Agent decides which live
|
||||
// plugin tools are needed from their advertised definitions.
|
||||
requestedAction = "general";
|
||||
} else if (!isAutoActionSelection(requestedSelection)) {
|
||||
requestedAction = requestedSelection;
|
||||
@@ -3687,9 +3743,60 @@ async function send() {
|
||||
if (step) upsertAgentStep(msg.agentSteps, step);
|
||||
}
|
||||
}
|
||||
// A plugin tool call waits for (or got) the user's approval: keep the
|
||||
// state on the tool card so the answer buttons live next to the call.
|
||||
if (event.type === "tool_approval_required" || event.type === "tool_approval_resolved") {
|
||||
const msg = runMessages[assistantIdx];
|
||||
if (msg?.agentSteps) {
|
||||
const key = toolCallStepKey(event.tool_call_id, agentEvents.length - 1, "tool_call_start");
|
||||
if (event.type === "tool_approval_required") {
|
||||
updateAgentStepApproval(msg.agentSteps, key, () => ({
|
||||
approvalId: event.approval_id,
|
||||
sessionId,
|
||||
pluginName: event.plugin_name,
|
||||
pluginTool: event.plugin_tool,
|
||||
connectionName: event.connection_name,
|
||||
args: event.args ?? {},
|
||||
expiresAtMs: Date.now() + event.timeout_secs * 1000,
|
||||
status: "pending",
|
||||
}));
|
||||
} else {
|
||||
updateAgentStepApproval(msg.agentSteps, key, (approval) => (approval && approval.approvalId === event.approval_id ? { ...approval, status: event.outcome } : approval));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runIsVisible()) scrollToBottom();
|
||||
};
|
||||
if (runPluginContext) {
|
||||
if (runPluginContext && requestedMode === "agent" && connection) {
|
||||
if (!generationCanContinue()) return;
|
||||
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
|
||||
const context: AiContext = {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
databaseType: "plugin",
|
||||
database: "",
|
||||
currentSql: "",
|
||||
tables: [],
|
||||
sqlFiles: [],
|
||||
truncated: false,
|
||||
};
|
||||
const instruction = [text, `Plugin: ${runPluginContext.pluginName}`, `Recommendation context: ${runPluginContext.title}`, "Use the connected plugin tools to retrieve the current state before answering."].filter(Boolean).join("\n\n");
|
||||
await runAgentStream(
|
||||
{
|
||||
config: activeConfig,
|
||||
action: requestedAction,
|
||||
mode: requestedMode,
|
||||
instruction,
|
||||
taskContractUserRequest: text,
|
||||
context,
|
||||
inlineImages: imageAttachments.map(({ mediaType, data }) => ({ mediaType, data })),
|
||||
},
|
||||
history,
|
||||
onEvent,
|
||||
sessionId,
|
||||
customPromptContext,
|
||||
);
|
||||
} else if (runPluginContext) {
|
||||
if (!generationCanContinue()) return;
|
||||
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
|
||||
const request = buildPluginAiRequest(
|
||||
@@ -4233,6 +4340,37 @@ function canCopyMessage(msg: ChatMessage): boolean {
|
||||
return messageCopyText(msg) !== null;
|
||||
}
|
||||
|
||||
/** Returns whether a user message can be submitted again. The helper accepts
|
||||
* the following visible index so the same eligibility rules cover text,
|
||||
* mentions, and attachment-only requests. */
|
||||
function canRetryUserMessage(visibleIndex: number): boolean {
|
||||
return retryableUserMessageIndex(visibleMessages.value, visibleIndex + 1) === visibleIndex;
|
||||
}
|
||||
|
||||
/** Re-runs a user request through the existing edit-and-resend path. This
|
||||
* removes the selected turn and later replies before sending with the current
|
||||
* AI configuration. */
|
||||
function retryUserMessage(visibleIndex: number) {
|
||||
if (isGenerating.value) return;
|
||||
if (!canRetryUserMessage(visibleIndex)) return;
|
||||
startEditMessage(visibleIndex);
|
||||
submitEdit(visibleIndex);
|
||||
}
|
||||
|
||||
const retryConfirmMessageIndex = ref<number | null>(null);
|
||||
|
||||
function cancelRetryUserMessage() {
|
||||
retryConfirmMessageIndex.value = null;
|
||||
}
|
||||
|
||||
function confirmRetryUserMessage() {
|
||||
const visibleIndex = retryConfirmMessageIndex.value;
|
||||
retryConfirmMessageIndex.value = null;
|
||||
if (visibleIndex !== null) retryUserMessage(visibleIndex);
|
||||
}
|
||||
|
||||
watch(conversationId, cancelRetryUserMessage);
|
||||
|
||||
function messageCopyKey(index: number): string {
|
||||
return `message:${index}`;
|
||||
}
|
||||
@@ -4642,7 +4780,7 @@ function selectConversation(conv: AiConversation) {
|
||||
const activeRun = backgroundAiRunsEnabled ? desktopAiRun<ChatMessage>(conv.id) : undefined;
|
||||
messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);
|
||||
if (pluginContext.value) {
|
||||
assistantMode.value = "ask";
|
||||
assistantMode.value = pluginContext.value.mode === "agent" && !boundConnection.value ? "ask" : (pluginContext.value.mode ?? "ask");
|
||||
activeAction.value = "general";
|
||||
}
|
||||
unreadConversations.delete(conv.id);
|
||||
@@ -4991,7 +5129,9 @@ onMounted(async () => {
|
||||
if (typeof ResizeObserver !== "undefined" && assistantRootRef.value) {
|
||||
promptPanelResizeObserver = new ResizeObserver(handlePanelResize);
|
||||
promptPanelResizeObserver.observe(assistantRootRef.value);
|
||||
if (promptPanelRef.value) promptPanelResizeObserver.observe(promptPanelRef.value);
|
||||
}
|
||||
scheduleResponsiveControlMeasurement(true);
|
||||
});
|
||||
|
||||
function maxTextareaHeight() {
|
||||
@@ -5008,8 +5148,91 @@ function clampTextareaHeight(height: number) {
|
||||
|
||||
function handlePanelResize() {
|
||||
textareaHeight.value = clampTextareaHeight(textareaHeight.value);
|
||||
scheduleResponsiveControlMeasurement();
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement | null): boolean {
|
||||
return !!element && element.scrollWidth > element.clientWidth + 1;
|
||||
}
|
||||
|
||||
function hasOverflowingLabel(element: HTMLElement | null, selector: string): boolean {
|
||||
if (!element) return false;
|
||||
return Array.from(element.querySelectorAll<HTMLElement>(selector)).some((label) => label.scrollWidth > label.clientWidth + 1);
|
||||
}
|
||||
|
||||
async function measureResponsiveControls(force = false) {
|
||||
const panel = promptPanelRef.value;
|
||||
if (!panel) return;
|
||||
// Reading clientWidth/scrollWidth here forces a document-wide synchronous
|
||||
// relayout, and the AI panel divider drag fires resize events every frame.
|
||||
// Skip measurements while the drag is in flight and re-measure once at the end.
|
||||
if (
|
||||
deferUntilPanelResizeEnd(() => {
|
||||
void measureResponsiveControls(force);
|
||||
})
|
||||
)
|
||||
return;
|
||||
const panelWidth = panel.clientWidth;
|
||||
if (!force && lastResponsiveControlWidth === panelWidth) return;
|
||||
|
||||
// Measure the full labels before deciding to compact. This lets a wide
|
||||
// composer recover from icon mode after it grows, while the actual
|
||||
// overflow checks decide independently for the context and action rows.
|
||||
if (compactContextControls.value || compactActionControls.value) {
|
||||
compactContextControls.value = false;
|
||||
compactActionControls.value = false;
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
const contextRow = panel.querySelector<HTMLElement>("[data-ai-composer-context-row]");
|
||||
const actionRow = panel.querySelector<HTMLElement>("[data-ai-composer-actions]");
|
||||
const contextOverflow = hasHorizontalOverflow(contextRow) || hasOverflowingLabel(contextRow, ".ai-template-selector-label, .ai-skills-selector-label");
|
||||
const actionOverflow = hasHorizontalOverflow(actionRow) || hasOverflowingLabel(actionRow, ".ai-mode-action-label, .ai-model-selector-label, .ai-prompt-queue-label");
|
||||
|
||||
compactContextControls.value = contextOverflow;
|
||||
compactActionControls.value = actionOverflow;
|
||||
lastResponsiveControlWidth = panelWidth;
|
||||
}
|
||||
|
||||
function scheduleResponsiveControlMeasurement(force = false) {
|
||||
responsiveControlMeasureForce ||= force;
|
||||
if (responsiveControlMeasureFrame !== null) return;
|
||||
const measure = () => {
|
||||
responsiveControlMeasureFrame = null;
|
||||
const forceMeasure = responsiveControlMeasureForce;
|
||||
responsiveControlMeasureForce = false;
|
||||
void measureResponsiveControls(forceMeasure);
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
responsiveControlMeasureFrame = window.requestAnimationFrame(measure);
|
||||
} else {
|
||||
void nextTick(measure);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
templateSelectorTriggerLabel.value,
|
||||
selectedSkillIds.value.join(","),
|
||||
modeActionTriggerLabel.value,
|
||||
activeFullConfig.value?.model,
|
||||
boundConnectionId.value,
|
||||
selectedDatabaseLabel.value,
|
||||
boundSchema.value,
|
||||
connectionStore.connections.length,
|
||||
showAiDatabaseSelector.value,
|
||||
showAiSchemaSelector.value,
|
||||
settings.aiConfigs.length,
|
||||
hasActiveRunForCurrentConversation.value,
|
||||
isGenerating.value,
|
||||
pluginContext.value?.pluginName,
|
||||
pluginContext.value?.title,
|
||||
currentQueuedInput.value?.text,
|
||||
],
|
||||
() => scheduleResponsiveControlMeasurement(true),
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
function startResize(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
isResizing.value = true;
|
||||
@@ -5070,6 +5293,11 @@ onUnmounted(() => {
|
||||
document.removeEventListener("dbx:tauri-file-drop", onTauriFileDrop as EventListener);
|
||||
window.removeEventListener(DBX_TABLE_REFERENCE_DROP_EVENT, onTableReferenceDropEvent);
|
||||
promptPanelResizeObserver?.disconnect();
|
||||
if (responsiveControlMeasureFrame !== null && typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
||||
window.cancelAnimationFrame(responsiveControlMeasureFrame);
|
||||
responsiveControlMeasureFrame = null;
|
||||
}
|
||||
responsiveControlMeasureForce = false;
|
||||
});
|
||||
|
||||
function triggerAction(action: AiAction, instruction?: string) {
|
||||
@@ -5093,12 +5321,21 @@ function openPluginConversation(request: AiPluginConversationRequest) {
|
||||
defaultModeInitialized = true;
|
||||
startNewChat();
|
||||
draftPluginContext.value = request.context;
|
||||
assistantMode.value = "ask";
|
||||
const pluginConnectionId = pluginContextConnectionId(request.context);
|
||||
const pluginConnection = pluginConnectionId ? connectionStore.getConfig(pluginConnectionId) : undefined;
|
||||
draftBinding.value = pluginConnection ? { connectionId: pluginConnection.id, connectionName: pluginConnection.name, database: "", schema: undefined } : null;
|
||||
assistantMode.value = request.mode === "agent" && !pluginConnection ? "ask" : (request.mode ?? request.context.mode ?? "ask");
|
||||
activeAction.value = "general";
|
||||
setPrompt(request.prompt, true);
|
||||
if (request.send) void send();
|
||||
}
|
||||
|
||||
function sendPluginRecommendation(item: { id: string; label: string; prompt: string }) {
|
||||
const update = props.pluginRecommendations;
|
||||
if (!update || !update.items.some((candidate) => candidate.id === item.id)) return;
|
||||
openPluginConversation(createPluginAiConversation({ id: update.pluginId, name: update.pluginName }, { title: item.label, prompt: item.prompt, context: update.context, send: true, mode: "agent" }));
|
||||
}
|
||||
|
||||
function setPrompt(text: string, fromPlugin = false) {
|
||||
if (!fromPlugin && pluginContext.value) startNewChat();
|
||||
prompt.value = text;
|
||||
@@ -5323,10 +5560,34 @@ async function openExternalUrl(url: string) {
|
||||
<div v-if="messages.length === 0" class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground">
|
||||
<Bot class="h-10 w-10 mb-3 opacity-30" />
|
||||
<p class="text-sm">{{ t(pluginContext ? "ai.pluginWelcome" : "ai.welcome") }}</p>
|
||||
<div v-if="pluginRecommendations?.items.length" class="mt-5 flex max-w-[95%] flex-wrap justify-center gap-2">
|
||||
<button
|
||||
v-for="recommendation in pluginRecommendations.items"
|
||||
:key="recommendation.id"
|
||||
type="button"
|
||||
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
:title="recommendation.prompt"
|
||||
@click="sendPluginRecommendation(recommendation)"
|
||||
>
|
||||
{{ recommendation.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative min-h-0 flex-1">
|
||||
<ScrollArea ref="scrollRef" class="ai-message-scroll h-full overflow-hidden">
|
||||
<div v-else class="relative flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea ref="scrollRef" class="ai-message-scroll min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<div v-if="pluginRecommendations?.items.length" class="flex shrink-0 flex-wrap justify-center gap-2 border-b border-border/60 px-3 py-2" data-ai-plugin-recommendations>
|
||||
<button
|
||||
v-for="recommendation in pluginRecommendations.items"
|
||||
:key="`history:${recommendation.id}`"
|
||||
type="button"
|
||||
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
:title="recommendation.prompt"
|
||||
@click="sendPluginRecommendation(recommendation)"
|
||||
>
|
||||
{{ recommendation.label }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-for="(msg, i) in visibleMessages" :key="i">
|
||||
<div v-if="awayUpdatesBaselineIndex >= 0 && i === awayUpdatesBaselineIndex" class="mb-1 flex items-center gap-2 py-0.5" role="separator" :aria-label="t('ai.awayUpdatesDivider')">
|
||||
<span class="h-px flex-1 bg-primary/25" />
|
||||
@@ -5478,8 +5739,26 @@ async function openExternalUrl(url: string) {
|
||||
<span class="truncate">{{ t("ai.routing.chip", { action: actionLabelFor(routedActionOf(msg)) }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="canCopyMessage(msg)" class="mt-1 flex justify-end">
|
||||
<div v-if="canCopyMessage(msg) || (!isGenerating && canRetryUserMessage(i))" class="mt-1 flex justify-end gap-1">
|
||||
<Popover v-if="!isGenerating && canRetryUserMessage(i)" :open="retryConfirmMessageIndex === i" @update:open="(open: boolean) => (retryConfirmMessageIndex = open ? i : null)">
|
||||
<PopoverTrigger as-child>
|
||||
<button data-ai-message-retry="user" type="button" class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200" :title="t('ai.retryMessage')" :aria-label="t('ai.retryMessage')">
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" :side-offset="8" class="w-72 max-w-[calc(100vw-2rem)] gap-2 p-3" @click.stop>
|
||||
<p class="flex items-start gap-2 text-xs text-foreground">
|
||||
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span>{{ t("ai.retryConfirmDescription") }}</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="outline" class="h-7 px-2 text-xs" @click="cancelRetryUserMessage">{{ t("common.cancel") }}</Button>
|
||||
<Button type="button" size="sm" variant="destructive" class="h-7 px-2 text-xs" @click="confirmRetryUserMessage">{{ t("common.confirm") }}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-if="canCopyMessage(msg)"
|
||||
data-ai-message-copy="user"
|
||||
type="button"
|
||||
class="rounded p-0.5 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
@@ -5524,16 +5803,17 @@ async function openExternalUrl(url: string) {
|
||||
<Loader2 v-if="step.tone === 'active' && step.toolName" class="h-3 w-3 shrink-0 animate-spin" />
|
||||
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="font-medium">{{ t(step.labelKey) }}</span>
|
||||
<span v-if="step.toolName" class="text-muted-foreground">: {{ step.toolName }}</span>
|
||||
<span v-if="step.toolName" class="text-muted-foreground">: {{ formatAgentToolName(step.toolName) }}</span>
|
||||
<template v-if="step.tone === 'active' && step.toolName">
|
||||
<span class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>{{ t("ai.agentSteps.executing") }}</span>
|
||||
<span>{{ step.approval?.status === "pending" || step.approval?.status === "submitting" ? t("ai.toolApproval.waiting") : t("ai.agentSteps.executing") }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="step.durationMs !== undefined" class="ml-auto shrink-0 tabular-nums" :class="step.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-chart-2'">{{ formatToolDurationMs(step.durationMs) }}</span>
|
||||
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="h-3 w-3 shrink-0 transition-transform duration-150" :class="[{ 'rotate-90': expandedSteps.has(step.key) }, !agentStepHasTail(step) ? 'ml-auto' : '']" />
|
||||
</button>
|
||||
<AiToolApprovalCard v-if="step.approval" :approval="step.approval" @resolve="resolveToolApproval(step, $event)" />
|
||||
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
|
||||
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
|
||||
<Button v-if="step.toolName === 'explain_query' && step.toolArgs?.sql" size="sm" variant="outline" class="mb-1 h-6 gap-1 text-[10px]" @click="emit('openExplainPlan', step.toolArgs.sql as string, conversationBinding)">
|
||||
@@ -5713,13 +5993,14 @@ async function openExternalUrl(url: string) {
|
||||
</div>
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
<div class="px-2 pb-2 pt-1">
|
||||
<div data-ai-composer-context-row :class="['ai-prompt-context-row mb-1 flex items-center gap-x-1 text-xs text-foreground/80', showAiSchemaSelector && 'ai-prompt-context-row--schema']">
|
||||
<details v-if="pluginContext" class="min-w-0 flex-1" data-ai-plugin-context>
|
||||
<summary class="cursor-pointer truncate">{{ pluginContext.pluginName }} · {{ pluginContext.title }}</summary>
|
||||
<pre class="max-h-56 overflow-auto whitespace-pre-wrap break-all p-2 text-[11px]">{{ pluginContextText(pluginContext) }}</pre>
|
||||
</details>
|
||||
<template v-else-if="connectionStore.connections.length">
|
||||
<DatabaseIcon v-if="boundConnection" :db-type="connectionIconType(boundConnection)" class="h-3 w-3 shrink-0" />
|
||||
<div data-ai-composer-context-row :class="['ai-prompt-context-row mb-1 flex min-w-0 items-center gap-x-1 text-xs text-foreground/80', showAiSchemaSelector && 'ai-prompt-context-row--schema', compactContextControls && 'ai-prompt-context-row--compact']">
|
||||
<div v-if="pluginContext && !connectionStore.connections.length" class="flex min-w-0 max-w-[14rem] items-center gap-1" data-ai-plugin-context :title="pluginContext.title">
|
||||
<ConnectionIcon v-if="pluginContextConnection" :connection="pluginContextConnection" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ pluginComposerConnectionName }}</span>
|
||||
</div>
|
||||
<template v-if="connectionStore.connections.length">
|
||||
<ConnectionIcon v-if="boundConnection" :connection="boundConnection" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<ConnectionTreeSelect
|
||||
:model-value="boundConnectionId"
|
||||
@@ -5728,12 +6009,12 @@ async function openExternalUrl(url: string) {
|
||||
:placeholder="t('editor.selectConnection')"
|
||||
:search-placeholder="t('editor.searchConnection')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
:trigger-class="['h-5 px-1 text-foreground/80', showAiSchemaSelector && 'min-w-0 max-w-56 flex-1']"
|
||||
:trigger-class="['h-5 min-w-0 max-w-full px-1 text-foreground/80', showAiSchemaSelector && 'max-w-56 flex-1']"
|
||||
trigger-icon-class="h-3 w-3"
|
||||
list-class="w-72 max-w-[calc(100vw-2rem)]"
|
||||
@update:model-value="(v) => changeConnection(v)"
|
||||
/>
|
||||
<template v-if="boundConnection">
|
||||
<template v-if="boundConnection && showAiDatabaseSelector">
|
||||
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||
<Popover
|
||||
@update:open="
|
||||
@@ -5743,7 +6024,7 @@ async function openExternalUrl(url: string) {
|
||||
"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" :title="selectedDatabaseLabel" :class="['h-5 max-w-64 justify-start border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'min-w-0 flex-1']">
|
||||
<Button variant="ghost" :title="selectedDatabaseLabel" :class="['h-5 min-w-0 max-w-64 justify-start border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'flex-1']">
|
||||
<span class="truncate">{{ selectedDatabaseLabel }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -5836,10 +6117,10 @@ async function openExternalUrl(url: string) {
|
||||
<!-- Skill selector (read-only user SKILL.md library) -->
|
||||
<Popover v-model:open="showSkillSelector">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
|
||||
<button type="button" class="ai-skills-selector-trigger flex min-w-0 items-center gap-1 rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('ai.skillsEntry')" :title="t('ai.skillsEntry')">
|
||||
<Layers class="h-3 w-3" />
|
||||
<span class="truncate">{{ t("ai.skillsEntry") }}</span>
|
||||
<span v-if="selectedSkillIds.length" class="rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
|
||||
<span class="ai-skills-selector-label truncate">{{ t("ai.skillsEntry") }}</span>
|
||||
<span v-if="selectedSkillIds.length" class="ai-skills-selector-count rounded-sm bg-primary px-1 text-[10px] font-medium text-primary-foreground">{{ selectedSkillIds.length }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 gap-0 p-1.5">
|
||||
@@ -6066,7 +6347,7 @@ async function openExternalUrl(url: string) {
|
||||
<Clock class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ t("ai.status.longRunningHint") }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
<div data-ai-composer-actions :class="['ai-prompt-action-row flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden', compactActionControls && 'ai-prompt-action-row--compact']">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="isGenerating" @click="selectCsvFile">
|
||||
@@ -6080,13 +6361,12 @@ async function openExternalUrl(url: string) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- Combined mode + action selector -->
|
||||
<span v-if="pluginContext" class="shrink-0 text-xs text-muted-foreground">{{ t("ai.modes.ask") }}</span>
|
||||
<Popover v-else v-model:open="modeActionOpen">
|
||||
<Popover v-model:open="modeActionOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="modeActionTriggerLabel">
|
||||
<button type="button" class="ai-mode-action-trigger flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="modeActionTriggerLabel" :title="modeActionTriggerLabel">
|
||||
<component :is="modeIcon" class="h-3 w-3" />
|
||||
<span>{{ modeActionTriggerLabel }}</span>
|
||||
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
<span class="ai-mode-action-label">{{ modeActionTriggerLabel }}</span>
|
||||
<svg class="ai-mode-action-chevron h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-56 gap-0 p-1.5" @click.stop>
|
||||
@@ -6124,12 +6404,17 @@ async function openExternalUrl(url: string) {
|
||||
</template>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="min-w-0 flex-1" />
|
||||
<span class="ai-prompt-action-spacer min-w-0 flex-1" />
|
||||
<template v-if="settings.aiConfigs.length > 0">
|
||||
<!-- Combined provider + model selector -->
|
||||
<Popover v-model:open="providerSelectorOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
class="ai-model-selector-trigger min-w-0 flex shrink items-center gap-1.5 max-w-[220px] rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:aria-label="activeFullConfig?.model || t('ai.selectModel')"
|
||||
:title="activeFullConfig?.model || t('ai.selectModel')"
|
||||
>
|
||||
<AiProviderLogo
|
||||
:provider="activeFullConfig?.provider ?? 'claude'"
|
||||
:label="aiConfigProviderLabel(activeFullConfig)"
|
||||
@@ -6137,8 +6422,8 @@ async function openExternalUrl(url: string) {
|
||||
:icon-path="activeFullConfig ? getAiProviderPreset(activeFullConfig.provider, activeFullConfig.endpoint).iconPath : undefined"
|
||||
class="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
|
||||
<svg class="h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
<span class="ai-model-selector-label min-w-0 truncate">{{ activeFullConfig?.model || t("ai.selectModel") }}</span>
|
||||
<svg class="ai-model-selector-chevron h-3 w-3 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="max-h-(--reka-popover-content-available-height) w-80 gap-0 overflow-y-auto p-1.5" @open-auto-focus.prevent>
|
||||
@@ -6326,14 +6611,21 @@ async function openExternalUrl(url: string) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
<button v-if="isGenerating" class="h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
|
||||
<button v-if="isGenerating" class="ai-prompt-send-control h-7 w-7 shrink-0 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center" :title="t('ai.stopGenerating')" @click="cancelStream">
|
||||
<Square class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button v-else-if="hasActiveRunForCurrentConversation" class="h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background-solid disabled:opacity-30 flex" :disabled="!canSubmitPrompt" :title="t('ai.queueSendHint')" @click="onSendClick">
|
||||
<button
|
||||
v-else-if="hasActiveRunForCurrentConversation"
|
||||
class="ai-prompt-send-control ai-prompt-queue-control h-7 shrink-0 items-center gap-1 rounded-full bg-foreground px-2.5 text-[11px] font-medium text-background-solid disabled:opacity-30 flex"
|
||||
:disabled="!canSubmitPrompt"
|
||||
:aria-label="t('ai.queueSend')"
|
||||
:title="t('ai.queueSendHint')"
|
||||
@click="onSendClick"
|
||||
>
|
||||
<Hourglass class="h-3.5 w-3.5" />
|
||||
<span>{{ t("ai.queueSend") }}</span>
|
||||
<span class="ai-prompt-queue-label">{{ t("ai.queueSend") }}</span>
|
||||
</button>
|
||||
<button v-else class="h-7 w-7 shrink-0 rounded-full bg-foreground text-background-solid flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
|
||||
<button v-else class="ai-prompt-send-control h-7 w-7 shrink-0 rounded-full bg-foreground text-background-solid flex items-center justify-center disabled:opacity-30" :disabled="!canSubmitPrompt" @click="send">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -6383,28 +6675,66 @@ async function openExternalUrl(url: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-prompt-context-container {
|
||||
container-type: inline-size;
|
||||
.ai-prompt-context-row--compact .ai-prompt-context-spacer {
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
@container (max-width: 28rem) {
|
||||
.ai-prompt-context-row--schema .ai-prompt-context-spacer {
|
||||
flex: 0 0 0;
|
||||
}
|
||||
.ai-prompt-context-row--compact .ai-template-selector-trigger,
|
||||
.ai-prompt-context-row--compact .ai-skills-selector-trigger {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
max-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ai-prompt-context-row--schema .ai-template-selector-trigger {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
max-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.ai-prompt-context-row--compact .ai-template-selector-label,
|
||||
.ai-prompt-context-row--compact .ai-template-selector-chevron,
|
||||
.ai-prompt-context-row--compact .ai-skills-selector-label,
|
||||
.ai-prompt-context-row--compact .ai-skills-selector-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-prompt-context-row--schema .ai-template-selector-label,
|
||||
.ai-prompt-context-row--schema .ai-template-selector-chevron {
|
||||
display: none;
|
||||
}
|
||||
.ai-prompt-action-row--compact {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-mode-action-trigger,
|
||||
.ai-prompt-action-row--compact .ai-mode-static-trigger,
|
||||
.ai-prompt-action-row--compact .ai-model-selector-trigger {
|
||||
flex: 0 0 1.75rem;
|
||||
width: 1.75rem;
|
||||
max-width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-mode-action-label,
|
||||
.ai-prompt-action-row--compact .ai-mode-action-chevron,
|
||||
.ai-prompt-action-row--compact .ai-model-selector-label,
|
||||
.ai-prompt-action-row--compact .ai-model-selector-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-model-selector-trigger {
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-prompt-send-control {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-prompt-queue-control {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ai-prompt-action-row--compact .ai-prompt-queue-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-markdown :deep(h1) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Loader2, ShieldAlert, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
|
||||
|
||||
/** Inline approval for a plugin tool call that may change state. */
|
||||
const props = defineProps<{ approval: AiAgentStepApproval }>();
|
||||
const emit = defineEmits<{ resolve: [approved: boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const expired = ref(false);
|
||||
|
||||
watch(
|
||||
() => [props.approval.status, props.approval.expiresAtMs] as const,
|
||||
([status, expiresAtMs], _previous, onCleanup) => {
|
||||
expired.value = expiresAtMs <= Date.now();
|
||||
if (expired.value || (status !== "pending" && status !== "submitting")) return;
|
||||
const timeout = setTimeout(() => {
|
||||
expired.value = true;
|
||||
}, expiresAtMs - Date.now());
|
||||
onCleanup(() => clearTimeout(timeout));
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const argumentsText = computed(() => JSON.stringify(props.approval.args ?? {}, null, 2));
|
||||
// A pending card restored from history after its deadline (or an app restart)
|
||||
// has nobody waiting for the answer anymore.
|
||||
const waiting = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting") && !expired.value);
|
||||
const displayStatus = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting" ? "expired" : props.approval.status));
|
||||
const expiresAt = computed(() => new Date(props.approval.expiresAtMs).toLocaleTimeString());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 border-t border-current/10 px-2 pb-2 pt-1.5 text-[10px]" data-ai-tool-approval>
|
||||
<div class="flex items-start gap-1.5 text-foreground">
|
||||
<ShieldAlert class="mt-px h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span class="min-w-0 wrap-anywhere">{{ t("ai.toolApproval.prompt", { plugin: approval.pluginName, tool: approval.pluginTool, connection: approval.connectionName }) }}</span>
|
||||
</div>
|
||||
<pre class="max-h-40 overflow-auto rounded bg-background/60 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap wrap-anywhere">{{ argumentsText }}</pre>
|
||||
<div v-if="waiting" class="flex flex-wrap items-center gap-1.5">
|
||||
<Button size="sm" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', true)">
|
||||
<Loader2 v-if="approval.status === 'submitting'" class="h-3 w-3 animate-spin" />
|
||||
<Check v-else class="h-3 w-3" />
|
||||
{{ t("ai.toolApproval.approve") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', false)">
|
||||
<X class="h-3 w-3" />
|
||||
{{ t("ai.toolApproval.deny") }}
|
||||
</Button>
|
||||
<span class="text-muted-foreground">{{ t("ai.toolApproval.expiresAt", { time: expiresAt }) }}</span>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">{{ t(`ai.toolApproval.status.${displayStatus}`) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,7 +151,7 @@ async function copyPreview() {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[620px]">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[620px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<List class="h-5 w-5 text-primary" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { cleanupMcpHistoryRetention, loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit } from "@/lib/backend/api";
|
||||
import { HISTORY_RETENTION_LIMITS, useHistoryRetentionSetting } from "@/composables/useHistoryRetentionSetting";
|
||||
import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
@@ -107,6 +108,7 @@ import {
|
||||
type ClickTableNavigationTarget,
|
||||
type EditorSettings,
|
||||
type SqlCompletionTriggerMode,
|
||||
type SqlTableCompletionSchemaQualification,
|
||||
type TableHoverLookupMode,
|
||||
SIDEBAR_INDENT_MIN,
|
||||
SIDEBAR_INDENT_MAX,
|
||||
@@ -132,12 +134,16 @@ import {
|
||||
aiTestConnection,
|
||||
checkMcpServerStatus,
|
||||
installMcpServer,
|
||||
installNativeMcpServer,
|
||||
uninstallMcpServer,
|
||||
uninstallNpmMcpServer,
|
||||
loadMcpHttpServerSettings,
|
||||
saveMcpHttpServerSettings,
|
||||
mcpHttpServerStatus,
|
||||
rotateMcpHttpServerToken,
|
||||
loadWebMcpHttpStatus,
|
||||
saveWebMcpHttpSettings,
|
||||
rotateWebMcpToken,
|
||||
forgetSnippetSavedToken,
|
||||
forgetWebdavSyncSecretsPassphrase,
|
||||
forgetWebdavSavedPassword,
|
||||
@@ -171,6 +177,7 @@ import {
|
||||
type McpHttpServerSettings,
|
||||
type McpHttpServerStatus,
|
||||
type WebMcpHttpStatus,
|
||||
type WebMcpHttpSettings,
|
||||
type McpServerStatus,
|
||||
type SnippetProvider,
|
||||
type SnippetSyncConfig,
|
||||
@@ -206,6 +213,7 @@ import {
|
||||
buildMcpWorkBuddyConfig,
|
||||
mcpWebBackendUrl,
|
||||
type McpLaunchConfig,
|
||||
preferMcpNativeLaunch,
|
||||
} from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
|
||||
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
|
||||
@@ -299,7 +307,10 @@ const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const historyRetention = useHistoryRetentionSetting();
|
||||
const mcpHistoryRetention = useHistoryRetentionSetting(loadMcpHistoryRetentionLimit, saveMcpHistoryRetentionLimit);
|
||||
const { draft: editHistoryRetentionLimit, loaded: historyRetentionLoaded, loading: historyRetentionLoading, saving: historyRetentionSaving, loadError: historyRetentionLoadError } = historyRetention;
|
||||
const { draft: editMcpHistoryRetentionLimit, loaded: mcpHistoryRetentionLoaded, loading: mcpHistoryRetentionLoading, saving: mcpHistoryRetentionSaving, loadError: mcpHistoryRetentionLoadError } = mcpHistoryRetention;
|
||||
const mcpHistoryCleanupLoading = ref(false);
|
||||
const connectionStore = useConnectionStore();
|
||||
const hasSqlServerConnection = computed(() => connectionStore.connections.some((connection) => effectiveDatabaseTypeForConnection(connection) === "sqlserver"));
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
@@ -613,6 +624,15 @@ const editShowLineNumbers = ref(settingsStore.editorSettings.showLineNumbers);
|
||||
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
|
||||
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
|
||||
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
||||
const editTableCompletionSchemaQualification = ref<SqlTableCompletionSchemaQualification>(settingsStore.editorSettings.tableCompletionSchemaQualification);
|
||||
const tableCompletionSchemaQualificationDescription = computed(() => {
|
||||
const key = {
|
||||
never: "settings.tableCompletionSchemaQualificationNeverDescription",
|
||||
collision: "settings.tableCompletionSchemaQualificationCollisionDescription",
|
||||
always: "settings.tableCompletionSchemaQualificationAlwaysDescription",
|
||||
}[editTableCompletionSchemaQualification.value];
|
||||
return t(key);
|
||||
});
|
||||
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
|
||||
const editSqlServerSpaceConfirmsCompletion = ref(settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion);
|
||||
const showSqlServerSpaceConfirmsCompletion = computed(() => hasSqlServerConnection.value || settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion || editSqlServerSpaceConfirmsCompletion.value);
|
||||
@@ -661,6 +681,8 @@ const debugLogCopied = ref(false);
|
||||
const debugLogDownloaded = ref(false);
|
||||
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
|
||||
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
|
||||
const editShowColumnHeaderTooltips = ref(settingsStore.editorSettings.showColumnHeaderTooltips);
|
||||
const editShowResultSourceDatabase = ref(settingsStore.editorSettings.showResultSourceDatabase);
|
||||
const editDataGridShowTransposeFieldMetadata = ref(settingsStore.editorSettings.dataGridShowTransposeFieldMetadata);
|
||||
const editColorizeDataGridCellTypes = ref(settingsStore.editorSettings.colorizeDataGridCellTypes);
|
||||
const editShowIndexIndicatorsInHeader = ref(settingsStore.editorSettings.showIndexIndicatorsInHeader);
|
||||
@@ -979,6 +1001,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
|
||||
showInsertValueHints: editShowInsertValueHints.value,
|
||||
autoAliasTables: editAutoAliasTables.value,
|
||||
tableCompletionSchemaQualification: editTableCompletionSchemaQualification.value,
|
||||
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
|
||||
sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion.value,
|
||||
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value,
|
||||
@@ -1002,6 +1025,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
tabSortMode: editTabSortMode.value,
|
||||
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
|
||||
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
|
||||
showColumnHeaderTooltips: editShowColumnHeaderTooltips.value,
|
||||
showResultSourceDatabase: editShowResultSourceDatabase.value,
|
||||
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata.value,
|
||||
colorizeDataGridCellTypes: editColorizeDataGridCellTypes.value,
|
||||
dataGridTypeColorSchemes: editDataGridTypeColorSchemes.value,
|
||||
@@ -1623,6 +1648,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
|
||||
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
||||
editTableCompletionSchemaQualification.value = settingsStore.editorSettings.tableCompletionSchemaQualification;
|
||||
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
|
||||
editSqlServerSpaceConfirmsCompletion.value = settingsStore.editorSettings.sqlServerSpaceConfirmsCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically;
|
||||
@@ -1647,6 +1673,8 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editTabSortMode.value = settingsStore.editorSettings.tabSortMode;
|
||||
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
|
||||
editShowColumnHeaderTooltips.value = settingsStore.editorSettings.showColumnHeaderTooltips;
|
||||
editShowResultSourceDatabase.value = settingsStore.editorSettings.showResultSourceDatabase;
|
||||
editDataGridShowTransposeFieldMetadata.value = settingsStore.editorSettings.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = settingsStore.editorSettings.colorizeDataGridCellTypes;
|
||||
editDataGridTypeColorSchemes.value = cloneDataGridTypeColorSchemes(settingsStore.editorSettings.dataGridTypeColorSchemes);
|
||||
@@ -1759,6 +1787,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
showCurrentStatementFrame: editShowCurrentStatementFrame,
|
||||
showInsertValueHints: editShowInsertValueHints,
|
||||
autoAliasTables: editAutoAliasTables,
|
||||
tableCompletionSchemaQualification: editTableCompletionSchemaQualification,
|
||||
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion,
|
||||
sqlServerSpaceConfirmsCompletion: editSqlServerSpaceConfirmsCompletion,
|
||||
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically,
|
||||
@@ -1780,6 +1809,8 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
tabSortMode: editTabSortMode,
|
||||
showColumnCommentsInHeader: editShowColumnCommentsInHeader,
|
||||
showColumnTypesInHeader: editShowColumnTypesInHeader,
|
||||
showColumnHeaderTooltips: editShowColumnHeaderTooltips,
|
||||
showResultSourceDatabase: editShowResultSourceDatabase,
|
||||
dataGridShowTransposeFieldMetadata: editDataGridShowTransposeFieldMetadata,
|
||||
colorizeDataGridCellTypes: editColorizeDataGridCellTypes,
|
||||
dataGridTypeColorSchemes: editDataGridTypeColorSchemes,
|
||||
@@ -1894,6 +1925,7 @@ watch(
|
||||
if (open) {
|
||||
syncEditorSettingsDraftFromStore();
|
||||
void historyRetention.load();
|
||||
void mcpHistoryRetention.load();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
@@ -1903,6 +1935,7 @@ watch(
|
||||
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
|
||||
} else {
|
||||
historyRetention.discard();
|
||||
mcpHistoryRetention.discard();
|
||||
clearThemePaletteOptionPreview();
|
||||
clearUiFontOptionPreview();
|
||||
restoreLocaleOptionPreview();
|
||||
@@ -2102,11 +2135,25 @@ const queryResultRowLimitDraftTouched = computed(
|
||||
normalizeTableOpenPageSizeDraft(editPageSize.value) !== normalizeTableOpenPageSizeDraft(editEditorSettingsBase.value.pageSize),
|
||||
);
|
||||
const hasBlockingQueryResultRowLimit = computed(() => queryResultRowLimitViolated.value && queryResultRowLimitDraftTouched.value);
|
||||
const hasApplyBlocker = computed(() => historyRetention.invalid.value || historyRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
|
||||
const hasApplyBlocker = computed(() => historyRetention.invalid.value || mcpHistoryRetention.invalid.value || historyRetentionSaving.value || mcpHistoryRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
|
||||
|
||||
async function cleanupMcpHistory() {
|
||||
if (mcpHistoryCleanupLoading.value) return;
|
||||
mcpHistoryCleanupLoading.value = true;
|
||||
try {
|
||||
const deleted = await cleanupMcpHistoryRetention();
|
||||
toast(t("settings.mcpHistoryCleanupCompleted", { count: deleted }));
|
||||
} catch (error) {
|
||||
toast(t("settings.mcpHistoryCleanupFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
} finally {
|
||||
mcpHistoryCleanupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasChanges(): boolean {
|
||||
return (
|
||||
historyRetention.changed.value ||
|
||||
mcpHistoryRetention.changed.value ||
|
||||
hasImportedSettingsPendingApply.value ||
|
||||
hasEditorDraftChanges.value ||
|
||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||
@@ -2123,6 +2170,7 @@ function hasChanges(): boolean {
|
||||
async function persistSettings() {
|
||||
if (hasApplyBlocker.value) return;
|
||||
await historyRetention.save();
|
||||
await mcpHistoryRetention.save();
|
||||
const editorSettingsPatch = editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value);
|
||||
const sidebarObjectDisplayChanged = editorSettingsPatch.sidebarObjectDisplay !== undefined && editorSettingsPatch.sidebarObjectDisplay !== settingsStore.editorSettings.sidebarObjectDisplay;
|
||||
const sidebarTablePageSizeChanged = editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
|
||||
@@ -2228,6 +2276,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editTableCompletionSchemaQualification.value = DEFAULT_EDITOR_SETTINGS.tableCompletionSchemaQualification;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
|
||||
@@ -2302,8 +2351,11 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
|
||||
} else if (tab === "data") {
|
||||
historyRetention.reset();
|
||||
mcpHistoryRetention.reset();
|
||||
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editShowColumnHeaderTooltips.value = DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips;
|
||||
editShowResultSourceDatabase.value = DEFAULT_EDITOR_SETTINGS.showResultSourceDatabase;
|
||||
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
|
||||
// Back to the built-in palette, but keep the user's saved schemes available.
|
||||
@@ -2361,6 +2413,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
|
||||
function resetAllDefaults() {
|
||||
historyRetention.reset();
|
||||
mcpHistoryRetention.reset();
|
||||
// Same contract as resetDefaultsForTab: a full reset also exits any
|
||||
// in-progress shortcut capture (#9066).
|
||||
editingShortcutId.value = null;
|
||||
@@ -2382,6 +2435,7 @@ function resetAllDefaults() {
|
||||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editTableCompletionSchemaQualification.value = DEFAULT_EDITOR_SETTINGS.tableCompletionSchemaQualification;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editSqlServerSpaceConfirmsCompletion.value = DEFAULT_EDITOR_SETTINGS.sqlServerSpaceConfirmsCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
|
||||
@@ -2411,6 +2465,7 @@ function resetAllDefaults() {
|
||||
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
|
||||
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editShowColumnHeaderTooltips.value = DEFAULT_EDITOR_SETTINGS.showColumnHeaderTooltips;
|
||||
editDataGridShowTransposeFieldMetadata.value = DEFAULT_EDITOR_SETTINGS.dataGridShowTransposeFieldMetadata;
|
||||
editColorizeDataGridCellTypes.value = DEFAULT_EDITOR_SETTINGS.colorizeDataGridCellTypes;
|
||||
// Reset the selection only; saved schemes survive a full settings reset.
|
||||
@@ -2629,6 +2684,12 @@ function onCompletionTriggerModeChange(v: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function onTableCompletionSchemaQualificationChange(v: any) {
|
||||
if (v === "never" || v === "collision" || v === "always") {
|
||||
editTableCompletionSchemaQualification.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
function onTableHoverLookupModeChange(v: any) {
|
||||
if (v === "current" || v === "fallback" || v === "always") {
|
||||
editTableHoverLookupMode.value = v;
|
||||
@@ -3289,6 +3350,11 @@ const mcpHttpSettings = ref<McpHttpServerSettings>({
|
||||
});
|
||||
const mcpHttpStatus = ref<McpHttpServerStatus | null>(null);
|
||||
const webMcpHttpStatus = ref<WebMcpHttpStatus | null>(null);
|
||||
const webMcpEnabledDraft = ref(false);
|
||||
const webMcpAllowedHostsText = ref("");
|
||||
const webMcpAllowedOriginsText = ref("");
|
||||
const webMcpSaving = ref(false);
|
||||
const webMcpError = ref("");
|
||||
const mcpHttpLoading = ref(false);
|
||||
const mcpHttpSaving = ref(false);
|
||||
const mcpHttpError = ref("");
|
||||
@@ -3466,6 +3532,7 @@ async function saveMcpPolicy(
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[];
|
||||
allowSalesforceDml: boolean;
|
||||
}[];
|
||||
groupPolicies?: McpGroupPolicy[];
|
||||
queryTimeoutSecs?: number | null;
|
||||
@@ -3654,6 +3721,14 @@ function onMcpGroupExecutionModeChange(groupId: string, mode: McpConnectionExecu
|
||||
void saveMcpPolicy({ groupPolicies });
|
||||
}
|
||||
|
||||
// A connection rule is only worth persisting when it actually changes something: an
|
||||
// explicit execution mode, a narrowed database scope, a per-database override, or the
|
||||
// Salesforce DML opt-in. Rules that merely restate the inherited defaults are dropped so
|
||||
// the stored policy stays readable and keeps following later global changes.
|
||||
function mcpConnectionPolicyIsMeaningful(rule: McpConnectionPolicy): boolean {
|
||||
return rule.executionModeConfigured || rule.databaseScope !== "all" || rule.databasePolicies.length > 0 || rule.allowSalesforceDml;
|
||||
}
|
||||
|
||||
function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") {
|
||||
if (mode === "high_risk_write" && !window.confirm(t("settings.mcpExecutionModeHighRiskConfirm"))) return;
|
||||
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
|
||||
@@ -3677,8 +3752,38 @@ function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConne
|
||||
databaseScope: existing?.databaseScope ?? ("all" as const),
|
||||
allowedDatabases: existing?.allowedDatabases ?? [],
|
||||
databasePolicies: selectedMode.databasePolicies,
|
||||
// Switching a connection to read-only silently revokes the DML opt-in, matching
|
||||
// the backend's ceiling: an agent must never keep a write path it lost.
|
||||
allowSalesforceDml: (existing?.allowSalesforceDml ?? false) && !selectedMode.readOnly,
|
||||
};
|
||||
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
function onMcpConnectionSalesforceDmlChange(connectionId: string, allowed: boolean) {
|
||||
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
|
||||
if (allowed) {
|
||||
// Read-only is a hard ceiling on the server too, so letting the box stay checked
|
||||
// here would only advertise a write path that every prepare call then refuses.
|
||||
const effectiveMode = mcpEffectiveExecutionMode(mcpInheritedGroupExecutionMode(connectionId), mcpConnectionExecutionMode(connectionId), "inherit");
|
||||
if (effectiveMode === "read_only") {
|
||||
toast(t("settings.mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked"), 5000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
|
||||
const next: McpConnectionPolicy = {
|
||||
connectionId,
|
||||
readOnly: existing?.readOnly ?? false,
|
||||
allowDangerousSql: existing?.allowDangerousSql ?? false,
|
||||
executionModeConfigured: existing?.executionModeConfigured ?? false,
|
||||
executionModePolicyVersion: existing?.executionModePolicyVersion ?? null,
|
||||
databaseScope: existing?.databaseScope ?? "all",
|
||||
allowedDatabases: existing?.allowedDatabases ?? [],
|
||||
databasePolicies: existing?.databasePolicies ?? [],
|
||||
allowSalesforceDml: allowed,
|
||||
};
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
@@ -3698,7 +3803,7 @@ function onMcpDatabaseExecutionModeChange(connectionId: string, databaseName: st
|
||||
databasePolicies,
|
||||
};
|
||||
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
|
||||
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
@@ -3755,7 +3860,12 @@ async function loadMcpHttpSettings() {
|
||||
mcpHttpError.value = "";
|
||||
try {
|
||||
if (isWeb) {
|
||||
webMcpHttpStatus.value = await loadWebMcpHttpStatus();
|
||||
const status = await loadWebMcpHttpStatus();
|
||||
webMcpHttpStatus.value = status;
|
||||
webMcpEnabledDraft.value = status.enabled;
|
||||
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
|
||||
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
|
||||
webMcpError.value = "";
|
||||
return;
|
||||
}
|
||||
const [settings, status] = await Promise.all([loadMcpHttpServerSettings(), mcpHttpServerStatus()]);
|
||||
@@ -3771,6 +3881,41 @@ async function loadMcpHttpSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebMcpSettings() {
|
||||
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
|
||||
webMcpSaving.value = true;
|
||||
webMcpError.value = "";
|
||||
const settings: WebMcpHttpSettings = {
|
||||
enabled: webMcpEnabledDraft.value,
|
||||
allowedHosts: mcpHttpList(webMcpAllowedHostsText.value),
|
||||
allowedOrigins: mcpHttpList(webMcpAllowedOriginsText.value),
|
||||
};
|
||||
try {
|
||||
const status = await saveWebMcpHttpSettings(settings);
|
||||
webMcpHttpStatus.value = status;
|
||||
webMcpEnabledDraft.value = status.enabled;
|
||||
webMcpAllowedHostsText.value = status.allowedHosts.join("\n");
|
||||
webMcpAllowedOriginsText.value = status.allowedOrigins.join("\n");
|
||||
} catch (error: unknown) {
|
||||
webMcpError.value = formatMcpHttpError(error);
|
||||
} finally {
|
||||
webMcpSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateWebMcpAccessToken() {
|
||||
if (webMcpSaving.value || webMcpHttpStatus.value?.deploymentManaged) return;
|
||||
webMcpSaving.value = true;
|
||||
webMcpError.value = "";
|
||||
try {
|
||||
webMcpHttpStatus.value = await rotateWebMcpToken();
|
||||
} catch (error: unknown) {
|
||||
webMcpError.value = formatMcpHttpError(error);
|
||||
} finally {
|
||||
webMcpSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMcpHttpSettings() {
|
||||
if (mcpHttpSaving.value) return;
|
||||
mcpHttpSaving.value = true;
|
||||
@@ -3814,6 +3959,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
|
||||
};
|
||||
}
|
||||
const env = mcpStatus.value?.data_dir ? { DBX_DATA_DIR: mcpStatus.value.data_dir } : undefined;
|
||||
if (mcpStatus.value?.native_bin_path) {
|
||||
return preferMcpNativeLaunch({ command: "dbx-mcp-server", env }, mcpStatus.value.native_bin_path);
|
||||
}
|
||||
if (mcpStatus.value?.node_path && mcpStatus.value.script_path) {
|
||||
return {
|
||||
command: mcpStatus.value.node_path,
|
||||
@@ -3829,17 +3977,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
|
||||
|
||||
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpTraeRecommendedConfig = computed(() => {
|
||||
// TRAE currently splits Windows executable paths containing spaces, so bypass Node and launch the native MCP binary directly.
|
||||
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
|
||||
return buildMcpTraeConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
|
||||
});
|
||||
const mcpTraeRecommendedConfig = computed(() => buildMcpTraeConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpQoderRecommendedConfig = computed(() => {
|
||||
// Qoder follows TRAE's mcpServers JSON format and has the same Windows path parsing limitation.
|
||||
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
|
||||
return buildMcpQoderConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
|
||||
});
|
||||
const mcpQoderRecommendedConfig = computed(() => buildMcpQoderConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpLaunchConfig.value));
|
||||
|
||||
@@ -3868,11 +4008,19 @@ const mcpStatusLabel = computed(() => {
|
||||
});
|
||||
|
||||
const mcpCommand = computed(() => {
|
||||
if (!mcpStatus.value) return "npm install -g @dbx-app/mcp-server@latest";
|
||||
if (!mcpStatus.value) return isWindows() ? 'powershell -NoProfile -Command "irm https://dbxio.com/install-mcp.ps1 | iex"' : "curl -fsSL https://dbxio.com/install-mcp | sh";
|
||||
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
|
||||
});
|
||||
|
||||
const mcpUninstallCommand = computed(() => mcpStatus.value?.uninstall_command || "npm uninstall -g @dbx-app/mcp-server");
|
||||
const mcpNativeMigrationAvailable = computed(() => mcpStatus.value?.installation_source === "npm");
|
||||
const mcpInstallDisabled = computed(() => mcpInstalling.value || mcpUninstalling.value || Boolean(mcpStatus.value?.installed && !mcpStatus.value.update_available && !mcpNativeMigrationAvailable.value));
|
||||
const mcpInstallButtonLabel = computed(() => {
|
||||
if (mcpInstalling.value) return t("settings.mcpInstalling");
|
||||
if (mcpNativeMigrationAvailable.value) return t("settings.mcpSwitchToNativeButton");
|
||||
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
|
||||
return mcpStatus.value.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate");
|
||||
});
|
||||
|
||||
async function refreshMcpStatus() {
|
||||
if (mcpStatusLoading.value) return;
|
||||
@@ -3913,7 +4061,7 @@ async function installMcp() {
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
try {
|
||||
const result = await installMcpServer();
|
||||
const result = mcpNativeMigrationAvailable.value || !mcpStatus.value?.installed ? await installNativeMcpServer() : await installMcpServer();
|
||||
mcpInstallMessage.value = result;
|
||||
mcpInstallError.value = false;
|
||||
// 安装成功后刷新状态
|
||||
@@ -3954,6 +4102,28 @@ async function uninstallMcp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallNpmMcpFallback() {
|
||||
if (mcpInstalling.value || mcpUninstalling.value || !mcpStatus.value?.npm_installed) return;
|
||||
if (!window.confirm(t("settings.mcpRemoveNpmFallbackConfirm"))) return;
|
||||
mcpUninstalling.value = true;
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
try {
|
||||
mcpInstallMessage.value = await uninstallNpmMcpServer();
|
||||
await refreshMcpStatus();
|
||||
notifyComponentUpdatesChanged();
|
||||
} catch (e: any) {
|
||||
mcpInstallMessage.value = t("settings.mcpRemoveNpmFallbackFailed", { error: e?.message || String(e) });
|
||||
mcpInstallError.value = true;
|
||||
} finally {
|
||||
mcpUninstalling.value = false;
|
||||
window.setTimeout(() => {
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WebDAV Sync ----------
|
||||
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
|
||||
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
|
||||
@@ -6300,6 +6470,25 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="settings-item flex items-start justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2" data-editor-table-completion-schema-qualification>
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label>{{ t("settings.tableCompletionSchemaQualification") }}</Label>
|
||||
<p class="text-xs leading-tight text-muted-foreground">
|
||||
{{ tableCompletionSchemaQualificationDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<Select :model-value="editTableCompletionSchemaQualification" @update:model-value="onTableCompletionSchemaQualificationChange">
|
||||
<SelectTrigger class="h-8 w-44 shrink-0">
|
||||
<SelectValue :placeholder="t('settings.tableCompletionSchemaQualification')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="never">{{ t("settings.tableCompletionSchemaQualificationNever") }}</SelectItem>
|
||||
<SelectItem value="collision">{{ t("settings.tableCompletionSchemaQualificationCollision") }}</SelectItem>
|
||||
<SelectItem value="always">{{ t("settings.tableCompletionSchemaQualificationAlways") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -7555,11 +7744,13 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Data Tab -->
|
||||
<section v-else-if="activeSettingsTab === 'data'" data-settings-search-id="data" :class="['flex flex-col gap-5 py-2', settingsSearchTargetClass('data')]">
|
||||
<div data-settings-search-id="history-retention" :class="['space-y-2', settingsSearchTargetClass('history-retention')]">
|
||||
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
|
||||
<div data-settings-search-id="history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('history-retention')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="history-retention-limit">{{ t("settings.historyRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.historyRetentionDescription") }}</p>
|
||||
</div>
|
||||
<Select :model-value="String(editHistoryRetentionLimit)" :disabled="!historyRetentionLoaded || historyRetentionSaving" @update:model-value="(value) => (editHistoryRetentionLimit = Number(value))">
|
||||
<SelectTrigger id="history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger id="history-retention-limit" class="w-40 shrink-0"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -7570,6 +7761,28 @@ onUnmounted(() => {
|
||||
<Button type="button" variant="outline" size="sm" @click="historyRetention.load">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-settings-search-id="mcp-history-retention" :class="['flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('mcp-history-retention')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="mcp-history-retention-limit">{{ t("settings.mcpHistoryRetentionLimit") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHistoryRetentionDescription") }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Select :model-value="String(editMcpHistoryRetentionLimit)" :disabled="!mcpHistoryRetentionLoaded || mcpHistoryRetentionSaving" @update:model-value="(value) => (editMcpHistoryRetentionLimit = Number(value))">
|
||||
<SelectTrigger id="mcp-history-retention-limit" class="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent
|
||||
><SelectItem v-for="limit in HISTORY_RETENTION_LIMITS" :key="limit" :value="String(limit)">{{ limit === 0 ? t("settings.historyRetentionUnlimited") : String(limit) }}</SelectItem></SelectContent
|
||||
>
|
||||
</Select>
|
||||
<p v-if="mcpHistoryRetentionLoading" class="text-xs text-muted-foreground">{{ t("common.loading") }}</p>
|
||||
<div v-if="mcpHistoryRetentionLoadError" class="flex items-center gap-2 text-xs text-destructive" role="alert">
|
||||
<span>{{ t("settings.historyRetentionLoadFailed", { error: mcpHistoryRetentionLoadError }) }}</span>
|
||||
<Button type="button" variant="outline" size="sm" @click="mcpHistoryRetention.load">{{ t("common.retry") }}</Button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpHistoryCleanupLoading || !mcpHistoryRetentionLoaded" @click="cleanupMcpHistory">
|
||||
{{ mcpHistoryCleanupLoading ? t("common.loading") : t("settings.mcpHistoryCleanup") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="data-grid-toolbar-layout" data-settings-search-id="data-grid-toolbar-layout" :class="['settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2', settingsSearchTargetClass('data-grid-toolbar-layout')]">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label for="data-grid-toolbar-layout-select">{{ t("settings.dataGridToolbarLayout") }}</Label>
|
||||
@@ -7908,6 +8121,28 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Switch id="show-column-types-in-header" v-model="editShowColumnTypesInHeader" />
|
||||
</div>
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="show-column-header-tooltips">
|
||||
{{ t("settings.showColumnHeaderTooltips") }}
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.showColumnHeaderTooltipsDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="show-column-header-tooltips" v-model="editShowColumnHeaderTooltips" />
|
||||
</div>
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="show-result-source-database">
|
||||
{{ t("settings.showResultSourceDatabase") }}
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.showResultSourceDatabaseDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="show-result-source-database" v-model="editShowResultSourceDatabase" />
|
||||
</div>
|
||||
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="data-grid-show-transpose-field-metadata">
|
||||
@@ -9337,7 +9572,7 @@ LIMIT 100;</pre
|
||||
<span class="flex w-full min-w-0 items-center gap-2">
|
||||
<AiProviderLogo :provider="provider.provider" :label="provider.label" :icon-slug="provider.iconSlug" :icon-path="provider.iconPath" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ provider.label }}</span>
|
||||
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t("ai.jalapenoSponsored") }}</Badge>
|
||||
<Badge v-if="provider.badgeKey" variant="outline" class="h-5 shrink-0 px-1.5 text-[10px] font-normal">{{ t(provider.badgeKey) }}</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -9637,25 +9872,63 @@ LIMIT 100;</pre
|
||||
</TabsList>
|
||||
<TabsContent value="http" class="m-0 space-y-4">
|
||||
<div v-if="isWeb" class="space-y-4">
|
||||
<div class="rounded-md border bg-muted/20 p-4 space-y-2">
|
||||
<div class="space-y-3 border-t border-border/60 pt-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label class="text-base">{{ t("settings.mcpHttpWebServiceTitle") }}</Label>
|
||||
<Badge :variant="webMcpHttpStatus?.enabled ? 'default' : 'outline'">{{ webMcpHttpStatus?.enabled ? t("settings.mcpHttpStatusLabelEnabled") : t("settings.mcpHttpStatusLabelDisabled") }}</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebServiceDescription") }}</p>
|
||||
<p v-if="mcpHttpError" class="text-xs text-destructive">{{ mcpHttpError }}</p>
|
||||
<template v-if="webMcpHttpStatus">
|
||||
<code class="block rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
|
||||
<p v-if="!webMcpHttpStatus.enabled" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDisabledHint") }}</p>
|
||||
<code v-else class="block overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpEndpoint }}</code>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
{{
|
||||
t("settings.mcpHttpWebTokenSourceAndHosts", {
|
||||
tokenSource: webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
|
||||
tokenSource: webMcpHttpStatus.tokenSource === "managed" ? t("settings.mcpHttpWebManagedTokenSource") : webMcpHttpStatus.tokenSource || t("settings.mcpHttpNotConfigured"),
|
||||
hosts: webMcpHttpStatus.allowedHosts.join(", ") || t("settings.mcpHttpNotConfigured"),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="webMcpHttpStatus.allowedOrigins.length" class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebAllowedOrigins", { origins: webMcpHttpStatus.allowedOrigins.join(", ") }) }}</p>
|
||||
<p v-if="webMcpHttpStatus.deploymentManaged" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebDeploymentManaged") }}</p>
|
||||
<p v-else-if="!webMcpHttpStatus.managementAvailable" class="text-xs text-muted-foreground">{{ t("settings.mcpHttpWebPasswordRequired") }}</p>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||
<Label for="web-mcp-enabled" class="text-sm">{{ t("settings.mcpHttpWebEnableLabel") }}</Label>
|
||||
<Switch id="web-mcp-enabled" v-model="webMcpEnabledDraft" :disabled="webMcpSaving" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="web-mcp-hosts">{{ t("settings.mcpHttpAllowedHostsLabel") }}</Label>
|
||||
<textarea id="web-mcp-hosts" v-model="webMcpAllowedHostsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="192.168.0.77:4224" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpHostsHint") }}</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="web-mcp-origins">{{ t("settings.mcpHttpAllowedOriginsLabel") }}</Label>
|
||||
<textarea id="web-mcp-origins" v-model="webMcpAllowedOriginsText" rows="2" :disabled="webMcpSaving" class="min-h-16 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs" placeholder="https://mcp-client.example.com" />
|
||||
<p class="text-[11px] text-muted-foreground">{{ t("settings.mcpHttpWebOriginsHint") }}</p>
|
||||
</div>
|
||||
<div v-if="webMcpHttpStatus.enabled && webMcpHttpStatus.accessToken" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label>Bearer Token</Label>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="webMcpSaving" @click="rotateWebMcpAccessToken">{{ t("settings.mcpHttpRotateToken") }}</Button>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<code class="min-w-0 flex-1 overflow-x-auto rounded border bg-background px-2 py-1.5 text-xs">{{ webMcpHttpStatus.accessToken }}</code>
|
||||
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('http-token', webMcpHttpStatus.accessToken || '')">
|
||||
<CheckCircle2 v-if="mcpCopied === 'http-token'" class="h-3.5 w-3.5 text-green-500" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="webMcpError" class="text-xs text-destructive">{{ webMcpError }}</p>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading || webMcpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
|
||||
<Button type="button" size="sm" :disabled="webMcpSaving || (webMcpEnabledDraft && !mcpHttpList(webMcpAllowedHostsText).length)" @click="saveWebMcpSettings">{{ t("settings.mcpHttpWebSave") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
|
||||
<Button v-if="!webMcpHttpStatus || webMcpHttpStatus.deploymentManaged" type="button" variant="outline" size="sm" :disabled="mcpHttpLoading" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReloadStatus") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9666,7 +9939,7 @@ LIMIT 100;</pre
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-base">{{ t("settings.mcpHttpServiceTitle") }}</Label>
|
||||
<Badge :variant="mcpHttpStatus?.running ? 'default' : 'outline'">
|
||||
{{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelPending") : t("settings.mcpHttpStatusLabelDisabled") }}
|
||||
{{ mcpHttpStatus?.running ? t("settings.mcpHttpStatusLabelRunning") : mcpHttpSettings.enabled ? t("settings.mcpHttpStatusLabelNotRunning") : t("settings.mcpHttpStatusLabelDisabled") }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">{{ t("settings.mcpHttpServiceDescription") }}</p>
|
||||
@@ -9732,10 +10005,9 @@ LIMIT 100;</pre
|
||||
{{ mcpHttpHasUnsavedChanges ? t("settings.mcpHttpUnsavedChangesHint") : t("settings.mcpHttpSaveHint") }}
|
||||
</p>
|
||||
<div class="flex shrink-0 justify-end gap-2">
|
||||
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
|
||||
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving || Boolean(mcpHttpDraftValidationError)" @click="saveMcpHttpSettings">
|
||||
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("settings.mcpHttpSaveAndStart") }}
|
||||
{{ t("settings.mcpHttpApply") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9744,10 +10016,9 @@ LIMIT 100;</pre
|
||||
<div v-else class="flex flex-col gap-3 rounded-md border border-dashed px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>{{ t("settings.mcpHttpDisabledHint") }}</p>
|
||||
<div class="flex shrink-0 justify-end gap-2">
|
||||
<Button type="button" variant="outline" :disabled="mcpHttpLoading || mcpHttpSaving" @click="loadMcpHttpSettings">{{ t("settings.mcpHttpReload") }}</Button>
|
||||
<Button type="button" :disabled="mcpHttpLoading || mcpHttpSaving" @click="saveMcpHttpSettings">
|
||||
<Loader2 v-if="mcpHttpSaving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("settings.mcpHttpSaveAndStop") }}
|
||||
{{ t("settings.mcpHttpApply") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9851,7 +10122,10 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb" class="space-y-2">
|
||||
<Label>{{ mcpStatus?.installed ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
|
||||
<Label>{{ mcpStatus?.installed && !mcpNativeMigrationAvailable ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
|
||||
<p v-if="mcpNativeMigrationAvailable" class="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-xs text-blue-700 dark:text-blue-300">
|
||||
{{ t("settings.mcpNativeMigrationHint") }}
|
||||
</p>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||
{{ mcpCommand }}
|
||||
@@ -9860,10 +10134,10 @@ LIMIT 100;</pre
|
||||
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="default" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
|
||||
<Button type="button" variant="default" :disabled="mcpInstallDisabled" @click="installMcp">
|
||||
<Loader2 v-if="mcpInstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available" class="mr-2 h-4 w-4" />
|
||||
{{ mcpInstalling ? t("settings.mcpInstalling") : !mcpStatus?.installed ? t("settings.mcpInstallButton") : mcpStatus?.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate") }}
|
||||
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available && !mcpNativeMigrationAvailable" class="mr-2 h-4 w-4" />
|
||||
{{ mcpInstallButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -9887,13 +10161,26 @@ LIMIT 100;</pre
|
||||
<CheckCircle2 v-if="mcpCopied === 'uninstall'" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus.npm_available" @click="uninstallMcp">
|
||||
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallMcp">
|
||||
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ mcpUninstalling ? t("settings.mcpUninstalling") : t("settings.mcpUninstallButton") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb && mcpStatus?.installation_source !== 'npm' && mcpStatus?.npm_installed" class="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-200">
|
||||
{{ t("settings.mcpNpmFallbackHint") }}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallNpmMcpFallback">
|
||||
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ t("settings.mcpRemoveNpmFallbackButton") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -9922,6 +10209,7 @@ LIMIT 100;</pre
|
||||
@update:scope="onMcpResourceScopeChange"
|
||||
@set:group-policy="onMcpGroupExecutionModeChange"
|
||||
@set:connection-policy="onMcpConnectionExecutionModeChange"
|
||||
@set:connection-salesforce-dml="onMcpConnectionSalesforceDmlChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10305,7 +10593,7 @@ LIMIT 100;</pre
|
||||
|
||||
<div v-if="mcpTransportTab === 'stdio'" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }}</span>
|
||||
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNativeManagementBoundary") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -111,7 +111,8 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
|
||||
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
|
||||
import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
|
||||
|
||||
@@ -122,24 +123,20 @@ import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOcc
|
||||
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
|
||||
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
|
||||
import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut";
|
||||
|
||||
import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
|
||||
|
||||
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
|
||||
|
||||
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
|
||||
|
||||
const props = defineProps<QueryEditorProps>();
|
||||
|
||||
function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
|
||||
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
|
||||
// clickhouse and soql ride the SQL editor but have no matching behavior dialect;
|
||||
// fall back to the connection's dialect (undefined for Salesforce).
|
||||
return props.syntaxDialect === "clickhouse" || props.syntaxDialect === "soql" ? props.dialect : (props.syntaxDialect ?? props.dialect);
|
||||
}
|
||||
|
||||
function queryEditorSelectionLanguage(): "sql" | "text" {
|
||||
@@ -158,6 +155,9 @@ const COMPLETION_ENTER_MAX_WAIT_MS = 125;
|
||||
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
|
||||
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
|
||||
// Master switch for colouring table/column names that the connected database does not have.
|
||||
// There is no settings entry yet: flip this to false to turn the whole feature off.
|
||||
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = true;
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
@@ -214,7 +214,8 @@ const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.ge
|
||||
const MAX_COMPLETION_TABLES = 200;
|
||||
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
|
||||
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
|
||||
|
||||
const SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS = 900;
|
||||
const SQL_UNKNOWN_OBJECT_DEBOUNCE_MS = 600;
|
||||
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const isGestureZooming = ref(false);
|
||||
@@ -437,6 +438,8 @@ interface EditorGestureEvent extends Event {
|
||||
}
|
||||
|
||||
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
|
||||
const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue);
|
||||
const fullEditorFeaturesEnabled = () => !largeDocumentMode;
|
||||
|
||||
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let editorIsActive = true;
|
||||
@@ -447,6 +450,7 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
|
||||
let postCompositionKeyGuardCleanup: (() => void) | null = null;
|
||||
|
||||
function runStatementGutterExtension(): import("@codemirror/state").Extension {
|
||||
if (!fullEditorFeaturesEnabled()) return [];
|
||||
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
|
||||
return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
|
||||
}
|
||||
@@ -776,7 +780,7 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) {
|
||||
}
|
||||
|
||||
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
|
||||
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
|
||||
if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
|
||||
|
||||
const sql = currentEditorDocText(currentView);
|
||||
const selection = currentView.state.selection.main;
|
||||
@@ -863,6 +867,11 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fullEditorFeaturesEnabled()) {
|
||||
contextObjectTarget.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = currentEditorDocText(currentView);
|
||||
if (!props.connectionId || props.database == null) {
|
||||
const candidate = queryTableCandidateAtSqlPosition({
|
||||
@@ -928,6 +937,7 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
|
||||
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
|
||||
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
|
||||
function resolvePreviewDmlCandidate(position?: number): string {
|
||||
if (!fullEditorFeaturesEnabled()) return "";
|
||||
const currentView = view.value;
|
||||
if (!currentView) return "";
|
||||
const selection = currentView.state.selection.main;
|
||||
@@ -1497,52 +1507,56 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) {
|
||||
currentView.focus();
|
||||
}
|
||||
|
||||
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({
|
||||
props,
|
||||
view,
|
||||
settingsStore,
|
||||
connectionStore,
|
||||
sqlDriverProfile,
|
||||
sqlStatementParameterOptions,
|
||||
sqlBehaviorDialect,
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
maxCompletionTables: MAX_COMPLETION_TABLES,
|
||||
runtime: {
|
||||
get setSqlDiagnosticsEffect() {
|
||||
return codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, loadSqlUnknownObjectSpans } =
|
||||
useQueryEditorDiagnostics({
|
||||
props,
|
||||
view,
|
||||
settingsStore,
|
||||
connectionStore,
|
||||
sqlDriverProfile,
|
||||
sqlStatementParameterOptions,
|
||||
sqlBehaviorDialect,
|
||||
queryEditorSelectionLanguage,
|
||||
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
|
||||
maxCompletionTables: MAX_COMPLETION_TABLES,
|
||||
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
fullFeaturesEnabled: fullEditorFeaturesEnabled,
|
||||
runtime: {
|
||||
get setSqlDiagnosticsEffect() {
|
||||
return codeMirrorRuntime.setSqlDiagnosticsEffect;
|
||||
},
|
||||
get diagnosticComp() {
|
||||
return codeMirrorRuntime.diagnosticComp;
|
||||
},
|
||||
get buildSqlDiagnosticExtension() {
|
||||
return codeMirrorRuntime.buildSqlDiagnosticExtension;
|
||||
},
|
||||
get codeMirrorCompletionStatus() {
|
||||
return codeMirrorRuntime.codeMirrorCompletionStatus;
|
||||
},
|
||||
get executableStatementRangeCache() {
|
||||
return executableStatementRangeCache;
|
||||
},
|
||||
set executableStatementRangeCache(value) {
|
||||
executableStatementRangeCache = value;
|
||||
},
|
||||
get editorIsActive() {
|
||||
return editorIsActive;
|
||||
},
|
||||
},
|
||||
get diagnosticComp() {
|
||||
return codeMirrorRuntime.diagnosticComp;
|
||||
metadata: {
|
||||
get cachedTables() {
|
||||
return completionMetadata.cachedTables;
|
||||
},
|
||||
cachedColumnsByTable,
|
||||
loadedColumnsByTable,
|
||||
usesOracleSessionCompletionColumns,
|
||||
findExactSemanticDiagnosticTable,
|
||||
completionCacheKey,
|
||||
ensureColumnsForTable,
|
||||
isMissingTableMetadataError,
|
||||
},
|
||||
get buildSqlDiagnosticExtension() {
|
||||
return codeMirrorRuntime.buildSqlDiagnosticExtension;
|
||||
},
|
||||
get codeMirrorCompletionStatus() {
|
||||
return codeMirrorRuntime.codeMirrorCompletionStatus;
|
||||
},
|
||||
get executableStatementRangeCache() {
|
||||
return executableStatementRangeCache;
|
||||
},
|
||||
set executableStatementRangeCache(value) {
|
||||
executableStatementRangeCache = value;
|
||||
},
|
||||
get editorIsActive() {
|
||||
return editorIsActive;
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
get cachedTables() {
|
||||
return completionMetadata.cachedTables;
|
||||
},
|
||||
cachedColumnsByTable,
|
||||
loadedColumnsByTable,
|
||||
usesOracleSessionCompletionColumns,
|
||||
findExactSemanticDiagnosticTable,
|
||||
completionCacheKey,
|
||||
ensureColumnsForTable,
|
||||
isMissingTableMetadataError,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function formatCurrentSql() {
|
||||
if (props.readOnly) return;
|
||||
@@ -1736,7 +1750,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
view,
|
||||
runtime: codeMirrorRuntime,
|
||||
beforeLoad() {
|
||||
hoverContent.initializeHighlighter();
|
||||
if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter();
|
||||
},
|
||||
async prepare(modules) {
|
||||
const initializedRuntime = modules.runtime;
|
||||
@@ -1793,6 +1807,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
currentExecutableStatementRange,
|
||||
executeSqlStatementFromGutter,
|
||||
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
|
||||
fullFeaturesEnabled: fullEditorFeaturesEnabled,
|
||||
});
|
||||
const initialSettings = settingsStore.editorSettings;
|
||||
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
|
||||
@@ -1826,21 +1841,23 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
|
||||
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
|
||||
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
|
||||
currentStatementFrameExtension,
|
||||
fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [],
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
initializedRuntime.historyResetComp.of(history()),
|
||||
foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-foldMarker-svg";
|
||||
span.innerHTML = open
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
return span;
|
||||
},
|
||||
}),
|
||||
sqlBlockFoldService,
|
||||
fullEditorFeaturesEnabled()
|
||||
? foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-foldMarker-svg";
|
||||
span.innerHTML = open
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
return span;
|
||||
},
|
||||
})
|
||||
: [],
|
||||
fullEditorFeaturesEnabled() ? sqlBlockFoldService : [],
|
||||
drawSelection(),
|
||||
editorClipboardLineEndingsExtension(EditorView),
|
||||
trimmedSelectionLayer(),
|
||||
@@ -1859,8 +1876,8 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
),
|
||||
),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
fullEditorFeaturesEnabled() ? indentOnInput() : [],
|
||||
fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [],
|
||||
crosshairCursor(),
|
||||
activeLineHighlighter,
|
||||
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
|
||||
@@ -1870,6 +1887,14 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
|
||||
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
|
||||
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
|
||||
fullEditorFeaturesEnabled()
|
||||
? createSqlUnknownObjectHighlights({
|
||||
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
|
||||
load: loadSqlUnknownObjectSpans,
|
||||
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
|
||||
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
|
||||
})
|
||||
: [],
|
||||
tooltips({ parent: tooltipParent }),
|
||||
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
|
||||
sqlCompletionTheme(EditorView),
|
||||
@@ -1896,15 +1921,17 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||
fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [],
|
||||
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
|
||||
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
|
||||
createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
|
||||
}),
|
||||
fullEditorFeaturesEnabled()
|
||||
? createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
|
||||
})
|
||||
: [],
|
||||
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
|
||||
sqlExtensions.buildResultSourceRangeExtension(),
|
||||
Prec.highest(
|
||||
@@ -2493,6 +2520,7 @@ function resumeQueryEditorBackgroundWork() {
|
||||
editorIsActive = true;
|
||||
registerTableReferenceDropListener();
|
||||
scheduleSemanticDiagnostics();
|
||||
view.value?.dispatch({ effects: [refreshSqlUnknownObjectHighlights.of(null)] });
|
||||
// Warm the database driver/pool while the user is still reading or typing, so
|
||||
// the first Run does not pay pool creation or external-driver startup.
|
||||
warmActiveTabConnection();
|
||||
@@ -2614,7 +2642,15 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full overflow-hidden relative" @wheel="recordExecutionViewportInteraction" @pointerdown="recordExecutionViewportInteraction" @gesturestart="onEditorGestureStart" @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd">
|
||||
<div
|
||||
class="h-full w-full overflow-hidden relative"
|
||||
:data-large-document-mode="largeDocumentMode || undefined"
|
||||
@wheel="recordExecutionViewportInteraction"
|
||||
@pointerdown="recordExecutionViewportInteraction"
|
||||
@gesturestart="onEditorGestureStart"
|
||||
@gesturechange="onEditorGestureChange"
|
||||
@gestureend="onEditorGestureEnd"
|
||||
>
|
||||
<QueryEditorContextMenu :get-state="getContextMenuState" :actions="contextMenuActions" @close="contextMenuOpen = false" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
ref="editorRef"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Minus, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
|
||||
import { CalendarClock, Check, Copy, Database, ListFilter, LoaderCircle, Maximize2, Minimize2, Minus, RefreshCw, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
@@ -17,6 +17,7 @@ import { hasHistoryDateRange, historyDateRangeIsValid, type HistoryDateRange } f
|
||||
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
|
||||
import { historyConnectionHasSelectedDatabase } from "@/lib/history/historySearch";
|
||||
import { historyEntrySource } from "@/lib/history/historyEntrySource";
|
||||
import { formatQueryDuration } from "@/lib/format/duration";
|
||||
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
@@ -33,18 +34,24 @@ const connectionStore = useConnectionStore();
|
||||
const props = defineProps<{
|
||||
currentConnectionId?: string;
|
||||
currentDatabase?: string;
|
||||
maximized?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [sql: string, entry: HistoryEntry];
|
||||
analyzeAi: [entry: HistoryEntry];
|
||||
close: [];
|
||||
toggleMaximize: [];
|
||||
}>();
|
||||
|
||||
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed";
|
||||
type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed" | "mcp_success";
|
||||
type HistorySource = "sql" | "mcp";
|
||||
|
||||
const searchText = ref("");
|
||||
const mcpToolName = ref("");
|
||||
const mcpToolOptions = ref<string[]>([]);
|
||||
const activeFilter = ref<HistoryFilter>("all");
|
||||
const activeSource = ref<HistorySource>("sql");
|
||||
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeOpen = ref(false);
|
||||
@@ -70,7 +77,9 @@ const filtersScrollable = ref(false);
|
||||
let filterScrollResizeObserver: ResizeObserver | null = null;
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
|
||||
const filters = computed<HistoryFilter[]>(() => (activeSource.value === "mcp" ? ["all", "failed", "mcp_success"] : ["all", "query", "data_change", "schema_change", "failed"]));
|
||||
const mcpQuickTools = computed(() => mcpToolOptions.value);
|
||||
const canClearHistory = computed(() => store.total > 0);
|
||||
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
|
||||
const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value));
|
||||
const dateRangeSummary = computed(() => {
|
||||
@@ -94,6 +103,10 @@ function activityKind(entry: HistoryEntry) {
|
||||
return resolveHistoryActivityKind(entry);
|
||||
}
|
||||
|
||||
function isMcpEntry(entry: HistoryEntry) {
|
||||
return entry.source === "mcp" || historyEntrySource(entry) === "MCP";
|
||||
}
|
||||
|
||||
function restore(entry: HistoryEntry) {
|
||||
emit("restore", entry.sql, entry);
|
||||
selectedEntry.value = null;
|
||||
@@ -122,13 +135,13 @@ function executeDelete() {
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
if (store.total > 0 || store.connectionOptions.length > 0) {
|
||||
if (canClearHistory.value) {
|
||||
showClearConfirm.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function executeClear() {
|
||||
store.clear();
|
||||
void store.clear(activeSource.value);
|
||||
showClearConfirm.value = false;
|
||||
}
|
||||
|
||||
@@ -148,11 +161,31 @@ function truncateSql(sql: string): string {
|
||||
return line.length > 120 ? line.slice(0, 120) + "..." : line;
|
||||
}
|
||||
|
||||
function formatHistoryJson(value?: string | null): string {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function isLegacyResponse(value?: string | null): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "null");
|
||||
return !!parsed && typeof parsed.content_blocks === "number" && !("content" in parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function entryTitle(entry: HistoryEntry) {
|
||||
if (isMcpEntry(entry)) return entry.mcp_tool_name || entry.operation || "MCP call";
|
||||
return entry.target || entry.operation || truncateSql(entry.sql);
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: HistoryEntry) {
|
||||
if (isMcpEntry(entry)) return truncateSql(entry.mcp_request_json || entry.target || entry.operation || "-");
|
||||
if (activityKind(entry) === "query") return truncateSql(entry.sql);
|
||||
return truncateSql(entry.sql || entry.target || entry.operation || "");
|
||||
}
|
||||
@@ -265,11 +298,13 @@ function buildHistorySearchRequest(): HistorySearchRequest {
|
||||
search_text: searchText.value,
|
||||
connections: selectedConnections.value.map((connection) => ({ ...connection })),
|
||||
databases: selectedDatabases.value.map((database) => ({ ...database })),
|
||||
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" ? activeFilter.value : undefined,
|
||||
success: activeFilter.value === "failed" ? false : undefined,
|
||||
activity_kind: activeFilter.value !== "all" && activeFilter.value !== "failed" && activeFilter.value !== "mcp_success" ? activeFilter.value : undefined,
|
||||
success: activeFilter.value === "failed" ? false : activeFilter.value === "mcp_success" ? true : undefined,
|
||||
started_at: localDateBoundary(dateRange.value.startDate, false),
|
||||
ended_at: localDateBoundary(dateRange.value.endDate, true),
|
||||
limit: 100,
|
||||
source: activeSource.value,
|
||||
mcp_tool_name: activeSource.value === "mcp" ? mcpToolName.value.trim() || undefined : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +316,10 @@ async function runHistorySearch() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshHistory() {
|
||||
void runHistorySearch();
|
||||
}
|
||||
|
||||
// Debounce all filter changes through one timer to avoid redundant history scans.
|
||||
function scheduleHistorySearch() {
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
@@ -356,11 +395,12 @@ function detailsRows(entry: HistoryEntry) {
|
||||
[t("history.detail.database"), entry.database || "-"],
|
||||
[t("history.detail.target"), entry.target || "-"],
|
||||
[t("history.detail.time"), formatFullTime(entry.executed_at)],
|
||||
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
|
||||
[t("history.detail.duration"), formatQueryDuration(entry.execution_time_ms)],
|
||||
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
|
||||
[t("history.detail.rollback"), canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable")],
|
||||
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
|
||||
];
|
||||
if (entry.source === "mcp" || historyEntrySource(entry) === "MCP") rows.splice(1, 0, [t("history.detail.mcpTool"), entry.mcp_tool_name || entry.operation || "-"]);
|
||||
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
|
||||
return rows;
|
||||
}
|
||||
@@ -476,6 +516,7 @@ async function closeHistory() {
|
||||
}
|
||||
|
||||
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
|
||||
const isMcp = isMcpEntry(entry);
|
||||
return [
|
||||
{
|
||||
label: t("history.viewDetails"),
|
||||
@@ -483,22 +524,35 @@ function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
|
||||
selectedEntry.value = entry;
|
||||
},
|
||||
},
|
||||
{ label: t("history.restore"), action: () => restore(entry) },
|
||||
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
|
||||
{ label: t("history.copy"), action: () => copyText(entry.sql) },
|
||||
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
|
||||
...(isMcp
|
||||
? []
|
||||
: [
|
||||
{ label: t("history.restore"), action: () => restore(entry) },
|
||||
{ label: t("history.analyzeWithAi"), action: () => emit("analyzeAi", entry), icon: Sparkles },
|
||||
{ label: t("history.copy"), action: () => copyText(entry.sql) },
|
||||
...(canRollbackHistoryEntry(entry) ? [{ label: t("history.rollback"), action: () => rollback(entry) }] : []),
|
||||
]),
|
||||
{ label: t("history.delete"), action: () => confirmDeleteEntry(entry.id), variant: "destructive" as const },
|
||||
];
|
||||
}
|
||||
|
||||
watch(activeSource, () => {
|
||||
void nextTick(updateFilterScrollability);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => filters.map((filter) => filterLabel(filter)).join("\0"),
|
||||
() => {
|
||||
void nextTick(updateFilterScrollability);
|
||||
() => store.entries,
|
||||
(entries) => {
|
||||
const known = new Set(mcpToolOptions.value);
|
||||
for (const entry of entries) {
|
||||
if (isMcpEntry(entry) && entry.mcp_tool_name) known.add(entry.mcp_tool_name);
|
||||
}
|
||||
mcpToolOptions.value = [...known].sort().slice(0, 50);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch([searchText, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
|
||||
watch([searchText, mcpToolName, activeSource, activeFilter, () => dateRange.value.startDate, () => dateRange.value.endDate, () => selectedConnections.value.map(connectionKey).join("\0"), () => selectedDatabases.value.map(databaseKey).join("\0")], scheduleHistorySearch);
|
||||
|
||||
onMounted(() => {
|
||||
store.setHistoryPanelActive(true);
|
||||
@@ -529,9 +583,16 @@ onBeforeUnmount(() => {
|
||||
<span class="text-xs font-medium">{{ t("history.title") }}</span>
|
||||
<span v-if="store.total > 0" class="text-[10px] text-muted-foreground">{{ store.total }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="store.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Button v-if="canClearHistory" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="store.loading" :title="t('history.refresh')" :aria-label="t('history.refresh')" @click="refreshHistory">
|
||||
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': store.loading }" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-label="t(maximized ? 'diff.restore' : 'diff.maximize')" :aria-pressed="!!maximized" @click="emit('toggleMaximize')">
|
||||
<Minimize2 v-if="maximized" class="h-3 w-3" />
|
||||
<Maximize2 v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('common.close')" :disabled="resolvingTransaction || (isRollingBack && !!pendingRollback)" @click="closeHistory">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -544,14 +605,42 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="border-b shrink-0">
|
||||
<div class="flex gap-1 px-2 pt-2">
|
||||
<button
|
||||
v-for="source in ['sql', 'mcp'] as HistorySource[]"
|
||||
:key="source"
|
||||
type="button"
|
||||
class="h-7 rounded border px-3 text-xs"
|
||||
:class="activeSource === source ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'"
|
||||
@click="
|
||||
activeSource = source;
|
||||
activeFilter = 'all';
|
||||
"
|
||||
>
|
||||
{{ t(`history.sources.${source}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<div ref="filterScrollRef" class="history-filter-scroll flex gap-1 px-2 pt-2" :class="{ 'history-filter-scroll--scrollable': filtersScrollable }">
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
|
||||
{{ filterLabel(filter) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center px-2 py-1">
|
||||
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input data-history-search v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
|
||||
<select v-if="activeSource === 'mcp'" data-mcp-tool-search v-model="mcpToolName" class="mr-1 h-5 max-w-52 rounded border bg-background px-1 text-xs outline-none" :title="t('history.mcp.toolSearch')">
|
||||
<option value="">{{ t("history.mcp.allTools") }}</option>
|
||||
<option v-for="tool in mcpQuickTools" :key="tool" :value="tool">{{ tool }}</option>
|
||||
</select>
|
||||
<Search v-if="activeSource !== 'mcp'" class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
data-history-search
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="flex-1 h-5 text-xs bg-transparent border rounded pr-1 outline-none placeholder:text-muted-foreground"
|
||||
:class="activeSource === 'mcp' ? 'pl-1' : 'pl-5'"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
@@ -687,28 +776,30 @@ onBeforeUnmount(() => {
|
||||
<RecycleScroller v-if="shouldVirtualizeHistory(store.entries.length)" class="min-h-0 flex-1" :items="store.entries" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
|
||||
<template #default="{ item: entry }">
|
||||
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu, isOpen }">
|
||||
<div class="h-[72px] cursor-pointer select-none border-b border-border/50 px-3 py-2 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'" @click="selectedEntry = entry" @contextmenu="onContextMenu">
|
||||
<div class="mb-0.5 flex items-center gap-1">
|
||||
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground">
|
||||
{{ kindShortLabel(entry) }}
|
||||
</span>
|
||||
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
|
||||
<span v-if="historyEntrySource(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
|
||||
{{ historyEntrySource(entry) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ entry.connection_name }}<template v-if="entry.database"> / {{ entry.database }}</template>
|
||||
<div class="h-[80px] cursor-pointer select-none border-b border-border/50 px-2 py-1.5 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : ''" @click="selectedEntry = entry" @contextmenu="onContextMenu">
|
||||
<div class="h-full rounded-md border bg-card px-2 py-1.5 shadow-sm transition-colors hover:border-primary/30 hover:bg-accent/40">
|
||||
<div class="mb-0.5 flex items-center gap-1">
|
||||
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center overflow-hidden rounded border px-1 text-[10px] leading-none text-muted-foreground">
|
||||
{{ isMcpEntry(entry) ? "MCP" : kindShortLabel(entry) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? `${entry.execution_time_ms}ms` : t("history.failed") }}
|
||||
</span>
|
||||
<span class="truncate font-medium">{{ entryTitle(entry) }}</span>
|
||||
<span v-if="historyEntrySource(entry) && !isMcpEntry(entry)" class="inline-flex h-5 shrink-0 items-center rounded border border-primary/30 bg-primary/5 px-1 text-[10px] font-medium text-primary">
|
||||
{{ historyEntrySource(entry) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-muted-foreground">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="truncate font-mono text-muted-foreground">{{ entrySubtitle(entry) }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-2">
|
||||
<span class="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ entry.connection_name || entry.target || t("history.scope.unknownConnection") }}<template v-if="entry.database"> / {{ entry.database }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ml-auto shrink-0" :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? formatQueryDuration(entry.execution_time_ms) : t("history.failed") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomContextMenu>
|
||||
@@ -743,7 +834,24 @@ onBeforeUnmount(() => {
|
||||
<div class="min-w-0 break-words">{{ value }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="selectedEntry.source === 'mcp' || historyEntrySource(selectedEntry) === 'MCP'" class="space-y-2">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t("history.mcp.request") }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json || '-')">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre class="max-h-64 overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_request_json || selectedEntry.sql || selectedEntry.details_json) }}</pre>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">{{ t("history.mcp.response") }}</div>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('history.copy')" :aria-label="t('history.copy')" @click="copyText(selectedEntry.mcp_response_json || '-')">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="isLegacyResponse(selectedEntry.mcp_response_json)" class="text-xs text-muted-foreground">{{ t("history.mcp.legacyResponse") }}</p>
|
||||
<pre class="max-h-[min(50vh,32rem)] overflow-auto rounded border bg-muted/30 p-3 text-xs">{{ formatHistoryJson(selectedEntry.mcp_response_json) }}</pre>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<div class="text-sm font-medium">SQL</div>
|
||||
<Button variant="ghost" size="sm" class="h-7" @click="copyText(selectedEntry.sql)">
|
||||
@@ -765,11 +873,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
|
||||
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
|
||||
<Sparkles class="h-4 w-4" />
|
||||
{{ t("history.analyzeWithAi") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<Button v-if="selectedEntry && selectedEntry.source !== 'mcp' && historyEntrySource(selectedEntry) !== 'MCP'" variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<label v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry) && canUseManualRollback" class="flex items-center gap-2 text-xs" :title="t('history.manualRollbackHint')">
|
||||
<input v-model="manualRollback" type="checkbox" :disabled="rollbackLocked" />
|
||||
{{ t("toolbar.manualTransaction") }}
|
||||
@@ -800,7 +908,7 @@ onBeforeUnmount(() => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("history.clear") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("history.confirmClear") }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ t("history.confirmClearSource", { source: t(`history.sources.${activeSource}`) }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showClearConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="executeClear">{{ t("dangerDialog.confirm") }}</Button>
|
||||
|
||||
@@ -150,7 +150,7 @@ async function copyResolvedSql() {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Braces class="h-5 w-5 text-primary" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { copyToClipboard, isPlainClipboardShortcut } from "@/lib/common/clipboard";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { createShikiSqlHighlighter, type SqlHighlighter } from "@/lib/sql/sqlHighlighter";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -47,6 +47,26 @@ const displaySql = computed(() => {
|
||||
|
||||
const hasSql = computed(() => props.sql.trim().length > 0);
|
||||
|
||||
const contentRef = ref<HTMLPreElement | null>(null);
|
||||
|
||||
function selectPreviewContent() {
|
||||
const el = contentRef.value;
|
||||
if (!el) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onContentKeydown(event: KeyboardEvent) {
|
||||
if (!isPlainClipboardShortcut(event, "a")) return;
|
||||
// Claim the shortcut before it reaches the surrounding data grid, then select
|
||||
// the preview text instead of the grid cells.
|
||||
if (selectPreviewContent()) event.preventDefault();
|
||||
}
|
||||
|
||||
async function initHighlighter() {
|
||||
if (highlighter) return;
|
||||
try {
|
||||
@@ -193,10 +213,10 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Shiki highlighted SQL -->
|
||||
<pre v-else-if="highlightedHtml" data-native-clipboard class="m-0 p-3 text-xs font-mono leading-relaxed whitespace-pre-wrap break-words select-text" v-html="highlightedHtml"></pre>
|
||||
<pre v-else-if="highlightedHtml" ref="contentRef" data-native-clipboard tabindex="0" class="m-0 p-3 text-xs font-mono leading-relaxed whitespace-pre-wrap break-words select-text outline-none" v-html="highlightedHtml" @keydown="onContentKeydown"></pre>
|
||||
|
||||
<!-- Plain text fallback -->
|
||||
<pre v-else data-native-clipboard class="p-3 text-xs font-mono whitespace-pre-wrap select-text">{{ displaySql }}</pre>
|
||||
<pre v-else ref="contentRef" data-native-clipboard tabindex="0" class="p-3 text-xs font-mono whitespace-pre-wrap select-text outline-none" @keydown="onContentKeydown">{{ displaySql }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+28
@@ -31,6 +31,34 @@ function bodyOf(fnSignature: string): string {
|
||||
}
|
||||
|
||||
describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
it("renders plugin recommendation chips and sends the selected snapshot immediately", () => {
|
||||
expect(source).toContain("pluginRecommendations?: PluginAiRecommendationHostUpdate");
|
||||
expect(source).toContain('v-for="recommendation in pluginRecommendations.items"');
|
||||
expect(source).toContain('@click="sendPluginRecommendation(recommendation)"');
|
||||
expect(source).toContain("`history:${recommendation.id}`");
|
||||
const recommendationStart = source.indexOf("function sendPluginRecommendation(");
|
||||
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("send: true");
|
||||
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("context: update.context");
|
||||
});
|
||||
|
||||
it("keeps the connection selector available after a plugin recommendation is selected", () => {
|
||||
const contextStart = source.lastIndexOf("data-ai-composer-context-row");
|
||||
const contextEnd = source.indexOf("data-ai-composer-actions", contextStart);
|
||||
const contextMarkup = source.slice(contextStart, contextEnd);
|
||||
|
||||
expect(contextMarkup).toContain('v-if="pluginContext && !connectionStore.connections.length"');
|
||||
expect(contextMarkup).toContain("data-ai-plugin-context");
|
||||
expect(contextMarkup).toContain('v-if="connectionStore.connections.length"');
|
||||
expect(contextMarkup).toContain("<ConnectionTreeSelect");
|
||||
expect(contextMarkup).toContain('@update:model-value="(v) => changeConnection(v)"');
|
||||
});
|
||||
|
||||
it("does not expose Agent mode for a plugin conversation without a connection", () => {
|
||||
expect(source).toContain("function canUsePluginAgentMode");
|
||||
expect(source).toContain('if (mode === "agent" && !canUsePluginAgentMode()) return;');
|
||||
expect(source).toContain('request.mode === "agent" && !pluginConnection ? "ask"');
|
||||
});
|
||||
|
||||
it("rebinding the conversation never rewrites the editor tab or the global active connection", () => {
|
||||
const body = bodyOf("async function changeConnection(connectionId: string)");
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// Mounted smoke test for the AI panel. The app loads the AI config at startup,
|
||||
// so the panel normally mounts with `isAiConfigLoaded` already true and its
|
||||
// `immediate` default-selection watcher runs during setup. That path once read
|
||||
// `boundConnection` before it was declared, and the TDZ ReferenceError kept the
|
||||
// panel from opening at all.
|
||||
import { createApp, h } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia } from "pinia";
|
||||
import i18n from "@/i18n";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AiAssistant from "@/components/editor/AiAssistant.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const empty = () => Promise.resolve([]);
|
||||
return {
|
||||
...actual,
|
||||
loadAiConversations: empty,
|
||||
loadAiRuns: empty,
|
||||
readUserSkills: empty,
|
||||
loadAiConfigs: empty,
|
||||
listPlugins: empty,
|
||||
loadPromptTemplates: empty,
|
||||
getAiGlobalCustomInstructions: () => Promise.resolve(""),
|
||||
saveAiChatSelection: () => Promise.resolve(),
|
||||
loadAiChatSelection: () => Promise.resolve(null),
|
||||
};
|
||||
});
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig, configureSettings?: (settings: ReturnType<typeof useSettingsStore>) => void, pluginRecommendations?: PluginAiRecommendationHostUpdate) {
|
||||
const pinia = createPinia();
|
||||
const errors: unknown[] = [];
|
||||
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant, { connection, pluginRecommendations })) });
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.config.errorHandler = (error) => errors.push(error);
|
||||
app.config.warnHandler = () => {};
|
||||
const settings = useSettingsStore(pinia);
|
||||
settings.isAiConfigLoaded = aiConfigLoaded;
|
||||
configureSettings?.(settings);
|
||||
if (connection) useConnectionStore(pinia).connections = [connection];
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app.mount(container);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
container.remove();
|
||||
});
|
||||
// Let mount-time loads settle so their failures surface here too.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return { errors, container };
|
||||
}
|
||||
|
||||
describe("AiAssistant mount", () => {
|
||||
it("keeps the real Agent mode and an interactive picker after clicking a plugin recommendation", async () => {
|
||||
const { container, errors } = await mountPanel(
|
||||
true,
|
||||
{ id: "plugin-connection", name: "Sample", db_type: "plugin", plugin_id: "sample.plugin", host: "localhost", port: 22, username: "", password: "" },
|
||||
(settings) => {
|
||||
settings.defaultAiMode = "agent";
|
||||
},
|
||||
{
|
||||
pluginId: "sample.plugin",
|
||||
pluginName: "Sample",
|
||||
contributionId: "workbench",
|
||||
workbenchId: "cluster",
|
||||
context: { connectionId: "plugin-connection" },
|
||||
items: [{ id: "overview", label: "Inspect cluster", prompt: "Inspect cluster" }],
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".ai-mode-action-trigger")?.textContent).toContain(i18n.global.t("ai.modes.agent"));
|
||||
container.querySelector<HTMLButtonElement>('button[title="Inspect cluster"]')!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const trigger = container.querySelector<HTMLButtonElement>(".ai-mode-action-trigger");
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.agent"));
|
||||
expect(container.querySelector(".ai-mode-static-trigger")).toBeNull();
|
||||
trigger!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
const askButton = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')).find((button) => button.textContent?.trim() === i18n.global.t("ai.modes.ask"));
|
||||
expect(askButton).toBeDefined();
|
||||
askButton!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.ask"));
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens when the AI config was loaded before the panel mounted", async () => {
|
||||
expect((await mountPanel(true)).errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens while the AI config is still loading", async () => {
|
||||
expect((await mountPanel(false)).errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders compactable composer controls with accessible labels", async () => {
|
||||
const { errors, container } = await mountPanel(true, undefined, (settings) => {
|
||||
settings.aiConfigs = [
|
||||
{
|
||||
id: "deepseek-default",
|
||||
name: "DeepSeek",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
authMethod: "api-key",
|
||||
endpoint: "https://api.deepseek.com",
|
||||
model: "deepseek-chat",
|
||||
apiStyle: "completions",
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
settings.activeModel = { configId: "deepseek-default", modelId: "deepseek-chat" };
|
||||
});
|
||||
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
expect(container.querySelector(".ai-prompt-context-container")).not.toBeNull();
|
||||
expect(container.querySelector("[data-ai-composer-actions]")).not.toBeNull();
|
||||
expect(container.querySelector<HTMLButtonElement>(".ai-template-selector-trigger")?.getAttribute("aria-label")).toBeTruthy();
|
||||
expect(container.querySelector<HTMLButtonElement>(".ai-skills-selector-trigger")?.getAttribute("aria-label")).toBe(i18n.global.t("ai.skillsEntry"));
|
||||
|
||||
const modeTrigger = container.querySelector<HTMLButtonElement>(".ai-mode-action-trigger");
|
||||
expect(modeTrigger?.getAttribute("aria-label")).toBeTruthy();
|
||||
expect(modeTrigger?.getAttribute("title")).toBe(modeTrigger?.getAttribute("aria-label"));
|
||||
|
||||
const modelTrigger = container.querySelector<HTMLButtonElement>(".ai-model-selector-trigger");
|
||||
expect(modelTrigger?.getAttribute("aria-label")).toBe("deepseek-chat");
|
||||
expect(modelTrigger?.getAttribute("title")).toBe("deepseek-chat");
|
||||
});
|
||||
|
||||
it.each(["plugin", "etcd"] as const)("hides database and schema selectors for %s connections", async (dbType) => {
|
||||
const { errors, container } = await mountPanel(true, { id: "connection", name: "Connection", db_type: dbType, plugin_id: dbType === "plugin" ? "sample.plugin" : undefined, host: "localhost", port: 22, username: "", password: "" });
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
const row = container.querySelector("[data-ai-composer-context-row]");
|
||||
expect(row?.textContent).toContain("Connection");
|
||||
expect(row?.textContent).not.toContain(i18n.global.t("editor.selectDatabase"));
|
||||
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps database and schema selectors for PostgreSQL", async () => {
|
||||
const { errors, container } = await mountPanel(true, { id: "postgres", name: "PostgreSQL", db_type: "postgres", host: "localhost", port: 5432, username: "", password: "" });
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
const row = container.querySelector("[data-ai-composer-context-row]");
|
||||
expect(row?.textContent).toContain(i18n.global.t("editor.selectDatabase"));
|
||||
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, reactive } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import AiToolApprovalCard from "../AiToolApprovalCard.vue";
|
||||
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function mountCard() {
|
||||
const approval = reactive<AiAgentStepApproval>({ approvalId: "approval", sessionId: "session", pluginName: "Plugin", pluginTool: "write", connectionName: "Connection", args: { target: "orders" }, expiresAtMs: Date.now() + 1000, status: "pending" });
|
||||
const onResolve = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(AiToolApprovalCard, { approval, onResolve });
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
container.remove();
|
||||
});
|
||||
return { approval, onResolve, container };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("AiToolApprovalCard", () => {
|
||||
it("expires without another backend event and removes its answer buttons", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { container, onResolve } = mountCard();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await nextTick();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.expired"));
|
||||
expect(onResolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards one-time approval and disables both buttons while submitting", async () => {
|
||||
const { container, approval, onResolve } = mountCard();
|
||||
container.querySelector("button")?.click();
|
||||
expect(onResolve).toHaveBeenCalledWith(true);
|
||||
approval.status = "submitting";
|
||||
await nextTick();
|
||||
expect([...container.querySelectorAll("button")].every((button) => button.disabled)).toBe(true);
|
||||
approval.status = "denied";
|
||||
await nextTick();
|
||||
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.denied"));
|
||||
});
|
||||
});
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
// Guards the settings-search "reveal" contract for the debug logging switch
|
||||
// (#10308): searching 日志 and picking the result must land on the tab that
|
||||
// actually renders the switch, because the dialog switches tabs from the search
|
||||
// entry's category. The entry used to declare "appearance" while the control
|
||||
// lives in the About section, so the highlight never appeared. Checked against
|
||||
// the real dialog source instead of mounting the multi-thousand-line dialog.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
|
||||
import dialogSource from "../EditorSettingsDialog.vue?raw";
|
||||
|
||||
const DEBUG_LOGGING_SWITCH_ID = 'id="debug-logging-enabled"';
|
||||
|
||||
function tabRenderingSnippet(snippet: string, marker: string): string | null {
|
||||
const sectionPattern = /activeSettingsTab === '([a-z-]+)'/g;
|
||||
const sections: { tab: string; index: number }[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = sectionPattern.exec(snippet))) sections.push({ tab: match[1], index: match.index });
|
||||
const markerIndex = snippet.indexOf(marker);
|
||||
if (markerIndex < 0) return null;
|
||||
let owningTab: string | null = null;
|
||||
for (const section of sections) {
|
||||
if (section.index < markerIndex) owningTab = section.tab;
|
||||
}
|
||||
return owningTab;
|
||||
}
|
||||
|
||||
describe("EditorSettingsDialog settings search targets", () => {
|
||||
it("renders the debug logging switch in the tab its search entry points at", () => {
|
||||
const owningTab = tabRenderingSnippet(dialogSource, DEBUG_LOGGING_SWITCH_ID);
|
||||
expect(owningTab).toBe("about");
|
||||
|
||||
const entry = SETTINGS_SEARCH_DEFINITIONS.find((definition) => definition.id === "about-debug-logs");
|
||||
expect(entry?.category).toBe(owningTab);
|
||||
expect(dialogSource).toContain(`data-settings-search-id="${entry?.targetId}"`);
|
||||
});
|
||||
});
|
||||
+6
@@ -111,6 +111,12 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
hoisted.saves.push(limit);
|
||||
hoisted.limit = limit;
|
||||
},
|
||||
loadMcpHistoryRetentionLimit: async () => hoisted.limit,
|
||||
saveMcpHistoryRetentionLimit: async (limit: number) => {
|
||||
hoisted.saves.push(limit);
|
||||
hoisted.limit = limit;
|
||||
},
|
||||
cleanupMcpHistoryRetention: async () => 0,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, h, nextTick, ref, shallowRef, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { language } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
|
||||
import type { QueryEditorProps } from "../queryEditorTypes";
|
||||
|
||||
const analysisProbe = vi.hoisted(() => ({
|
||||
documentLengths: [] as number[],
|
||||
failAtLength: Number.POSITIVE_INFINITY,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/sql/executableStatementRangeCache")>();
|
||||
return {
|
||||
...actual,
|
||||
executableStatementRangeCacheForDoc: (...args: Parameters<typeof actual.executableStatementRangeCacheForDoc>) => {
|
||||
analysisProbe.documentLengths.push(args[1].length);
|
||||
if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis");
|
||||
return actual.executableStatementRangeCacheForDoc(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import QueryEditor from "../QueryEditor.vue";
|
||||
|
||||
function oraclePackage(procedureCount: number): string {
|
||||
const procedures = Array.from(
|
||||
{ length: procedureCount },
|
||||
(_, index) => ` PROCEDURE process_order_${index}(p_order_id NUMBER) IS
|
||||
l_status VARCHAR2(30);
|
||||
BEGIN
|
||||
SELECT status INTO l_status FROM orders WHERE id = p_order_id;
|
||||
IF l_status = 'PENDING' THEN
|
||||
UPDATE orders SET status = 'PROCESSED' WHERE id = p_order_id;
|
||||
END IF;
|
||||
EXCEPTION
|
||||
WHEN NO_DATA_FOUND THEN NULL;
|
||||
END process_order_${index};
|
||||
`,
|
||||
).join("");
|
||||
return `CREATE OR REPLACE PACKAGE BODY huge_pkg AS\n${procedures}END huge_pkg;\n/\n`;
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
analysisProbe.documentLengths = [];
|
||||
analysisProbe.failAtLength = Number.POSITIVE_INFINITY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
||||
});
|
||||
|
||||
async function mountEditor(modelValue: string, overrides: Partial<QueryEditorProps> = {}) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const editor = shallowRef<InstanceType<typeof QueryEditor>>();
|
||||
const source = ref(modelValue);
|
||||
const onUpdateModelValue = vi.fn((value: string) => {
|
||||
source.value = value;
|
||||
});
|
||||
const app: App = createApp({
|
||||
render: () =>
|
||||
h(QueryEditor, {
|
||||
modelValue: source.value,
|
||||
tabId: "oracle-source",
|
||||
databaseType: "oracle",
|
||||
dialect: "postgres",
|
||||
autoFocus: false,
|
||||
ref: editor,
|
||||
"onUpdate:modelValue": onUpdateModelValue,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
|
||||
app.mount(host);
|
||||
cleanups.push(() => {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 });
|
||||
const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!;
|
||||
return { editor: editor.value!, host, onUpdateModelValue, view };
|
||||
}
|
||||
|
||||
describe("QueryEditor large document mode", () => {
|
||||
it("keeps the full editor feature path for ordinary documents", async () => {
|
||||
const source = oraclePackage(8);
|
||||
const { host, view } = await mountEditor(source);
|
||||
|
||||
expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false);
|
||||
expect(analysisProbe.documentLengths).toContain(source.length);
|
||||
expect(view.state.facet(language)).not.toBeNull();
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
});
|
||||
|
||||
it("keeps a realistically large package editable and searchable without full-document analysis", async () => {
|
||||
const source = oraclePackage(4_000);
|
||||
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
|
||||
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
|
||||
|
||||
const { editor, host, onUpdateModelValue, view } = await mountEditor(source);
|
||||
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
expect(analysisProbe.documentLengths).not.toContain(source.length);
|
||||
expect(view.state.facet(language)).toBeNull();
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
expect(view.state.readOnly).toBe(false);
|
||||
expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24));
|
||||
expect(editor.openSearch()).toBe(true);
|
||||
await nextTick();
|
||||
expect(host.querySelector(".editor-search-panel input")).not.toBeNull();
|
||||
|
||||
view.dispatch({ changes: { from: source.length, insert: "-- edited\n" }, userEvent: "input" });
|
||||
await nextTick();
|
||||
expect(onUpdateModelValue).toHaveBeenLastCalledWith(`${source}-- edited\n`);
|
||||
});
|
||||
|
||||
it("preserves read-only state when full-document analysis would fail", async () => {
|
||||
const source = oraclePackage(4_000);
|
||||
analysisProbe.failAtLength = QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH + 1;
|
||||
|
||||
const { host, view } = await mountEditor(source, { readOnly: true });
|
||||
|
||||
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
|
||||
expect(analysisProbe.documentLengths).not.toContain(source.length);
|
||||
expect(view.state.doc.toString()).toBe(source);
|
||||
expect(view.state.readOnly).toBe(true);
|
||||
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
|
||||
});
|
||||
});
|
||||
+21
-1
@@ -5,6 +5,7 @@ import QueryHistory from "../QueryHistory.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
search: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
executeInManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
@@ -40,7 +41,7 @@ vi.mock("@/stores/historyStore", () => ({
|
||||
nextCursor: null,
|
||||
setHistoryPanelActive: vi.fn(),
|
||||
loadConnectionOptions: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn().mockResolvedValue(undefined),
|
||||
search: mocks.search,
|
||||
add: mocks.add,
|
||||
}),
|
||||
}));
|
||||
@@ -229,3 +230,22 @@ describe("QueryHistory manual rollback SQL", () => {
|
||||
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryHistory source filters", () => {
|
||||
it("searches MCP independently and resets success when switching back to SQL", async () => {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(QueryHistory);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
button("history.sources.mcp").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", activity_kind: undefined, success: undefined })));
|
||||
expect(document.body.textContent).not.toContain("history.filters.schema_change");
|
||||
button("history.filters.mcp_success").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "mcp", success: true, activity_kind: undefined })));
|
||||
button("history.sources.sql").click();
|
||||
await vi.waitFor(() => expect(mocks.search).toHaveBeenLastCalledWith(expect.objectContaining({ source: "sql", success: undefined, activity_kind: undefined })));
|
||||
expect(document.body.textContent).toContain("history.filters.schema_change");
|
||||
expect(document.body.textContent).not.toContain("history.sources.all");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +193,17 @@ describe("SqlParameterDialog raw parameter action", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlParameterDialog surface", () => {
|
||||
it("keeps the dialog background opaque while a wallpaper is active", async () => {
|
||||
await mountDialog();
|
||||
const content = document.body.querySelector('[data-slot="dialog-content"]');
|
||||
expect(content).not.toBeNull();
|
||||
const classes = content!.className;
|
||||
expect(classes).toContain("bg-background-solid");
|
||||
expect(classes).not.toMatch(/(^|\s)!bg-background(\s|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlParameterDialog ignore parameter action", () => {
|
||||
it("closes the dialog and emits the original SQL without remembering or mutating values", async () => {
|
||||
const { state, onExecute } = await mountDialog();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
highlight: vi.fn<(content: string) => string>(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { sqlFormatter: {} } }) }));
|
||||
vi.mock("@/lib/sql/sqlHighlighter", () => ({
|
||||
createShikiSqlHighlighter: async () => (content: string) => mocks.highlight(content),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h("button", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = () =>
|
||||
defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
return { Tooltip: passthrough(), TooltipTrigger: passthrough(), TooltipContent: passthrough() };
|
||||
});
|
||||
|
||||
import SqlPreviewPanel from "@/components/editor/SqlPreviewPanel.vue";
|
||||
|
||||
const SQL = "UPDATE users SET name = 'Ada' WHERE id = 1;";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPanel(sql = SQL) {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
app = createApp(SqlPreviewPanel, { sql });
|
||||
app.mount(host);
|
||||
await flushUi();
|
||||
return host;
|
||||
}
|
||||
|
||||
function previewText(host: HTMLElement): HTMLPreElement {
|
||||
const pre = host.querySelector("pre");
|
||||
if (!pre) throw new Error("SQL preview text not found");
|
||||
return pre;
|
||||
}
|
||||
|
||||
function dispatchSelectAll(target: HTMLElement, modifiers: KeyboardEventInit = { ctrlKey: true }) {
|
||||
const event = new KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true, ...modifiers });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.highlight.mockImplementation(() => "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
app?.unmount();
|
||||
app = null;
|
||||
host?.remove();
|
||||
host = null;
|
||||
mocks.highlight.mockClear();
|
||||
});
|
||||
|
||||
describe("SqlPreviewPanel select all", () => {
|
||||
it("selects the preview text on Ctrl+A instead of leaving the shortcut to the data grid", async () => {
|
||||
const mounted = await mountPanel();
|
||||
const pre = previewText(mounted);
|
||||
|
||||
expect(pre.getAttribute("data-native-clipboard")).toBe("");
|
||||
expect(pre.tabIndex).toBe(0);
|
||||
pre.focus();
|
||||
expect(document.activeElement).toBe(pre);
|
||||
|
||||
const event = dispatchSelectAll(pre);
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(selection?.rangeCount).toBe(1);
|
||||
expect(selection?.toString()).toBe(SQL);
|
||||
expect(selection?.anchorNode && pre.contains(selection.anchorNode)).toBe(true);
|
||||
});
|
||||
|
||||
it("supports Cmd+A and selects the highlighted SQL", async () => {
|
||||
mocks.highlight.mockImplementation((content) => `<span class="tok">${content}</span>`);
|
||||
const mounted = await mountPanel();
|
||||
const pre = previewText(mounted);
|
||||
expect(pre.innerHTML).toContain('class="tok"');
|
||||
|
||||
const event = dispatchSelectAll(pre, { metaKey: true });
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(selection?.rangeCount).toBe(1);
|
||||
expect(selection?.toString()).toBe(SQL);
|
||||
});
|
||||
|
||||
it("keeps the shortcut untouched for other modifiers and keys", async () => {
|
||||
const mounted = await mountPanel();
|
||||
const pre = previewText(mounted);
|
||||
|
||||
expect(dispatchSelectAll(pre, { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false);
|
||||
expect(dispatchSelectAll(pre, { ctrlKey: true, altKey: true }).defaultPrevented).toBe(false);
|
||||
expect(dispatchSelectAll(pre, { ctrlKey: false }).defaultPrevented).toBe(false);
|
||||
const other = new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true, ctrlKey: true });
|
||||
pre.dispatchEvent(other);
|
||||
expect(other.defaultPrevented).toBe(false);
|
||||
expect(window.getSelection()?.rangeCount ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("lets the event keep bubbling so the surrounding grid can observe it", async () => {
|
||||
const mounted = await mountPanel();
|
||||
const pre = previewText(mounted);
|
||||
const bubbled = vi.fn();
|
||||
mounted.addEventListener("keydown", bubbled);
|
||||
|
||||
dispatchSelectAll(pre);
|
||||
|
||||
expect(bubbled).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -91,8 +91,23 @@ describe("QueryEditor batch selection ownership", () => {
|
||||
batch.toggleAllBatchColumnSelection(currentView, session.key);
|
||||
expect(next.selectedKeys.size).toBe(0);
|
||||
expect(next.completionOptions.size).toBe(0);
|
||||
batch.prepareBatchColumnSelectionSession([], next.document, next.from, next.to);
|
||||
expect(batch.expandedRendering).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the checked fields when a sibling result carries no column candidates (dbx#9973)", () => {
|
||||
const { batch, session, items, currentView, row } = createHarness();
|
||||
batch.toggleAllBatchColumnSelection(currentView, session.key);
|
||||
// One completion request builds several result sets and only some of them
|
||||
// carry column candidates. The empty one must not drop the session that the
|
||||
// rendered checkbox rows still point at.
|
||||
expect(batch.prepareBatchColumnSelectionSession([], session.document, session.from, session.to)).toBeNull();
|
||||
expect(batch.expandedRendering).toBe(true);
|
||||
expect(batch.prepareBatchColumnSelectionSession(items, session.document, session.from, session.to)).toBe(session);
|
||||
expect(session.selectedKeys).toEqual(new Set(session.candidates.map((candidate) => candidate.key)));
|
||||
const { checkbox } = row(0);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
checkbox.dispatchEvent(new PointerEvent("pointerdown", { button: 0, buttons: 1, isPrimary: true, pointerId: 5, bubbles: true }));
|
||||
expect(session.selectedKeys.has(session.candidates[0].key)).toBe(false);
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
it("guards label clicks and applies checked fields together, never an individual row", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user