Merge remote-tracking branch 'origin/main' into feat/12892-redis-danger-copy-command

This commit is contained in:
t8y2 committed 2026-10-25 01:02:56 +08:00
commit 845afe77f8
873 files changed
+104917 -5771

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const workflow = readFileSync(new URL("../workflows/cancel-superseded-ci.yml", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const script = workflow.split(" script: |\n")[1].replace(/^ /gm, "");
const execute = new (Object.getPrototypeOf(async function () {}).constructor)("github", "context", "core", script);
const repo = { owner: "upstream", repo: "dbx" };
const pull = {
number: 42,
state: "open",
created_at: "2026-10-01T00:00:00Z",
base: { ref: "main", repo: { id: 1 } },
head: { ref: "fix/ci", sha: "new", repo: { id: 2, owner: { login: "contributor" } } },
};
function run(overrides = {}) {
return {
id: 100,
workflow_id: 10,
event: "pull_request",
head_branch: "fix/ci",
head_repository: { id: 2 },
head_sha: "old",
status: "pending",
created_at: "2026-10-02T00:00:00Z",
pull_requests: [],
...overrides,
};
}
async function invoke({ pages = [[run()]], pulls = [pull], history = [[pull]], historyError, cancelError, completed = true, readError } = {}) {
const cancelled = [];
let reads = 0;
let listed = false;
const actions = {
getWorkflow: async (args) => {
assert.deepEqual(args, { ...repo, workflow_id: "ci.yml" });
return { data: { id: 10 } };
},
listWorkflowRuns() {},
cancelWorkflowRun: async (args) => {
assert.equal(listed, true);
assert.deepEqual({ owner: args.owner, repo: args.repo }, repo);
cancelled.push(args.run_id);
if (cancelError) throw cancelError;
},
getWorkflowRun: async () => {
if (readError) throw readError;
return { data: { status: completed ? "completed" : "in_progress" } };
},
};
const listPulls = () => {};
const github = {
rest: {
actions,
pulls: {
list: listPulls,
get: async (args) => {
assert.deepEqual(args, { ...repo, pull_number: pull.number });
return { data: structuredClone(pulls[Math.min(reads++, pulls.length - 1)]) };
},
},
},
paginate: async (method, args) => {
if (method === listPulls) {
assert.deepEqual(args, { ...repo, state: "all", head: "contributor:fix/ci", per_page: 100 });
if (historyError) throw historyError;
return history.flat();
}
assert.equal(method, actions.listWorkflowRuns);
assert.deepEqual(args, { ...repo, workflow_id: 10, event: "pull_request", branch: "fix/ci", per_page: 100 });
listed = true;
return pages.flat();
},
};
await execute(github, { repo, payload: { repository: { id: 1 }, pull_request: structuredClone(pull) } }, { info() {} });
return { cancelled, reads, listed };
}
test("cancels old active CI runs from all pages, including fork runs without PR metadata", async () => {
const pages = [
[run(), run({ id: 101, status: "in_progress", pull_requests: [{ number: 42 }] })],
[run({ id: 102, status: "queued" }), run({ id: 103, head_sha: "new" })],
[run({ id: 104, status: "waiting" }), run({ id: 105, status: "requested" })],
];
assert.deepEqual((await invoke({ pages })).cancelled, [100, 101, 102, 104, 105]);
});
test("preserves other PRs, workflows, repositories, branches, completed runs and the latest commit", async () => {
const variants = [
{ pull_requests: [{ number: 44 }] },
{ workflow_id: 11 },
{ event: "push" },
{ head_repository: { id: 3 } },
{ head_repository: null },
{ head_branch: "another-pr" },
{ status: "completed" },
{ head_sha: "new" },
{ head_sha: undefined },
{ status: undefined },
{ pull_requests: undefined },
];
assert.deepEqual((await invoke({ pages: [variants.map((value, index) => run({ id: index, ...value }))] })).cancelled, []);
});
test("does not infer run ownership when an older PR is reopened after its branch was reused", async () => {
const other = { ...pull, number: 44, state: "closed", created_at: "2026-10-02T00:00:00Z", base: { ...pull.base, ref: "release" } };
const history = [[pull], [other]];
assert.deepEqual((await invoke({ history })).cancelled, []);
assert.deepEqual((await invoke({ history, pages: [[run({ pull_requests: [{ number: 42 }] })]] })).cancelled, [100]);
});
test("branch uniqueness is scoped to the source repository and fails closed on missing identity", async () => {
const otherRepo = { ...pull, number: 44, head: { ...pull.head, repo: { id: 3 } } };
assert.deepEqual((await invoke({ history: [[pull, otherRepo]] })).cancelled, [100]);
const unknownRepo = { ...otherRepo, head: { ...pull.head, repo: null } };
assert.deepEqual((await invoke({ history: [[pull, unknownRepo]] })).cancelled, []);
assert.deepEqual((await invoke({ history: [[]] })).cancelled, []);
await assert.rejects(invoke({ historyError: new Error("history unavailable") }), /history unavailable/);
});
test("ignores stale events and PRs whose current identity or state has changed", async () => {
const variants = [
{ state: "closed" },
{ head: { ...pull.head, sha: "newer" } },
{ head: { ...pull.head, ref: "renamed" } },
{ head: { ...pull.head, repo: { id: 3 } } },
{ head: { ...pull.head, repo: null } },
{ base: { ...pull.base, ref: "release" } },
{ base: { ...pull.base, repo: { id: 4 } } },
];
for (const value of variants) {
const result = await invoke({ pulls: [{ ...pull, ...value }] });
assert.deepEqual(result.cancelled, []);
assert.equal(result.listed, false);
}
});
test("stops before cancellation if another commit arrives while listing runs", async () => {
const newer = { ...pull, head: { ...pull.head, sha: "newer" } };
assert.deepEqual((await invoke({ pulls: [pull, newer], pages: [[run(), run({ id: 101, head_sha: "newer" })]] })).cancelled, []);
});
test("rechecks the PR before every cancellation", async () => {
const newer = { ...pull, head: { ...pull.head, sha: "newer" } };
assert.deepEqual((await invoke({ pulls: [pull, pull, newer], pages: [[run(), run({ id: 101 })]] })).cancelled, [100]);
});
test("accepts a cancel conflict only when the run has finished", async () => {
const conflict = Object.assign(new Error("conflict"), { status: 409 });
assert.deepEqual((await invoke({ cancelError: conflict })).cancelled, [100]);
await assert.rejects(invoke({ cancelError: conflict, completed: false }), /conflict/);
await assert.rejects(invoke({ cancelError: conflict, readError: new Error("read failed") }), /read failed/);
await assert.rejects(invoke({ cancelError: Object.assign(new Error("forbidden"), { status: 403 }) }), /forbidden/);
});
test("the privileged workflow only handles main PR synchronization and never executes PR code", () => {
assert.match(workflow, /pull_request_target:\n types: \[synchronize\]\n branches: \[main\]/);
assert.match(workflow, /permissions:\n actions: write\n pull-requests: read\n/);
assert.doesNotMatch(workflow, /actions\/checkout|\brun:|secrets\.|workflow_run:|force-cancel|forceCancelWorkflowRun|\$\{\{/);
assert.equal((workflow.match(/uses:/g) ?? []).length, 1);
});
+54
View File
@@ -0,0 +1,54 @@
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
import org.gradle.api.artifacts.repositories.MavenArtifactRepository
def target = System.getenv('DBX_CI_GRADLE_ROOT')
def backup = System.getenv('DBX_CI_GRADLE_SOURCE') == 'backup'
def mirror = 'https://maven-central.storage-download.googleapis.com/maven2/'
def applies = { File directory -> target != null && directory.canonicalPath == target }
beforeSettings { settings ->
if (applies(settings.settingsDir)) {
if (System.getenv('DBX_CI_GRADLE_PHASE') == 'execute') {
gradle.startParameter.offline = true
}
if (backup) {
settings.pluginManagement.repositories.clear()
settings.pluginManagement.repositories.maven { url = uri(mirror) }
}
}
}
afterProject { project ->
if (applies(project.rootDir) && backup) {
project.repositories.withType(MavenArtifactRepository).each { repository ->
if (repository.url.toString() in ['https://repo.maven.apache.org/maven2/',
'https://repo.maven.apache.org/maven2']) {
if (repository.credentials.username || repository.credentials.password) {
throw new GradleException('CI mirror switching does not support authenticated Central repositories')
}
repository.url = uri(mirror)
}
}
}
}
projectsEvaluated {
if (applies(rootProject.rootDir)) {
rootProject.tasks.register('dbxCiAcquireDependencies') {
doLast {
rootProject.allprojects.each { project ->
project.configurations.findAll { configuration ->
configuration.canBeResolved &&
(System.getenv('DBX_CI_GRADLE_SCOPE') != 'jdbc' ||
configuration.name in ['compileClasspath', 'runtimeClasspath', 'annotationProcessor', 'shadow'])
}.each { configuration ->
def artifacts = configuration.incoming.artifactView {
componentFilter { identifier -> identifier instanceof ModuleComponentIdentifier }
}.files.files
println("Acquired ${project.path}:${configuration.name}: ${artifacts.size()} external artifacts")
}
}
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Acquire Gradle dependencies with bounded recovery, then execute the original command once offline."""
import argparse
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[2]
INIT = Path(__file__).with_name("ci-gradle.init.gradle")
starting_process = False
pending_cancel = None
class Cancelled(BaseException):
def __init__(self, signum):
self.signum = signum
def cancel(signum, _frame):
global pending_cancel
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
if starting_process:
pending_cancel = signum
else:
raise Cancelled(signum)
def dispose(process):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
return process.communicate()[0] or ""
def run_process(command, cwd, env, budget, capture=True):
global starting_process, pending_cancel
process = None
timed_out = False
try:
starting_process = True
process = subprocess.Popen(command, cwd=cwd, env=env,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.STDOUT if capture else None,
text=True, start_new_session=True)
starting_process = False
if pending_cancel is not None:
signum, pending_cancel = pending_cancel, None
raise Cancelled(signum)
try:
output = process.communicate(timeout=budget)[0] or ""
except subprocess.TimeoutExpired:
timed_out = True
output = dispose(process)
except BaseException:
if process is not None:
dispose(process)
raise
finally:
starting_process = False
if capture:
def redact(match):
url = urlsplit(match[0])
return f"{url.scheme}://{url.hostname}{url.path}"
print(re.sub(r"https?://[^\s'\"<>]+", redact, output), end="", flush=True)
return process.returncode, output, timed_out
def public_maven_url(value):
url = urlsplit(value)
return (url.scheme == "https" and not url.username and not url.password and
not url.query and not url.fragment and url.port in (None, 443) and
((url.hostname == "repo.maven.apache.org" and url.path.startswith("/maven2/")) or
(url.hostname == "plugins.gradle.org" and url.path.startswith("/m2/")) or
(url.hostname == "maven-central.storage-download.googleapis.com" and url.path.startswith("/maven2/"))))
def acquisition_failure(output, timed_out=False):
if re.search(r"certificate|PKIX|SSLHandshake|checksum|signature|verification failed|"
r"No space left|Permission denied|Could not compile|Compilation failed|"
r"Could not find |Could not resolve.*conflict|401\b|404\b", output, re.I):
return None
section = output.split("* What went wrong:", 1)[-1].split("* Try:", 1)[0]
requests = re.findall(r"Could not (?:GET|HEAD) '([^']+)'", section)
if timed_out and "* What went wrong:" not in output and not requests:
return "transient"
if not requests or not all(public_maven_url(url) for url in requests):
return None
network = re.compile(r"timed out|Connection (?:reset|refused)|UnknownHostException|"
r"Temporary failure|Name or service not known|Remote host terminated|"
r"Received status code (?:403|429|500|502|503|504)\b", re.I)
context = re.compile(r"^(?:A problem occurred configuring |Could not resolve |"
r"Could not get resource |Could not (?:GET|HEAD) '|"
r"There (?:is|are) \d+ more failure)")
details = re.findall(r"^\s*>\s*(.+)$", section, re.M)
if not details or any(not context.search(line) and not network.search(line) for line in details):
return None
if any(re.search(r"Received status code (?!403\b|429\b|500\b|502\b|503\b|504\b)\d+", line)
for line in details):
return None
if re.search(r"Received status code 403\b", section):
return "forbidden"
return "transient" if timed_out or network.search(section) else None
def wrapper_failure(output, timed_out):
if re.search(r"certificate|PKIX|checksum|signature|Permission denied|No space left|"
r"HTTP response code: (?:401|403|404)", output, re.I):
return False
return timed_out or bool(re.search(r"timed out|UnknownHostException|Connection (?:reset|refused)|"
r"HTTP response code: (?:429|500|502|503|504)", output, re.I))
def acquire_and_run(project, scope, command, runner=run_process, sleep=time.sleep):
env = {**os.environ, "LC_ALL": "C", "DBX_CI_GRADLE_ROOT": str(project.resolve()),
"DBX_CI_GRADLE_SCOPE": scope, "DBX_CI_GRADLE_SOURCE": "primary",
"DBX_CI_GRADLE_PHASE": "acquire"}
env["GRADLE_OPTS"] = env.get("GRADLE_OPTS", "") + " -Dorg.gradle.daemon=false -Duser.language=en -Duser.country=US"
home = Path(env.get("GRADLE_USER_HOME", str(Path.home() / ".gradle"))).expanduser().resolve()
env["GRADLE_USER_HOME"] = str(home)
init_directory = home / "init.d"
init_directory.mkdir(parents=True, exist_ok=True)
wrapper = [str(project / "gradlew"), "--no-daemon", "--console=plain"]
init_path = None
try:
with tempfile.NamedTemporaryFile(prefix="dbx-ci-acquire-", suffix=".gradle", dir=init_directory,
delete=False) as temporary:
init_path = Path(temporary.name)
temporary.write(INIT.read_bytes())
for attempt in range(3):
print(f"Gradle phase=wrapper attempt={attempt + 1} budget=180s", flush=True)
code, output, timed_out = runner([*wrapper, "--version"], project, env, 180)
if code == 0 and not timed_out:
break
if attempt == 2 or not wrapper_failure(output, timed_out):
return code or 1
sleep(2 ** (attempt + 1))
acquisition_count = 0
for source in ["primary", "backup"]:
env["DBX_CI_GRADLE_SOURCE"] = source
attempts = 2 if source == "primary" else 3 - acquisition_count
for attempt in range(attempts):
acquisition_count += 1
print(f"Gradle phase=acquire source={source} attempt={attempt + 1} budget=600s", flush=True)
code, output, timed_out = runner([*wrapper, "dbxCiAcquireDependencies"], project, env, 600)
if code == 0 and not timed_out:
env["DBX_CI_GRADLE_PHASE"] = "execute"
print(f"Gradle phase=execute source={source} offline=true attempts=1", flush=True)
code, _output, _timed_out = runner(command, project, env, None, capture=False)
return code
reason = acquisition_failure(output, timed_out)
if reason is None:
return code or 1
if source == "primary" and (reason == "forbidden" or attempt + 1 == attempts):
break
if attempt + 1 == attempts:
return code or 1
sleep(2)
if source == "primary":
print("Gradle switching once to Google-hosted Maven Central mirror", flush=True)
return 1
finally:
if init_path is not None:
init_path.unlink()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("project", choices=["agents", "jdbc"])
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ["--"] else args.command
if not command:
parser.error("an original command is required after --")
project = ROOT / ("agents" if args.project == "agents" else "plugins/jdbc")
signal.signal(signal.SIGINT, cancel)
signal.signal(signal.SIGTERM, cancel)
try:
return acquire_and_run(project, args.project, command)
except Cancelled as error:
print("Gradle cancelled; no acquisition retry", file=sys.stderr)
return 128 + error.signum
if __name__ == "__main__":
sys.exit(main())
+222
View File
@@ -0,0 +1,222 @@
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import textwrap
import time
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location("ci_gradle", Path(__file__).with_name("ci-gradle.py"))
gradle = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(gradle)
OK = (0, "", False)
def failure(status=403, url="https://repo.maven.apache.org/maven2/example/a.pom"):
return (1, f"""FAILURE: Build failed with an exception.
* What went wrong:
A problem occurred configuring root project 'fixture'.
> Could not resolve all artifacts for configuration 'classpath'.
> Could not resolve example:a:1.
> Could not get resource '{url}'.
> Could not GET '{url}'.
> Received status code {status} from server: Forbidden
* Try:
> Run with --stacktrace option to get the stack trace.
""", False)
class AcquisitionTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.project = self.root / "project"
self.project.mkdir()
self.home = self.root / "gradle-home"
self.calls = []
self.sleeps = []
def execute(self, responses):
responses = iter(responses)
def run(command, cwd, env, budget, capture=True):
self.assertEqual(cwd, self.project)
self.assertEqual(len(list((self.home / "init.d").glob("dbx-ci-acquire-*.gradle"))), 1)
self.calls.append((command, dict(env), budget, capture))
result = next(responses)
if isinstance(result, BaseException):
raise result
return result
with patch.dict(os.environ, {"GRADLE_USER_HOME": str(self.home)}):
try:
return gradle.acquire_and_run(self.project, "agents", ["original", "test", "shadowJar", "--continue"],
runner=run, sleep=self.sleeps.append)
finally:
self.assertFalse(list((self.home / "init.d").glob("dbx-ci-acquire-*.gradle")))
def test_success_acquires_before_one_offline_original_command(self):
self.assertEqual(self.execute([OK, OK, OK]), 0)
self.assertEqual([call[2] for call in self.calls], [180, 600, None])
self.assertEqual(self.calls[1][0][-1], "dbxCiAcquireDependencies")
self.assertEqual(self.calls[2][0], ["original", "test", "shadowJar", "--continue"])
self.assertEqual(self.calls[2][1]["DBX_CI_GRADLE_PHASE"], "execute")
self.assertEqual(self.calls[2][1]["DBX_CI_GRADLE_SOURCE"], "primary")
self.assertFalse(self.calls[2][3])
self.assertEqual(self.sleeps, [])
def test_public_forbidden_switches_once_without_repeating_original(self):
self.assertEqual(self.execute([OK, failure(), OK, OK]), 0)
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls],
["primary", "primary", "backup", "backup"])
self.assertEqual(self.sleeps, [])
def test_transient_retries_once_before_backup(self):
self.assertEqual(self.execute([OK, failure(503), failure(503), OK, OK]), 0)
self.assertEqual(self.sleeps, [2])
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls[1:]],
["primary", "primary", "backup", "backup"])
def test_backup_failure_and_execution_failure_are_terminal(self):
for responses, count in [([OK, failure(), failure(), failure()], 4),
([OK, OK, failure()], 3)]:
with self.subTest(responses=responses):
self.calls.clear()
self.assertEqual(self.execute(responses), 1)
self.assertEqual(len(self.calls), count)
def test_forbidden_preserves_remaining_attempt_for_backup(self):
backup_error = failure(503, "https://maven-central.storage-download.googleapis.com/maven2/example/a.pom")
self.assertEqual(self.execute([OK, failure(), backup_error, OK, OK]), 0)
self.assertEqual([call[1]["DBX_CI_GRADLE_SOURCE"] for call in self.calls[1:]],
["primary", "backup", "backup", "backup"])
self.assertEqual(self.sleeps, [2])
def test_timeout_recovers_only_acquisition(self):
timeout = (-9, "Downloading dependencies\n", True)
self.assertEqual(self.execute([OK, timeout, timeout, OK, OK]), 0)
self.assertEqual(self.sleeps, [2])
def test_wrapper_has_separate_bounded_retry_and_no_project_task(self):
self.assertEqual(self.execute([(1, "java.net.SocketTimeoutException: Read timed out", False), OK, OK, OK]), 0)
self.assertEqual(self.sleeps, [2])
self.assertTrue(all(call[0][-1] == "--version" for call in self.calls[:2]))
def test_deterministic_private_and_mixed_failures_do_not_switch(self):
errors = [failure(404), failure(401), failure(url="https://private.test/maven2/a.pom"),
failure(url="https://user:password@repo.maven.apache.org/maven2/a.pom"),
failure(url="https://repo.maven.apache.org/maven2/a.pom?token=secret"),
(1, failure()[1] + "\n> No space left on device", False),
(1, failure()[1].replace("* Try:", "> PKIX certificate failure\n* Try:"), False),
(1, failure()[1].replace("* Try:", "> An unknown failure\n* Try:"), False)]
for error in errors:
with self.subTest(error=error):
self.calls.clear()
self.assertEqual(self.execute([OK, error]), 1)
self.assertEqual(len(self.calls), 2)
def test_unknown_wrapper_failure_does_not_enter_acquisition(self):
self.assertEqual(self.execute([(1, "checksum verification failed", False)]), 1)
self.assertEqual(len(self.calls), 1)
def test_cancellation_never_switches_and_preserves_other_init_scripts(self):
directory = self.home / "init.d"
directory.mkdir(parents=True)
other = directory / "existing.gradle"
other.write_text("// existing")
with self.assertRaises(gradle.Cancelled):
self.execute([OK, gradle.Cancelled(signal.SIGTERM)])
self.assertEqual(len(self.calls), 2)
self.assertEqual(other.read_text(), "// existing")
def test_original_exception_still_cleans_up(self):
with self.assertRaises(OSError):
self.execute([OK, OK, OSError("cannot start original")])
self.assertEqual(len(self.calls), 3)
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
def test_spawn_cancellation_reaps_child_before_init_cleanup(self):
events = []
with patch.object(gradle.signal, "signal"), patch.object(gradle.os, "killpg", side_effect=lambda *_: events.append("kill")), \
patch.object(gradle.subprocess, "Popen") as popen:
child = popen.return_value
child.communicate.side_effect = lambda: (events.append("reap") or "", None)
def spawn(*_args, **_kwargs):
gradle.cancel(signal.SIGTERM, None)
return child
popen.side_effect = spawn
with self.assertRaises(gradle.Cancelled):
gradle.run_process(["gradle"], self.project, {}, 1)
self.assertEqual(events, ["kill", "reap"])
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
def test_timeout_and_cancellation_dispose_owned_descendants(self):
for signum in [0, signal.SIGTERM, signal.SIGINT]:
with self.subTest(signum=signum):
case = self.root / str(signum)
case.mkdir()
pids = case / "pids"
harness = textwrap.dedent("""
import importlib.util, os, signal, sys
from pathlib import Path
spec = importlib.util.spec_from_file_location('acquire', sys.argv[1])
acquire = importlib.util.module_from_spec(spec); spec.loader.exec_module(acquire)
signal.signal(signal.SIGINT, acquire.cancel)
signal.signal(signal.SIGTERM, acquire.cancel)
os.environ['GRADLE_USER_HOME'] = sys.argv[2]
payload = '''import os, subprocess, sys, time
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])
with open(sys.argv[1], 'a') as output: print(os.getpid(), child.pid, file=output)
time.sleep(60)
'''
def run(command, cwd, env, budget, capture=True):
if command[-1] == '--version': return 0, '', False
if not capture: raise RuntimeError('execution reached without acquisition')
return acquire.run_process([sys.executable, '-c', payload, sys.argv[3]], cwd, env, 1)
try:
code = acquire.acquire_and_run(Path(sys.argv[2]), 'agents', ['original'], runner=run, sleep=lambda _: None)
sys.exit(code)
except acquire.Cancelled as error:
sys.exit(128 + error.signum)
""")
process = subprocess.Popen([sys.executable, "-c", harness, gradle.__file__,
str(case), str(pids)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
deadline = time.monotonic() + 10
while (not pids.exists() or not pids.read_text().strip()) and process.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue(pids.exists() and pids.read_text().strip(), "fake acquisition did not start")
if signum:
process.send_signal(signum)
output = process.communicate(timeout=10)[0]
self.assertNotEqual(process.returncode, 0, output)
self.assertNotIn("phase=execute", output)
self.assertEqual(len(pids.read_text().splitlines()), 1 if signum else 3)
self.assertFalse(list((case / "init.d").glob("dbx-ci-acquire-*.gradle")))
for pid in pids.read_text().split():
status = Path(f"/proc/{pid}/stat")
deadline = time.monotonic() + 5
while status.exists() and status.read_text().split()[2] != "Z" and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue(not status.exists() or status.read_text().split()[2] == "Z", f"owned child {pid} survived")
finally:
if pids.exists():
for line in pids.read_text().splitlines():
try:
os.killpg(int(line.split()[0]), signal.SIGKILL)
except ProcessLookupError:
pass
if process.poll() is None:
process.kill()
process.communicate(timeout=5)
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -22,8 +22,10 @@ The PR author, not the trigger actor or author association, determines this
policy, so a maintainer retrigger cannot bypass contributor limits.
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
also enqueue separate runs; this policy does not deduplicate superseded commits.
beyond that limit are cancelled by GitHub. Superseded commits on the same
contributor PR (base `main`) are cancelled by the separate
`cancel-superseded-ci.yml` workflow: on `synchronize` it re-reads the PR and
cancels that PR's older runs unless the event SHA is once again the head.
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
archive=$(mktemp)
trap 'rm -f -- "$archive"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
curl --fail --location --silent --show-error \
--retry 2 --retry-connrefused --retry-delay 2 \
--connect-timeout 15 --max-time 120 --retry-max-time 360 \
https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
--output "$archive"
sudo tar -C /usr/local --strip-components=1 -xzf "$archive"
mold --version
+117
View File
@@ -0,0 +1,117 @@
import http.server
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import unittest
SCRIPT = Path(__file__).with_name("ci-install-mold.sh")
@unittest.skipUnless(sys.platform == "linux", "requires the Linux CI shell and curl")
class MoldDownloadTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.bin = self.root / "bin"
self.bin.mkdir()
self.downloads = self.root / "downloads"
self.downloads.mkdir()
self.statuses = [200]
self.requests = 0
self.started = threading.Event()
self.release = threading.Event()
owner = self
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
status = owner.statuses[min(owner.requests, len(owner.statuses) - 1)]
owner.requests += 1
self.send_response(200 if status == "partial" else status)
self.send_header("Content-Length", "1000" if status == "partial" else "7")
self.end_headers()
self.wfile.write(b"archive")
self.wfile.flush()
owner.started.set()
if status == "partial":
owner.release.wait(10)
def log_message(self, *_args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
self.addCleanup(self.release.set)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
"REAL_CURL": shutil.which("curl"), "TEST_URL": f"http://127.0.0.1:{server.server_port}/mold",
"TEST_ROOT": str(self.root), "TMPDIR": str(self.downloads)}
self.tool("curl", """import os, sys
args = sys.argv[1:]
assert '--retry-all-errors' not in args
for option, value in [('--retry', '2'), ('--connect-timeout', '15'), ('--max-time', '120'), ('--retry-max-time', '360')]:
assert args[args.index(option) + 1] == value
assert 'https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz' in args
args = [os.environ['TEST_URL'] if arg.startswith('https://') else arg for arg in args]
os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *args])
""")
self.tool("sudo", """import os, sys
from pathlib import Path
assert sys.argv[1:6] == ['tar', '-C', '/usr/local', '--strip-components=1', '-xzf']
assert Path(sys.argv[-1]).read_bytes() == b'archive'
with (Path(os.environ['TEST_ROOT']) / 'installed').open('a') as output: output.write('once\\n')
""")
self.tool("mold", "print('mold 2.42.1')\n")
def tool(self, name, body):
path = self.bin / name
path.write_text(f"#!{sys.executable}\n" + body)
path.chmod(0o755)
def run_script(self):
result = subprocess.run(["bash", str(SCRIPT)], env=self.env, capture_output=True, text=True, timeout=20)
self.assertEqual(list(self.downloads.iterdir()), [])
return result
def test_transient_status_recovers_before_one_install(self):
self.statuses = [503, 503, 200]
result = self.run_script()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.requests, 3)
self.assertEqual((self.root / "installed").read_text(), "once\n")
def test_not_found_and_exhausted_retries_never_install(self):
for statuses, requests in [([404], 1), ([503], 3)]:
with self.subTest(statuses=statuses):
self.statuses = statuses
self.requests = 0
result = self.run_script()
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self.requests, requests)
self.assertFalse((self.root / "installed").exists())
def test_cancellation_cleans_partial_file_without_install(self):
self.statuses = ["partial"]
process = subprocess.Popen(["bash", str(SCRIPT)], env=self.env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, start_new_session=True)
try:
self.assertTrue(self.started.wait(5), "curl did not start")
os.killpg(process.pid, signal.SIGTERM)
process.communicate(timeout=5)
self.assertNotEqual(process.returncode, 0)
self.assertFalse((self.root / "installed").exists())
self.assertEqual(list(self.downloads.iterdir()), [])
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGKILL)
process.communicate(timeout=5)
if __name__ == "__main__":
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Download Ubuntu dependencies with at most two mirror switches, then install offline."""
import argparse
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
SOURCES = Path("/etc/apt/sources.list.d/ubuntu.sources")
PRIMARY = "https://archive.ubuntu.com/ubuntu/"
BACKUP = "https://mirrors.edge.kernel.org/ubuntu/"
LAST_RESORT = "http://azure.archive.ubuntu.com/ubuntu/"
MIRRORS = [("primary", PRIMARY), ("backup", BACKUP), ("last-resort", LAST_RESORT)]
PACKAGES = ["libwebkit2gtk-4.1-dev", "libappindicator3-dev", "librsvg2-dev",
"patchelf", "libssl-dev", "libsecret-1-dev"]
PACKAGE_GROUPS = {"gtk": PACKAGES, "packaging": ["zstd"],
"duckdb": ["libdbus-1-dev", "pkg-config"]}
ACQUIRE_OPTIONS = ["-o", "Acquire::Retries=0", "-o", "Acquire::http::Timeout=30",
"-o", "Acquire::https::Timeout=30", "-o", "APT::Update::Error-Mode=any"]
starting_process = False
pending_cancel = None
def rewrite_ubuntu_uris(source, mirror):
known = {"mirror+file:/etc/apt/apt-mirrors.txt", PRIMARY, PRIMARY.rstrip("/"),
BACKUP, BACKUP.rstrip("/"), LAST_RESORT, LAST_RESORT.rstrip("/")}
found = 0
in_uris = False
result = []
def replace(match):
nonlocal found
if match.group() in known:
found += 1
return mirror
return match.group()
for line in source.splitlines(keepends=True):
if line.lstrip().startswith("#"):
result.append(line)
continue
field = re.match(r"^([A-Za-z][A-Za-z0-9-]*):", line)
if field:
in_uris = field[1].lower() == "uris"
elif not line.strip() or not line[0].isspace():
in_uris = False
if in_uris:
prefix = field.end() if field else 0
line = line[:prefix] + re.sub(r"\S+", replace, line[prefix:])
result.append(line)
if not found:
raise ValueError("Ubuntu sources do not contain the expected runner mirror URI")
return "".join(result)
def is_retryable_acquisition_failure(output, timed_out):
if re.search(r"signature|NO_PUBKEY|GPG error|certificate|not signed|Release file|"
r"Hash Sum mismatch|unexpected size|Unable to locate package|unmet dependencies|"
r"held broken packages|Could not get lock|Unable to acquire|Waiting for cache lock|"
r"dpkg was interrupted|Permission denied|No space left|404\s", output, re.I):
return False
reasons = []
for line in output.splitlines():
fetch = re.match(r"[EW]: Failed to fetch \S+\s+(.+)", line)
if fetch:
reasons.append(fetch[1])
elif line.startswith("E:") and not line.startswith((
"E: Unable to fetch some archives,", "E: Some index files failed to download."
)):
return False
network = re.compile(r"Temporary failure resolving|Could not resolve|Could not connect|"
r"Connection (?:failed|timed out|reset)|Network is unreachable|"
r"Error reading from server|Remote end closed|"
r"(?:^|\s)(?:429|500|502|503|504)(?:\s|$)", re.I)
if any(not network.search(reason) for reason in reasons):
return False
return timed_out or bool(reasons)
class Cancelled(BaseException):
def __init__(self, signum):
self.signum = signum
def cancel(signum, _frame):
global pending_cancel
# Finish child disposal and source restoration even if cancellation repeats.
signal.signal(signal.SIGINT, signal.SIG_IGN)
signal.signal(signal.SIGTERM, signal.SIG_IGN)
if starting_process:
pending_cancel = signum
return
raise Cancelled(signum)
def dispose(process):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
return process.communicate()[0]
def run_apt(arguments, budget):
global starting_process, pending_cancel
started = time.monotonic()
process = None
timed_out = False
try:
starting_process = True
process = subprocess.Popen(
["apt-get", *ACQUIRE_OPTIONS, *arguments], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, env={**os.environ, "LC_ALL": "C"},
start_new_session=True,
)
starting_process = False
if pending_cancel is not None:
signum, pending_cancel = pending_cancel, None
raise Cancelled(signum)
try:
output = process.communicate(timeout=budget)[0]
except subprocess.TimeoutExpired:
timed_out = True
output = dispose(process)
except BaseException:
if process is not None:
dispose(process)
raise
finally:
starting_process = False
print(output, end="", flush=True)
print(f"APT finished elapsed={time.monotonic() - started:.1f}s "
f"exit={process.returncode} timed_out={timed_out}", flush=True)
return process.returncode, output, timed_out
def install(source_path=SOURCES, group="gtk"):
packages = PACKAGE_GROUPS[group]
original = source_path.read_bytes()
source = original.decode("utf-8")
candidates = [(label, rewrite_ubuntu_uris(source, mirror)) for label, mirror in MIRRORS]
try:
for index, (label, candidate) in enumerate(candidates):
source_path.write_bytes(candidate.encode("utf-8"))
for phase, arguments, budget in [
("index", ["update"], 60),
("download", ["--download-only", "install", "-y", *packages], 120),
]:
print(f"APT source={label} phase={phase} budget={budget}s", flush=True)
code, output, timed_out = run_apt(arguments, budget)
if code != 0 or timed_out:
if index + 1 < len(candidates) and is_retryable_acquisition_failure(output, timed_out):
reason = "acquisition deadline" if timed_out else "transient acquisition error"
print(f"APT switching to next source {MIRRORS[index + 1][1]}: {reason}", flush=True)
break
raise RuntimeError(f"APT {label} {phase} failed (exit={code}, timed_out={timed_out})")
else:
break
print("APT phase=install budget=1200s downloads=disabled", flush=True)
code, _output, timed_out = run_apt(["--no-download", "install", "-y", *packages], 1200)
if code != 0 or timed_out:
raise RuntimeError(f"APT offline install failed (exit={code}, timed_out={timed_out})")
finally:
source_path.write_bytes(original)
if __name__ == "__main__":
signal.signal(signal.SIGINT, cancel)
signal.signal(signal.SIGTERM, cancel)
try:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--group", choices=PACKAGE_GROUPS, default="gtk")
install(group=parser.parse_args().group)
except Cancelled as error:
print("APT cancelled; no mirror retry", file=sys.stderr)
sys.exit(128 + error.signum)
except Exception as error:
print(f"::error::{error}", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,282 @@
import importlib.util
import os
import signal
import subprocess
import sys
import tempfile
import textwrap
import time
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPT = Path(__file__).with_name("ci-install-system-deps.py")
spec = importlib.util.spec_from_file_location("deps", SCRIPT)
deps = importlib.util.module_from_spec(spec)
spec.loader.exec_module(deps)
SOURCE = """Types: deb
URIs: mirror+file:/etc/apt/apt-mirrors.txt
Suites: noble noble-updates noble-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs:
mirror+file:/etc/apt/apt-mirrors.txt
Suites: noble-security
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs: https://security.ubuntu.com/ubuntu/
Suites: noble-security
Components: main
Types: deb
URIs: https://example.test/apt
Suites: stable
Components: main
"""
OK = (0, "", False)
TIMEOUT = (-9, "", True)
NETWORK = (100, "E: Failed to fetch http://azure.archive.ubuntu.com/a.deb Connection timed out\n"
"E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?\n", False)
class InstallerTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.source = Path(self.directory.name) / "ubuntu.sources"
self.source.write_bytes(SOURCE.encode())
self.calls = []
def execute(self, results, group="gtk"):
responses = iter(results)
def run(arguments, budget):
self.calls.append((arguments, budget, self.source.read_text()))
return next(responses)
with patch.object(deps, "run_apt", side_effect=run):
try:
deps.install(self.source, group)
finally:
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
def test_success_downloads_before_one_offline_install(self):
self.execute([OK, OK, OK])
self.assertEqual([call[1] for call in self.calls], [60, 120, 1200])
self.assertEqual(self.calls[1][0], ["--download-only", "install", "-y", *deps.PACKAGES])
self.assertEqual(self.calls[2][0], ["--no-download", "install", "-y", *deps.PACKAGES])
self.assertTrue(all(deps.BACKUP not in call[2] for call in self.calls))
def test_fixed_package_groups_preserve_each_caller_list(self):
for group, packages in {"packaging": ["zstd"],
"duckdb": ["libdbus-1-dev", "pkg-config"]}.items():
with self.subTest(group=group):
self.calls.clear()
self.execute([OK, OK, OK], group)
self.assertEqual(self.calls[1][0], ["--download-only", "install", "-y", *packages])
self.assertEqual(self.calls[2][0], ["--no-download", "install", "-y", *packages])
def test_official_primary_and_known_runner_sources(self):
self.assertEqual(deps.PRIMARY, "https://archive.ubuntu.com/ubuntu/")
for uri in [deps.PRIMARY, deps.BACKUP, deps.LAST_RESORT, "mirror+file:/etc/apt/apt-mirrors.txt"]:
with self.subTest(uri=uri):
source = SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", uri)
rewritten = deps.rewrite_ubuntu_uris(source, deps.PRIMARY)
self.assertIn("URIs: https://archive.ubuntu.com/ubuntu/", rewritten)
self.assertIn("URIs: https://security.ubuntu.com/ubuntu/", rewritten)
self.assertIn("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg", rewritten)
def test_timeout_or_network_failure_switches_once_and_reuses_cache(self):
for failure in [TIMEOUT, NETWORK]:
with self.subTest(failure=failure):
self.calls.clear()
self.execute([OK, failure, OK, OK, OK])
self.assertEqual([call[1] for call in self.calls], [60, 120, 60, 120, 1200])
self.assertIn(deps.PRIMARY, self.calls[1][2])
self.assertNotIn(deps.PRIMARY, self.calls[2][2])
self.assertIn(deps.BACKUP, self.calls[2][2])
self.assertFalse(any("clean" in call[0] or "--fix-missing" in call[0] for call in self.calls))
self.assertTrue(all(not any("Dir::Cache" in arg for arg in call[0]) for call in self.calls))
def test_network_index_failure_refreshes_backup_before_downloading(self):
self.execute([NETWORK, OK, OK, OK])
self.assertEqual([call[0][0] for call in self.calls], ["update", "update", "--download-only", "--no-download"])
def test_all_three_sources_fail_without_installing_partial_downloads(self):
with self.assertRaises(RuntimeError):
self.execute([OK, TIMEOUT, OK, NETWORK, OK, NETWORK])
self.assertEqual(len(self.calls), 6)
self.assertFalse(any("--no-download" in call[0] for call in self.calls))
def test_second_switch_is_last_resort_and_never_loops(self):
self.execute([OK, TIMEOUT, OK, NETWORK, OK, OK, OK])
self.assertEqual(len(self.calls), 7)
self.assertIn(deps.PRIMARY, self.calls[1][2])
self.assertIn(deps.BACKUP, self.calls[3][2])
self.assertIn(deps.LAST_RESORT, self.calls[5][2])
self.assertEqual(self.calls[6][0][0], "--no-download")
def test_deterministic_and_unknown_errors_never_switch(self):
for output in ["E: Unable to locate package missing", "W: GPG error: invalid signatures",
"E: Failed to fetch https://archive.ubuntu.com/a.deb Certificate verification failed",
"E: Failed to fetch http://azure.archive.ubuntu.com/a.deb 404 Not Found",
"E: Failed to fetch http://azure.archive.ubuntu.com/a.deb Hash Sum mismatch",
"E: Could not get lock /var/lib/dpkg/lock", "E: No space left on device",
"E: Unknown error", NETWORK[1] + "E: unmet dependencies"]:
for timed_out in [False, True]:
with self.subTest(output=output, timed_out=timed_out):
self.calls.clear()
with self.assertRaises(RuntimeError):
self.execute([OK, (100, output, timed_out)])
self.assertEqual(len(self.calls), 2)
self.assertFalse(deps.is_retryable_acquisition_failure("", False))
self.assertFalse(deps.is_retryable_acquisition_failure(
NETWORK[1] + "E: Failed to fetch https://example.test/a.deb Unknown error", False))
def test_http_transient_errors_are_allowed_but_not_unknown_fetch_errors(self):
for status in [429, 500, 502, 503, 504]:
self.assertTrue(deps.is_retryable_acquisition_failure(
f"E: Failed to fetch https://archive.ubuntu.com/a.deb {status} Server error", False))
self.assertFalse(deps.is_retryable_acquisition_failure(
"E: Failed to fetch https://archive.ubuntu.com/a.deb Something went wrong", False))
def test_install_failure_is_terminal_without_retry(self):
for failure in [TIMEOUT, NETWORK, (100, "E: dpkg failed", False)]:
self.calls.clear()
with self.assertRaises(RuntimeError):
self.execute([OK, OK, failure])
self.assertEqual(len(self.calls), 3)
def test_source_rewrite_preserves_all_non_uri_fields_and_security(self):
rewritten = deps.rewrite_ubuntu_uris(SOURCE, deps.BACKUP)
self.assertEqual(rewritten, SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", deps.BACKUP))
self.assertEqual(deps.rewrite_ubuntu_uris(SOURCE.replace("mirror+file:/etc/apt/apt-mirrors.txt", deps.PRIMARY), deps.BACKUP), rewritten)
self.source.write_text("Types: deb\nURIs: https://unknown.test/ubuntu\nSuites: noble\n")
original = self.source.read_bytes()
with self.assertRaises(ValueError), patch.object(deps, "run_apt") as run:
deps.install(self.source)
run.assert_not_called()
self.assertEqual(self.source.read_bytes(), original)
def test_run_apt_uses_an_owned_process_group_and_c_locale(self):
with patch.object(deps.subprocess, "Popen") as popen:
popen.return_value.communicate.return_value = ("downloaded\n", None)
popen.return_value.returncode = 0
self.assertEqual(deps.run_apt(["--download-only", "install", "-y", *deps.PACKAGES], 120), (0, "downloaded\n", False))
self.assertTrue(popen.call_args.kwargs["start_new_session"])
self.assertEqual(popen.call_args.kwargs["env"]["LC_ALL"], "C")
self.assertIn("Acquire::Retries=0", popen.call_args.args[0])
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups")
def test_cancellation_during_spawn_reaps_before_restoring_sources(self):
events = []
write = Path.write_bytes
def record_write(path, content):
if content == SOURCE.encode():
events.append("restore")
return write(path, content)
with patch.object(deps.subprocess, "Popen") as popen, \
patch.object(deps.signal, "signal"), \
patch.object(deps.os, "killpg", side_effect=lambda *_: events.append("kill")), \
patch.object(Path, "write_bytes", autospec=True, side_effect=record_write):
process = popen.return_value
process.communicate.side_effect = lambda: (events.append("reap") or "", None)
def spawn(*_args, **_kwargs):
events.append("spawn")
deps.cancel(signal.SIGTERM, None)
return process
popen.side_effect = spawn
with self.assertRaises(deps.Cancelled):
deps.install(self.source)
popen.assert_called_once()
self.assertEqual(events, ["spawn", "kill", "reap", "restore"])
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
def run_child_case(self, signum=0):
pid_file = Path(self.directory.name) / f"child-{signum}.pid"
payload = textwrap.dedent("""
import os, subprocess, sys, time
child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])
open(sys.argv[1], 'w').write(f'{os.getpid()} {child.pid}')
time.sleep(60)
""")
harness = textwrap.dedent("""
import importlib.util, signal, subprocess, sys
from pathlib import Path
spec = importlib.util.spec_from_file_location('deps', sys.argv[1])
deps = importlib.util.module_from_spec(spec); spec.loader.exec_module(deps)
deps.signal.signal(signal.SIGINT, deps.cancel)
deps.signal.signal(signal.SIGTERM, deps.cancel)
original_popen = subprocess.Popen
deps.subprocess.Popen = lambda *a, **kw: original_popen([sys.executable, '-c', sys.argv[3], sys.argv[4]], **kw)
original_run = deps.run_apt
deps.run_apt = lambda args, budget: original_run(args, 3)
try:
deps.install(Path(sys.argv[2]))
except deps.Cancelled as error:
sys.exit(128 + error.signum)
except RuntimeError:
sys.exit(1)
""")
process = subprocess.Popen([sys.executable, "-c", harness, str(SCRIPT), str(self.source), payload, str(pid_file)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def cleanup():
if pid_file.exists() and pid_file.read_text():
try:
os.killpg(int(pid_file.read_text().split()[0]), signal.SIGKILL)
except ProcessLookupError:
pass
if process.poll() is None:
process.kill()
process.communicate(timeout=5)
self.addCleanup(cleanup)
deadline = time.monotonic() + 10
while not pid_file.exists() and process.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue(pid_file.exists(), "fake apt did not start")
if signum:
process.send_signal(signum)
output = process.communicate(timeout=15)[0]
self.assertEqual(process.returncode, 128 + signum if signum else 1, output)
self.assertEqual(self.source.read_bytes(), SOURCE.encode())
self.assertNotIn("phase=install", output)
child = Path(f"/proc/{pid_file.read_text().split()[1]}/stat")
def child_running():
try:
return child.read_text().split()[2] != "Z"
except FileNotFoundError:
return False
deadline = time.monotonic() + 5
while child_running() and time.monotonic() < deadline:
time.sleep(0.02)
self.assertFalse(child_running(), "descendant still running")
return output
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
def test_timeout_disposes_descendants_before_backup(self):
output = self.run_child_case()
self.assertIn("timed_out=True", output)
self.assertEqual(output.count("switching to next source"), 2)
@unittest.skipUnless(sys.platform == "linux", "requires Linux process groups and /proc")
def test_cancellation_disposes_descendants_without_backup_and_restores_sources(self):
for signum in [signal.SIGINT, signal.SIGTERM]:
with self.subTest(signum=signum):
output = self.run_child_case(signum)
self.assertNotIn("switching to next source", output)
if __name__ == "__main__":
unittest.main()
+15 -1
View File
@@ -232,7 +232,21 @@ test("code generation and test-only dependencies participate in impact analysis"
assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types"));
});
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
for (const file of [
"Cargo.toml",
"Cargo.lock",
".cargo/config.toml",
"rust-toolchain.toml",
"vendor/wry/src/lib.rs",
"crates/dbx-sql/Cargo.toml",
".github/workflows/ci.yml",
".github/scripts/ci-plan.mjs",
".github/scripts/ci-gate.test.mjs",
".github/scripts/ci-gradle.py",
".github/scripts/ci-gradle.init.gradle",
".github/scripts/ci-install-system-deps.py",
".github/scripts/ci-install-mold.sh",
]) {
test(`shared input ${file} retains full workspace and Agent coverage`, () => {
const result = plan([file]);
assert.deepEqual(groups(result), ["workspace"]);
+90 -10
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8");
const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8").replaceAll("\r\n", "\n");
function job(name, content = workflow) {
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
const pattern = /^ ([\w-]+):\s*$/gm;
@@ -279,16 +279,92 @@ test("Rust build timings remain available after failures without waiting for can
assert.ok(job("agent-rust").includes('--manifest-path "drivers/$DRIVER/Cargo.toml" --locked --no-fail-fast --timings'));
});
test("Linux Rust dependency installs bound apt mirror stalls", () => {
for (const name of ["packages", "rust-fmt-clippy", "rust-test", "agent-rust"]) {
const content = job(name);
assert.equal((content.match(/sudo timeout --kill-after=30s 10m apt-get/g) ?? []).length, 2);
for (const option of ["Acquire::Retries=3", "Acquire::http::Timeout=30", "Acquire::https::Timeout=30"]) {
assert.ok(content.includes(option));
}
test("Linux dependency installs share bounded acquisition before offline installation", () => {
for (const name of ["packages", "rust-fmt-clippy", "rust-test"]) {
assert.ok(job(name).includes("sudo python3 .github/scripts/ci-install-system-deps.py"));
assert.ok(!job(name).includes("apt-get"));
}
for (const name of ["agent-checks", "agent-java"]) {
assert.ok(job(name).includes("sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging"));
}
assert.ok(job("agent-rust").includes("sudo python3 ../.github/scripts/ci-install-system-deps.py --group duckdb"));
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-install-system-deps.test.py"));
});
test("Java CI acquires before original commands without retrying packaging or tests", () => {
assert.ok(job("agent-java").includes("python3 ../.github/scripts/ci-gradle.py agents -- ./gradlew test shadowJar --continue"));
for (const name of ["jdbc", "offline-jdbc-release"]) {
assert.ok(job(name).includes("python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh"));
}
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-gradle.test.py"));
});
test("mold downloads are isolated from extraction in CI and keepalive", () => {
for (const name of ["packages", "rust-test"]) {
assert.ok(job(name).includes("run: bash .github/scripts/ci-install-mold.sh"));
}
assert.ok(job("agent-rust").includes("run: bash ../.github/scripts/ci-install-mold.sh"));
const keepalive = readFileSync(new URL("../workflows/cache-keepalive.yml", import.meta.url), "utf8");
assert.ok(keepalive.includes("run: bash .github/scripts/ci-install-mold.sh"));
assert.ok(keepalive.includes("sudo python3 .github/scripts/ci-install-system-deps.py"));
assert.ok(job("github-scripts").includes("python3 .github/scripts/ci-install-mold.test.py"));
assert.ok(job("github-scripts").includes("./.github/scripts/prepare-webview2-win7-runtime.test.ps1"));
});
for (const name of ["rust-test", "agent-integration"]) {
for (const scenario of ["ready", "delayed", "unavailable", "missing-mirror"]) {
test(`${name} Docker readiness: ${scenario}`, () => {
const step = job(name).match(/ - name: Configure public Docker Hub cache\n([\s\S]*?)(?=\n - )/)[1];
const block = step.slice(step.indexOf(" timeout 30s")).replace(/^ /gm, "");
const directory = mkdtempSync(path.join(tmpdir(), "dbx-docker-ready-"));
try {
const result = spawnSync(
"bash",
[
"-e",
"-o",
"pipefail",
"-c",
`
sudo() { "$@"; }
systemctl() { printf 'restart\\n' >> "$STATE/restarts"; }
timeout() { shift; "$@"; }
sleep() { :; }
${process.platform === "win32" ? 'python3() { python "$@"; }' : ""}
docker() {
local count=0
[[ ! -f "$STATE/count" ]] || read -r count < "$STATE/count"
count=$((count + 1)); printf '%s\\n' "$count" > "$STATE/count"
if [[ "$SCENARIO" == unavailable ]] || { [[ "$SCENARIO" == delayed ]] && ((count < 3)); }; then
printf 'daemon not ready\\n' >&2; return 124
fi
if [[ "$SCENARIO" == missing-mirror ]]; then printf '[]\\n'; else printf '["https://mirror.gcr.io/"]\\n'; fi
}
${block}
printf 'verified\\n'
`,
],
{ env: { ...process.env, STATE: directory.replaceAll("\\", "/"), SCENARIO: scenario }, encoding: "utf8", timeout: 15000 },
);
assert.ifError(result.error);
assert.equal(readFileSync(path.join(directory, "restarts"), "utf8"), "restart\n");
const count = Number(readFileSync(path.join(directory, "count"), "utf8"));
if (scenario === "ready" || scenario === "delayed") {
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /verified/);
assert.equal(count, scenario === "ready" ? 1 : 3);
} else {
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.stdout, /verified/);
assert.equal(count, scenario === "unavailable" ? 12 : 1);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
}
}
test("Rust test jobs install pinned nextest and retain separate doctests", () => {
const pluginDevHost = readFileSync(new URL("../workflows/plugin-dev-host.yml", import.meta.url), "utf8");
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
@@ -344,7 +420,8 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
const win7 = job("windows-win7-bundle");
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"'])
assert.ok(win7.includes(setting));
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
assert.ok(win7.includes('version: "v0.16.0"'));
assert.ok(win7.includes("sccache --show-stats"));
@@ -387,7 +464,10 @@ test("Win7 TLS artifacts come from main and retain a build fallback", () => {
const win7 = job("windows-win7-bundle");
assert.ok(win7.includes("actions: read"));
assert.doesNotMatch(win7, /uses: actions\/cache(?:\/restore|\/save)?@v4/);
for (const [name, id] of [["AWS-LC", "aws-lc-artifact"], ["OpenSSL", "openssl-artifact"]]) {
for (const [name, id] of [
["AWS-LC", "aws-lc-artifact"],
["OpenSSL", "openssl-artifact"],
]) {
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id != ''`));
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id == ''`));
assert.ok(win7.includes(`if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.${id}.outputs.run_id == ''`));
@@ -73,11 +73,16 @@ try {
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'
$stale = Join-Path (Split-Path $binary) 'dbx-mcp.exe~RF8c73e4f.TMP'
[IO.File]::WriteAllText($stale, 'old executable')
$rerun = Install-DbxMcp | Out-String
Assert-True ($rerun.Contains('dbx-mcp 0.4.96 already up to date')) 'Rerun not idempotent'
Assert-True (!(Test-Path -LiteralPath $stale)) 'Up-to-date install left replacement temp file'
[IO.File]::WriteAllText($stale, 'old executable')
'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'
Assert-True (!(Test-Path -LiteralPath $stale)) 'Upgrade left replacement temp file'
Remove-Item -LiteralPath $binary
$repaired = Install-DbxMcp | Out-String
Assert-True ($repaired.Contains('Installed dbx-mcp 0.4.96')) 'Orphan marker prevented installation'
@@ -107,6 +112,61 @@ try {
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'
$installDir = Split-Path $binary
$preservedNames = @('dbx-mcp.exe', '.dbx-mcp-version', 'other.tmp', 'dbx-mcp.exe~RFxyz.TMP', 'dbx-mcp.exe~RF.TMP')
foreach ($name in $preservedNames | Select-Object -Skip 2) {
[IO.File]::WriteAllText((Join-Path $installDir $name), 'preserve')
}
$nested = Join-Path $installDir 'dbx-mcp.exe~RF123.TMP'
[IO.Directory]::CreateDirectory($nested) | Out-Null
$nestedFile = Join-Path $nested 'dbx-mcp.exe~RFabc.TMP'
[IO.File]::WriteAllText($nestedFile, 'nested')
$stale = Join-Path $installDir 'DBX-MCP.EXE~rfABC123.tmp'
[IO.File]::WriteAllText($stale, 'old executable')
Remove-McpReplacementTempFiles $installDir
Assert-True (!(Test-Path -LiteralPath $stale)) 'Case-insensitive replacement cleanup failed'
foreach ($name in $preservedNames) {
Assert-True (Test-Path -LiteralPath (Join-Path $installDir $name) -PathType Leaf) "Cleanup removed $name"
}
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'Cleanup changed active binary'
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.97') 'Cleanup changed version marker'
Assert-True ((Get-Content -LiteralPath $nestedFile -Raw) -eq 'nested') 'Cleanup recursed into a directory'
Remove-McpReplacementTempFiles (Join-Path $testRoot 'missing')
$script:count++
Write-Output 'PASS cleanup preserves active binary, marker, unrelated files and directories'
$linkTarget = Join-Path $testRoot 'linked-target'
[IO.Directory]::CreateDirectory($linkTarget) | Out-Null
$targetFile = Join-Path $linkTarget 'dbx-mcp.exe~RF789.TMP'
[IO.File]::WriteAllText($targetFile, 'preserve link target')
$directoryLink = Join-Path $installDir 'dbx-mcp.exe~RF789.TMP'
$linkType = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' }
New-Item -ItemType $linkType -Path $directoryLink -Target $linkTarget | Out-Null
Remove-McpReplacementTempFiles $installDir
Remove-McpReplacementTempFiles $directoryLink
Assert-True ((Get-Content -LiteralPath $targetFile -Raw) -eq 'preserve link target') 'Cleanup followed directory link'
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
$fileLink = Join-Path $installDir 'dbx-mcp.exe~RFabc.TMP'
New-Item -ItemType SymbolicLink -Path $fileLink -Target $targetFile | Out-Null
Remove-McpReplacementTempFiles $installDir
Assert-True ((Get-Item -LiteralPath $fileLink).Attributes -band [IO.FileAttributes]::ReparsePoint) 'Cleanup removed file link'
}
$script:count++
Write-Output 'PASS cleanup skips links'
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
$lockedPath = Join-Path $installDir 'dbx-mcp.exe~RF456.TMP'
[IO.File]::WriteAllText($lockedPath, 'locked')
$locked = [IO.File]::Open($lockedPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
try {
[IO.File]::WriteAllText($stale, 'old executable')
Remove-McpReplacementTempFiles $installDir
Assert-True (Test-Path -LiteralPath $lockedPath) 'Cleanup removed locked file'
Assert-True (!(Test-Path -LiteralPath $stale)) 'Locked file prevented cleanup of other files'
} finally { $locked.Dispose() }
Remove-McpReplacementTempFiles $installDir
Assert-True (!(Test-Path -LiteralPath $lockedPath)) 'Cleanup did not retry released file'
$script:count++
Write-Output 'PASS cleanup skips locked files and retries after release'
}
Write-Output "PowerShell installer scenarios passed: $script:count"
} finally {
Set-Variable HOME -Scope Global -Force -Value $originalHome
@@ -7,6 +7,64 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Get-WebView2Archive {
param([string]$Uri, [string]$OutFile)
for ($attempt = 0; $attempt -lt 3; $attempt++) {
try {
Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec 120
return
} catch {
$errorObject = $_.Exception
$retryable = $false
if ($errorObject.PSObject.Properties["Response"] -and $null -ne $errorObject.Response) {
$retryable = [int]$errorObject.Response.StatusCode -in @(408, 429, 500, 502, 503, 504)
} elseif ($errorObject -is [System.Net.Http.HttpRequestException] -and $null -ne $errorObject.StatusCode) {
$retryable = [int]$errorObject.StatusCode -in @(408, 429, 500, 502, 503, 504)
} elseif ($errorObject -is [System.Net.Http.HttpRequestException]) {
$networkCause = $false
$unsafeCause = $false
for ($cause = $errorObject.InnerException; $null -ne $cause; $cause = $cause.InnerException) {
if ($cause -is [System.Security.Authentication.AuthenticationException] -or
$cause -is [System.OperationCanceledException]) {
$unsafeCause = $true
} elseif ($cause -is [System.Net.Sockets.SocketException]) {
$networkCause = $networkCause -or $cause.SocketErrorCode -in @(
[System.Net.Sockets.SocketError]::ConnectionRefused,
[System.Net.Sockets.SocketError]::ConnectionReset,
[System.Net.Sockets.SocketError]::ConnectionAborted,
[System.Net.Sockets.SocketError]::TimedOut,
[System.Net.Sockets.SocketError]::HostNotFound,
[System.Net.Sockets.SocketError]::TryAgain,
[System.Net.Sockets.SocketError]::NetworkUnreachable,
[System.Net.Sockets.SocketError]::HostUnreachable,
[System.Net.Sockets.SocketError]::NetworkDown
)
}
}
$retryable = $networkCause -and !$unsafeCause
} elseif ($errorObject -is [System.Net.WebException]) {
$retryable = $errorObject.Status -in @(
[System.Net.WebExceptionStatus]::Timeout,
[System.Net.WebExceptionStatus]::ConnectFailure,
[System.Net.WebExceptionStatus]::NameResolutionFailure,
[System.Net.WebExceptionStatus]::ConnectionClosed,
[System.Net.WebExceptionStatus]::ReceiveFailure,
[System.Net.WebExceptionStatus]::SendFailure
)
} elseif ($errorObject -is [System.Threading.Tasks.TaskCanceledException]) {
$retryable = $true
}
if (!$retryable -or $attempt -eq 2) { throw }
Write-Host "WebView2 archive acquisition failed temporarily; retry $($attempt + 2)/3."
if (Test-Path -LiteralPath $OutFile) {
Remove-Item -LiteralPath $OutFile -Force
}
Start-Sleep -Seconds ([math]::Pow(2, $attempt + 1))
}
}
}
$runtimeVersion = "109.0.1518.78"
$runtimeFolderName = "Microsoft.WebView2.FixedVersionRuntime.$runtimeVersion.x64"
$archiveName = "$runtimeFolderName.cab"
@@ -32,7 +90,7 @@ if (Test-Path $archivePath) {
if (!(Test-Path $archivePath)) {
Write-Host "Downloading WebView2 fixed runtime $runtimeVersion for Windows 7..."
Invoke-WebRequest -Uri $runtimeUrl -OutFile $archivePath
Get-WebView2Archive -Uri $runtimeUrl -OutFile $archivePath
}
$actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
@@ -0,0 +1,91 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$source = Join-Path $PSScriptRoot "prepare-webview2-win7-runtime.ps1"
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($source, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw $errors[0] }
$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq "Get-WebView2Archive" }, $true)
. ([scriptblock]::Create($definition.Extent.Text))
$directory = Join-Path ([IO.Path]::GetTempPath()) "dbx-ci-webview-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $directory | Out-Null
$outFile = Join-Path $directory "archive.cab"
function Invoke-WebRequest {
param($Uri, $OutFile, $TimeoutSec)
if ($TimeoutSec -ne 120) { throw "Download timeout changed" }
if ($global:dbxWebviewTestcalls -gt 0 -and (Test-Path -LiteralPath $OutFile)) { throw "Partial download was reused" }
$result = $global:dbxWebviewTestresponses[$global:dbxWebviewTestcalls]
$global:dbxWebviewTestcalls++
[IO.File]::WriteAllText($OutFile, "partial")
switch ($result) {
"ok" { [IO.File]::WriteAllText($OutFile, "complete"); return }
"timeout" { throw [Net.WebException]::new("timeout", [Net.WebExceptionStatus]::Timeout) }
"refused" { throw [Net.Http.HttpRequestException]::new("connection refused", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionRefused)) }
"dns" { throw [Net.Http.HttpRequestException]::new("name lookup failed", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::HostNotFound)) }
"socket-denied" { throw [Net.Http.HttpRequestException]::new("access denied", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::AccessDenied)) }
"cancel-socket" { throw [Net.Http.HttpRequestException]::new("cancelled", [OperationCanceledException]::new("cancelled", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionReset))) }
"tls-socket" { throw [Net.Http.HttpRequestException]::new("TLS failed", [Security.Authentication.AuthenticationException]::new("certificate failure", [Net.Sockets.SocketException]::new([int][Net.Sockets.SocketError]::ConnectionReset))) }
"tls" { throw [Net.WebException]::new("certificate failure", [Net.WebExceptionStatus]::TrustFailure) }
"cancel" { throw [OperationCanceledException]::new("cancelled") }
"unknown" { throw "Unknown failure" }
default { throw [Net.Http.HttpRequestException]::new("HTTP failure", $null, [Net.HttpStatusCode][int]$result) }
}
}
function Start-Sleep { param($Seconds) $global:dbxWebviewTestdelays += $Seconds }
try {
foreach ($case in @(
@{ Responses = @("ok"); Count = 1; Delays = @(); Success = $true },
@{ Responses = @("503", "ok"); Count = 2; Delays = @(2); Success = $true },
@{ Responses = @("timeout", "429", "ok"); Count = 3; Delays = @(2, 4); Success = $true },
@{ Responses = @("refused", "ok"); Count = 2; Delays = @(2); Success = $true },
@{ Responses = @("dns", "ok"); Count = 2; Delays = @(2); Success = $true },
@{ Responses = @("socket-denied"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("cancel-socket"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("tls-socket"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("503", "503", "503"); Count = 3; Delays = @(2, 4); Success = $false },
@{ Responses = @("404"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("403"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("tls"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("cancel"); Count = 1; Delays = @(); Success = $false },
@{ Responses = @("unknown"); Count = 1; Delays = @(); Success = $false }
)) {
Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue
$global:dbxWebviewTestresponses = $case.Responses
$global:dbxWebviewTestcalls = 0
$global:dbxWebviewTestdelays = @()
$success = $true
try { Get-WebView2Archive -Uri "https://example.test/archive.cab" -OutFile $outFile } catch { $success = $false }
if ($success -ne $case.Success -or $global:dbxWebviewTestcalls -ne $case.Count -or
($global:dbxWebviewTestdelays -join ",") -ne ($case.Delays -join ",")) {
throw "Unexpected acquisition result for $($case.Responses -join ','): success=$success calls=$global:dbxWebviewTestcalls delays=$global:dbxWebviewTestdelays"
}
}
# Exercise the actual entry point: downloaded corrupt bytes must not be retried or expanded.
Remove-Item -LiteralPath $outFile -Force
$global:dbxWebviewTestresponses = @("ok")
$global:dbxWebviewTestcalls = 0
$global:dbxWebviewTestdelays = @()
$failed = $false
try { & $source -RuntimeDirectory (Join-Path $directory "runtime") -DownloadDirectory $directory } catch {
$failed = $_.Exception.Message -like "*SHA-256 mismatch*"
}
if (!$failed -or $global:dbxWebviewTestcalls -ne 1) { throw "Corrupt archive did not fail once before extraction" }
function Get-FileHash { param($LiteralPath, $Algorithm) [pscustomobject]@{ Hash = "7622281cf83de1a35e3a471f432f7a897d65f0a7d3975df08512b7b253dd45c7" } }
function Get-AuthenticodeSignature { param($LiteralPath) [pscustomobject]@{ Status = [System.Management.Automation.SignatureStatus]::NotSigned; SignerCertificate = $null } }
$failed = $false
$global:dbxWebviewTestcalls = 0
try { & $source -RuntimeDirectory (Join-Path $directory "runtime") -DownloadDirectory $directory } catch {
$failed = $_.Exception.Message -like "*valid Microsoft signature*"
}
if (!$failed -or $global:dbxWebviewTestcalls -ne 0) { throw "Invalid signature did not fail before extraction without re-download" }
Write-Host "WebView2 acquisition and integrity tests passed."
} finally {
$resolved = [IO.Path]::GetFullPath($directory)
if ((Split-Path -Parent $resolved) -ne ([IO.Path]::GetTempPath()).TrimEnd('\', '/')) { throw "Unexpected test cleanup path" }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
+3 -15
View File
@@ -41,25 +41,13 @@ jobs:
- uses: actions/checkout@v7
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
run: sudo python3 .github/scripts/ci-install-system-deps.py
# Pin mold because runner image updates can change bundled C++ links.
# See the ci.yml rust-test note for the pinned upstream build.
- name: Install mold linker
run: |
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
| sudo tar -C /usr/local --strip-components=1 -xz
mold --version
timeout-minutes: 8
run: bash .github/scripts/ci-install-mold.sh
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
@@ -0,0 +1,78 @@
name: Cancel superseded PR CI
on:
pull_request_target:
types: [synchronize]
branches: [main]
permissions:
actions: write
pull-requests: read
jobs:
cancel:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/github-script@v8
with:
script: |
const repo = context.repo;
const eventPull = context.payload.pull_request;
const readPull = async () => (await github.rest.pulls.get({ ...repo, pull_number: eventPull.number })).data;
const isCurrent = (pull) => pull.state === 'open'
&& pull.base.ref === 'main'
&& pull.base.repo.id === context.payload.repository.id
&& pull.head.repo?.id
&& pull.head.repo.id === eventPull.head.repo?.id
&& pull.head.ref === eventPull.head.ref
&& pull.head.sha === eventPull.head.sha;
const pull = await readPull();
if (!isCurrent(pull)) return;
const { data: workflow } = await github.rest.actions.getWorkflow({ ...repo, workflow_id: 'ci.yml' });
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
...repo,
workflow_id: workflow.id,
event: 'pull_request',
branch: pull.head.ref,
per_page: 100,
});
let uniquePull;
for (const run of runs) {
if (run.workflow_id !== workflow.id || run.event !== 'pull_request'
|| run.head_repository?.id !== pull.head.repo.id || run.head_branch !== pull.head.ref
|| !['queued', 'pending', 'in_progress', 'waiting', 'requested'].includes(run.status)
|| !run.head_sha || run.head_sha === pull.head.sha
|| !Array.isArray(run.pull_requests)) continue;
if (run.pull_requests.length) {
if (!run.pull_requests.some((pr) => pr.number === pull.number)) continue;
} else {
// Fork runs may omit PR metadata. Only infer ownership for a branch never shared by multiple PRs.
if (uniquePull === undefined) {
const history = await github.paginate(github.rest.pulls.list, {
...repo,
state: 'all',
head: `${pull.head.repo.owner.login}:${pull.head.ref}`,
per_page: 100,
});
const matches = history.filter((pr) => pr.head.ref === pull.head.ref
&& (!pr.head.repo?.id || pr.head.repo.id === pull.head.repo.id));
uniquePull = matches.length === 1 && matches[0].number === pull.number
&& matches[0].head.repo?.id === pull.head.repo.id;
}
if (!uniquePull) {
core.info(`Skipping CI run ${run.id}: its PR ownership is ambiguous.`);
continue;
}
}
if (!isCurrent(await readPull())) return;
try {
await github.rest.actions.cancelWorkflowRun({ ...repo, run_id: run.id });
core.info(`Requested cancellation of superseded CI run ${run.id}.`);
} catch (error) {
if (error.status !== 409) throw error;
const { data: latest } = await github.rest.actions.getWorkflowRun({ ...repo, run_id: run.id });
if (latest.status !== 'completed') throw error;
}
}
+70 -69
View File
@@ -174,6 +174,19 @@ jobs:
- name: Docker mirror config tests
run: python3 .github/scripts/configure-docker-mirror.test.py
- name: APT acquisition tests
run: python3 .github/scripts/ci-install-system-deps.test.py
- name: Gradle acquisition tests
run: python3 .github/scripts/ci-gradle.test.py
- name: Mold acquisition tests
run: python3 .github/scripts/ci-install-mold.test.py
- name: WebView2 acquisition tests
shell: pwsh
run: ./.github/scripts/prepare-webview2-win7-runtime.test.ps1
- name: PowerShell MCP installer tests
shell: pwsh
run: ./.github/scripts/install-mcp-powershell.test.ps1
@@ -207,25 +220,13 @@ jobs:
cache: pnpm
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
run: sudo python3 .github/scripts/ci-install-system-deps.py
# Pin mold because runner image updates can change bundled DuckDB C++ links.
# The pinned build prevents duckdb_create_config runtime failures.
- name: Install mold linker
run: |
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
| sudo tar -C /usr/local --strip-components=1 -xz
mold --version
timeout-minutes: 8
run: bash .github/scripts/ci-install-mold.sh
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
@@ -324,6 +325,21 @@ jobs:
- name: Setup Rust for standard Windows
uses: dtolnay/rust-toolchain@1.97.1
- name: MCP replacement temporary file cleanup tests
shell: pwsh
run: |
@'
#[path = "src-tauri/src/commands/mcp_cleanup.rs"]
mod cleanup;
'@ | rustc --edition 2021 --test -o "$env:RUNNER_TEMP/mcp-cleanup-tests.exe" -
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& "$env:RUNNER_TEMP/mcp-cleanup-tests.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: PowerShell MCP installer tests
shell: pwsh
run: ./.github/scripts/install-mcp-powershell.test.ps1
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: "v0.16.0"
@@ -891,17 +907,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
run: sudo python3 .github/scripts/ci-install-system-deps.py
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
@@ -998,30 +1004,27 @@ jobs:
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
for attempt in {1..12}; do
if mirrors="$(timeout 5s docker info --format '{{json .RegistryConfig.Mirrors}}')"; then
printf '%s\n' "$mirrors" | python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
break
fi
if ((attempt == 12)); then
echo '::error::Docker did not become ready after restart' >&2
exit 1
fi
sleep 2
done
- name: Install system dependencies
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
run: sudo python3 .github/scripts/ci-install-system-deps.py
if: matrix.group != 'foundation'
# Pin mold because runner image updates can change bundled C++ links.
# See the packages job note for the pinned upstream build.
- name: Install mold linker
run: |
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
| sudo tar -C /usr/local --strip-components=1 -xz
mold --version
timeout-minutes: 8
run: bash .github/scripts/ci-install-mold.sh
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
@@ -1150,7 +1153,10 @@ jobs:
node .github/scripts/check-jdbc-plugin-version.mjs "$BASE_SHA" HEAD
- name: JDBC plugin package check
run: ./plugins/jdbc/package.sh
run: python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh
- name: JDBC response recovery tests
run: bash ./plugins/jdbc/gradlew -p plugins/jdbc --no-daemon test --tests app.dbx.jdbc.LargeResponseRecoveryTest --tests app.dbx.jdbc.StatementCancelTest --tests app.dbx.jdbc.DeferredLobValuesTest
offline-jdbc-release:
needs: changes
@@ -1176,7 +1182,7 @@ jobs:
env:
DBX_OFFLINE_JDBC_MAVEN_CACHE: ~/.cache/dbx-offline-jdbc-maven
run: |
./plugins/jdbc/package.sh
python3 .github/scripts/ci-gradle.py jdbc -- ./package.sh
JDBC_VERSION="$(sed -nE "s/^version[[:space:]]*=[[:space:]]*'([^']+)'.*/\1/p" plugins/jdbc/build.gradle | head -n 1)"
node agents/scripts/build_offline_jdbc_payload.mjs \
release \
@@ -1303,8 +1309,10 @@ jobs:
- '.github/workflows/ci.yml'
jdbc:
- 'plugins/jdbc/**'
- '.github/scripts/ci-gradle*'
offline_jdbc:
- 'plugins/jdbc/**'
- '.github/scripts/ci-gradle*'
- 'agents/scripts/build_offline_jdbc_payload.mjs'
- 'agents/scripts/build_offline_zip.sh'
- 'agents/scripts/verify_offline_jdbc_release.mjs'
@@ -1437,9 +1445,7 @@ jobs:
with:
node-version: 22.23.3
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging
- name: Agent script tests
run: |
python3 -m unittest discover -s scripts -p '*_test.py'
@@ -1467,11 +1473,9 @@ jobs:
with:
cache-provider: basic
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y zstd
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group packaging
- name: Java agent tests and packages
run: ./gradlew test shadowJar --continue
run: python3 ../.github/scripts/ci-gradle.py agents -- ./gradlew test shadowJar --continue
- name: Agent jar validation
run: python3 scripts/validate_agent_jars.py
agent-rust:
@@ -1489,26 +1493,14 @@ jobs:
- uses: actions/checkout@v7
- name: Install system dependencies
if: matrix.driver == 'duckdb'
run: |
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
update
sudo timeout --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y libdbus-1-dev pkg-config
run: sudo python3 ../.github/scripts/ci-install-system-deps.py --group duckdb
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
# Pin mold because runner image updates can change bundled DuckDB C++ links.
# See the packages job note for the pinned upstream build.
- name: Install mold linker
run: |
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
| sudo tar -C /usr/local --strip-components=1 -xz
mold --version
timeout-minutes: 8
run: bash ../.github/scripts/ci-install-mold.sh
- name: Setup sccache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -1627,8 +1619,17 @@ jobs:
[[ "$ID" == ubuntu ]] || exit 0
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
timeout 30s sudo systemctl restart docker
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
for attempt in {1..12}; do
if mirrors="$(timeout 5s docker info --format '{{json .RegistryConfig.Mirrors}}')"; then
printf '%s\n' "$mirrors" | python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
break
fi
if ((attempt == 12)); then
echo '::error::Docker did not become ready after restart' >&2
exit 1
fi
sleep 2
done
- name: Setup RocketMQ server Java
if: matrix.scenario == 'rocketmq'
uses: actions/setup-java@v6
+2
View File
@@ -173,3 +173,5 @@ Small PRs are easier to review and merge.
- [Official docs](https://dbxio.com/en/docs/what-is-dbx)
Merged contributors appear on the [DBX contributors wall](https://dbxio.com/en/community).
The wall recognizes a GitHub account after it authors at least one PR merged into `t8y2/dbx`; commit counts alone do not qualify. Open your PR from the account you want credited.
+2
View File
@@ -172,3 +172,5 @@ make docs
- [官方文档](https://dbxio.com/cn/docs/what-is-dbx)
合并后的贡献者会出现在 [DBX 贡献墙](https://dbxio.com/cn/community)。
贡献墙按发起并合入 `t8y2/dbx` 的 PR 的 GitHub 账号认定;仅有提交记录不满足展示条件。请用希望署名的账号创建 PR。
Generated
+8 -16
View File
@@ -2143,7 +2143,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.50"
version = "0.6.53"
dependencies = [
"anyhow",
"axum",
@@ -2153,6 +2153,7 @@ dependencies = [
"csv",
"dbx-core",
"dbx-mcp",
"dbx-platform",
"dbx-tauri-consul",
"dbx-tauri-schema",
"deadpool-postgres",
@@ -2161,6 +2162,7 @@ dependencies = [
"grep",
"gtk",
"ignore",
"libc",
"libloading 0.8.9",
"log",
"minisign-verify",
@@ -2191,7 +2193,6 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
@@ -2203,6 +2204,7 @@ dependencies = [
"tokio-util",
"uuid",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-sys 0.61.2",
"wry",
@@ -2229,7 +2231,7 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.126"
version = "0.4.129"
dependencies = [
"async-trait",
"dbx-core",
@@ -2393,6 +2395,7 @@ dependencies = [
"mysql_common",
"percent-encoding",
"rust_decimal",
"rustls 0.23.43",
"serde_json",
"sqlparser",
"tokio",
@@ -2414,7 +2417,6 @@ dependencies = [
"log",
"openssl",
"percent-encoding",
"portpicker",
"postgres-openssl",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
@@ -2570,7 +2572,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.126"
version = "0.4.129"
dependencies = [
"async-trait",
"axum",
@@ -2754,7 +2756,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.50"
version = "0.6.53"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
@@ -9806,16 +9808,6 @@ dependencies = [
"zbus 5.18.0",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
id 'com.gradleup.shadow' version '9.0.0-beta12' apply false
id 'com.gradleup.shadow' version '9.6.1' apply false
}
def infrastructureProjects = ['common', 'test-support'] as Set
@@ -144,6 +144,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
public static final String CAPABILITY_EXACT_METADATA_IDENTITY_V1 = "exact_metadata_identity_v1";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -181,7 +182,8 @@ public final class AgentProtocol {
CAPABILITY_MONGO_REPLACE_DOCUMENT,
CAPABILITY_MONGO_BULK_WRITE,
CAPABILITY_MONGO_RENAME_COLLECTION,
CAPABILITY_MONGO_FIND_CURSOR
CAPABILITY_MONGO_FIND_CURSOR,
CAPABILITY_EXACT_METADATA_IDENTITY_V1
));
public static final List<String> MULTI_SESSION_CAPABILITIES;
@@ -357,6 +359,13 @@ public final class AgentProtocol {
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, CAPABILITIES);
}
public static HandshakeResult withExactMetadataIdentity(HandshakeResult result, boolean supported) {
if (!supported) return result;
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(result.capabilities);
capabilities.add(CAPABILITY_EXACT_METADATA_IDENTITY_V1);
return new HandshakeResult(result.protocolVersion, result.agentProtocolVersion, capabilities);
}
public static HandshakeResult multiSessionHandshakeResult() {
return new HandshakeResult(
MULTI_SESSION_PROTOCOL_VERSION,
@@ -16,6 +16,9 @@ public interface DatabaseAgent {
default boolean supportsBlobBindStatements() { return false; }
/** Opt in only when dictionary-derived owner/name values are preserved without fallback. */
default boolean supportsExactMetadataIdentity() { return false; }
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
String schema, int timeoutSecs, boolean transaction) {
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
@@ -101,6 +104,10 @@ public interface DatabaseAgent {
throw new UnsupportedOperationException("Object source is not supported");
}
default ObjectSource getObjectSourceWithOrigin(String schema, String name, String objectType) {
return getObjectSource(schema, name, objectType);
}
default String getTableDdl(String schema, String table) {
List<IndexInfo> indexes;
try {
@@ -126,6 +133,10 @@ public interface DatabaseAgent {
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
}
default String getTableDdl(String schema, String table, boolean exactIdentity) {
return getTableDdl(schema, table);
}
/**
* Returns the comment/description for a table, or null if not available.
* Default implementation looks up the comment from listTables results.
@@ -5,6 +5,7 @@ import java.util.List;
import java.util.Objects;
public final class IndexInfo {
private String owner;
private String name;
private List<String> columns;
private boolean is_unique;
@@ -76,6 +77,10 @@ public final class IndexInfo {
return name;
}
public String getOwner() { return owner; }
public void setOwner(String owner) { this.owner = owner; }
public List<String> getColumns() {
return columns;
}
@@ -193,6 +198,7 @@ public final class IndexInfo {
&& is_primary == that.is_primary
&& constraint_backed == that.constraint_backed
&& Objects.equals(name, that.name)
&& Objects.equals(owner, that.owner)
&& Objects.equals(columns, that.columns)
&& Objects.equals(filter, that.filter)
&& Objects.equals(index_type, that.index_type)
@@ -208,6 +214,7 @@ public final class IndexInfo {
@Override
public int hashCode() {
return Objects.hash(
owner,
name,
columns,
is_unique,
@@ -186,14 +186,17 @@ public final class JsonRpcServer {
observingAgent.beforeAgentMethod(method, stringOrNull(params, "sessionId"));
}
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
return AgentProtocol.handshakeResult();
return AgentProtocol.withExactMetadataIdentity(AgentProtocol.handshakeResult(), agent.supportsExactMetadataIdentity());
}
if (AgentProtocol.METHOD_CONNECT.equals(method)) {
ConnectParams connectParams = gson.fromJson(params, ConnectParams.class);
agent.connect(connectParams);
lastConnectParams = connectParams;
lastConnectionValidationTimeMillis = 0L;
return Collections.singletonMap("ok", true);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("exactMetadataIdentity", agent.supportsExactMetadataIdentity());
return result;
}
if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
Map<String, Object> result = agent.testConnectionWithInfo(gson.fromJson(params, ConnectParams.class));
@@ -219,6 +222,14 @@ public final class JsonRpcServer {
}
return Collections.singletonMap("ok", true);
}
boolean exactIdentity = booleanOrDefault(params, "exact_identity", false);
if (exactIdentity) {
if (!agent.supportsExactMetadataIdentity()) {
throw new UnsupportedOperationException("Agent does not support exact_metadata_identity_v1");
}
String schema = stringOrNull(params, "schema");
if (schema == null || schema.isBlank()) throw new IllegalArgumentException("schema is required for exact metadata identity");
}
ensureLiveConnection(method);
if (AgentProtocol.METHOD_CONNECTION_INFO.equals(method)) {
Map<String, Object> result = new LinkedHashMap<>();
@@ -254,21 +265,33 @@ public final class JsonRpcServer {
}
if (AgentProtocol.METHOD_GET_OBJECT_SOURCE.equals(method)) {
switchCatalog(params);
return agent.getObjectSource(
params.get("schema").getAsString(),
params.get("name").getAsString(),
params.get("object_type").getAsString()
);
boolean includeOrigin = params.has("include_source_origin") && params.get("include_source_origin").getAsBoolean();
String schema = params.get("schema").getAsString();
String name = params.get("name").getAsString();
String objectType = params.get("object_type").getAsString();
ObjectSource source = includeOrigin
? agent.getObjectSourceWithOrigin(schema, name, objectType)
: agent.getObjectSource(schema, name, objectType);
if (includeOrigin) {
JsonObject result = gson.toJsonTree(source).getAsJsonObject();
if (source.getSourceOrigin() != null) {
result.addProperty("source_origin", source.getSourceOrigin());
}
return result;
}
return source;
}
if (AgentProtocol.METHOD_GET_TABLE_DDL.equals(method)) {
switchCatalog(params);
return agent.getTableDdl(params.get("schema").getAsString(), params.get("table").getAsString());
return agent.getTableDdl(params.get("schema").getAsString(), params.get("table").getAsString(), exactIdentity);
}
if (AgentProtocol.METHOD_GET_COLUMNS.equals(method)) {
switchCatalog(params);
String currentSchema = params.has("current_schema") && !params.get("current_schema").isJsonNull()
? params.get("current_schema").getAsString() : null;
return agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
return exactIdentity
? agent.getColumns(params.get("schema").getAsString(), params.get("table").getAsString())
: agent.getColumnsInContext(params.get("schema").getAsString(), params.get("table").getAsString(), currentSchema);
}
if (AgentProtocol.METHOD_LIST_INDEXES.equals(method)) {
switchCatalog(params);
@@ -641,7 +664,8 @@ public final class JsonRpcServer {
stringOrNull(params, "filter"),
intOrNull(params, "limit"),
intOrNull(params, "offset"),
stringListOrNull(params, "object_types")
stringListOrNull(params, "object_types"),
booleanOrDefault(params, "exact_identity", false)
);
}
@@ -12,12 +12,22 @@ public final class MetadataListConstraints {
private final Integer limit;
private final Integer offset;
private final List<String> objectTypes;
private final boolean exactIdentity;
public MetadataListConstraints(String filter, Integer limit, Integer offset, List<String> objectTypes) {
this(filter, limit, offset, objectTypes, false);
}
public MetadataListConstraints(String filter, Integer limit, Integer offset, List<String> objectTypes, boolean exactIdentity) {
this.filter = normalizeFilter(filter);
this.limit = limit == null || limit <= 0 ? null : limit;
this.offset = offset == null || offset <= 0 ? null : offset;
this.objectTypes = normalizeObjectTypes(objectTypes);
this.exactIdentity = exactIdentity;
}
public boolean isExactIdentity() {
return exactIdentity;
}
public static MetadataListConstraints orNone(MetadataListConstraints constraints) {
@@ -57,7 +67,7 @@ public final class MetadataListConstraints {
}
public MetadataListConstraints withoutPaging() {
return new MetadataListConstraints(filter, null, null, objectTypes);
return new MetadataListConstraints(filter, null, null, objectTypes, exactIdentity);
}
public String fuzzyLikePattern() {
@@ -46,6 +46,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
private final AtomicBoolean closed = new AtomicBoolean();
private ScheduledExecutorService maintenance;
private boolean blobBindStatements;
private boolean exactMetadataIdentity;
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
this(agentFactory, new JdbcConnectionPoolRegistry(), RuntimeLimits.defaults());
@@ -98,6 +99,11 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
return server;
}
public MultiSessionJsonRpcServer withExactMetadataIdentity() {
exactMetadataIdentity = true;
return this;
}
public void run() {
synchronized (outputLock) {
protocolOutput.println("{\"ready\":true}");
@@ -146,7 +152,7 @@ public final class MultiSessionJsonRpcServer implements AutoCloseable {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = sessionHandlerFactory == null
? AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements)
? AgentProtocol.withExactMetadataIdentity(AgentProtocol.multiSessionJdbcHandshakeResult(blobBindStatements), exactMetadataIdentity)
: customHandshake();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
@@ -9,6 +9,7 @@ public final class ObjectInfo {
private String comment;
private Boolean valid;
private String parent_name;
private String parent_schema;
public ObjectInfo() {
this("", "", null, null, null);
@@ -78,6 +79,14 @@ public final class ObjectInfo {
this.parent_name = parentName;
}
public String getParent_schema() {
return parent_schema;
}
public void setParent_schema(String parentSchema) {
this.parent_schema = parentSchema;
}
@Override
public boolean equals(Object other) {
if (this == other) return true;
@@ -88,12 +97,13 @@ public final class ObjectInfo {
&& Objects.equals(schema, that.schema)
&& Objects.equals(comment, that.comment)
&& Objects.equals(parent_name, that.parent_name)
&& Objects.equals(parent_schema, that.parent_schema)
&& Objects.equals(valid, that.valid);
}
@Override
public int hashCode() {
return Objects.hash(name, object_type, schema, comment, valid, parent_name);
return Objects.hash(name, object_type, schema, comment, valid, parent_name, parent_schema);
}
@Override
@@ -8,6 +8,7 @@ public final class ObjectSource {
private String schema;
private String source;
private boolean editable;
private transient String sourceOrigin;
public ObjectSource() {
this("", "", null, "", true);
@@ -49,6 +50,14 @@ public final class ObjectSource {
return editable;
}
public String getSourceOrigin() {
return sourceOrigin;
}
public void setSourceOrigin(String sourceOrigin) {
this.sourceOrigin = sourceOrigin;
}
public void setName(String name) {
this.name = name;
}
@@ -2,7 +2,7 @@
"protocolVersion": 1,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "exact_metadata_identity_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl"],
"commonMethods": [
@@ -2,7 +2,7 @@
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": ["protocolVersion", "agentProtocolVersion", "capabilities"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "multi_session", "structured_error_v1", "blob_bind_statements_v1"],
"allCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "kv", "kv_ttl", "kv_cas", "kv_list_values", "kv_status", "kv_history", "etcd_compaction", "etcd_defrag", "etcd_watch", "etcd_lease", "etcd_auth", "mongo_drop_database", "mongo_clone_collection", "mongo_run_command", "mongo_insert_documents", "mongo_replace_document", "mongo_bulk_write", "mongo_rename_collection", "mongo_find_cursor", "exact_metadata_identity_v1", "multi_session", "structured_error_v1", "blob_bind_statements_v1"],
"capabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session"],
"defaultSqlCapabilities": ["connect", "test_connection", "metadata", "query", "paged_query", "transaction", "ddl", "multi_session", "structured_error_v1"],
"commonMethods": [
@@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -54,6 +55,23 @@ class CommonJavaCompatibilityTest {
assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_CONNECT));
assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_QUERY));
assertTrue(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_METADATA));
assertFalse(AgentProtocol.CAPABILITIES.contains(AgentProtocol.CAPABILITY_EXACT_METADATA_IDENTITY_V1));
}
@Test
void objectInfoRetainsIndependentOwnersWithoutChangingLegacyConstructors() {
Gson gson = new Gson();
ObjectInfo legacy = gson.fromJson("{\"name\":\"T\",\"object_type\":\"TABLE\"}", ObjectInfo.class);
assertNull(legacy.getParent_schema());
ObjectInfo index = new ObjectInfo(" Ix(a) ", "INDEX", "Index Owner", null);
index.setParent_schema(" Table Owner ");
index.setParent_name(" T(a) ");
ObjectInfo restored = gson.fromJson(gson.toJson(index), ObjectInfo.class);
assertEquals(index, restored);
assertEquals(index.hashCode(), restored.hashCode());
assertEquals(" Table Owner ", restored.getParent_schema());
restored.setParent_schema("Other Owner");
assertFalse(index.equals(restored));
}
@Test
@@ -120,6 +138,47 @@ class CommonJavaCompatibilityTest {
assertTrue(containsCapability(result.getAsJsonArray("capabilities"), "metadata"));
}
@Test
void exactMetadataRequiresExplicitAgentSupportBeforeReading() {
JsonRpcServer server = new JsonRpcServer(new MinimalAgent());
JsonObject old = JsonParser.parseString(server.handleRequest("{\"id\":1,\"method\":\"get_columns\",\"params\":{\"schema\":\"APP\",\"table\":\"T\"}}")).getAsJsonObject();
assertTrue(old.has("result"));
JsonObject exact = JsonParser.parseString(server.handleRequest("{\"id\":2,\"method\":\"get_columns\",\"params\":{\"schema\":\"APP\",\"table\":\"T\",\"exact_identity\":true}}")).getAsJsonObject();
assertTrue(exact.has("error"), exact.toString());
assertTrue(exact.getAsJsonObject("error").get("message").getAsString().contains("exact_metadata_identity_v1"));
}
@Test
void explicitExactIdentityAgentAdvertisesTheCapability() {
MinimalAgent agent = new MinimalAgent() {
public boolean supportsExactMetadataIdentity() { return true; }
};
JsonObject handshake = JsonParser.parseString(new JsonRpcServer(agent).handleRequest("{\"id\":1,\"method\":\"handshake\",\"params\":{}}")).getAsJsonObject();
assertTrue(containsCapability(handshake.getAsJsonObject("result").getAsJsonArray("capabilities"), "exact_metadata_identity_v1"));
}
@Test
void connectedSessionReportsItsOwnExactIdentitySupport() {
try (MultiSessionJsonRpcServer server = new MultiSessionJsonRpcServer(() -> new MinimalAgent() {
private boolean exactIdentity = true;
@Override
public void connect(ConnectParams params) { exactIdentity = !"legacy".equals(params.getDatabase()); }
@Override
public boolean supportsExactMetadataIdentity() { return exactIdentity; }
}).withExactMetadataIdentity()) {
JsonObject legacy = JsonParser.parseString(server.handleRequest("{\"id\":1,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"legacy\",\"database\":\"legacy\"}} ")).getAsJsonObject();
JsonObject current = JsonParser.parseString(server.handleRequest("{\"id\":2,\"method\":\"open_session\",\"params\":{\"agentSessionId\":\"current\",\"database\":\"current\"}} ")).getAsJsonObject();
assertTrue(legacy.getAsJsonObject("result").get("ok").getAsBoolean());
assertTrue(legacy.getAsJsonObject("result").has("exactMetadataIdentity"));
assertFalse(legacy.getAsJsonObject("result").get("exactMetadataIdentity").getAsBoolean());
assertTrue(current.getAsJsonObject("result").get("exactMetadataIdentity").getAsBoolean());
JsonObject ordinary = JsonParser.parseString(server.handleRequest("{\"id\":3,\"method\":\"get_columns\",\"params\":{\"agentSessionId\":\"legacy\",\"schema\":\"APP\",\"table\":\"T\"}} ")).getAsJsonObject();
assertTrue(ordinary.has("result"), ordinary.toString());
JsonObject exact = JsonParser.parseString(server.handleRequest("{\"id\":4,\"method\":\"get_columns\",\"params\":{\"agentSessionId\":\"legacy\",\"schema\":\"APP\",\"table\":\"T\",\"exact_identity\":true}} ")).getAsJsonObject();
assertTrue(exact.has("error"), exact.toString());
}
}
@Test
void constraintMetadataUnsupportedIsNotAnEmptySuccess() {
JsonRpcServer server = new JsonRpcServer(new MinimalAgent());
@@ -574,6 +574,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
return queryConstrainedTables(schema, MetadataListConstraints.NONE);
}
@Override
public boolean supportsExactMetadataIdentity() { return !legacyJdbcMetadata; }
@Override
public List<TableInfo> listTables(String schema, List<String> objectTypes) {
return queryConstrainedTables(schema, new MetadataListConstraints(null, null, null, objectTypes));
@@ -586,6 +589,10 @@ public final class DamengAgent extends AbstractJdbcAgent {
private List<TableInfo> queryConstrainedTables(String rawSchema, MetadataListConstraints constraints) {
final String schema = normalizeSchema(rawSchema);
if (constraints.isExactIdentity()) {
if (legacyJdbcMetadata) throw new UnsupportedOperationException("Legacy DM metadata does not support exact_metadata_identity_v1");
return withViewValidity(executeConstrainedTables(buildAccessibleConstrainedTablesQuery(schema, constraints), constraints), schema);
}
if (legacyJdbcMetadata) {
return withViewValidity(executeJdbcMetadataTables(schema, constraints), schema);
}
@@ -990,7 +997,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
|| constraints.objectTypeAllowed("FUNCTION")
|| constraints.objectTypeAllowed("SEQUENCE")
|| constraints.objectTypeAllowed("PACKAGE")
|| constraints.objectTypeAllowed("PACKAGE_BODY");
|| constraints.objectTypeAllowed("PACKAGE_BODY")
|| constraints.objectTypeAllowed("TYPE")
|| constraints.objectTypeAllowed("TYPE_BODY");
}
private static void appendDamengObjectTypePredicate(
@@ -1041,6 +1050,12 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (constraints.objectTypeAllowed("PACKAGE_BODY")) {
result.add("PACKAGE BODY");
}
if (constraints.objectTypeAllowed("TYPE")) {
result.add("TYPE");
}
if (constraints.objectTypeAllowed("TYPE_BODY")) {
result.add("TYPE BODY");
}
return result;
}
@@ -1075,6 +1090,12 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (constraints.objectTypeAllowed("PACKAGE_BODY")) {
result.add("PACKAGE BODY");
}
if (constraints.objectTypeAllowed("TYPE")) {
result.add("TYPE");
}
if (constraints.objectTypeAllowed("TYPE_BODY")) {
result.add("TYPE BODY");
}
return result;
}
@@ -1176,12 +1197,53 @@ public final class DamengAgent extends AbstractJdbcAgent {
private List<ObjectInfo> queryConstrainedObjects(String rawSchema, MetadataListConstraints constraints) {
final String schema = normalizeSchema(rawSchema);
List<ObjectInfo> objects = queryConstrainedObjectsWithoutValidity(schema, constraints);
List<ObjectInfo> objects;
if (constraints.objectTypeAllowed("INDEX") && (constraints.hasObjectTypes() || constraints.isExactIdentity())) {
// Merge before paging: separate dictionary sources must not each consume the offset.
objects = new ArrayList<>();
List<String> otherTypes = damengObjectTypes(constraints);
if (!otherTypes.isEmpty()) {
objects.addAll(queryConstrainedObjectsWithoutValidity(schema,
new MetadataListConstraints(constraints.getFilter(), null, null, otherTypes, constraints.isExactIdentity())));
}
objects.addAll(querySchemaIndexes(schema, constraints.withoutPaging()));
objects.sort(java.util.Comparator.comparing(ObjectInfo::getName).thenComparing(ObjectInfo::getObject_type));
objects = constraints.filterObjects(objects);
} else {
objects = queryConstrainedObjectsWithoutValidity(schema, constraints);
}
applyViewValidity(objects, schema);
return objects;
}
private List<ObjectInfo> querySchemaIndexes(String schema, MetadataListConstraints constraints) {
return unchecked(() -> {
List<Object> args = new ArrayList<>();
args.add(schema);
StringBuilder sql = new StringBuilder("SELECT OWNER, INDEX_NAME, TABLE_OWNER, TABLE_NAME FROM ALL_INDEXES i WHERE i.OWNER = ?");
appendNameFilter(sql, args, "i.INDEX_NAME", constraints);
sql.append(" ORDER BY i.INDEX_NAME");
List<ObjectInfo> objects = new ArrayList<>();
try (PreparedStatement statement = requireConnected().prepareStatement(sql.toString())) {
bind(statement, args);
try (ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
ObjectInfo index = new ObjectInfo(rows.getString(2), "INDEX", rows.getString(1), null);
index.setParent_schema(rows.getString(3));
index.setParent_name(rows.getString(4));
objects.add(index);
}
}
}
return constraints.filterObjects(objects);
});
}
private List<ObjectInfo> queryConstrainedObjectsWithoutValidity(String schema, MetadataListConstraints constraints) {
if (constraints.isExactIdentity()) {
if (legacyJdbcMetadata) throw new UnsupportedOperationException("Legacy DM metadata does not support exact_metadata_identity_v1");
return executeConstrainedObjects(schema, buildAccessibleConstrainedObjectsQuery(schema, constraints), constraints);
}
if (legacyJdbcMetadata) {
return executeJdbcMetadataObjects(schema, constraints);
}
@@ -1436,7 +1498,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
RuntimeException dbmsError;
try {
String source = readDbmsMetadataObjectSource(dbmsType, name, schema);
return new ObjectSource(name, objectType, schema, source);
if (isOracleFamilyType(objectType) && !notBlank(source)) {
dbmsError = new IllegalStateException("DM8 returned empty type source for " + schema + "." + name);
} else {
return new ObjectSource(name, objectType, schema, source, !isOracleFamilyType(objectType));
}
} catch (Exception error) {
// 检查型异常(SQLException 等)由 unchecked 透传后在此处统一处理。
RuntimeException runtimeError = error instanceof RuntimeException runtime ? runtime : new RuntimeException(error);
@@ -1446,7 +1512,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
dbmsError = runtimeError;
}
try {
return buildCatalogObjectSource(schema, name, objectType, dbmsError);
ObjectSource source = buildCatalogObjectSource(schema, name, objectType, dbmsError);
if (isOracleFamilyType(objectType)) {
if (source == null || !notBlank(source.getSource()) || source.getSource().startsWith(CATALOG_BODY_HINT)) {
throw new IllegalStateException("DM8 could not read complete type source for " + schema + "." + name, dbmsError);
}
return new ObjectSource(name, objectType, schema, source.getSource(), false);
}
return source;
} catch (RuntimeException fallbackError) {
fallbackError.addSuppressed(dbmsError);
throw fallbackError;
@@ -1454,6 +1527,11 @@ public final class DamengAgent extends AbstractJdbcAgent {
});
}
private static boolean isOracleFamilyType(String objectType) {
String normalized = normalizeObjectSourceType(objectType);
return "TYPE".equals(normalized) || "TYPE_BODY".equals(normalized);
}
private String readDbmsMetadataObjectSource(String dbmsType, String name, String schema) throws Exception {
String sql = "SELECT /*+ PARALLEL(1) */ DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL";
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
@@ -1530,6 +1608,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
default -> throw new IllegalArgumentException("Unsupported object type: " + objectType);
}
if (isOracleFamilyType(objectType)) {
throw new IllegalStateException("DM8 could not read complete type source for " + schema + "." + name + " (" + objectType + ")", dbmsError);
}
return unavailableObjectSource(schema, name, objectType, dbmsError);
}
@@ -1667,6 +1748,9 @@ public final class DamengAgent extends AbstractJdbcAgent {
String sourceType,
String source
) {
if (isOracleFamilyType(objectType)) {
return oracleFamilyTypeSourceMatches(source, objectType);
}
if (!("FUNCTION".equals(objectType) || "PROCEDURE".equals(objectType))
|| !"PROC".equalsIgnoreCase(sourceType)) {
return true;
@@ -1677,6 +1761,30 @@ public final class DamengAgent extends AbstractJdbcAgent {
: routineSourceStartsWithType(source, objectType);
}
private static boolean oracleFamilyTypeSourceMatches(String source, String objectType) {
String expected = "TYPE_BODY".equals(normalizeObjectSourceType(objectType)) ? "TYPE BODY" : "TYPE";
String firstDeclaration = oracleFamilyTypeDeclaration(source.stripLeading());
return expected.equals(firstDeclaration);
}
private static String oracleFamilyTypeDeclaration(String source) {
String candidate = stripTypeDdlPrefix(source);
if (startsWithRoutineDeclaration(candidate, "TYPE BODY")) {
return "TYPE BODY";
}
return startsWithRoutineDeclaration(candidate, "TYPE") ? "TYPE" : "";
}
private static String stripTypeDdlPrefix(String source) {
String upper = source.toUpperCase(Locale.ROOT);
for (String prefix : List.of("CREATE OR REPLACE ", "CREATE ", "ALTER ")) {
if (upper.startsWith(prefix)) {
return source.substring(prefix.length()).stripLeading();
}
}
return source;
}
private String readDamengRoutineObjectType(String schema, String name) {
String sql = """
SELECT o.INFO1
@@ -1760,12 +1868,20 @@ public final class DamengAgent extends AbstractJdbcAgent {
JOIN SYS.SYSOBJECTS o ON o.ID = t.ID
JOIN SYS.SYSOBJECTS s ON s.ID = o.SCHID AND s.TYPE$ = 'SCH' AND s.NAME = ?
WHERE o.NAME = ?
ORDER BY t.SEQNO
""".stripIndent().trim();
boolean typedType = isOracleFamilyType(objectType);
if (typedType) {
sql += " AND EXISTS (SELECT 1 FROM ALL_OBJECTS a WHERE a.OBJECT_ID = o.ID"
+ " AND a.OWNER = s.NAME AND a.OBJECT_NAME = o.NAME AND a.OBJECT_TYPE = ?)";
}
sql += " ORDER BY t.SEQNO";
StringBuilder source = new StringBuilder();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, name);
if (typedType) {
stmt.setString(3, "TYPE_BODY".equals(normalizeObjectSourceType(objectType)) ? "TYPE BODY" : "TYPE");
}
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String line = coalesce(readTextColumn(rs, 1));
@@ -1875,8 +1991,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
@Override
public String getTableDdl(String rawSchema, String table) {
return getTableDdl(rawSchema, table, false);
}
@Override
public String getTableDdl(String rawSchema, String table, boolean exactIdentity) {
final String schema = normalizeSchema(rawSchema);
if (legacyJdbcMetadata) {
if (exactIdentity) throw new UnsupportedOperationException("Legacy DM metadata does not support exact_metadata_identity_v1");
return super.getTableDdl(schema, table);
}
try {
@@ -1900,6 +2022,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
throw new IllegalArgumentException("Table not found: " + schema + "." + table);
});
} catch (RuntimeException error) {
if (exactIdentity) throw error;
if (!isDamengMetadataUnavailableError(error)) {
throw error;
}
@@ -2331,12 +2454,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
MAX(CASE WHEN constraint_metadata.TYPE$ = 'P' THEN 1 ELSE 0 END) AS IS_PK,
index_metadata.TYPE$,
MAX(CASE WHEN constraint_metadata.TYPE$ IN ('P', 'U') THEN 1 ELSE 0 END) AS CONSTRAINT_BACKED,
index_metadata.FLAG
index_metadata.FLAG, index_schema_object.NAME AS INDEX_OWNER
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.SYSOBJECTS index_schema_object ON index_schema_object.ID = index_object.SCHID
AND index_schema_object.TYPE$ = 'SCH'
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(
@@ -2348,8 +2473,8 @@ public final class DamengAgent extends AbstractJdbcAgent {
AND constraint_metadata.INDEXID = index_metadata.ID
AND constraint_metadata.TYPE$ IN ('P', 'U')
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
ORDER BY index_object.NAME
GROUP BY index_schema_object.NAME, index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
ORDER BY index_schema_object.NAME, index_object.NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
@@ -2369,6 +2494,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
null
);
index.setConstraint_backed(constraintBacked);
index.setOwner(rs.getString(8));
result.add(index);
}
}
@@ -2386,14 +2512,14 @@ public final class DamengAgent extends AbstractJdbcAgent {
i.UNIQUENESS,
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
i.INDEX_TYPE,
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED, i.OWNER AS INDEX_OWNER
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
AND c.CONSTRAINT_TYPE IN ('P', 'U')
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
ORDER BY i.INDEX_NAME
GROUP BY i.OWNER, i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
ORDER BY i.OWNER, i.INDEX_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
@@ -2411,6 +2537,7 @@ public final class DamengAgent extends AbstractJdbcAgent {
null
);
index.setConstraint_backed("1".equals(rs.getString(6)));
index.setOwner(rs.getString(7));
result.add(index);
}
}
@@ -3441,6 +3568,6 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
public static void main(String[] args) {
new MultiSessionJsonRpcServer(DamengAgent::new).run();
new MultiSessionJsonRpcServer(DamengAgent::new).withExactMetadataIdentity().run();
}
}
@@ -235,14 +235,15 @@ class DamengAgentMetadataTest {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of(
// UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index.
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "Y", "0", "BT", "1", 0),
Arrays.asList("UX_USERS_CODE", "CODE", "Y", "0", "BT", "0", 0),
Arrays.asList("IDX_USERS_NAME", "NAME", "N", "0", "BT", "0", 0),
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "Y", "0", "BT", "1", 0, " Index Owner "),
Arrays.asList("UX_USERS_CODE", "CODE", "Y", "0", "BT", "0", 0, " Index Owner "),
Arrays.asList("IDX_USERS_NAME", "NAME", "N", "0", "BT", "0", 0, " Index Owner "),
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4, " Index Owner ")
)));
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
Assertions.assertTrue(indexes.stream().allMatch(index -> " Index Owner ".equals(index.getOwner())));
Assertions.assertEquals(
List.of("UX_USERS_EMAIL", "UX_USERS_CODE", "IDX_USERS_NAME", "PK_USERS"),
indexes.stream().map(IndexInfo::getName).toList()
@@ -275,12 +276,13 @@ class DamengAgentMetadataTest {
List.of(),
new SQLException("no SYS index catalog privilege"),
"FROM ALL_INDEXES i",
List.of(Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"))
List.of(Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0", " Index Owner "))
));
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
Assertions.assertEquals(List.of("IDX_USERS_NAME"), indexes.stream().map(IndexInfo::getName).toList());
Assertions.assertEquals(" Index Owner ", indexes.get(0).getOwner());
Assertions.assertTrue(indexOfSql(sqls, "SYS.SYSINDEXES") >= 0, String.join("\n", sqls));
Assertions.assertTrue(
indexOfSql(sqls, "ALL_INDEXES") > indexOfSql(sqls, "SYS.SYSINDEXES"),
@@ -1398,10 +1400,112 @@ class DamengAgentMetadataTest {
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
Assertions.assertTrue(source.getSource().contains("CREATE OR REPLACE TYPE"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
@Test
void fallsBackToCompleteCatalogSourceWhenGetDdlReturnsNullOrBlank() {
for (String emptyDdl : Arrays.asList(null, "", " ")) {
for (String objectType : List.of("TYPE", "TYPE_BODY")) {
String declaration = "TYPE_BODY".equals(objectType) ? "TYPE BODY" : "TYPE";
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, getDdlResultWithCatalogConnection(
sqls,
emptyDdl,
List.of(List.of("CREATE OR REPLACE " + declaration + " \"APP\".\"T_ADDR\" AS"),
List.of(" CITY VARCHAR2(64)"), List.of("END;"))
));
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", objectType);
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + declaration), source.getSource());
Assertions.assertFalse(source.isEditable());
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", sqls));
}
}
DamengAgent noCatalogAgent = new DamengAgent();
TestSupport.setPrivateConnection(noCatalogAgent,
getDdlResultWithCatalogConnection(new ArrayList<>(), "", List.of()));
Assertions.assertThrows(RuntimeException.class,
() -> noCatalogAgent.getObjectSource("APP", "T_ADDR", "TYPE"));
}
@Test
void returnsTypeBodySourceOnlyForTypeBodyAndTypeSourceOnlyForType() {
for (String[] requestedAndDeclaration : List.of(
new String[] {"TYPE", "CREATE OR REPLACE TYPE \"APP\".\"T_ADDR\" AS OBJECT (CITY VARCHAR2(64));"},
new String[] {"TYPE_BODY", "\nCREATE OR REPLACE TYPE BODY \"APP\".\"T_ADDR\" AS MEMBER FUNCTION CITY_NAME RETURN VARCHAR2 IS\n TYPE local_items IS TABLE OF NUMBER;\n /* Example text, not a second type:\n CREATE TYPE demo AS OBJECT (n NUMBER);\n */\n v_example VARCHAR2(100) := q'!CREATE TYPE demo AS OBJECT (\nn NUMBER);!';\n BEGIN RETURN 'X'; END; END;"}
)) {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
missingDbmsMetadataPackageError(), new ArrayList<>(),
List.of(List.of(requestedAndDeclaration[1])), null, List.of(), null));
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", requestedAndDeclaration[0]);
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + ("TYPE_BODY".equals(requestedAndDeclaration[0]) ? "TYPE BODY" : "TYPE")), source.getSource());
if ("TYPE_BODY".equals(requestedAndDeclaration[0])) {
Assertions.assertTrue(source.getSource().contains("TYPE local_items IS TABLE OF NUMBER"), source.getSource());
Assertions.assertTrue(source.getSource().contains("CREATE TYPE demo AS OBJECT"), source.getSource());
Assertions.assertTrue(source.getSource().contains("q'!CREATE TYPE demo AS OBJECT"), source.getSource());
}
Assertions.assertFalse(source.isEditable());
}
}
@Test
void rejectsWrongTypeHeadersAndBindsSystemTextLookupToExactTypeCategory() {
String typeDdl = "CREATE OR REPLACE TYPE \"APP\".\"T_ADDR\" AS OBJECT (CITY VARCHAR2(64));";
String typeBodyDdl = "CREATE OR REPLACE TYPE BODY \"APP\".\"T_ADDR\" AS MEMBER FUNCTION CITY_NAME RETURN VARCHAR2 IS BEGIN RETURN 'X'; END; END;";
for (String[] requestedAndWrongSource : List.of(
new String[] {"TYPE_BODY", typeDdl},
new String[] {"TYPE", typeBodyDdl}
)) {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
List<String> boundValues = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
missingDbmsMetadataPackageError(), sqls, List.of(), missingCatalogViewError("ALL_SOURCE"),
List.of(List.of(requestedAndWrongSource[1])), null, boundValues));
Assertions.assertThrows(RuntimeException.class,
() -> agent.getObjectSource("APP", "T_ADDR", requestedAndWrongSource[0]));
String query = sqls.stream().filter(sql -> sql.contains("SYS.SYSTEXTS")).findFirst().orElseThrow();
Assertions.assertTrue(query.contains("a.OBJECT_ID = o.ID"), query);
Assertions.assertTrue(query.contains("a.OWNER = s.NAME AND a.OBJECT_NAME = o.NAME"), query);
Assertions.assertTrue(query.contains("a.OBJECT_TYPE = ?"), query);
Assertions.assertEquals(List.of("APP", "T_ADDR", "TYPE_BODY".equals(requestedAndWrongSource[0])
? "TYPE BODY" : "TYPE"), boundValues);
}
}
@Test
void readsCorrectTypeBodyFromSystemTextsFallback() {
String typeBodyDdl = "\nTYPE BODY \"APP\".\"T_ADDR\" AS MEMBER FUNCTION CITY_NAME RETURN VARCHAR2 IS\n TYPE local_items IS TABLE OF NUMBER;\n /* Example text, not a second type:\n CREATE TYPE demo AS OBJECT (n NUMBER);\n */\n v_example VARCHAR2(100) := q'!CREATE TYPE demo AS OBJECT (\nn NUMBER);!';\n BEGIN RETURN 'X'; END; END;";
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
List<String> boundValues = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
missingDbmsMetadataPackageError(), sqls, List.of(), missingCatalogViewError("ALL_SOURCE"),
List.of(List.of(typeBodyDdl)), null, boundValues));
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE_BODY");
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE TYPE BODY"), source.getSource());
Assertions.assertTrue(source.getSource().contains("TYPE local_items IS TABLE OF NUMBER"), source.getSource());
Assertions.assertTrue(source.getSource().contains("CREATE TYPE demo AS OBJECT"), source.getSource());
Assertions.assertTrue(source.getSource().contains("q'!CREATE TYPE demo AS OBJECT"), source.getSource());
Assertions.assertFalse(source.isEditable());
String query = sqls.stream().filter(sql -> sql.contains("SYS.SYSTEXTS")).findFirst().orElseThrow();
Assertions.assertTrue(query.contains("a.OBJECT_ID = o.ID"), query);
Assertions.assertTrue(query.contains("a.OWNER = s.NAME AND a.OBJECT_NAME = o.NAME"), query);
Assertions.assertTrue(query.contains("a.OBJECT_TYPE = ?"), query);
Assertions.assertEquals(List.of("APP", "T_ADDR", "TYPE BODY"), boundValues);
}
@Test
void fallsBackToAllSourcePackageBodyDdlWhenDbmsMetadataPackageIsMissing() {
DamengAgent agent = new DamengAgent();
@@ -1594,7 +1698,7 @@ class DamengAgentMetadataTest {
ObjectSource source = agent.getObjectSource("APP", "T_ADDR", "TYPE");
Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE TYPE"), source.getSource());
Assertions.assertTrue(source.isEditable());
Assertions.assertFalse(source.isEditable());
}
@Test
@@ -1645,6 +1749,26 @@ class DamengAgentMetadataTest {
Assertions.assertTrue(sqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", sqls));
}
@Test
void typeGetterRejectsUnavailablePlaceholderWhenCatalogSourcesAreEmptyOrUnavailable() {
for (String type : List.of("TYPE", "TYPE_BODY")) {
DamengAgent emptyAgent = new DamengAgent();
List<String> emptySqls = new ArrayList<>();
TestSupport.setPrivateConnection(emptyAgent, catalogFallbackConnection(
missingDbmsMetadataPackageError(), emptySqls, List.of(), null, List.of(), null));
Assertions.assertThrows(RuntimeException.class, () -> emptyAgent.getObjectSource("APP", "T_NO_SOURCE", type));
DamengAgent unavailableAgent = new DamengAgent();
List<String> unavailableSqls = new ArrayList<>();
TestSupport.setPrivateConnection(unavailableAgent, catalogFallbackConnection(
missingDbmsMetadataPackageError(), unavailableSqls, List.of(),
missingCatalogViewError("ALL_SOURCE"), List.of(), missingCatalogViewError("SYS.SYSTEXTS")));
Assertions.assertThrows(RuntimeException.class, () -> unavailableAgent.getObjectSource("APP", "T_NO_SOURCE", type));
Assertions.assertTrue(unavailableSqls.stream().anyMatch(sql -> sql.contains("FROM ALL_SOURCE")), String.join("\n", unavailableSqls));
Assertions.assertTrue(unavailableSqls.stream().anyMatch(sql -> sql.contains("SYS.SYSTEXTS")), String.join("\n", unavailableSqls));
}
}
@Test
void propagatesConnectionResetWithoutCatalogFallback() {
DamengAgent agent = new DamengAgent();
@@ -2464,7 +2588,7 @@ class DamengAgentMetadataTest {
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
indexRow("UX_USERS_EMAIL", "EMAIL", "Y", "BT", 0),
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0),
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4, "APP")
),
sqls
);
@@ -2766,7 +2890,7 @@ class DamengAgentMetadataTest {
}
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType, int flags) {
return List.of(name, columns, uniqueness, "0", indexType, "0", flags);
return List.of(name, columns, uniqueness, "0", indexType, "0", flags, "APP");
}
private static List<List<Object>> dictionaryIndexRows(List<List<Object>> systemRows) {
@@ -2776,7 +2900,7 @@ class DamengAgentMetadataTest {
"Y".equals(row.get(2)) ? "UNIQUE" : "NONUNIQUE",
row.get(3),
"ST".equals(row.get(4)) ? "SPATIAL" : "NORMAL",
row.get(5)
row.get(5), row.get(7)
)).toList();
}
@@ -2928,6 +3052,35 @@ class DamengAgentMetadataTest {
});
}
private static Connection getDdlResultWithCatalogConnection(
List<String> sqls,
String getDdlSource,
List<List<Object>> allSourceRows
) {
return proxy(Connection.class, (method, args) -> {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
sqls.add(sql);
if (sql.contains("DBMS_METADATA.GET_DDL")) {
return metadataStatement(List.of(Arrays.asList((Object) getDdlSource)));
}
if (sql.contains("FROM ALL_SOURCE")) {
return metadataStatement(allSourceRows);
}
if (sql.contains("SYS.SYSTEXTS")) {
return metadataStatement(List.of());
}
}
if ("close".equals(method.getName())) {
return null;
}
if ("isClosed".equals(method.getName())) {
return false;
}
return defaultValue(method.getReturnType());
});
}
/** 模拟 DM 实例未安装 DBMS_METADATA 系统包时的典型错误([‑2207]/[‑3325])。 */
private static SQLException missingDbmsMetadataPackageError() {
return new SQLException(
@@ -2954,6 +3107,19 @@ class DamengAgentMetadataTest {
SQLException allSourceError,
List<List<Object>> systemTextRows,
SQLException systemTextError
) {
return catalogFallbackConnection(
dbmsMetadataError, sqls, allSourceRows, allSourceError, systemTextRows, systemTextError, null);
}
private static Connection catalogFallbackConnection(
SQLException dbmsMetadataError,
List<String> sqls,
List<List<Object>> allSourceRows,
SQLException allSourceError,
List<List<Object>> systemTextRows,
SQLException systemTextError,
List<String> boundSystemTextValues
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
@@ -2978,7 +3144,22 @@ class DamengAgentMetadataTest {
if (systemTextError != null) {
return failingMetadataStatement(systemTextError);
}
return metadataStatement(systemTextRows);
if (boundSystemTextValues == null) {
return metadataStatement(systemTextRows);
}
return proxy(PreparedStatement.class, (statementMethod, statementArgs) -> {
if ("setString".equals(statementMethod.getName())) {
boundSystemTextValues.add((String) statementArgs[1]);
return null;
}
if ("executeQuery".equals(statementMethod.getName())) {
return metadataResultSet(systemTextRows);
}
if ("close".equals(statementMethod.getName())) {
return null;
}
return defaultValue(statementMethod.getReturnType());
});
}
if (sql.contains("FROM ALL_VIEWS")) {
return metadataStatement(List.of(List.of("SELECT 1 AS ID FROM DUAL")));
@@ -489,6 +489,19 @@ class DamengAgentTest extends JdbcFakeExecutionBehaviorTest {
assertEquals(List.of("APP", "SEQUENCE", "PACKAGE", "PACKAGE BODY", 20), query.args());
}
@Test
void constrainedObjectQueriesIncludeTypesAndBodies() {
DamengAgent.MetadataQuery query = DamengAgent.buildConstrainedObjectsQuery(
"APP", new MetadataListConstraints(null, 20, null, List.of("TYPE", "TYPE_BODY")));
assertTrue(query.sql().contains("o.OBJECT_TYPE IN (?, ?)"));
assertEquals(List.of("APP", "TYPE", "TYPE BODY", 20), query.args());
DamengAgent.MetadataQuery raw = DamengAgent.buildRawConstrainedObjectsQuery(
"APP", new MetadataListConstraints(null, null, null, List.of("TYPE", "TYPE_BODY")));
assertTrue(raw.sql().contains("o.OBJECT_TYPE IN (?, ?)"));
assertEquals(List.of("APP", "TYPE", "TYPE BODY"), raw.args());
}
@Test
void rawObjectQueryIncludesDamengPackageBodyCatalogType() {
DamengAgent.MetadataQuery query = DamengAgent.buildRawConstrainedObjectsQuery(
@@ -0,0 +1,76 @@
package com.dbx.agent.dameng;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.test.TestSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class DamengSchemaIndexTest {
@Test
void indexListingPreservesRawParentIdentity() {
DamengAgent agent = new DamengAgent();
List<String> owners = new ArrayList<>();
TestSupport.setPrivateConnection(agent, connection(owners, false));
List<ObjectInfo> objects = agent.listObjects(" Index Owner ",
new MetadataListConstraints(null, 2, 0, List.of("INDEX")));
assertEquals(1, objects.size());
ObjectInfo index = objects.get(0);
assertEquals(" Ix (a) ", index.getName());
assertEquals(" Index Owner ", index.getSchema());
assertEquals("INDEX", index.getObject_type());
assertEquals("Table Owner", index.getParent_schema());
assertEquals(" T(a) ", index.getParent_name());
assertEquals(List.of(" Index Owner "), owners);
}
@Test
void indexDictionaryPermissionFailureDoesNotUseTableOnlyFallback() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, connection(new ArrayList<>(), true));
RuntimeException error = assertThrows(RuntimeException.class,
() -> agent.listObjects("APP", new MetadataListConstraints(null, 2, 0, List.of("INDEX"))));
assertEquals(-5669, ((SQLException) error.getCause()).getErrorCode());
}
private static Connection connection(List<String> owners, boolean denied) {
return proxy(Connection.class, (target, method, args) -> {
if (method.getName().equals("prepareStatement")) {
assertTrue(((String) args[0]).contains("ALL_INDEXES"), (String) args[0]);
return proxy(PreparedStatement.class, (statement, operation, values) -> {
if (operation.getName().equals("setString") && values[0].equals(1)) owners.add((String) values[1]);
if (operation.getName().equals("executeQuery")) {
if (denied) throw new SQLException("权限不足", "42000", -5669);
int[] row = {0};
String[] data = {" Index Owner ", " Ix (a) ", "Table Owner", " T(a) "};
return proxy(ResultSet.class, (result, read, arguments) -> switch (read.getName()) {
case "next" -> row[0]++ == 0;
case "getString" -> data[(Integer) arguments[0] - 1];
default -> null;
});
}
return null;
});
}
if (method.getName().equals("isClosed")) return false;
return null;
});
}
private static <T> T proxy(Class<T> type, java.lang.reflect.InvocationHandler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.firebirdsql.jdbc:jaybird:6.0.5'
implementation 'org.firebirdsql.jdbc:jaybird:6.0.7'
}
tasks.named('shadowJar') {
+1 -1
View File
@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'com.gradleup.shadow' version '9.0.0-beta12'
id 'com.gradleup.shadow' version '9.6.1'
}
repositories {
@@ -160,8 +160,8 @@ public final class KafkaAgent {
case "mq_get_topic_config" -> getTopicConfig(params);
case "mq_alter_topic_config" -> alterTopicConfig(params);
// Consumer groups
case "mq_list_consumer_groups" -> listConsumerGroups(params);
case "mq_get_consumer_group_snapshot" -> getConsumerGroupSnapshot(params);
case "mq_list_consumer_groups" -> listConsumerGroups(params, requireAdmin());
case "mq_get_consumer_group_snapshot" -> getConsumerGroupSnapshot(params, requireAdmin());
case "mq_describe_consumer_group" -> describeConsumerGroup(params);
case "mq_delete_consumer_group" -> deleteConsumerGroup(params);
case "mq_reset_consumer_group_offsets" -> resetConsumerGroupOffsets(params);
@@ -1008,14 +1008,41 @@ public final class KafkaAgent {
// Consumer groups
// -----------------------------------------------------------------------
private static Object listConsumerGroups(JsonObject params) throws Exception {
AdminClient admin = requireAdmin();
/** Milliseconds left before {@code deadline}; at least 1 so an already-completed future is still read. */
static long remainingMillis(long deadline) {
return Math.max(1, deadline - System.currentTimeMillis());
}
/**
* Await each group's description against one shared deadline. Waiting the full request
* timeout per group let unreachable group coordinators stack N timeouts, keeping the agent
* busy long enough that topic requests behind it failed too (#13122).
*/
static Map<String, ConsumerGroupDescription> awaitGroupDescriptions(
DescribeConsumerGroupsResult described,
List<String> groupIds,
long deadline,
java.util.function.BiConsumer<String, String> onError
) {
Map<String, ConsumerGroupDescription> descriptions = new HashMap<>();
for (String groupId : groupIds) {
try {
descriptions.put(groupId, described.describedGroups().get(groupId).get(remainingMillis(deadline), TimeUnit.MILLISECONDS));
} catch (Exception error) {
onError.accept(groupId, normalizeErrorMessage(error));
}
}
return descriptions;
}
static Object listConsumerGroups(JsonObject params, Admin admin) throws Exception {
int timeout = requestTimeout(params);
long deadline = System.currentTimeMillis() + timeout;
String filterTopic = stringOrEmpty(params, "topic");
Collection<ConsumerGroupListing> groups = admin.listConsumerGroups(
new ListConsumerGroupsOptions().timeoutMs(timeout))
.all().get(timeout, TimeUnit.MILLISECONDS);
.all().get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
List<String> groupIds = groups.stream()
.map(ConsumerGroupListing::groupId)
@@ -1025,25 +1052,8 @@ public final class KafkaAgent {
return Collections.singletonMap("groups", Collections.emptyList());
}
// Batch-describe every group in one Kafka Admin request. Per-group
// failures degrade to an empty member list instead of failing the batch.
Map<String, ConsumerGroupDescription> descriptions = new HashMap<>();
DescribeConsumerGroupsResult described = admin.describeConsumerGroups(
groupIds,
new DescribeConsumerGroupsOptions().timeoutMs(timeout)
);
for (String groupId : groupIds) {
try {
descriptions.put(
groupId,
described.describedGroups().get(groupId).get(timeout, TimeUnit.MILLISECONDS)
);
} catch (Exception error) {
logger().debug("Consumer group details unavailable for {}: {}", groupId, normalizeErrorMessage(error));
}
}
// Batch-resolve committed offsets for every group in one Admin request.
// Start both independent lookups before waiting. A slow description must
// not consume the deadline before committed offsets can even be requested.
Map<String, ListConsumerGroupOffsetsSpec> offsetSpecs = new LinkedHashMap<>();
for (String groupId : groupIds) {
offsetSpecs.put(groupId, new ListConsumerGroupOffsetsSpec());
@@ -1051,19 +1061,24 @@ public final class KafkaAgent {
Map<String, Map<TopicPartition, OffsetAndMetadata>> committedOffsets = new HashMap<>();
ListConsumerGroupOffsetsResult offsetsResult = admin.listConsumerGroupOffsets(
offsetSpecs,
new ListConsumerGroupOffsetsOptions().timeoutMs(timeout)
new ListConsumerGroupOffsetsOptions().timeoutMs((int) remainingMillis(deadline))
);
DescribeConsumerGroupsResult described = admin.describeConsumerGroups(
groupIds,
new DescribeConsumerGroupsOptions().timeoutMs((int) remainingMillis(deadline))
);
for (String groupId : groupIds) {
try {
Map<TopicPartition, OffsetAndMetadata> rows = offsetsResult
.partitionsToOffsetAndMetadata(groupId)
.get(timeout, TimeUnit.MILLISECONDS);
.get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
committedOffsets.put(groupId, rows == null ? Collections.emptyMap() : rows);
} catch (Exception error) {
committedOffsets.put(groupId, Collections.emptyMap());
logger().debug("Committed offsets unavailable for {}: {}", groupId, normalizeErrorMessage(error));
}
}
Map<String, ConsumerGroupDescription> descriptions = awaitGroupDescriptions(described, groupIds, deadline, (groupId, message) -> logger().debug("Consumer group details unavailable for {}: {}", groupId, message));
// End offsets: one batched Admin request limited to the requested
// topic's partitions (deduplicated across groups). Without a topic
@@ -1086,13 +1101,13 @@ public final class KafkaAgent {
try {
ListOffsetsResult latestOffsets = admin.listOffsets(
endOffsetSpecs,
new ListOffsetsOptions().timeoutMs(timeout)
new ListOffsetsOptions().timeoutMs((int) remainingMillis(deadline))
);
for (TopicPartition topicPartition : topicPartitions) {
try {
ListOffsetsResult.ListOffsetsResultInfo info = latestOffsets
.partitionResult(topicPartition)
.get(timeout, TimeUnit.MILLISECONDS);
.get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
endOffsets.put(topicPartition, info.offset());
} catch (Exception error) {
// Leave the partition absent so callers can tell an
@@ -1119,6 +1134,7 @@ public final class KafkaAgent {
g.put("groupId", groupId);
g.put("state", listing == null ? "UNKNOWN" : listing.state().map(Enum::name).orElse("UNKNOWN"));
g.put("simpleGroup", listing != null && listing.isSimpleConsumerGroup());
g.put("membersAvailable", description != null);
g.put("members", memberMaps(description == null ? Collections.emptyList() : description.members()));
g.put("committedOffsets", offsetRows(committed, filterTopic));
g.put("endOffsets", endOffsetRows(committed, endOffsets, filterTopic));
@@ -1189,12 +1205,12 @@ public final class KafkaAgent {
return rows;
}
private static Object getConsumerGroupSnapshot(JsonObject params) throws Exception {
AdminClient admin = requireAdmin();
static Object getConsumerGroupSnapshot(JsonObject params, Admin admin) throws Exception {
int timeout = requestTimeout(params);
long deadline = System.currentTimeMillis() + timeout;
Collection<ConsumerGroupListing> listings = admin.listConsumerGroups(
new ListConsumerGroupsOptions().timeoutMs(timeout))
.all().get(timeout, TimeUnit.MILLISECONDS);
.all().get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
List<ConsumerGroupListing> sortedListings = new ArrayList<>(listings);
sortedListings.sort(Comparator.comparing(ConsumerGroupListing::groupId));
@@ -1210,34 +1226,25 @@ public final class KafkaAgent {
Map<String, Boolean> committedOffsetsAvailable = new HashMap<>();
Map<String, LinkedHashSet<String>> errors = new HashMap<>();
DescribeConsumerGroupsResult described = admin.describeConsumerGroups(
groupIds,
new DescribeConsumerGroupsOptions().timeoutMs(timeout)
);
for (String groupId : groupIds) {
try {
descriptions.put(
groupId,
described.describedGroups().get(groupId).get(timeout, TimeUnit.MILLISECONDS)
);
} catch (Exception error) {
snapshotErrors(errors, groupId).add("Consumer group details unavailable: " + normalizeErrorMessage(error));
}
}
// Offset and description requests share one deadline, but neither needs
// the other's result. Launch both before awaiting per-group responses.
Map<String, ListConsumerGroupOffsetsSpec> offsetSpecs = new LinkedHashMap<>();
for (String groupId : groupIds) {
offsetSpecs.put(groupId, new ListConsumerGroupOffsetsSpec());
}
ListConsumerGroupOffsetsResult offsetsResult = admin.listConsumerGroupOffsets(
offsetSpecs,
new ListConsumerGroupOffsetsOptions().timeoutMs(timeout)
new ListConsumerGroupOffsetsOptions().timeoutMs((int) remainingMillis(deadline))
);
DescribeConsumerGroupsResult described = admin.describeConsumerGroups(
groupIds,
new DescribeConsumerGroupsOptions().timeoutMs((int) remainingMillis(deadline))
);
for (String groupId : groupIds) {
try {
Map<TopicPartition, OffsetAndMetadata> groupOffsets = offsetsResult
.partitionsToOffsetAndMetadata(groupId)
.get(timeout, TimeUnit.MILLISECONDS);
.get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
committedOffsets.put(groupId, groupOffsets == null ? Collections.emptyMap() : groupOffsets);
committedOffsetsAvailable.put(groupId, true);
} catch (Exception error) {
@@ -1246,6 +1253,7 @@ public final class KafkaAgent {
snapshotErrors(errors, groupId).add("Committed offsets unavailable: " + normalizeErrorMessage(error));
}
}
descriptions.putAll(awaitGroupDescriptions(described, groupIds, deadline, (groupId, message) -> snapshotErrors(errors, groupId).add("Consumer group details unavailable: " + message)));
Map<String, Set<TopicPartition>> assignedPartitions = new HashMap<>();
Set<TopicPartition> allPartitions = new LinkedHashSet<>();
@@ -1272,13 +1280,13 @@ public final class KafkaAgent {
try {
ListOffsetsResult latestOffsets = admin.listOffsets(
endOffsetSpecs,
new ListOffsetsOptions().timeoutMs(timeout)
new ListOffsetsOptions().timeoutMs((int) remainingMillis(deadline))
);
for (TopicPartition topicPartition : allPartitions) {
try {
ListOffsetsResult.ListOffsetsResultInfo info = latestOffsets
.partitionResult(topicPartition)
.get(timeout, TimeUnit.MILLISECONDS);
.get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
endOffsets.put(topicPartition, info.offset());
} catch (Exception error) {
endOffsetErrors.put(topicPartition, normalizeErrorMessage(error));
@@ -1627,7 +1635,8 @@ public final class KafkaAgent {
int count = validatedPeekCount(intOrDefault(params, "count", 10));
PeekStartPosition startPosition = peekStartPosition(params);
boolean explicitStartPosition = stringOrNull(params, "startPosition") != null;
validatePeekRequest(startPosition, explicitStartPosition, partition, offset);
Long startTimestamp = longOrNull(params, "timestamp");
validatePeekRequest(startPosition, explicitStartPosition, partition, offset, startTimestamp);
boolean legacyOffsetRequest = !explicitStartPosition && offset != null;
JsonObject conn = activeConnection;
@@ -1650,14 +1659,25 @@ public final class KafkaAgent {
Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(candidatePartitions, requestTimeout);
Map<TopicPartition, Long> timestampOffsets = Collections.emptyMap();
if (startPosition == PeekStartPosition.TIMESTAMP) {
Map<TopicPartition, Long> byTime = new LinkedHashMap<>();
for (TopicPartition tp : candidatePartitions) {
byTime.put(tp, startTimestamp);
}
timestampOffsets = peekTimestampOffsets(consumer.offsetsForTimes(byTime, requestTimeout));
}
List<TopicPartition> readablePartitions = new ArrayList<>();
Map<TopicPartition, Long> seekOffsets = new LinkedHashMap<>();
for (TopicPartition tp : candidatePartitions) {
long beginningOffset = beginningOffsets.getOrDefault(tp, 0L);
long endOffset = endOffsets.getOrDefault(tp, beginningOffset);
Long requestedOffset = requestedPeekOffset(
startPosition, offset, legacyOffsetRequest, beginningOffset, endOffset
);
Long requestedOffset = startPosition == PeekStartPosition.TIMESTAMP
? timestampOffsets.get(tp)
: requestedPeekOffset(
startPosition, offset, legacyOffsetRequest, beginningOffset, endOffset
);
if (requestedOffset == null) {
continue;
}
@@ -2139,6 +2159,7 @@ public final class KafkaAgent {
EARLIEST,
LATEST,
OFFSET,
TIMESTAMP,
}
/** Omitting startPosition preserves the old earliest default (or explicit legacy offset) behavior. */
@@ -2151,16 +2172,47 @@ public final class KafkaAgent {
case "earliest" -> PeekStartPosition.EARLIEST;
case "latest" -> PeekStartPosition.LATEST;
case "offset" -> PeekStartPosition.OFFSET;
case "timestamp" -> PeekStartPosition.TIMESTAMP;
default -> throw new IllegalArgumentException("Unsupported peek startPosition: " + value);
};
}
/**
* First offset whose batch timestamp is &gt;= {@code timestamp}, per partition. Kafka
* answers null for a partition whose newest record is older than the requested time;
* that partition is simply left out of the read rather than silently falling back to
* its tail, so a time window never shows data from outside itself.
*/
static Map<TopicPartition, Long> peekTimestampOffsets(
Map<TopicPartition, OffsetAndTimestamp> located
) {
Map<TopicPartition, Long> resolved = new LinkedHashMap<>();
located.forEach((tp, offsetAndTimestamp) -> {
if (offsetAndTimestamp != null) {
resolved.put(tp, offsetAndTimestamp.offset());
}
});
return resolved;
}
static void validatePeekRequest(
PeekStartPosition startPosition,
boolean explicitStartPosition,
Integer partition,
Long offset
Long offset,
Long timestamp
) {
if (startPosition != PeekStartPosition.TIMESTAMP && timestamp != null) {
throw new IllegalArgumentException("timestamp is only supported when startPosition is timestamp");
}
if (startPosition == PeekStartPosition.TIMESTAMP) {
if (timestamp == null) {
throw new IllegalArgumentException("timestamp is required when startPosition is timestamp");
}
if (timestamp < 0) {
throw new IllegalArgumentException("timestamp must be a non-negative epoch millisecond value");
}
}
if (partition != null && partition < 0) {
throw new IllegalArgumentException("partition must be non-negative");
}
@@ -2195,6 +2247,8 @@ public final class KafkaAgent {
return switch (startPosition) {
case LATEST -> endOffset > beginningOffset ? beginningOffset : null;
case OFFSET -> offset;
// Resolved per partition through offsetsForTimes before this is reached.
case TIMESTAMP -> null;
case EARLIEST -> legacyOffsetRequest ? offset : beginningOffset;
};
}
@@ -9,6 +9,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.gson.JsonParser;
import com.google.gson.Gson;
import java.lang.reflect.Proxy;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -25,6 +27,9 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CountDownLatch;
@@ -34,19 +39,33 @@ import javax.net.ssl.X509TrustManager;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.consumer.OffsetAndTimestamp;
import java.util.LinkedHashMap;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.admin.AlterConfigOp;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.ConsumerGroupListing;
import org.apache.kafka.clients.admin.ListConsumerGroupsResult;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsOptions;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
import org.apache.kafka.clients.admin.ListOffsetsResult;
import org.apache.kafka.clients.admin.internals.CoordinatorKey;
import org.apache.kafka.clients.admin.ConsumerGroupDescription;
import org.apache.kafka.clients.admin.DescribeConsumerGroupsResult;
import org.apache.kafka.clients.admin.Config;
import org.apache.kafka.clients.admin.ConfigEntry;
import org.apache.kafka.clients.admin.RaftVoterEndpoint;
import org.apache.kafka.clients.admin.TopicDescription;
import org.apache.kafka.clients.admin.TopicListing;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.ConsumerGroupState;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.TopicPartitionInfo;
import org.apache.kafka.common.Uuid;
import org.apache.kafka.common.errors.UnsupportedVersionException;
import org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooDefs;
@@ -55,6 +74,8 @@ import org.apache.zookeeper.client.ZKClientConfig;
import org.apache.zookeeper.server.NIOServerCnxnFactory;
import org.apache.zookeeper.server.ZooKeeperServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.io.TempDir;
class KafkaAgentTest {
@@ -142,6 +163,107 @@ class KafkaAgentTest {
assertEquals("End offsets unavailable for 1 partition(s)", group.get("error"));
}
@Test
void groupDescriptionsShareOneDeadlineInsteadOfOneTimeoutPerGroup() {
// Coordinators that never answer used to cost the full timeout per group (#13122).
List<String> groupIds = List.of("a", "b", "c", "d", "e");
Map<String, KafkaFuture<ConsumerGroupDescription>> pending = new HashMap<>();
for (String groupId : groupIds) pending.put(groupId, new KafkaFutureImpl<>());
List<String> failed = new ArrayList<>();
long start = System.currentTimeMillis();
Map<String, ConsumerGroupDescription> descriptions = KafkaAgent.awaitGroupDescriptions(
new DescribeConsumerGroupsResult(pending),
groupIds,
start + 300,
(groupId, message) -> failed.add(groupId)
);
long elapsed = System.currentTimeMillis() - start;
assertTrue(descriptions.isEmpty());
assertEquals(groupIds, failed);
assertTrue(elapsed < 1000, "waited " + elapsed + "ms; five groups must share one 300ms deadline");
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void consumerGroupQueriesKeepCommittedTopicsWhenDescriptionsTimeOut(boolean snapshot) throws Exception {
TopicPartition partition = new TopicPartition("orders", 0);
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
try {
Admin admin = (Admin) Proxy.newProxyInstance(Admin.class.getClassLoader(), new Class<?>[] {Admin.class},
(proxy, method, args) -> {
return switch (method.getName()) {
case "listConsumerGroups" -> {
var constructor = ListConsumerGroupsResult.class.getDeclaredConstructor(KafkaFuture.class);
constructor.setAccessible(true);
yield constructor.newInstance(KafkaFuture.completedFuture(List.of(
new ConsumerGroupListing("orders-service", false),
new ConsumerGroupListing("slow-group", false)
)));
}
case "describeConsumerGroups" -> new DescribeConsumerGroupsResult(Map.of(
"orders-service", KafkaFuture.completedFuture(new ConsumerGroupDescription(
"orders-service", false, List.of(), "", ConsumerGroupState.EMPTY, Node.noNode()
)),
"slow-group", new KafkaFutureImpl<>()
));
case "listConsumerGroupOffsets" -> {
// Model an offsets response that needs a small amount of network time.
// If descriptions consumed the deadline before this request was sent,
// the broker cannot return even these independently available offsets.
int timeout = ((ListConsumerGroupOffsetsOptions) args[1]).timeoutMs();
KafkaFutureImpl<Map<TopicPartition, OffsetAndMetadata>> offsets = new KafkaFutureImpl<>();
executor.schedule(() -> {
if (timeout < 50) {
offsets.completeExceptionally(new org.apache.kafka.common.errors.TimeoutException("offsets deadline expired"));
} else {
offsets.complete(Map.of(partition, new OffsetAndMetadata(3)));
}
}, Math.min(50, timeout), TimeUnit.MILLISECONDS);
var constructor = ListConsumerGroupOffsetsResult.class.getDeclaredConstructor(Map.class);
constructor.setAccessible(true);
yield constructor.newInstance(Map.of(
CoordinatorKey.byGroupId("orders-service"), offsets,
CoordinatorKey.byGroupId("slow-group"), offsets
));
}
case "listOffsets" -> new ListOffsetsResult(Map.of(
partition, KafkaFuture.completedFuture(new ListOffsetsResult.ListOffsetsResultInfo(5, -1, Optional.empty()))
));
default -> throw new UnsupportedOperationException(method.getName());
};
});
JsonObject params = new JsonObject();
params.addProperty("timeout_ms", 300);
params.addProperty("topic", "orders");
Object result = snapshot
? KafkaAgent.getConsumerGroupSnapshot(params, admin)
: KafkaAgent.listConsumerGroups(params, admin);
var groups = new Gson().toJsonTree(result).getAsJsonObject().getAsJsonArray("groups");
assertEquals(2, groups.size());
for (var row : groups) {
JsonObject group = row.getAsJsonObject();
if (snapshot) {
assertEquals(1, group.getAsJsonArray("topics").size(), "committed topic survives description timeout");
assertEquals("orders", group.getAsJsonArray("topics").get(0).getAsString());
assertEquals(3, group.getAsJsonArray("partitions").get(0).getAsJsonObject().get("currentOffset").getAsInt());
if (group.get("groupId").getAsString().equals("slow-group")) {
assertTrue(group.get("error").getAsString().contains("Consumer group details unavailable"));
assertFalse(group.has("memberCount"));
}
} else {
assertEquals(!group.get("groupId").getAsString().equals("slow-group"), group.get("membersAvailable").getAsBoolean());
assertEquals(1, group.getAsJsonArray("committedOffsets").size(), "committed offsets survive description timeout");
assertEquals("orders", group.getAsJsonArray("committedOffsets").get(0).getAsJsonObject().get("topic").getAsString());
assertEquals(3, group.getAsJsonArray("committedOffsets").get(0).getAsJsonObject().get("offset").getAsInt());
}
}
} finally {
executor.shutdownNow();
}
}
@Test
void consumerGroupListRowsFilterToTheRequestedTopicAndSortByPartition() {
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
@@ -828,21 +950,21 @@ class KafkaAgentTest {
@Test
void offsetStartPositionAllowsAllPartitionsButRequiresANonNegativeOffset() {
assertDoesNotThrow(() ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, null, 0L));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, null, 0L, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, null));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, null, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, -1, 0L));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, -1, 0L, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, -1L));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.OFFSET, true, 0, -1L, null));
}
@Test
void nonOffsetStartPositionsRejectAnOffset() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, 0, 7L));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, 0, 7L, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, 0, 7L));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, 0, 7L, null));
}
@Test
@@ -855,14 +977,14 @@ class KafkaAgentTest {
@Test
void everyStartPositionRejectsNegativePartitions() {
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, -1, null));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.LATEST, true, -1, null, null));
assertThrows(IllegalArgumentException.class, () ->
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, -1, null));
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, true, -1, null, null));
}
@Test
void legacyOffsetWithoutStartPositionKeepsTheExistingReadBehavior() {
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, false, null, 7L);
KafkaAgent.validatePeekRequest(KafkaAgent.PeekStartPosition.EARLIEST, false, null, 7L, null);
assertEquals(7L, KafkaAgent.requestedPeekOffset(
KafkaAgent.PeekStartPosition.EARLIEST, 7L, true, 0L, 10L
));
@@ -1551,4 +1673,36 @@ class KafkaAgentTest {
}
}
}
@Test
void peekAcceptsTimestampStartPosition() {
assertEquals(
KafkaAgent.PeekStartPosition.TIMESTAMP,
KafkaAgent.peekStartPosition(JsonParser.parseString("{\"startPosition\":\"timestamp\"}").getAsJsonObject())
);
assertDoesNotThrow(() -> KafkaAgent.validatePeekRequest(
KafkaAgent.PeekStartPosition.TIMESTAMP, true, null, null, 1_700_000_000_000L));
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatePeekRequest(
KafkaAgent.PeekStartPosition.TIMESTAMP, true, null, null, null));
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatePeekRequest(
KafkaAgent.PeekStartPosition.TIMESTAMP, true, null, null, -1L));
assertThrows(IllegalArgumentException.class, () -> KafkaAgent.validatePeekRequest(
KafkaAgent.PeekStartPosition.EARLIEST, true, null, null, 1_700_000_000_000L));
}
@Test
void peekTimestampOffsetsDropsPartitionsWithoutAMatch() {
// Kafka answers null for a partition whose newest record is older than the requested
// time. Dropping it keeps a time window from silently showing data from outside itself.
TopicPartition withData = new TopicPartition("t", 0);
TopicPartition tooOld = new TopicPartition("t", 1);
Map<TopicPartition, OffsetAndTimestamp> located = new LinkedHashMap<>();
located.put(withData, new OffsetAndTimestamp(42L, 1_700_000_000_000L));
located.put(tooOld, null);
Map<TopicPartition, Long> resolved = KafkaAgent.peekTimestampOffsets(located);
assertEquals(42L, resolved.get(withData));
assertFalse(resolved.containsKey(tooOld));
}
}
+72 -87
View File
@@ -126,6 +126,7 @@ type objectInfo struct {
ParentSchema *string `json:"parent_schema,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
Comment *string `json:"comment"`
Signature *string `json:"signature,omitempty"`
Valid *bool `json:"valid,omitempty"`
CustomTypeKind *string `json:"custom_type_kind,omitempty"`
HasMembers *bool `json:"has_members,omitempty"`
@@ -1671,34 +1672,11 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
}
}
if constraintsAllowRoutines(constraints) {
var query string
if s.mode.mysqlCompat {
query = fmt.Sprintf(`SELECT ROUTINE_NAME, ROUTINE_TYPE, ROUTINE_COMMENT
FROM information_schema.routines
WHERE ROUTINE_SCHEMA = %s
ORDER BY ROUTINE_NAME`, quoteLiteral(effective))
} else {
catalog := "sys_catalog"
function := "sys"
if s.mode.postgresCatalog {
catalog, function = "pg_catalog", "pg"
}
query = fmt.Sprintf(`SELECT p.proname, CASE WHEN p.prorettype = 2278 THEN 'PROCEDURE' ELSE 'FUNCTION' END, d.description
FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid = p.pronamespace
LEFT JOIN %s.%s_description d ON d.objoid = p.oid AND d.objsubid = 0
WHERE n.nspname = %s ORDER BY p.proname`, catalog, function, catalog, function, catalog, function, quoteLiteral(effective))
}
rows, queryErr := s.metadataQuery(query)
if queryErr == nil {
for rows.Next() {
var name, kind string
var comment sql.NullString
if rows.Scan(&name, &kind, &comment) == nil {
result = append(result, objectInfo{Name: name, ObjectType: kind, Schema: effective, Comment: nullStringPtr(comment)})
}
}
_ = rows.Close()
routines, routineErr := s.listRoutineObjects(effective)
if routineErr != nil {
return nil, fmt.Errorf("list routines in schema %q: %w", effective, routineErr)
}
result = append(result, routines...)
}
if constraintsAllowTriggers(constraints) {
triggers, triggerErr := s.listTriggerObjects(effective)
@@ -1980,10 +1958,7 @@ func (s *server) cacheMissingColumnGenerationCapability(err error) bool {
func applyColumnGenerationMetadata(column *columnInfo, identity, generated string, expression sql.NullString) error {
identity = strings.ToLower(strings.TrimSpace(identity))
generated = strings.ToLower(strings.TrimSpace(generated))
if generated != "" {
if generated != "s" || identity != "" {
return fmt.Errorf("column %q has unsupported generation metadata (identity=%q, generated=%q)", column.Name, identity, generated)
}
if generated == "s" {
if !expression.Valid || strings.TrimSpace(expression.String) == "" {
return fmt.Errorf("generated column %q has no generation expression", column.Name)
}
@@ -1991,11 +1966,12 @@ func applyColumnGenerationMetadata(column *columnInfo, identity, generated strin
// in the established extra clause, never in column_default.
column.Extra = stringPtr("GENERATED ALWAYS AS (" + expression.String + ") STORED")
column.ColumnDefault = nil
} else if identity != "" {
column.Extra = kingbaseIdentityClause(identity)
if column.Extra == nil {
return fmt.Errorf("column %q has unsupported identity metadata %q", column.Name, identity)
}
return nil
}
// Unrecognised identity codes (KingbaseES reports e.g. "i") keep the plain
// column default instead of failing the whole table load.
if clause := kingbaseIdentityClause(identity); clause != nil {
column.Extra = clause
column.ColumnDefault = nil
}
return nil
@@ -2780,6 +2756,10 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
}
func (s *server) getObjectSourceForRelation(schema, name, objectType, relationName string) (map[string]any, error) {
return s.getObjectSourceWithSignature(schema, name, objectType, relationName, nil)
}
func (s *server) getObjectSourceWithSignature(schema, name, objectType, relationName string, signature *string) (map[string]any, error) {
effective, err := s.effectiveSchema(schema)
if err != nil {
return nil, err
@@ -2787,60 +2767,11 @@ func (s *server) getObjectSourceForRelation(schema, name, objectType, relationNa
source := ""
kind := strings.ToUpper(objectType)
if kind == "VIEW" {
if s.mode.mysqlCompat {
err = s.requireDBQueryRow("SELECT view_definition FROM information_schema.views WHERE table_schema = "+quoteLiteral(effective)+" AND table_name = "+quoteLiteral(name), &source)
} else {
catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef"
if s.mode.postgresCatalog {
catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef"
} else if s.usePgViewDefinition {
function = "pg_get_viewdef"
}
querySource := func(definitionFunction string) error {
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
return s.requireDBQueryRow(query, &source)
}
err = querySource(function)
if err != nil && function == "sys_get_viewdef" && isUndefinedFunction(err, function) {
s.usePgViewDefinition = true
err = querySource("pg_get_viewdef")
}
}
source, err = s.getViewSource(effective, name)
} else if kind == "MATERIALIZED_VIEW" {
source, err = s.getMaterializedViewSource(effective, name)
} else if kind == "FUNCTION" || kind == "PROCEDURE" {
catalog, prefix, function := "sys_catalog", "sys", "sys_get_functiondef"
if s.mode.postgresCatalog {
catalog, prefix, function = "pg_catalog", "pg", "pg_get_functiondef"
} else if s.usePgFunctionDefinition {
function = "pg_get_functiondef"
}
querySource := func(definitionFunction string) error {
query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name))
return s.requireDBQueryRow(query, &source)
}
queryLegacySource := func() error {
legacyFunction := "GET_FUNC_DDL"
if kind == "PROCEDURE" {
legacyFunction = "GET_PROCEDURE_DDL"
}
query := fmt.Sprintf("SELECT DBMS_METADATA.%s(CAST(%s AS varchar(128)), CAST(%s AS varchar(128)))", legacyFunction, quoteLiteral(name), quoteLiteral(effective))
return s.requireDBQueryRow(query, &source)
}
if s.useLegacyRoutineDefinition {
err = queryLegacySource()
} else {
err = querySource(function)
if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) {
s.usePgFunctionDefinition = true
function = "pg_get_functiondef"
err = querySource(function)
}
if err != nil && !s.mode.postgresCatalog && function == "pg_get_functiondef" && isUndefinedFunction(err, function) {
s.useLegacyRoutineDefinition = true
err = queryLegacySource()
}
}
source, err = s.getRoutineSource(effective, name, kind, signature)
} else if kind == "TRIGGER" {
definitions, triggerErr := s.listTriggerDefinitionsFor(effective, relationName, name)
if triggerErr != nil {
@@ -2863,6 +2794,60 @@ func (s *server) getObjectSourceForRelation(schema, name, objectType, relationNa
return result, nil
}
func (s *server) getViewSource(schema, name string) (string, error) {
querySource := func(query string) (string, error) {
var source sql.NullString
if err := s.requireDBQueryRow(query, &source); err != nil {
return "", err
}
if !source.Valid || strings.TrimSpace(source.String) == "" {
return "", sql.ErrNoRows
}
return source.String, nil
}
if s.mode.mysqlCompat {
// information_schema can expose a view while hiding its definition from
// a non-owner with SELECT privileges. Try the catalogs as the same user.
source, err := querySource("SELECT view_definition FROM information_schema.views WHERE table_schema = " + quoteLiteral(schema) + " AND table_name = " + quoteLiteral(name))
if err == nil {
return source, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
}
catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef"
if s.mode.postgresCatalog {
catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef"
} else if s.usePgViewDefinition {
function = "pg_get_viewdef"
}
queryCatalog := func(definitionFunction string) (string, error) {
query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s AND c.relkind = 'v' LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(name))
return querySource(query)
}
source, err := queryCatalog(function)
if err == nil {
return source, nil
}
if function == "sys_get_viewdef" {
if isUndefinedFunction(err, function) {
s.usePgViewDefinition = true
} else if !errors.Is(err, sql.ErrNoRows) {
return "", err
}
source, err = queryCatalog("pg_get_viewdef")
if err == nil {
return source, nil
}
}
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("view %q.%q does not exist or its source definition is unavailable to the current user", schema, name)
}
return "", err
}
func (s *server) getMaterializedViewSource(schema, name string) (string, error) {
catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef"
if s.mode.postgresCatalog {
+13 -1
View File
@@ -142,6 +142,8 @@ type server struct {
usePgViewDefinition bool
usePgFunctionDefinition bool
useLegacyRoutineDefinition bool
routineKindUnsupported bool
routineIdentityFallback int
catalogIdentityUnsupported bool
catalogGeneratedUnsupported bool
catalogOIDUnsupported bool
@@ -424,7 +426,13 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "get_object_source":
result, err := s.getObjectSourceForRelation(stringParam(params, "schema"), stringParam(params, "name"), stringParam(params, "object_type"), stringParam(params, "relation_name"))
var signature *string
if raw, ok := params["signature"]; ok {
if err := json.Unmarshal(raw, &signature); err != nil {
return nil, false, fmt.Errorf("invalid routine signature: %w", err)
}
}
result, err := s.getObjectSourceWithSignature(stringParam(params, "schema"), stringParam(params, "name"), stringParam(params, "object_type"), stringParam(params, "relation_name"), signature)
return result, false, err
case "get_type_details":
result, err := s.getTypeDetails(stringParam(params, "schema"), stringParam(params, "name"))
@@ -489,6 +497,8 @@ func (s *server) connect(cp connectParams) error {
s.usePgViewDefinition = false
s.usePgFunctionDefinition = false
s.useLegacyRoutineDefinition = false
s.routineKindUnsupported = false
s.routineIdentityFallback = 0
s.catalogIdentityUnsupported = false
s.catalogGeneratedUnsupported = false
s.catalogOIDUnsupported = false
@@ -691,6 +701,8 @@ func (s *server) disconnect() error {
s.usePgViewDefinition = false
s.usePgFunctionDefinition = false
s.useLegacyRoutineDefinition = false
s.routineKindUnsupported = false
s.routineIdentityFallback = 0
s.catalogIdentityUnsupported = false
s.catalogGeneratedUnsupported = false
s.catalogOIDUnsupported = false
+38 -35
View File
@@ -2673,8 +2673,8 @@ func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
}, nil
case strings.Contains(query, "sys_proc p"):
return &valueRows{
columns: []string{"proname", "kind", "comment"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
columns: []string{"proname", "kind", "comment", "signature"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil, ""}},
}, nil
case strings.Contains(query, "sys_class c"):
return &valueRows{
@@ -2708,15 +2708,15 @@ func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
func TestListObjectsIncludesMySQLCompatRoutines(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM information_schema.routines"):
if !strings.Contains(query, "WHERE ROUTINE_SCHEMA = 'team''s'") {
case strings.Contains(query, "FROM sys_catalog.sys_proc p"):
if !strings.Contains(query, "n.nspname = 'team''s'") {
return nil, errors.New("routine query did not quote the schema: " + query)
}
return &valueRows{
columns: []string{"routine_name", "routine_type", "routine_comment"},
columns: []string{"routine_name", "routine_type", "comment", "signature"},
rows: [][]driver.Value{
{"format_name", "FUNCTION", "formats a name"},
{"refresh_cache", "PROCEDURE", nil},
{"format_name", "FUNCTION", "formats a name", "value text"},
{"refresh_cache", "PROCEDURE", nil, ""},
},
}, nil
default:
@@ -2741,7 +2741,7 @@ func TestListObjectsIncludesMySQLCompatRoutines(t *testing.T) {
t.Fatalf("unexpected function: %#v", objects[1])
}
queries := state.snapshotQueries()
if len(queries) != 1 || !strings.Contains(queries[0], "FROM information_schema.routines") {
if len(queries) != 1 || !strings.Contains(queries[0], "FROM sys_catalog.sys_proc p") {
t.Fatalf("expected exactly one routine query, got %v", queries)
}
}
@@ -2754,14 +2754,14 @@ func TestListObjectsFiltersSortsAndPagesMySQLCompatObjects(t *testing.T) {
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
rows: [][]driver.Value{{"match_table", "TABLE", nil, nil, nil}},
}, nil
case strings.Contains(query, "FROM information_schema.routines"):
case strings.Contains(query, "FROM sys_catalog.sys_proc p"):
return &valueRows{
columns: []string{"routine_name", "routine_type", "routine_comment"},
columns: []string{"routine_name", "routine_type", "comment", "signature"},
rows: [][]driver.Value{
{"z_match_fn", "FUNCTION", nil},
{"other_fn", "FUNCTION", nil},
{"match_proc", "PROCEDURE", nil},
{"a_match_fn", "FUNCTION", nil},
{"z_match_fn", "FUNCTION", nil, ""},
{"other_fn", "FUNCTION", nil, ""},
{"match_proc", "PROCEDURE", nil, ""},
{"a_match_fn", "FUNCTION", nil, ""},
},
}, nil
default:
@@ -2794,14 +2794,14 @@ func TestListObjectsFiltersMySQLCompatRoutineKindsWithoutTableScan(t *testing.T)
for _, objectType := range []string{"FUNCTION", "PROCEDURE"} {
t.Run(objectType, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "FROM information_schema.routines") {
if !strings.Contains(query, "FROM sys_catalog.sys_proc p") {
return nil, errors.New("routine-only request must not scan tables: " + query)
}
return &valueRows{
columns: []string{"routine_name", "routine_type", "routine_comment"},
columns: []string{"routine_name", "routine_type", "comment", "signature"},
rows: [][]driver.Value{
{"format_name", "FUNCTION", nil},
{"refresh_cache", "PROCEDURE", nil},
{"format_name", "FUNCTION", nil, ""},
{"refresh_cache", "PROCEDURE", nil, ""},
},
}, nil
}}
@@ -2817,7 +2817,7 @@ func TestListObjectsFiltersMySQLCompatRoutineKindsWithoutTableScan(t *testing.T)
t.Fatalf("expected only %s, got %#v", objectType, objects)
}
queries := state.snapshotQueries()
if len(queries) != 1 || !strings.Contains(queries[0], "FROM information_schema.routines") {
if len(queries) != 1 || !strings.Contains(queries[0], "FROM sys_catalog.sys_proc p") {
t.Fatalf("expected one routine query, got %v", queries)
}
})
@@ -2828,7 +2828,7 @@ func TestListObjectsSkipsMySQLCompatRoutineQueryForNonRoutineConstraints(t *test
for _, objectType := range []string{"TABLE", "TYPE"} {
t.Run(objectType, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if strings.Contains(query, "information_schema.routines") {
if strings.Contains(query, "sys_proc p") || strings.Contains(query, "information_schema.routines") {
return nil, errors.New("non-routine request must not query routines: " + query)
}
if objectType == "TABLE" && strings.Contains(query, "FROM sys_catalog.sys_class c") {
@@ -2870,8 +2870,8 @@ func TestListObjectsUsesCatalogRoutinesOutsideMySQLCompat(t *testing.T) {
return nil, errors.New("unexpected query: " + query)
}
return &valueRows{
columns: []string{"proname", "kind", "comment"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
columns: []string{"proname", "kind", "comment", "signature"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil, ""}},
}, nil
}}
server := newServer()
@@ -2972,7 +2972,8 @@ func TestListObjectsTriggerFallbackExcludesConstraintTriggers(t *testing.T) {
}
}
func TestListObjectsKeepsTablesWhenMySQLCompatRoutineQueryFails(t *testing.T) {
func TestListObjectsPropagatesMySQLCompatRoutineQueryFailure(t *testing.T) {
routineErr := errors.New("routine catalog unavailable")
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
@@ -2980,8 +2981,8 @@ func TestListObjectsKeepsTablesWhenMySQLCompatRoutineQueryFails(t *testing.T) {
columns: []string{"relname", "relkind", "comment", "parent_schema", "parent_name"},
rows: [][]driver.Value{{"orders", "TABLE", "orders table", nil, nil}},
}, nil
case strings.Contains(query, "FROM information_schema.routines"):
return nil, errors.New("routine catalog unavailable")
case strings.Contains(query, "FROM sys_catalog.sys_proc p"):
return nil, routineErr
default:
return nil, errors.New("unexpected query: " + query)
}
@@ -2991,15 +2992,12 @@ func TestListObjectsKeepsTablesWhenMySQLCompatRoutineQueryFails(t *testing.T) {
server.mode.mysqlCompat = true
objects, err := server.listObjects("public", metadataListConstraints{})
if err != nil {
t.Fatal(err)
}
if len(objects) != 1 || objects[0].Name != "orders" || objects[0].ObjectType != "TABLE" || objects[0].Comment == nil || *objects[0].Comment != "orders table" {
t.Fatalf("table must survive a best-effort routine query failure: %#v", objects)
if !errors.Is(err, routineErr) || len(objects) != 0 {
t.Fatalf("an incomplete list must not be returned as a success: objects=%#v err=%v", objects, err)
}
queries := state.snapshotQueries()
if len(queries) != 2 || !strings.Contains(queries[1], "FROM information_schema.routines") {
t.Fatalf("unexpected best-effort query sequence: %v", queries)
if len(queries) != 2 || !strings.Contains(queries[1], "FROM sys_catalog.sys_proc p") {
t.Fatalf("unexpected query sequence after routine failure: %v", queries)
}
}
@@ -3099,8 +3097,8 @@ func TestListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) {
switch {
case strings.Contains(query, "sys_proc p"):
return &valueRows{
columns: []string{"proname", "kind", "comment"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
columns: []string{"proname", "kind", "comment", "signature"},
rows: [][]driver.Value{{"format_name", "FUNCTION", nil, ""}},
}, nil
case strings.Contains(query, "sys_class c"):
return &valueRows{
@@ -3740,6 +3738,8 @@ func TestObjectSourceDoesNotFallbackOnUnrelatedErrors(t *testing.T) {
func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "COUNT(*)"):
return &valueRows{columns: []string{"count"}, rows: [][]driver.Value{{int64(1)}}}, nil
case strings.Contains(query, "sys_get_functiondef("):
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function SYS_GET_FUNCTIONDEF(OID) does not exist"}
case strings.Contains(query, "pg_get_functiondef("):
@@ -3767,7 +3767,7 @@ func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
}
queries := state.snapshotQueries()
if len(queries) != 4 || !strings.Contains(queries[2], "DBMS_METADATA.GET_FUNC_DDL") || !strings.Contains(queries[3], "DBMS_METADATA.GET_FUNC_DDL") || !server.useLegacyRoutineDefinition {
if len(queries) != 6 || !strings.Contains(queries[3], "DBMS_METADATA.GET_FUNC_DDL") || !strings.Contains(queries[5], "DBMS_METADATA.GET_FUNC_DDL") || !server.useLegacyRoutineDefinition {
t.Fatalf("V7 routine DDL choice was not cached: %v", queries)
}
}
@@ -3977,6 +3977,9 @@ func TestColumnDDLDefinitionPreservesCompatibilityExtras(t *testing.T) {
if extra := kingbaseIdentityClause(""); extra != nil {
t.Fatalf("non-identity column must not expose an extra clause: %#v", extra)
}
if extra := kingbaseIdentityClause("i"); extra != nil {
t.Fatalf("unrecognised identity code must not expose an extra clause: %#v", extra)
}
}
func TestRenderTableDDLUsesBacktickIdentifiersInMySQLCompatMode(t *testing.T) {
@@ -17,7 +17,7 @@ func generationMetadataRows(query, identity, generated string, expression driver
if strings.Contains(query, "FROM information_schema.columns c") {
columns := []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"}
values := []driver.Value{"value", "integer", "integer", "YES", expression, nil, int64(32), int64(0), nil}
if generated != "" {
if generated == "s" {
values[4] = nil
}
if strings.Contains(query, "AS generation_expression") {
@@ -47,6 +47,7 @@ func TestGenerationMetadataPreservesColumnSemanticsAcrossQueryPaths(t *testing.T
{name: "stored generated", generated: "s", expression: `(quantity * price)`, wantExtra: `GENERATED ALWAYS AS ((quantity * price)) STORED`},
{name: "always identity", identity: "a", expression: "nextval('public.value_seq'::regclass)", wantExtra: "GENERATED ALWAYS AS IDENTITY"},
{name: "default identity", identity: "d", expression: "nextval('public.value_seq'::regclass)", wantExtra: "GENERATED BY DEFAULT AS IDENTITY"},
{name: "mysql-compat identity i", identity: "i", expression: "nextval('public.value_seq'::regclass)", wantDefault: true},
} {
t.Run(fmt.Sprintf("%s/pgCatalog=%v/%s", mode, pgCatalog, test.name), func(t *testing.T) {
catalog, prefix := "sys_catalog", "sys"
@@ -133,16 +134,14 @@ func TestGeneratedCatalogCapabilityFallbackIsIndependentAndCached(t *testing.T)
}
}
func TestGenerationMetadataDoesNotSilentlyDowngradeUnknownOrIncompleteColumns(t *testing.T) {
func TestIncompleteGenerationMetadataReportsError(t *testing.T) {
for _, mysql := range []bool{false, true} {
for _, test := range []struct {
name, identity, generated string
expression driver.Value
}{
{name: "unknown generation", generated: "future", expression: "a * 2"},
{name: "missing expression", generated: "s"},
{name: "blank expression", generated: "s", expression: " "},
{name: "unknown identity", identity: "future"},
} {
t.Run(fmt.Sprintf("mysql=%v/%s", mysql, test.name), func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
@@ -157,7 +156,48 @@ func TestGenerationMetadataDoesNotSilentlyDowngradeUnknownOrIncompleteColumns(t
_, err = srv.queryCatalogColumns("public", "orders", "sys_catalog", "sys", "sys_get_expr")
}
if err == nil || !strings.Contains(err.Error(), "value") {
t.Fatalf("unsupported/incomplete generation must report the affected column: %v", err)
t.Fatalf("incomplete generation must report the affected column: %v", err)
}
})
}
}
}
func TestUnknownGenerationOrIdentityMetadataDegradesGracefully(t *testing.T) {
for _, mysql := range []bool{false, true} {
for _, test := range []struct {
name, identity, generated string
expression driver.Value
}{
{name: "unknown generation", generated: "future", expression: "a * 2"},
{name: "unknown identity", identity: "future", expression: "42"},
{name: "mysql-compat identity", identity: "i", expression: "nextval('public.orders_id_seq'::regclass)"},
} {
t.Run(fmt.Sprintf("mysql=%v/%s", mysql, test.name), func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
return generationMetadataRows(query, test.identity, test.generated, test.expression), nil
}}
srv := newServer()
srv.db = openMetadataDB(t, state)
var columns []columnInfo
var err error
if mysql {
columns, err = srv.informationSchemaColumns("public", "orders", nil)
} else {
columns, err = srv.queryCatalogColumns("public", "orders", "sys_catalog", "sys", "sys_get_expr")
}
if err != nil {
t.Fatalf("unknown metadata must not error: %v", err)
}
if len(columns) != 1 {
t.Fatalf("expected 1 column, got %d", len(columns))
}
col := columns[0]
if col.Extra != nil {
t.Fatalf("unknown metadata must leave Extra nil, got: %v", *col.Extra)
}
if col.ColumnDefault == nil || *col.ColumnDefault != test.expression {
t.Fatalf("default was not preserved: %#v", col.ColumnDefault)
}
})
}
@@ -0,0 +1,205 @@
package main
import (
"context"
"database/sql"
"fmt"
"strings"
)
func (s *server) routineCatalog() (string, string) {
if s.mode.postgresCatalog {
return "pg_catalog", "pg"
}
return "sys_catalog", "sys"
}
// Retry only missing catalog capabilities. Permission, transport and scan
// failures must not turn a partially loaded routine list into a success.
func (s *server) routineQuery(withIdentity bool, build func(kind, filter, identity string) string) (*sql.Rows, error) {
return s.routineQueryContext(context.Background(), withIdentity, build)
}
func (s *server) routineQueryContext(ctx context.Context, withIdentity bool, build func(kind, filter, identity string) string) (*sql.Rows, error) {
db, err := s.requireDB()
if err != nil {
return nil, err
}
_, prefix := s.routineCatalog()
for {
kind := "CASE p.prokind WHEN 'p' THEN 'PROCEDURE' WHEN 'f' THEN 'FUNCTION' END"
filter := "p.prokind IN ('p', 'f')"
if s.routineKindUnsupported {
// Preserve the pre-prokind Kingbase catalog behavior only after the
// server explicitly reports that the modern discriminator is absent.
kind = "CASE WHEN p.prorettype = 2278 THEN 'PROCEDURE' ELSE 'FUNCTION' END"
filter = "true"
}
identityFunction := prefix + "_get_function_identity_arguments"
identity := identityFunction + "(p.oid)"
if s.routineIdentityFallback == 1 {
identityFunction = "pg_get_function_identity_arguments"
identity = identityFunction + "(p.oid)"
} else if s.routineIdentityFallback == 2 {
identityFunction = "oidvectortypes"
identity = identityFunction + "(p.proargtypes)"
}
rows, err := db.QueryContext(ctx, build(kind, filter, identity))
if err == nil {
return rows, nil
}
if !s.routineKindUnsupported && isUndefinedColumn(err, "prokind") {
s.routineKindUnsupported = true
continue
}
if withIdentity && s.routineIdentityFallback < 2 && isUndefinedFunction(err, identityFunction) {
if identityFunction == "sys_get_function_identity_arguments" {
s.routineIdentityFallback = 1
} else {
s.routineIdentityFallback = 2
}
continue
}
return nil, err
}
}
func (s *server) listRoutineObjects(schema string) ([]objectInfo, error) {
catalog, prefix := s.routineCatalog()
rows, err := s.routineQuery(true, func(kind, filter, identity string) string {
return fmt.Sprintf(`SELECT p.proname, %s, d.description, %s AS signature
FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid = p.pronamespace
LEFT JOIN %s.%s_description d ON d.objoid = p.oid AND d.objsubid = 0
WHERE n.nspname = %s AND %s ORDER BY p.proname, signature`, kind, identity, catalog, prefix, catalog, prefix, catalog, prefix, quoteLiteral(schema), filter)
})
if err != nil {
return nil, err
}
defer rows.Close()
result := []objectInfo{}
for rows.Next() {
var name, kind, signature string
var comment sql.NullString
if err := rows.Scan(&name, &kind, &comment, &signature); err != nil {
return nil, err
}
// Empty identifies the zero-argument overload and must survive JSON.
result = append(result, objectInfo{Name: name, ObjectType: kind, Schema: schema, Comment: nullStringPtr(comment), Signature: &signature})
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (s *server) getRoutineSource(schema, name, kind string, signature *string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
defer cancel()
catalog, prefix := s.routineCatalog()
function := prefix + "_get_functiondef"
if s.usePgFunctionDefinition {
function = "pg_get_functiondef"
}
querySource := func(definitionFunction string) (string, error) {
rows, err := s.routineQueryContext(ctx, signature != nil, func(kindExpression, filter, identity string) string {
if signature != nil {
filter += " AND " + identity + " = " + quoteLiteral(*signature)
}
return fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s AND (%s)=%s AND %s ORDER BY p.oid LIMIT 2", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(name), kindExpression, quoteLiteral(kind), filter)
})
if err != nil {
return "", err
}
defer rows.Close()
var source sql.NullString
count := 0
for rows.Next() {
if err := rows.Scan(&source); err != nil {
return "", err
}
count++
}
if err := rows.Err(); err != nil {
return "", err
}
if count > 1 {
return "", fmt.Errorf("routine %q.%q is ambiguous; signature is required to select an overload", schema, name)
}
if count == 0 || !source.Valid || strings.TrimSpace(source.String) == "" {
return "", fmt.Errorf("routine %q.%q with the requested type and signature does not exist or its source is unavailable", schema, name)
}
return source.String, nil
}
if s.useLegacyRoutineDefinition {
return s.getLegacyRoutineSource(ctx, schema, name, kind, signature)
}
source, err := querySource(function)
if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) {
s.usePgFunctionDefinition = true
function = "pg_get_functiondef"
source, err = querySource(function)
}
if err != nil && !s.mode.postgresCatalog && function == "pg_get_functiondef" && isUndefinedFunction(err, function) {
s.useLegacyRoutineDefinition = true
return s.getLegacyRoutineSource(ctx, schema, name, kind, signature)
}
return source, err
}
func (s *server) getLegacyRoutineSource(ctx context.Context, schema, name, kind string, signature *string) (string, error) {
catalog, prefix := s.routineCatalog()
countMatches := func(matchSignature bool) (int, error) {
rows, err := s.routineQueryContext(ctx, matchSignature, func(kindExpression, filter, identity string) string {
if matchSignature {
filter += " AND " + identity + " = " + quoteLiteral(*signature)
}
return fmt.Sprintf("SELECT COUNT(*) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s AND (%s)=%s AND %s", catalog, prefix, catalog, prefix, quoteLiteral(schema), quoteLiteral(name), kindExpression, quoteLiteral(kind), filter)
})
if err != nil {
return 0, err
}
defer rows.Close()
var count int
for rows.Next() {
if err := rows.Scan(&count); err != nil {
return 0, err
}
}
return count, rows.Err()
}
// The V7 DDL API takes only a name. Even a supplied signature cannot make
// that API choose between overloads, so only a unique routine is safe.
count, err := countMatches(false)
if err != nil {
return "", err
}
if count > 1 {
return "", fmt.Errorf("legacy routine source API cannot select overloaded routine %q.%q", schema, name)
}
if count == 1 && signature != nil {
count, err = countMatches(true)
if err != nil {
return "", err
}
}
if count != 1 {
return "", fmt.Errorf("routine %q.%q with the requested type and signature does not exist", schema, name)
}
function := "GET_FUNC_DDL"
if kind == "PROCEDURE" {
function = "GET_PROCEDURE_DDL"
}
query := fmt.Sprintf("SELECT DBMS_METADATA.%s(CAST(%s AS varchar(128)), CAST(%s AS varchar(128)))", function, quoteLiteral(name), quoteLiteral(schema))
var source sql.NullString
db, err := s.requireDB()
if err != nil {
return "", err
}
if err := db.QueryRowContext(ctx, query).Scan(&source); err != nil {
return "", err
}
if !source.Valid || strings.TrimSpace(source.String) == "" {
return "", fmt.Errorf("routine %q.%q source is unavailable", schema, name)
}
return source.String, nil
}
@@ -0,0 +1,88 @@
package main
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"testing"
"time"
"gitea.com/kingbase/gokb"
)
type routineDeadlineConnector struct {
query func(context.Context, string) (driver.Rows, error)
}
func (connector routineDeadlineConnector) Connect(context.Context) (driver.Conn, error) {
return &routineDeadlineConn{query: connector.query}, nil
}
func (routineDeadlineConnector) Driver() driver.Driver { return routineDeadlineDriver{} }
type routineDeadlineDriver struct{}
func (routineDeadlineDriver) Open(string) (driver.Conn, error) { return nil, driver.ErrSkip }
type routineDeadlineConn struct {
query func(context.Context, string) (driver.Rows, error)
}
func (*routineDeadlineConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (*routineDeadlineConn) Close() error { return nil }
func (*routineDeadlineConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (connection *routineDeadlineConn) QueryContext(ctx context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
return connection.query(ctx, query)
}
func TestRoutineSourceSharesDeadlineAcrossCapabilityAndLegacyFallbacks(t *testing.T) {
contexts := []context.Context{}
queries := []string{}
db := sql.OpenDB(routineDeadlineConnector{query: func(ctx context.Context, query string) (driver.Rows, error) {
contexts = append(contexts, ctx)
queries = append(queries, query)
if strings.Contains(query, "p.prokind") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column p.prokind does not exist"}
}
for _, function := range []string{"sys_get_function_identity_arguments", "pg_get_function_identity_arguments", "sys_get_functiondef", "pg_get_functiondef"} {
if strings.Contains(query, function+"(") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function " + function + "(oid) does not exist"}
}
}
if strings.Contains(query, "SELECT COUNT(*)") {
return &valueRows{columns: []string{"count"}, rows: [][]driver.Value{{int64(1)}}}, nil
}
if strings.Contains(query, "DBMS_METADATA.GET_PROCEDURE_DDL") {
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"CREATE PROCEDURE recovered_source"}}}, nil
}
return nil, errors.New("unexpected routine deadline probe query: " + query)
}})
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
s := newServer()
s.db = db
started := time.Now()
source, err := s.getObjectSourceWithSignature("public", "unique_name", "PROCEDURE", "", stringPtr("value integer"))
finished := time.Now()
if err != nil || source["source"] != "CREATE PROCEDURE recovered_source" {
t.Fatalf("fallback chain failed before deadline verification: source=%v err=%v", source, err)
}
if len(queries) != 8 || !strings.Contains(queries[len(queries)-1], "DBMS_METADATA.GET_PROCEDURE_DDL") {
t.Fatalf("expected kind, identity and source fallbacks followed by legacy validation: %v", queries)
}
deadline, ok := contexts[0].Deadline()
if !ok || deadline.Before(started.Add(metadataTimeout)) || deadline.After(finished.Add(metadataTimeout)) {
t.Fatalf("routine source did not forward the metadata timeout to the driver: deadline=%v present=%v", deadline, ok)
}
for index, ctx := range contexts {
got, ok := ctx.Deadline()
if !ok || !got.Equal(deadline) || ctx.Done() != contexts[0].Done() {
t.Fatalf("fallback %d reset or lost the shared deadline: want=%v got=%v present=%v", index, deadline, got, ok)
}
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatalf("source completion did not cancel the driver context for query %d: %v", index, ctx.Err())
}
}
}
@@ -0,0 +1,147 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestKingbaseRoutineMetadataIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
username := os.Getenv("KINGBASE_TEST_USERNAME")
if host == "" || portText == "" || username == "" {
t.Skip("Kingbase integration environment is not configured")
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
database := os.Getenv("KINGBASE_TEST_DATABASE")
if database == "" {
database = "test"
}
cp := connectParams{Host: host, Port: port, Database: database, Username: username, Password: os.Getenv("KINGBASE_TEST_PASSWORD")}
if strings.HasPrefix(host, "/") {
cp.URLParams = "sslmode=disable"
}
s := newServer()
if err := s.connect(cp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.disconnect() })
if s.mode.legacyV7 {
t.Skip("routine metadata integration requires Kingbase V8 procedure syntax")
}
schema := "dbx_routines_" + strconv.FormatInt(time.Now().UnixNano(), 36)
qualifiedSchema := quoteIdentifier(schema)
mustExecute(t, s, "CREATE SCHEMA "+qualifiedSchema)
t.Cleanup(func() {
for _, statement := range []string{
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".no_args()",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".input_only(integer)",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".inout_value(integer)",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".two_inout(integer, text)",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".overloaded()",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".overloaded(integer)",
"DROP PROCEDURE IF EXISTS " + qualifiedSchema + ".overloaded(text)",
"DROP FUNCTION IF EXISTS " + qualifiedSchema + ".void_function()",
"DROP FUNCTION IF EXISTS " + qualifiedSchema + ".scalar_function()",
"DROP SCHEMA IF EXISTS " + qualifiedSchema,
} {
if _, err := s.executeQuery(queryOptions{SQL: statement}); err != nil {
t.Errorf("cleanup %s: %v", statement, err)
}
}
})
for _, definition := range []string{
"PROCEDURE " + qualifiedSchema + ".no_args() LANGUAGE plpgsql AS $$ BEGIN NULL; END; $$",
"PROCEDURE " + qualifiedSchema + ".input_only(IN value integer) LANGUAGE plpgsql AS $$ BEGIN NULL; END; $$",
"PROCEDURE " + qualifiedSchema + ".inout_value(INOUT value integer) LANGUAGE plpgsql AS $$ BEGIN value := value + 1; END; $$",
"PROCEDURE " + qualifiedSchema + ".two_inout(INOUT value integer, INOUT label text) LANGUAGE plpgsql AS $$ BEGIN value := 13046; label := 'probe'; END; $$",
"PROCEDURE " + qualifiedSchema + ".overloaded() LANGUAGE plpgsql AS $$ BEGIN RAISE NOTICE 'no_args_overload_marker'; END; $$",
"PROCEDURE " + qualifiedSchema + ".overloaded(IN value integer) LANGUAGE plpgsql AS $$ BEGIN RAISE NOTICE 'integer_overload_marker'; END; $$",
"PROCEDURE " + qualifiedSchema + ".overloaded(IN value text) LANGUAGE plpgsql AS $$ BEGIN RAISE NOTICE 'text_overload_marker'; END; $$",
"FUNCTION " + qualifiedSchema + ".void_function() RETURNS void LANGUAGE plpgsql AS $$ BEGIN NULL; END; $$",
"FUNCTION " + qualifiedSchema + ".scalar_function() RETURNS integer LANGUAGE sql AS $$ SELECT 13046; $$",
} {
mustExecute(t, s, "CREATE "+definition)
}
mustExecute(t, s, "COMMENT ON PROCEDURE "+qualifiedSchema+".inout_value(integer) IS 'INOUT procedure comment'")
procedures, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}})
if err != nil || len(procedures) != 7 {
t.Fatalf("expected all seven procedures, including INOUT and overloads: objects=%+v err=%v", procedures, err)
}
functions, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"FUNCTION"}})
if err != nil || len(functions) != 2 {
t.Fatalf("expected scalar and void functions: objects=%+v err=%v", functions, err)
}
for _, item := range functions {
if item.Name != "void_function" && item.Name != "scalar_function" {
t.Fatalf("procedure was incorrectly classified as a function: %+v", item)
}
}
overloads := []objectInfo{}
for _, item := range procedures {
if item.ObjectType != "PROCEDURE" || item.Schema != schema {
t.Fatalf("routine type or schema was lost: %+v", item)
}
if item.Name == "inout_value" && (item.Comment == nil || *item.Comment != "INOUT procedure comment") {
t.Fatalf("procedure comment was lost: %+v", item)
}
if item.Name == "overloaded" {
overloads = append(overloads, item)
}
}
if len(overloads) != 3 {
t.Fatalf("overloaded procedures need distinct signatures: %+v", overloads)
}
if _, _, err := s.dispatch("get_object_source", map[string]json.RawMessage{
"schema": rawJSON(schema), "name": rawJSON("overloaded"), "object_type": rawJSON("PROCEDURE"),
}); err == nil || !strings.Contains(strings.ToLower(err.Error()), "ambiguous") {
t.Fatalf("an overloaded name without a signature must report ambiguity: %v", err)
}
markers := map[string]bool{}
signatures := map[string]bool{}
for _, item := range overloads {
if item.Signature == nil || signatures[*item.Signature] {
t.Fatalf("overload signature is missing or duplicated: %+v", item)
}
signatures[*item.Signature] = true
marker := "integer_overload_marker"
if *item.Signature == "" {
marker = "no_args_overload_marker"
} else if strings.Contains(*item.Signature, "text") {
marker = "text_overload_marker"
}
result, _, err := s.dispatch("get_object_source", map[string]json.RawMessage{
"schema": rawJSON(schema), "name": rawJSON(item.Name), "object_type": rawJSON(item.ObjectType), "signature": rawJSON(*item.Signature),
})
if err != nil {
t.Fatalf("source for overload %q failed: %v", *item.Signature, err)
}
source := fmt.Sprint(result.(map[string]any)["source"])
if !strings.Contains(source, marker) {
t.Fatalf("signature %q selected the wrong overload, expected %s: %s", *item.Signature, marker, source)
}
markers[marker] = true
}
if len(markers) != 3 || !signatures[""] {
t.Fatalf("different overload signatures did not select distinct source bodies: %v", markers)
}
first, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}, Filter: "overloaded", Limit: 1})
if err != nil || len(first) != 1 || first[0].Signature == nil {
t.Fatalf("first overload page failed: %+v err=%v", first, err)
}
second, err := s.listObjects(schema, metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}, Filter: "overloaded", Limit: 1, Offset: 1})
if err != nil || len(second) != 1 || second[0].Signature == nil || *first[0].Signature == *second[0].Signature {
t.Fatalf("overload paging duplicated or lost an identity: first=%+v second=%+v err=%v", first, second, err)
}
t.Logf("verified seven procedures, two functions, comments and all three overload sources (mode=%s)", s.mode.compatibilityMode)
}
@@ -0,0 +1,239 @@
package main
import (
"database/sql/driver"
"errors"
"strings"
"testing"
"gitea.com/kingbase/gokb"
)
func TestRoutineMetadataUsesCatalogKindAndPreservesOverloads(t *testing.T) {
for _, mode := range []struct {
name string
mode kingbaseMode
catalog string
}{
{name: "mysql", mode: kingbaseMode{mysqlCompat: true}, catalog: "sys_catalog.sys_proc"},
{name: "sys", catalog: "sys_catalog.sys_proc"},
{name: "pg", mode: kingbaseMode{postgresCatalog: true}, catalog: "pg_catalog.pg_proc"},
} {
t.Run(mode.name, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "FROM "+mode.catalog+" p") || !strings.Contains(query, "p.prokind") || !strings.Contains(query, "'team''s'") {
return nil, errors.New("routine query lost its catalog, kind or schema: " + query)
}
if strings.Contains(strings.ToLower(query), "routine_comment") || strings.Contains(query, "prorettype") {
return nil, errors.New("routine query must not rely on a missing comment column or a return-type classification: " + query)
}
if !strings.Contains(query, "ORDER BY p.proname, signature") {
return nil, errors.New("overloads need a stable database order before paging: " + query)
}
return &valueRows{columns: []string{"name", "kind", "comment", "signature"}, rows: [][]driver.Value{
{"inout_procedure", "PROCEDURE", "INOUT comment", "INOUT value integer"},
{"overloaded", "PROCEDURE", nil, ""},
{"overloaded", "PROCEDURE", nil, "value integer"},
{"overloaded", "PROCEDURE", nil, "value text"},
{"void_function", "FUNCTION", nil, ""},
}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
s.mode = mode.mode
objects, err := s.listObjects("team's", metadataListConstraints{ObjectTypes: []string{"PROCEDURE", "FUNCTION"}})
if err != nil || len(objects) != 5 {
t.Fatalf("routine list failed: objects=%+v err=%v", objects, err)
}
if objects[0].Name != "inout_procedure" || objects[0].ObjectType != "PROCEDURE" || objects[0].Comment == nil || *objects[0].Comment != "INOUT comment" {
t.Fatalf("INOUT procedure identity/comment was lost: %+v", objects[0])
}
if objects[4].Name != "void_function" || objects[4].ObjectType != "FUNCTION" {
t.Fatalf("void function was classified as a procedure: %+v", objects[4])
}
for index, signature := range []string{"", "value integer", "value text"} {
item := objects[index+1]
if item.Name != "overloaded" || item.Signature == nil || *item.Signature != signature || item.Schema != "team's" {
t.Fatalf("overload identity/order was lost: %+v; want signature %q", item, signature)
}
}
page, err := s.listObjects("team's", metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}, Filter: "overloaded", Limit: 1, Offset: 1})
if err != nil || len(page) != 1 || page[0].Signature == nil || *page[0].Signature != "value integer" {
t.Fatalf("overload paging was not stable: page=%+v err=%v", page, err)
}
})
}
}
func TestRoutineMetadataPropagatesErrors(t *testing.T) {
for _, test := range []struct {
name string
rows *valueRows
err error
}{
{name: "permission", err: &gokb.Error{Code: gokb.ErrorCode("42501"), Message: "permission denied for sys_proc"}},
{name: "network", err: errors.New("connection reset by peer")},
{name: "unrelated missing column", err: &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column unknown_column does not exist"}},
{name: "scan", rows: &valueRows{columns: []string{"name", "kind", "comment", "signature"}, rows: [][]driver.Value{{nil, "PROCEDURE", nil, ""}}}},
{name: "stream", rows: &valueRows{columns: []string{"name", "kind", "comment", "signature"}, rows: [][]driver.Value{{"first", "PROCEDURE", nil, ""}}, nextErr: errors.New("routine stream interrupted")}},
} {
t.Run(test.name, func(t *testing.T) {
state := &metadataDriverState{query: func(string) (driver.Rows, error) { return test.rows, test.err }}
s := newServer()
s.db = openMetadataDB(t, state)
s.mode.mysqlCompat = true
_, err := s.listObjects("public", metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}})
if err == nil {
t.Fatal("routine failure was silently returned as an empty or partial list")
}
if test.err != nil && !errors.Is(err, test.err) {
t.Fatalf("query error was replaced: want %v, got %v", test.err, err)
}
if test.rows != nil && test.rows.nextErr != nil && !errors.Is(err, test.rows.nextErr) {
t.Fatalf("stream error was replaced: want %v, got %v", test.rows.nextErr, err)
}
if queries := state.snapshotQueries(); len(queries) != 1 {
t.Fatalf("unrelated error triggered a fallback: %v", queries)
}
})
}
}
func TestRoutineMetadataFallsBackOnlyWhenKindColumnIsMissing(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if strings.Contains(query, "p.prokind") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column p.prokind does not exist"}
}
if !strings.Contains(query, "prorettype") {
return nil, errors.New("legacy routine query lost its compatibility classification: " + query)
}
return &valueRows{columns: []string{"name", "kind", "comment", "signature"}, rows: [][]driver.Value{{"legacy_proc", "PROCEDURE", nil, ""}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
for call := 0; call < 2; call++ {
objects, err := s.listObjects("public", metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}})
if err != nil || len(objects) != 1 {
t.Fatalf("legacy routine fallback failed: objects=%+v err=%v", objects, err)
}
}
queries := state.snapshotQueries()
if len(queries) != 3 || strings.Contains(queries[1], "p.prokind") || strings.Contains(queries[2], "p.prokind") {
t.Fatalf("missing kind-column capability was not cached: %v", queries)
}
}
func TestRoutineMetadataCachesSignatureFunctionFallback(t *testing.T) {
for _, fallback := range []string{"pg", "oidvectortypes"} {
t.Run(fallback, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
for _, function := range []string{"sys_get_function_identity_arguments", "pg_get_function_identity_arguments"} {
if strings.Contains(query, function+"(") && (strings.HasPrefix(function, "sys_") || fallback == "oidvectortypes") {
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function " + function + "(oid) does not exist"}
}
}
want := "pg_get_function_identity_arguments("
if fallback == "oidvectortypes" {
want = "oidvectortypes("
}
if !strings.Contains(query, want) {
return nil, errors.New("unexpected signature fallback: " + query)
}
return &valueRows{columns: []string{"name", "kind", "comment", "signature"}, rows: [][]driver.Value{{"overloaded", "PROCEDURE", nil, "integer"}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
for call := 0; call < 2; call++ {
objects, err := s.listObjects("public", metadataListConstraints{ObjectTypes: []string{"PROCEDURE"}})
if err != nil || len(objects) != 1 || objects[0].Signature == nil || *objects[0].Signature != "integer" {
t.Fatalf("signature fallback failed: objects=%+v err=%v", objects, err)
}
}
wantQueries := 3
if fallback == "oidvectortypes" {
wantQueries = 4
}
if queries := state.snapshotQueries(); len(queries) != wantQueries {
t.Fatalf("signature capability fallback was not cached: %v", queries)
}
})
}
}
func TestRoutineSourceUsesExplicitSignatureAndDetectsAmbiguity(t *testing.T) {
for _, signature := range []string{"", "value integer", "value \"Type's\""} {
t.Run(signature, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
for _, fragment := range []string{"'team''s'", "'proc''s'", "p.prokind", "identity_arguments(", quoteLiteral(signature)} {
if !strings.Contains(query, fragment) {
return nil, errors.New("routine source query lost a quoted identity component: " + query)
}
}
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"selected overload"}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
source, err := s.getObjectSourceWithSignature("team's", "proc's", "PROCEDURE", "", &signature)
if err != nil || source["source"] != "selected overload" {
t.Fatalf("explicit overload source failed: source=%v err=%v", source, err)
}
})
}
t.Run("missing signature", func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"first overload"}, {"second overload"}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
_, err := s.getObjectSource("public", "overloaded", "PROCEDURE")
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "ambiguous") {
t.Fatalf("missing signature silently selected one overload: %v", err)
}
})
}
func TestLegacyRoutineSourceDoesNotIgnoreOverloadIdentity(t *testing.T) {
for _, signature := range []*string{nil, stringPtr("value integer")} {
t.Run("overloaded", func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "SELECT COUNT(*)") || strings.Contains(query, "DBMS_METADATA") {
return nil, errors.New("legacy name-only DDL must not run for an ambiguous name: " + query)
}
return &valueRows{columns: []string{"count"}, rows: [][]driver.Value{{int64(2)}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
s.useLegacyRoutineDefinition = true
_, err := s.getObjectSourceWithSignature("public", "overloaded", "PROCEDURE", "", signature)
if err == nil || !strings.Contains(err.Error(), "overloaded") {
t.Fatalf("legacy name-only API must reject overloaded source, got %v", err)
}
if queries := state.snapshotQueries(); len(queries) != 1 {
t.Fatalf("legacy fallback ignored ambiguity: %v", queries)
}
})
}
t.Run("wrong signature", func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if !strings.Contains(query, "SELECT COUNT(*)") || strings.Contains(query, "DBMS_METADATA") {
return nil, errors.New("legacy name-only DDL must not run for a mismatched signature: " + query)
}
var count int64 = 1
if strings.Contains(query, "identity_arguments(") {
count = 0
}
return &valueRows{columns: []string{"count"}, rows: [][]driver.Value{{count}}}, nil
}}
s := newServer()
s.db = openMetadataDB(t, state)
s.useLegacyRoutineDefinition = true
_, err := s.getObjectSourceWithSignature("public", "unique_name", "PROCEDURE", "", stringPtr("wrong_type"))
if err == nil || !strings.Contains(err.Error(), "does not exist") {
t.Fatalf("legacy fallback silently ignored the requested signature: %v", err)
}
if queries := state.snapshotQueries(); len(queries) != 2 {
t.Fatalf("unexpected signature validation queries: %v", queries)
}
})
}
@@ -0,0 +1,103 @@
package main
import (
"database/sql"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
)
func TestKingbaseNonOwnerViewSourceIntegration(t *testing.T) {
host := os.Getenv("KINGBASE_TEST_HOST")
portText := os.Getenv("KINGBASE_TEST_PORT")
username := os.Getenv("KINGBASE_TEST_USERNAME")
if host == "" || portText == "" || username == "" {
t.Skip("Kingbase integration environment is not configured")
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
database := os.Getenv("KINGBASE_TEST_DATABASE")
if database == "" {
database = "test"
}
cp := connectParams{Host: host, Port: port, Database: database, Username: username, Password: os.Getenv("KINGBASE_TEST_PASSWORD")}
if strings.HasPrefix(host, "/") {
cp.URLParams = "sslmode=disable"
}
server := newServer()
if err := server.connect(cp); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = server.disconnect() })
// SET ROLE and metadata queries must share the connection for the whole test.
server.db.SetConnMaxLifetime(0)
rolesCatalog := "sys_catalog.sys_roles"
if server.mode.postgresCatalog {
rolesCatalog = "pg_catalog.pg_roles"
}
var superuser bool
if err := server.requireDBQueryRow("SELECT rolsuper FROM "+rolesCatalog+" WHERE rolname = current_user", &superuser); err != nil {
t.Fatal(err)
}
if !superuser {
t.Skip("non-owner view source integration requires a superuser to create and assume an isolated test role")
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
schema := "dbx_view_src_" + suffix
role := "dbx_view_reader_" + suffix
view := "visible_view"
qualifiedView := quoteIdentifier(schema) + "." + quoteIdentifier(view)
mustExecute(t, server, "CREATE ROLE "+quoteIdentifier(role)+" NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT")
t.Cleanup(func() {
for _, statement := range []string{
"RESET ROLE",
"DROP VIEW IF EXISTS " + qualifiedView,
"DROP SCHEMA IF EXISTS " + quoteIdentifier(schema),
"DROP ROLE IF EXISTS " + quoteIdentifier(role),
} {
if _, err := server.executeQuery(queryOptions{SQL: statement}); err != nil {
t.Errorf("cleanup %s: %v", statement, err)
}
}
})
mustExecute(t, server, "CREATE SCHEMA "+quoteIdentifier(schema))
mustExecute(t, server, "CREATE VIEW "+qualifiedView+" AS SELECT 13002 AS issue_no")
mustExecute(t, server, "GRANT USAGE ON SCHEMA "+quoteIdentifier(schema)+" TO "+quoteIdentifier(role))
mustExecute(t, server, "GRANT SELECT ON "+qualifiedView+" TO "+quoteIdentifier(role))
ownerSource, err := server.getObjectSource(schema, view, "VIEW")
if err != nil || !strings.Contains(fmt.Sprint(ownerSource["source"]), "13002") {
t.Fatalf("owner could not read view source: source=%v err=%v", ownerSource, err)
}
mustExecute(t, server, "SET ROLE "+quoteIdentifier(role))
var currentUser string
if err := server.requireDBQueryRow("SELECT current_user", &currentUser); err != nil || currentUser != role {
t.Fatalf("test role was not applied: current_user=%q err=%v", currentUser, err)
}
var value int
if err := server.requireDBQueryRow("SELECT issue_no FROM "+qualifiedView, &value); err != nil || value != 13002 {
t.Fatalf("non-owner cannot select from test view: value=%d err=%v", value, err)
}
if server.mode.mysqlCompat {
var definition sql.NullString
query := "SELECT view_definition FROM information_schema.views WHERE table_schema = " + quoteLiteral(schema) + " AND table_name = " + quoteLiteral(view)
if err := server.requireDBQueryRow(query, &definition); err != nil {
t.Fatal(err)
}
if definition.Valid {
t.Fatal("expected a hidden information_schema definition for the non-owner role")
}
t.Log("non-owner can SELECT the view while information_schema.view_definition is NULL")
}
source, err := server.getObjectSource(schema, view, "VIEW")
if err != nil || !strings.Contains(fmt.Sprint(source["source"]), "13002") {
t.Fatalf("non-owner could not read view source: source=%v err=%v", source, err)
}
t.Logf("non-owner view source recovered (mysqlCompat=%v, postgresCatalog=%v)", server.mode.mysqlCompat, server.mode.postgresCatalog)
}
@@ -0,0 +1,230 @@
package main
import (
"database/sql/driver"
"errors"
"strings"
"testing"
"gitea.com/kingbase/gokb"
)
func TestOrdinaryViewSourceFallsBackFromHiddenInformationSchemaDefinition(t *testing.T) {
for _, test := range []struct {
name string
rows [][]driver.Value
}{
{name: "NULL", rows: [][]driver.Value{{nil}}},
{name: "empty", rows: [][]driver.Value{{""}}},
{name: "whitespace", rows: [][]driver.Value{{" \t\n"}}},
{name: "no rows"},
} {
t.Run(test.name, func(t *testing.T) {
const definition = " SELECT 'kept verbatim' AS value;\n"
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if strings.Contains(query, "information_schema.views") {
if !strings.Contains(query, "table_schema = 'app''s'") || !strings.Contains(query, "table_name = 'View''s'") {
return nil, errors.New("information schema query lost its quoted object identity: " + query)
}
return &valueRows{columns: []string{"view_definition"}, rows: test.rows}, nil
}
for _, fragment := range []string{"SELECT sys_get_viewdef(c.oid)", "FROM sys_catalog.sys_class", "n.nspname='app''s'", "c.relname='View''s'", "c.relkind = 'v'"} {
if !strings.Contains(query, fragment) {
return nil, errors.New("fallback query lost its catalog or object identity: " + query)
}
}
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{definition}}}, nil
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.mysqlCompat = true
source, err := server.getObjectSource("app's", "View's", "VIEW")
if err != nil || source["source"] != definition || source["schema"] != "app's" || source["name"] != "View's" {
t.Fatalf("non-owner view definition was not recovered: source=%#v err=%v", source, err)
}
if queries := state.snapshotQueries(); len(queries) != 2 {
t.Fatalf("expected one information schema query and one catalog fallback: %v", queries)
}
if server.usePgViewDefinition {
t.Fatal("an object-specific missing definition must not change the function capability cache")
}
})
}
}
func TestOrdinaryViewSourceUsesPostgresCatalog(t *testing.T) {
for _, mysqlCompat := range []bool{false, true} {
t.Run(map[bool]string{false: "direct", true: "information schema fallback"}[mysqlCompat], func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if mysqlCompat && strings.Contains(query, "information_schema.views") {
return &valueRows{columns: []string{"view_definition"}, rows: [][]driver.Value{{nil}}}, nil
}
for _, fragment := range []string{"SELECT pg_get_viewdef(c.oid)", "FROM pg_catalog.pg_class", "pg_catalog.pg_namespace", "n.nspname='app'", "c.relname='orders'", "c.relkind = 'v'"} {
if !strings.Contains(query, fragment) {
return nil, errors.New("unexpected PostgreSQL catalog query: " + query)
}
}
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT 1 AS order_id"}}}, nil
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.mysqlCompat = mysqlCompat
server.mode.postgresCatalog = true
source, err := server.getObjectSource("app", "orders", "VIEW")
if err != nil || source["source"] != "SELECT 1 AS order_id" {
t.Fatalf("unexpected PostgreSQL catalog source: source=%#v err=%v", source, err)
}
wantQueries := 1
if mysqlCompat {
wantQueries++
}
if queries := state.snapshotQueries(); len(queries) != wantQueries {
t.Fatalf("unexpected catalog probes: %v", queries)
}
})
}
}
func TestOrdinaryViewSourceCachesUndefinedSysFunction(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "information_schema.views"):
return &valueRows{columns: []string{"view_definition"}, rows: [][]driver.Value{{nil}}}, nil
case strings.Contains(query, "sys_get_viewdef("):
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_viewdef(oid) does not exist"}
case strings.Contains(query, "pg_get_viewdef("):
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT 1"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.mysqlCompat = true
for _, name := range []string{"first_view", "second_view"} {
source, err := server.getObjectSource("app", name, "VIEW")
if err != nil || source["source"] != "SELECT 1" {
t.Fatalf("view %s failed: source=%#v err=%v", name, source, err)
}
}
queries := state.snapshotQueries()
if !server.usePgViewDefinition || len(queries) != 5 || !strings.Contains(queries[1], "sys_get_viewdef(") || !strings.Contains(queries[2], "pg_get_viewdef(") || !strings.Contains(queries[4], "pg_get_viewdef(") {
t.Fatalf("missing sys function was not cached correctly: cached=%v queries=%v", server.usePgViewDefinition, queries)
}
}
func TestOrdinaryViewSourceEmptySysDefinitionDoesNotPoisonCache(t *testing.T) {
for _, test := range []struct {
name string
rows [][]driver.Value
}{
{name: "NULL", rows: [][]driver.Value{{nil}}},
{name: "whitespace", rows: [][]driver.Value{{" \t"}}},
{name: "no rows"},
} {
t.Run(test.name, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
switch {
case strings.Contains(query, "sys_get_viewdef(") && strings.Contains(query, "c.relname='first_view'"):
return &valueRows{columns: []string{"source"}, rows: test.rows}, nil
case strings.Contains(query, "sys_get_viewdef(") && strings.Contains(query, "c.relname='second_view'"):
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT 'sys'"}}}, nil
case strings.Contains(query, "pg_get_viewdef("):
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT 'pg'"}}}, nil
default:
return nil, errors.New("unexpected query: " + query)
}
}}
server := newServer()
server.db = openMetadataDB(t, state)
for _, testView := range []struct{ name, source string }{{"first_view", "SELECT 'pg'"}, {"second_view", "SELECT 'sys'"}} {
source, err := server.getObjectSource("app", testView.name, "VIEW")
if err != nil || source["source"] != testView.source {
t.Fatalf("view-specific fallback failed: source=%#v err=%v", source, err)
}
}
if queries := state.snapshotQueries(); len(queries) != 3 || server.usePgViewDefinition {
t.Fatalf("object-specific empty definition poisoned the cache: cached=%v queries=%v", server.usePgViewDefinition, queries)
}
})
}
}
func TestOrdinaryViewSourceRejectsUnavailableDefinition(t *testing.T) {
for _, mode := range []struct {
name string
mysql, postgres bool
wantQueries int
}{
{name: "mysql", mysql: true, wantQueries: 3},
{name: "sys", wantQueries: 2},
{name: "postgres", postgres: true, wantQueries: 1},
} {
for _, empty := range []struct {
name string
rows [][]driver.Value
}{
{name: "NULL", rows: [][]driver.Value{{nil}}},
{name: "blank", rows: [][]driver.Value{{" \n"}}},
{name: "no rows"},
} {
t.Run(mode.name+"/"+empty.name, func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
return &valueRows{columns: []string{"view_definition"}, rows: empty.rows}, nil
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.mysqlCompat = mode.mysql
server.mode.postgresCatalog = mode.postgres
_, err := server.getObjectSource("app", "unavailable_view", "VIEW")
if err == nil || !strings.Contains(err.Error(), "view") || !strings.Contains(err.Error(), "source") || strings.Contains(err.Error(), "Scan error") {
t.Fatalf("expected an explicit unavailable view source error, got %v", err)
}
if queries := state.snapshotQueries(); len(queries) != mode.wantQueries || server.usePgViewDefinition {
t.Fatalf("unbounded probes or invalid capability cache: cached=%v queries=%v", server.usePgViewDefinition, queries)
}
})
}
}
}
func TestOrdinaryViewSourcePreservesQueryErrors(t *testing.T) {
for _, stage := range []struct {
name string
wantQueries int
}{
{name: "information_schema.views", wantQueries: 1},
{name: "sys_get_viewdef(", wantQueries: 2},
{name: "pg_get_viewdef(", wantQueries: 3},
} {
for _, queryErr := range []error{
&gokb.Error{Code: gokb.ErrorCode("42501"), Message: "permission denied"},
errors.New("connection reset by peer"),
} {
t.Run(stage.name+"/"+queryErr.Error(), func(t *testing.T) {
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
if strings.Contains(query, stage.name) {
return nil, queryErr
}
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{nil}}}, nil
}}
server := newServer()
server.db = openMetadataDB(t, state)
server.mode.mysqlCompat = true
_, err := server.getObjectSource("app", "orders", "VIEW")
if !errors.Is(err, queryErr) {
t.Fatalf("query error was swallowed or replaced: want=%v got=%v", queryErr, err)
}
if queries := state.snapshotQueries(); len(queries) != stage.wantQueries || server.usePgViewDefinition {
t.Fatalf("unrelated error triggered fallback or poisoned cache: cached=%v queries=%v", server.usePgViewDefinition, queries)
}
})
}
}
}
@@ -670,6 +670,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
return queryObjects(schema, MetadataListConstraints.NONE);
}
@Override
public boolean supportsExactMetadataIdentity() { return true; }
@Override
public List<ObjectInfo> listObjects(String schema, MetadataListConstraints constraints) {
return queryObjects(schema, MetadataListConstraints.orNone(constraints));
@@ -683,15 +686,18 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
return List.of();
}
String baseSql = """
SELECT o.OBJECT_NAME, o.OBJECT_TYPE, c.COMMENTS
SELECT o.OBJECT_NAME, o.OBJECT_TYPE, c.COMMENTS,
i.TABLE_OWNER AS PARENT_SCHEMA, i.TABLE_NAME AS PARENT_NAME
FROM ALL_OBJECTS o
LEFT JOIN ALL_TAB_COMMENTS c ON c.OWNER = o.OWNER AND c.TABLE_NAME = o.OBJECT_NAME
AND o.OBJECT_TYPE IN ('TABLE', 'VIEW')
LEFT JOIN ALL_INDEXES i ON o.OBJECT_TYPE = 'INDEX'
AND i.OWNER = o.OWNER AND i.INDEX_NAME = o.OBJECT_NAME
WHERE o.OWNER = ? AND o.OBJECT_TYPE IN (%s)
""".stripIndent().trim();
MetadataSql query = oceanBaseMetadataSql(
String.format(baseSql, placeholders(objectTypes.size())),
"OBJECT_NAME, OBJECT_TYPE, COMMENTS",
"OBJECT_NAME, OBJECT_TYPE, COMMENTS, PARENT_SCHEMA, PARENT_NAME",
"OBJECT_NAME",
"""
ORDER BY CASE OBJECT_TYPE
@@ -716,8 +722,13 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String objectType = rs.getString(2);
result.add(new ObjectInfo(rs.getString(1),
objectType.replace(' ', '_'), owner, rs.getString(3)));
ObjectInfo object = new ObjectInfo(rs.getString(1),
objectType.replace(' ', '_'), owner, rs.getString(3));
if ("INDEX".equals(objectType)) {
object.setParent_schema(rs.getString(4));
object.setParent_name(rs.getString(5));
}
result.add(object);
}
}
}
@@ -1292,7 +1303,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
private static List<String> oceanBaseObjectTypes(MetadataListConstraints constraints) {
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY");
List<String> supported = List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY", "INDEX");
if (!constraints.hasObjectTypes()) {
return supported;
}
@@ -1307,12 +1318,21 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
@Override
public ObjectSource getObjectSource(String schema, String name, String objectType) {
return getObjectSource(schema, name, objectType, false);
}
@Override
public ObjectSource getObjectSourceWithOrigin(String schema, String name, String objectType) {
return getObjectSource(schema, name, objectType, true);
}
private ObjectSource getObjectSource(String schema, String name, String objectType, boolean exactMetadataKind) {
return unchecked(() -> {
String owner = normalizeSchema(schema);
String objectName = normalizeObjectName(name);
String normalizedType = normalizeObjectSourceType(objectType);
if (prefersDictionarySource(normalizedType)) {
return getDictionaryFirstObjectSource(owner, objectName, normalizedType);
return getDictionaryFirstObjectSource(owner, objectName, normalizedType, exactMetadataKind);
}
String source;
SQLException metadataError = null;
@@ -1343,8 +1363,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
});
}
private ObjectSource getDictionaryFirstObjectSource(String owner, String name, String objectType) throws SQLException {
private ObjectSource getDictionaryFirstObjectSource(String owner, String name, String objectType, boolean exactMetadataKind) throws SQLException {
String source;
String origin = "oceanbase-dictionary";
SQLException dictionaryError = null;
try {
source = queryDictionarySource(owner, name, objectType);
@@ -1355,7 +1376,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
if (source == null || source.trim().isEmpty()) {
try {
source = queryDbmsMetadataSource(owner, name, objectType);
source = queryDbmsMetadataSource(owner, name,
exactMetadataKind && "PACKAGE".equals(objectType) ? "PACKAGE_SPEC" : objectType);
origin = "oracle-metadata";
} catch (SQLException metadataError) {
if (dictionaryError != null) {
dictionaryError.addSuppressed(metadataError);
@@ -1364,7 +1387,15 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
throw metadataError;
}
}
return new ObjectSource(name, objectType, owner, source == null ? "" : source);
if ("TYPE".equals(objectType) || "TYPE_BODY".equals(objectType)) {
if (source == null || source.trim().isEmpty()) {
throw new SQLException("No complete source available for " + owner + "." + name + " (" + objectType + ")");
}
return new ObjectSource(name, objectType, owner, source, false);
}
ObjectSource result = new ObjectSource(name, objectType, owner, source == null ? "" : source);
result.setSourceOrigin(origin);
return result;
}
private String queryDbmsMetadataSource(String owner, String name, String objectType) throws SQLException {
@@ -1942,7 +1973,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
String tableName = normalizeObjectName(table);
String sql = """
SELECT i.INDEX_NAME, ic.COLUMN_NAME, ic.COLUMN_POSITION, i.UNIQUENESS,
c.CONSTRAINT_TYPE, i.INDEX_TYPE, i.PARTITIONED, i.VISIBILITY, ic.DESCEND, ie.COLUMN_EXPRESSION
c.CONSTRAINT_TYPE, i.INDEX_TYPE, i.PARTITIONED, i.VISIBILITY, ic.DESCEND, ie.COLUMN_EXPRESSION, i.OWNER AS INDEX_OWNER
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic
ON i.INDEX_NAME = ic.INDEX_NAME
@@ -1959,29 +1990,33 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
ON i.INDEX_NAME = c.INDEX_NAME
AND i.TABLE_OWNER = c.OWNER
AND i.TABLE_NAME = c.TABLE_NAME
AND i.OWNER = c.INDEX_OWNER
AND c.CONSTRAINT_TYPE = 'P'
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION
ORDER BY i.OWNER, i.INDEX_NAME, ic.COLUMN_POSITION
""".stripIndent().trim();
Map<String, IndexInfo> indexesByName = new LinkedHashMap<>();
Map<List<String>, IndexInfo> indexesByName = new LinkedHashMap<>();
try (var stmt = requireConnection().prepareStatement(sql)) {
stmt.setString(1, owner);
stmt.setString(2, tableName);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String indexName = rs.getString("INDEX_NAME");
IndexInfo index = indexesByName.get(indexName);
String indexOwner = rs.getString("INDEX_OWNER");
var identity = java.util.Arrays.asList(indexOwner, indexName);
IndexInfo index = indexesByName.get(identity);
if (index == null) {
IndexInfo item = new IndexInfo(indexName, new ArrayList<>(),
"UNIQUE".equalsIgnoreCase(rs.getString("UNIQUENESS")),
"P".equalsIgnoreCase(rs.getString("CONSTRAINT_TYPE")),
null, rs.getString("INDEX_TYPE"), null, null);
item.setKey_is_expression(new ArrayList<>());
item.setOwner(indexOwner);
item.setKey_directions(new ArrayList<>());
item.setKey_metadata_complete(true);
item.setVisibility(rs.getString("VISIBILITY"));
indexesByName.put(indexName, item);
indexesByName.put(identity, item);
index = item;
}
String column = rs.getString("COLUMN_NAME");
@@ -2433,6 +2468,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent {
}
public static void main(String[] args) {
MultiSessionJsonRpcServer.withBlobBindings(OceanBaseOracleAgent::new).run();
MultiSessionJsonRpcServer.withBlobBindings(OceanBaseOracleAgent::new).withExactMetadataIdentity().run();
}
}
@@ -32,6 +32,7 @@ class OceanBaseOracleIndexRpcTest {
var indexes = response.getAsJsonArray("result");
assertEquals(1, indexes.size());
JsonObject index = indexes.get(0).getAsJsonObject();
assertEquals(" Index Owner ", index.get("owner").getAsString());
assertEquals(List.of("LOWER(\"EVENT_NAME\")", "EVENT_TS"), index.getAsJsonArray("columns").asList()
.stream().map(value -> value.getAsString()).toList());
assertEquals(List.of(true, false), index.getAsJsonArray("key_is_expression").asList()
@@ -67,6 +68,7 @@ class OceanBaseOracleIndexRpcTest {
String descend, String visibility) {
Map<String, String> row = new HashMap<>();
row.put("INDEX_NAME", name);
row.put("INDEX_OWNER", " Index Owner ");
row.put("COLUMN_NAME", column);
row.put("COLUMN_POSITION", position);
row.put("UNIQUENESS", uniqueness);
@@ -79,6 +81,21 @@ class OceanBaseOracleIndexRpcTest {
return row;
}
@Test
void keepsSameNameIndexesFromDifferentDictionaryOwnersSeparate() {
var first = row(" Ix(a) ", "ID", "1", "NONUNIQUE", "I", "NORMAL", "NO", null, "ASC", "VISIBLE");
var other = row(" Ix(a) ", "CODE", "1", "NONUNIQUE", "I", "NORMAL", "NO", null, "ASC", "VISIBLE");
other.put("INDEX_OWNER", "Other");
var response = new Fixture(List.of(first, other)).request(" Parent ", " T(a) ");
assertFalse(response.has("error"), response.toString());
var indexes = response.getAsJsonArray("result");
assertEquals(2, indexes.size());
assertEquals(" Index Owner ", indexes.get(0).getAsJsonObject().get("owner").getAsString());
assertEquals("Other", indexes.get(1).getAsJsonObject().get("owner").getAsString());
assertEquals(1, indexes.get(0).getAsJsonObject().getAsJsonArray("columns").size());
assertEquals(1, indexes.get(1).getAsJsonObject().getAsJsonArray("columns").size());
}
private static final class Fixture {
final List<String> sql = new ArrayList<>();
final List<String> parameters = new ArrayList<>();
@@ -11,6 +11,9 @@ import com.dbx.agent.ExecuteQueryOptions;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
import com.dbx.agent.ObjectSource;
import com.dbx.agent.JsonRpcServer;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.dbx.agent.QueryPageOptions;
import com.dbx.agent.QueryPageResult;
import com.dbx.agent.QueryResult;
@@ -1276,6 +1279,50 @@ class OceanBaseOracleAgentTest {
Assertions.assertEquals(List.of("APP", "P1", "PROCEDURE", "PROCEDURE", "P1", "APP"), params);
}
@Test
void strictPackageSourceReportsActualFallbackOriginWithoutChangingOrdinaryResponses() throws Exception {
Method request = JsonRpcServer.class.getDeclaredMethod("handleRequest", String.class);
request.setAccessible(true);
for (boolean fallback : new boolean[]{false, true}) {
for (boolean strict : new boolean[]{false, true}) {
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
ResultSet rows = resultSet(new String[]{fallback ? "DDL" : "TEXT"}, new Object[][]{
{fallback ? "CREATE OR REPLACE PACKAGE BODY APP.P AS END;" : "PACKAGE BODY P AS END;"}
});
TestSupport.setPrivateConnection(agent, fallback
? objectSourceFallbackConnection(new ArrayList<>(), new ArrayList<>(), rows)
: objectSourceConnection(new ArrayList<>(), new ArrayList<>(), rows));
String response = (String) request.invoke(new JsonRpcServer(agent),
"{\"id\":1,\"method\":\"get_object_source\",\"params\":{\"schema\":\"APP\",\"name\":\"P\",\"object_type\":\"PACKAGE_BODY\",\"include_source_origin\":" + strict + "}}");
JsonObject envelope = JsonParser.parseString(response).getAsJsonObject();
Assertions.assertFalse(envelope.has("error"), response);
JsonObject result = envelope.getAsJsonObject("result");
if (strict) {
Assertions.assertTrue(result.has("source_origin"), response);
Assertions.assertEquals(fallback ? "oracle-metadata" : "oceanbase-dictionary", result.get("source_origin").getAsString());
} else {
Assertions.assertFalse(result.has("source_origin"), response);
}
}
}
}
@Test
void strictSpecificationMetadataFallbackRequestsOnlyTheSpecification() throws Exception {
Method request = JsonRpcServer.class.getDeclaredMethod("handleRequest", String.class);
request.setAccessible(true);
for (boolean strict : new boolean[]{false, true}) {
List<String> params = new ArrayList<>();
OceanBaseOracleAgent agent = new OceanBaseOracleAgent();
TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(new ArrayList<>(), params,
resultSet(new String[]{"DDL"}, new Object[][]{{"CREATE OR REPLACE PACKAGE APP.P AS END;"}})));
String response = (String) request.invoke(new JsonRpcServer(agent),
"{\"id\":1,\"method\":\"get_object_source\",\"params\":{\"schema\":\"APP\",\"name\":\"P\",\"object_type\":\"PACKAGE\",\"include_source_origin\":" + strict + "}}");
Assertions.assertFalse(JsonParser.parseString(response).getAsJsonObject().has("error"), response);
Assertions.assertEquals(List.of("APP", "P", "PACKAGE", strict ? "PACKAGE_SPEC" : "PACKAGE", "P", "APP"), params);
}
}
@Test
void synonymSourcePreservesOwnersQuotedNamesAndRemoteLinkDomains() {
for (String owner : List.of("Mixed.Owner", "PUBLIC")) {
@@ -23,6 +23,38 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OceanBaseOracleObjectListTest {
@Test
void schemaIndexPagesPreserveBothOwnersAndRawNames() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row(" Ix (a) ", "INDEX", null, "Table Owner", " T(a) "));
List<ObjectInfo> objects = jdbc.agent.listObjects("Index Owner", constraints("Ix", 2, 2, "INDEX"));
assertEquals(1, objects.size());
String json = new com.google.gson.Gson().toJson(objects.get(0));
com.google.gson.JsonObject object = com.google.gson.JsonParser.parseString(json).getAsJsonObject();
assertEquals(" Ix (a) ", object.get("name").getAsString());
assertEquals("Index Owner", object.get("schema").getAsString());
assertEquals("INDEX", object.get("object_type").getAsString());
assertEquals("Table Owner", object.get("parent_schema").getAsString());
assertEquals(" T(a) ", object.get("parent_name").getAsString());
assertEquals(List.of("Index Owner", "INDEX", "%I%X%", "%I%X%", 4L, 2), call.args);
call.assertClosed();
}
@Test
void schemaIndexDictionaryFailureDoesNotBecomeAnEmptyPage() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows();
SQLException denied = new SQLException("ORA-01031: insufficient privileges", "42000", 1031);
call.executeFailure = denied;
RuntimeException error = assertThrows(RuntimeException.class,
() -> jdbc.agent.listObjects("APP", constraints(null, 2, 0, "INDEX")));
assertSame(denied, error.getCause());
assertTrue(call.statementClosed);
}
@Test
void tableAndViewCommentsSurviveObjectListing() {
JdbcFixture jdbc = new JdbcFixture();
@@ -78,13 +110,13 @@ class OceanBaseOracleObjectListTest {
};
assertEquals(List.of("APP", "TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY"), call.args);
"PACKAGE BODY", "SEQUENCE", "SYNONYM", "TYPE", "TYPE BODY", "INDEX"), call.args);
assertEquals(List.of("TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE",
"PACKAGE_BODY", "SEQUENCE", "SYNONYM"), objects.stream().map(ObjectInfo::getObject_type).toList());
assertEquals(8, objects.size());
assertTrue(objects.stream().allMatch(object -> "APP".equals(object.getSchema())));
assertTrue(objects.stream().allMatch(object -> object.getValid() == null));
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertTrue(call.sql.contains("OBJECT_TYPE IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), call.sql);
assertFalse(call.sql.contains("ROWNUM"), call.sql);
assertObjectOrder(call.sql);
call.assertClosed();
@@ -145,6 +177,29 @@ class OceanBaseOracleObjectListTest {
call.assertClosed();
}
@Test
void typeBodySourcePreservesQuotedIdentityAndIsReadOnly() {
JdbcFixture jdbc = new JdbcFixture();
JdbcCall call = jdbc.rows(row("TYPE BODY \"Mixed Type\" AS\n"), row("END;\n"));
ObjectSource source = jdbc.agent.getObjectSource("Mixed.Owner", "Mixed Type", "TYPE_BODY");
assertEquals("CREATE OR REPLACE TYPE BODY \"Mixed Type\" AS\nEND;", source.getSource());
assertEquals("TYPE_BODY", source.getObject_type());
assertFalse(source.isEditable());
assertEquals(List.of("Mixed.Owner", "Mixed Type", "TYPE BODY"), call.args);
call.assertClosed();
}
@Test
void missingTypeSourceFailsInsteadOfReturningAnEmptyEditor() {
JdbcFixture jdbc = new JdbcFixture();
jdbc.rows();
jdbc.rows();
assertThrows(RuntimeException.class, () -> jdbc.agent.getObjectSource("APP", "MISSING_T", "TYPE"));
}
@Test
void filteredPagesKeepSameNameSpecAndBodyAndDoNotApplyOffsetTwice() {
JdbcFixture jdbc = new JdbcFixture();
+318 -79
View File
@@ -155,11 +155,13 @@ FROM USER_MVIEWS mv
const oracleListTablesOrderSQL = `ORDER BY OBJECT_NAME`
const oracleListTablesSQL = oracleListTablesBaseSQL + "\n" + oracleListTablesOrderSQL
const oracleListObjectsBaseSQL = `
SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS
SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS, PARENT_SCHEMA, PARENT_NAME
FROM (
SELECT t.TABLE_NAME AS OBJECT_NAME,
'TABLE' AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
CAST(NULL AS VARCHAR2(128)) AS PARENT_SCHEMA,
CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM ALL_TABLES t
WHERE t.OWNER = :1
AND t.NESTED = 'NO'
@@ -177,23 +179,31 @@ SELECT o.OBJECT_NAME,
WHEN 'MATERIALIZED VIEW' THEN 'MATERIALIZED_VIEW'
ELSE o.OBJECT_TYPE
END AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
i.TABLE_OWNER AS PARENT_SCHEMA,
i.TABLE_NAME AS PARENT_NAME
FROM ALL_OBJECTS o
LEFT JOIN ALL_INDEXES i
ON o.OBJECT_TYPE = 'INDEX' AND i.OWNER = o.OWNER AND i.INDEX_NAME = o.OBJECT_NAME
WHERE o.OWNER = :2
AND o.OBJECT_TYPE IN ('VIEW', 'MATERIALIZED VIEW', 'PROCEDURE', 'FUNCTION', 'SEQUENCE', 'PACKAGE', 'PACKAGE BODY')
AND o.OBJECT_TYPE IN ('VIEW', 'MATERIALIZED VIEW', 'PROCEDURE', 'FUNCTION', 'SEQUENCE', 'PACKAGE', 'PACKAGE BODY', 'INDEX')
UNION ALL
SELECT s.SYNONYM_NAME AS OBJECT_NAME,
'SYNONYM' AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
CAST(NULL AS VARCHAR2(128)) AS PARENT_SCHEMA,
CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM ALL_SYNONYMS s
WHERE s.OWNER = :3
)`
const oracleListObjectsSessionUserBaseSQL = `
SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS
SELECT OBJECT_NAME, OBJECT_TYPE, COMMENTS, PARENT_SCHEMA, PARENT_NAME
FROM (
SELECT t.TABLE_NAME AS OBJECT_NAME,
'TABLE' AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
CAST(NULL AS VARCHAR2(128)) AS PARENT_SCHEMA,
CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM USER_TABLES t
WHERE t.NESTED = 'NO'
AND NOT EXISTS (
@@ -206,16 +216,22 @@ UNION ALL
SELECT o.OBJECT_NAME,
CASE o.OBJECT_TYPE
WHEN 'PACKAGE BODY' THEN 'PACKAGE_BODY'
WHEN 'TYPE BODY' THEN 'TYPE_BODY'
WHEN 'MATERIALIZED VIEW' THEN 'MATERIALIZED_VIEW'
ELSE o.OBJECT_TYPE
END AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
i.TABLE_OWNER AS PARENT_SCHEMA,
i.TABLE_NAME AS PARENT_NAME
FROM USER_OBJECTS o
WHERE o.OBJECT_TYPE IN ('VIEW', 'MATERIALIZED VIEW', 'PROCEDURE', 'FUNCTION', 'SEQUENCE', 'PACKAGE', 'PACKAGE BODY')
LEFT JOIN USER_INDEXES i ON o.OBJECT_TYPE = 'INDEX' AND i.INDEX_NAME = o.OBJECT_NAME
WHERE o.OBJECT_TYPE IN ('VIEW', 'MATERIALIZED VIEW', 'PROCEDURE', 'FUNCTION', 'SEQUENCE', 'PACKAGE', 'PACKAGE BODY', 'INDEX', 'TYPE', 'TYPE BODY')
UNION ALL
SELECT s.SYNONYM_NAME AS OBJECT_NAME,
'SYNONYM' AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
CAST(NULL AS VARCHAR2(128)) AS PARENT_SCHEMA,
CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM USER_SYNONYMS s
)`
const oracleListObjectsOrderSQL = `ORDER BY CASE OBJECT_TYPE
@@ -227,9 +243,10 @@ const oracleListObjectsOrderSQL = `ORDER BY CASE OBJECT_TYPE
WHEN 'SEQUENCE' THEN 5
WHEN 'SYNONYM' THEN 6
WHEN 'PACKAGE' THEN 7
ELSE 8
END, OBJECT_NAME`
const oracleListObjectsSQL = oracleListObjectsBaseSQL + "\n" + oracleListObjectsOrderSQL
WHEN 'TYPE' THEN 8
WHEN 'TYPE_BODY' THEN 9
ELSE 10
END, OBJECT_NAME, OBJECT_TYPE`
const oracleListTriggersSQL = `
SELECT t.TRIGGER_NAME,
t.TRIGGERING_EVENT,
@@ -462,17 +479,20 @@ type tableInfo struct {
}
type metadataListConstraints struct {
Filter string
Limit int
Offset int
ObjectTypes []string
Filter string
Limit int
Offset int
ObjectTypes []string
ExactIdentity bool
}
type objectInfo struct {
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema string `json:"schema"`
Comment *string `json:"comment"`
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema string `json:"schema"`
Comment *string `json:"comment"`
ParentSchema *string `json:"parent_schema,omitempty"`
ParentName *string `json:"parent_name,omitempty"`
}
type columnInfo struct {
@@ -491,6 +511,7 @@ type columnInfo struct {
}
type indexInfo struct {
Owner *string `json:"owner,omitempty"`
Name string `json:"name"`
Columns []string `json:"columns"`
IsUnique bool `json:"is_unique"`
@@ -725,7 +746,7 @@ func (r *runtimeServer) dispatch(method string, params map[string]json.RawMessag
return map[string]any{
"protocolVersion": multiSessionProtocolVersion,
"agentProtocolVersion": multiSessionProtocolVersion,
"capabilities": []string{"connect", "test_connection", "metadata", "query", "transaction", "ddl", "multi_session"},
"capabilities": []string{"connect", "test_connection", "metadata", "query", "transaction", "ddl", "multi_session", "exact_metadata_identity_v1"},
}, false, nil
case "open_session":
agentSessionID := stringParam(params, "agentSessionId")
@@ -911,7 +932,7 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
return map[string]any{
"protocolVersion": protocolVersion,
"agentProtocolVersion": protocolVersion,
"capabilities": []string{"connect", "test_connection", "metadata", "query", "transaction", "ddl"},
"capabilities": []string{"connect", "test_connection", "metadata", "query", "transaction", "ddl", "exact_metadata_identity_v1"},
}, false, nil
case "connect":
var cp connectParams
@@ -959,24 +980,30 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
case "get_columns":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.getColumns(schema, table)
result, err := s.getColumnsWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "get_table_comment":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.getTableComment(schema, table)
result, err := s.getTableCommentWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "get_object_source":
schema := stringParam(params, "schema")
name := stringParam(params, "name")
objectType := stringParam(params, "object_type")
source, err := s.getObjectSource(schema, name, objectType)
if err == nil && boolParam(params, "include_source_origin") {
switch strings.ToUpper(objectType) {
case "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY":
source["source_origin"] = "oracle-dictionary"
}
}
return source, false, err
case "get_table_ddl":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
objectType := stringParam(params, "object_type")
ddl, err := s.getTableDDLWithOptions(schema, table, objectType, boolParam(params, "portable"))
ddl, err := s.getTableDDLWithIdentity(schema, table, objectType, boolParam(params, "portable"), boolParam(params, "exact_identity"))
return ddl, false, err
case "execute_query":
var opts queryOptions
@@ -1012,22 +1039,22 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
case "list_indexes":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.listIndexes(schema, table)
result, err := s.listIndexesWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "list_foreign_keys":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.listForeignKeys(schema, table)
result, err := s.listForeignKeysWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "list_constraints":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.listConstraints(schema, table)
result, err := s.listConstraintsWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "list_triggers":
schema := stringParam(params, "schema")
table := stringParam(params, "table")
result, err := s.listTriggers(schema, table)
result, err := s.listTriggersWithIdentity(schema, table, boolParam(params, "exact_identity"))
return result, false, err
case "get_explain_info":
sqlText := stringParam(params, "sql")
@@ -1772,6 +1799,16 @@ func (s *server) normalizeSchema(schema string) (string, error) {
return resolveOracleSchema(schema, s.currentSchema, s.sessionUser)
}
func (s *server) metadataSchema(schema string, exactIdentity bool) (string, error) {
if !exactIdentity {
return s.normalizeSchema(schema)
}
if schema == "" {
return "", errors.New("schema is required for exact metadata identity")
}
return schema, nil
}
func resolveOracleSchema(schema string, currentSchema, sessionUser func() (string, error)) (string, error) {
if schema = strings.TrimSpace(schema); schema != "" {
return strings.ToUpper(schema), nil
@@ -1783,6 +1820,11 @@ func resolveOracleSchema(schema string, currentSchema, sessionUser func() (strin
}
func (s *server) sessionUser() (string, error) {
username, err := s.rawSessionUser()
return strings.ToUpper(username), err
}
func (s *server) rawSessionUser() (string, error) {
db, err := s.requireDB()
if err != nil {
return "", err
@@ -1791,7 +1833,7 @@ func (s *server) sessionUser() (string, error) {
if err := db.QueryRow("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL").Scan(&username); err != nil {
return "", err
}
return strings.ToUpper(username), nil
return username, nil
}
func (s *server) schemaIsSessionUser(schema string) bool {
@@ -1799,6 +1841,14 @@ func (s *server) schemaIsSessionUser(schema string) bool {
return err == nil && strings.EqualFold(schema, username)
}
func (s *server) metadataSchemaIsSessionUser(schema string, exactIdentity bool) bool {
if !exactIdentity {
return s.schemaIsSessionUser(schema)
}
username, err := s.rawSessionUser()
return err == nil && schema == username
}
type oracleMetadataListQuery struct {
SQL string
Args []any
@@ -1818,10 +1868,11 @@ func metadataListConstraintsFromParams(params map[string]json.RawMessage) metada
offset = 0
}
return metadataListConstraints{
Filter: stringParam(params, "filter"),
Limit: limit,
Offset: offset,
ObjectTypes: objectTypes,
Filter: stringParam(params, "filter"),
Limit: limit,
Offset: offset,
ObjectTypes: objectTypes,
ExactIdentity: boolParam(params, "exact_identity"),
}
}
@@ -1848,12 +1899,25 @@ func oracleListSessionUserTablesQuery(constraints metadataListConstraints) oracl
}
func oracleListObjectsQuery(schema string, constraints metadataListConstraints) oracleMetadataListQuery {
return oracleListObjectsQueryWithTypeOwner(schema, schema, constraints)
}
func oracleListObjectsQueryWithTypeOwner(schema, typeOwner string, constraints metadataListConstraints) oracleMetadataListQuery {
baseSQL := oracleListObjectsBaseSQL + `
UNION ALL
SELECT o.OBJECT_NAME,
CASE o.OBJECT_TYPE WHEN 'TYPE BODY' THEN 'TYPE_BODY' ELSE o.OBJECT_TYPE END AS OBJECT_TYPE,
CAST(NULL AS VARCHAR2(4000)) AS COMMENTS,
CAST(NULL AS VARCHAR2(128)) AS PARENT_SCHEMA,
CAST(NULL AS VARCHAR2(128)) AS PARENT_NAME
FROM ALL_OBJECTS o
WHERE o.OWNER = :4 AND o.OBJECT_TYPE IN ('TYPE', 'TYPE BODY')`
return oracleConstrainedMetadataListQuery(
oracleListObjectsBaseSQL,
"OBJECT_NAME, OBJECT_TYPE, COMMENTS",
baseSQL,
"OBJECT_NAME, OBJECT_TYPE, COMMENTS, PARENT_SCHEMA, PARENT_NAME",
"OBJECT_TYPE",
oracleListObjectsOrderSQL,
[]any{schema, schema, schema},
[]any{schema, schema, schema, typeOwner},
constraints,
)
}
@@ -1861,7 +1925,7 @@ func oracleListObjectsQuery(schema string, constraints metadataListConstraints)
func oracleListSessionUserObjectsQuery(constraints metadataListConstraints) oracleMetadataListQuery {
return oracleConstrainedMetadataListQuery(
oracleListObjectsSessionUserBaseSQL,
"OBJECT_NAME, OBJECT_TYPE, COMMENTS",
"OBJECT_NAME, OBJECT_TYPE, COMMENTS, PARENT_SCHEMA, PARENT_NAME",
"OBJECT_TYPE",
oracleListObjectsOrderSQL,
nil,
@@ -1954,17 +2018,17 @@ func oracleFuzzyLikePattern(value string) string {
}
func (s *server) listTables(schema string, constraints metadataListConstraints) ([]tableInfo, error) {
schema, err := s.normalizeSchema(schema)
schema, err := s.metadataSchema(schema, constraints.ExactIdentity)
if err != nil {
return nil, err
}
query := oracleListTablesQuery(schema, constraints)
if s.schemaIsSessionUser(schema) {
if s.metadataSchemaIsSessionUser(schema, constraints.ExactIdentity) {
query = oracleListSessionUserTablesQuery(constraints)
}
rows, err := s.queryRows(query.SQL, query.Args)
if err != nil {
if isOraclePGALimitError(err) {
if isOraclePGALimitError(err) && !constraints.ExactIdentity {
return []tableInfo{}, nil
}
return nil, err
@@ -1982,17 +2046,22 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
}
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
schema, err := s.normalizeSchema(schema)
rawTypeOwner := schema
schema, err := s.metadataSchema(schema, constraints.ExactIdentity)
if err != nil {
return nil, err
}
query := oracleListObjectsQuery(schema, constraints)
if s.schemaIsSessionUser(schema) {
if rawTypeOwner == "" {
rawTypeOwner = schema
}
query := oracleListObjectsQueryWithTypeOwner(schema, rawTypeOwner, constraints)
useSessionUser := rawTypeOwner == schema && s.metadataSchemaIsSessionUser(schema, constraints.ExactIdentity)
if useSessionUser {
query = oracleListSessionUserObjectsQuery(constraints)
}
rows, err := s.queryRows(query.SQL, query.Args)
if err != nil {
if isOraclePGALimitError(err) {
if isOraclePGALimitError(err) && !constraints.ExactIdentity && !metadataIncludesIndexes(constraints.ObjectTypes) {
return []objectInfo{}, nil
}
return nil, err
@@ -2001,15 +2070,27 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
var result []objectInfo
for rows.Next() {
var item objectInfo
item.Schema = schema
if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment); err != nil {
if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment, &item.ParentSchema, &item.ParentName); err != nil {
return nil, err
}
item.Schema = schema
if strings.EqualFold(item.ObjectType, "TYPE") || strings.EqualFold(item.ObjectType, "TYPE_BODY") {
item.Schema = rawTypeOwner
}
result = append(result, item)
}
return emptyIfNil(result), rows.Err()
}
func metadataIncludesIndexes(objectTypes []string) bool {
for _, objectType := range normalizedMetadataObjectTypes(objectTypes) {
if objectType == "INDEX" {
return true
}
}
return false
}
func (s *server) completionAssistantSearch(request completionAssistantRequest) (completionAssistantResponse, error) {
limit := request.MaxResults
if limit <= 0 {
@@ -2658,10 +2739,17 @@ func oracleObjectNameCandidates(name string) (string, string, bool) {
}
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
schema, err := s.normalizeSchema(schema)
return s.getColumnsWithIdentity(schema, table, false)
}
func (s *server) getColumnsWithIdentity(schema, table string, exactIdentity bool) ([]columnInfo, error) {
schema, err := s.metadataSchema(schema, exactIdentity)
if err != nil {
return nil, err
}
if exactIdentity {
return s.getColumnsByName(schema, table)
}
exact, uppercase, hasUppercaseFallback := oracleObjectNameCandidates(table)
result, err := s.getColumnsByName(schema, exact)
if err != nil || len(result) > 0 || !hasUppercaseFallback {
@@ -2726,10 +2814,18 @@ ORDER BY c.COLUMN_ID`, []any{schema, table})
}
func (s *server) getTableComment(schema, table string) (*string, error) {
schema, err := s.normalizeSchema(schema)
return s.getTableCommentWithIdentity(schema, table, false)
}
func (s *server) getTableCommentWithIdentity(schema, table string, exactIdentity bool) (*string, error) {
schema, err := s.metadataSchema(schema, exactIdentity)
if err != nil {
return nil, err
}
if exactIdentity {
comment, _, err := s.getTableCommentByName(schema, table)
return comment, err
}
exact, uppercase, hasUppercaseFallback := oracleObjectNameCandidates(table)
comment, found, err := s.getTableCommentByName(schema, exact)
if err != nil || found || !hasUppercaseFallback {
@@ -2801,11 +2897,17 @@ ORDER BY COLUMN_ID`, []any{schema, table})
}
func (s *server) listIndexes(schema, table string) ([]indexInfo, error) {
schema, err := s.normalizeSchema(schema)
return s.listIndexesWithIdentity(schema, table, false)
}
func (s *server) listIndexesWithIdentity(schema, table string, exactIdentity bool) ([]indexInfo, error) {
schema, err := s.metadataSchema(schema, exactIdentity)
if err != nil {
return nil, err
}
table = strings.TrimSpace(table)
if !exactIdentity {
table = strings.TrimSpace(table)
}
rows, err := s.queryRows(`
SELECT i.INDEX_NAME,
ic.COLUMN_NAME,
@@ -2816,7 +2918,7 @@ SELECT i.INDEX_NAME,
i.VISIBILITY,
ic.DESCEND,
ie.COLUMN_EXPRESSION,
ic.COLUMN_POSITION
ic.COLUMN_POSITION, i.OWNER
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON ic.INDEX_OWNER = i.OWNER AND ic.INDEX_NAME = i.INDEX_NAME
AND ic.TABLE_OWNER = i.TABLE_OWNER AND ic.TABLE_NAME = i.TABLE_NAME
@@ -2826,27 +2928,30 @@ LEFT JOIN ALL_CONSTRAINTS pk ON pk.OWNER = i.TABLE_OWNER
AND pk.TABLE_NAME = i.TABLE_NAME
AND pk.CONSTRAINT_TYPE = 'P'
AND pk.INDEX_NAME = i.INDEX_NAME
AND pk.INDEX_OWNER = i.OWNER
WHERE i.TABLE_OWNER = :1 AND i.TABLE_NAME = :2
ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table})
ORDER BY i.OWNER, i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table})
if err != nil {
return nil, err
}
defer s.closeRows(rows)
byName := map[string]*indexInfo{}
order := []string{}
byName := map[[2]string]*indexInfo{}
order := [][2]string{}
for rows.Next() {
var name, column, uniqueness, indexType, partitioned, visibility, direction string
var name, column, uniqueness, indexType, partitioned, visibility, direction, owner string
var expression sql.NullString
var primary int
var position int
if err := rows.Scan(&name, &column, &uniqueness, &primary, &indexType, &partitioned, &visibility, &direction, &expression, &position); err != nil {
if err := rows.Scan(&name, &column, &uniqueness, &primary, &indexType, &partitioned, &visibility, &direction, &expression, &position, &owner); err != nil {
return nil, err
}
item := byName[name]
identity := [2]string{owner, name}
item := byName[identity]
if item == nil {
visible := visibility
item = &indexInfo{
Owner: &owner,
Name: name,
Columns: []string{},
IsUnique: uniqueness == "UNIQUE",
@@ -2858,8 +2963,8 @@ ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table})
Visibility: &visible,
KeyMetadataComplete: true,
}
byName[name] = item
order = append(order, name)
byName[identity] = item
order = append(order, identity)
}
if position != len(item.Columns)+1 || (direction != "ASC" && direction != "DESC") || (visibility != "VISIBLE" && visibility != "INVISIBLE") || item.Visibility == nil || *item.Visibility != visibility || partitioned != "NO" || (indexType != "NORMAL" && indexType != "FUNCTION-BASED NORMAL") {
item.KeyMetadataComplete = false
@@ -2886,11 +2991,17 @@ ORDER BY i.INDEX_NAME, ic.COLUMN_POSITION`, []any{schema, table})
}
func (s *server) listForeignKeys(schema, table string) ([]foreignKeyInfo, error) {
schema, err := s.normalizeSchema(schema)
return s.listForeignKeysWithIdentity(schema, table, false)
}
func (s *server) listForeignKeysWithIdentity(schema, table string, exactIdentity bool) ([]foreignKeyInfo, error) {
schema, err := s.metadataSchema(schema, exactIdentity)
if err != nil {
return nil, err
}
table = strings.TrimSpace(table)
if !exactIdentity {
table = strings.TrimSpace(table)
}
rows, err := s.queryRows(`
SELECT ac.CONSTRAINT_NAME,
acc.COLUMN_NAME,
@@ -2954,11 +3065,22 @@ func oracleSystemNotNullConstraint(kind string, generated sql.NullString, defini
// only contains constraints a user would recognize as such, matching how
// tools like DBeaver/Navicat present Oracle constraints.
func (s *server) listConstraints(schema, table string) ([]constraintInfo, error) {
schema, err := s.normalizeSchemaForIdentity(schema)
return s.listConstraintsWithIdentity(schema, table, false)
}
func (s *server) listConstraintsWithIdentity(schema, table string, exactIdentity bool) ([]constraintInfo, error) {
var err error
if exactIdentity {
schema, err = s.metadataSchema(schema, true)
} else {
schema, err = s.normalizeSchemaForIdentity(schema)
}
if err != nil {
return nil, err
}
table = strings.TrimSpace(table)
if !exactIdentity {
table = strings.TrimSpace(table)
}
// SEARCH_CONDITION is a LONG column: Oracle rejects LONG values in WHERE
// clauses, functions, or ORDER BY (ORA-00932), so it can only appear in
// the SELECT list here. The NOT-NULL-check exclusion below is therefore
@@ -3148,11 +3270,17 @@ WHERE OWNER = :1 AND TABLE_NAME = :2 AND INDEX_OWNER = :3 AND INDEX_NAME = :4
}
func (s *server) listTriggers(schema, table string) ([]triggerInfo, error) {
schema, err := s.normalizeSchema(schema)
return s.listTriggersWithIdentity(schema, table, false)
}
func (s *server) listTriggersWithIdentity(schema, table string, exactIdentity bool) ([]triggerInfo, error) {
schema, err := s.metadataSchema(schema, exactIdentity)
if err != nil {
return nil, err
}
table = strings.TrimSpace(table)
if !exactIdentity {
table = strings.TrimSpace(table)
}
rows, err := s.queryRows(oracleListTriggersSQL, []any{schema, table})
if err != nil {
return nil, err
@@ -3263,12 +3391,52 @@ func oracleTriggerBody(source, description string) (string, bool) {
}
func (s *server) getObjectSource(schema, name, objectType string) (map[string]any, error) {
upperType := strings.ToUpper(objectType)
if upperType == "TYPE" || upperType == "TYPE_BODY" || upperType == "TYPE BODY" {
if schema == "" {
var err error
schema, err = s.normalizeSchemaForIdentity(schema)
if err != nil {
return nil, err
}
}
if upperType != "TYPE" {
upperType = "TYPE BODY"
}
source, found, sourceErr := s.loadObjectSourceText(schema, name, upperType)
if sourceErr != nil || !found || strings.TrimSpace(source) == "" {
db, dbErr := s.requireDB()
if dbErr != nil {
if sourceErr != nil {
return nil, errors.Join(sourceErr, dbErr)
}
return nil, dbErr
}
ddlType := "TYPE"
if upperType == "TYPE BODY" {
ddlType = "TYPE_BODY"
}
var ddl sql.NullString
ddlErr := db.QueryRow("SELECT DBMS_METADATA.GET_DDL(:1, :2, :3) FROM DUAL", ddlType, name, schema).Scan(&ddl)
if ddlErr == nil && ddl.Valid && strings.TrimSpace(ddl.String) != "" {
source = ddl.String
} else {
if ddlErr == nil {
ddlErr = fmt.Errorf("empty source returned")
}
if sourceErr != nil {
return nil, errors.Join(sourceErr, ddlErr)
}
return nil, ddlErr
}
}
return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": source, "editable": false}, nil
}
var err error
schema, err = s.normalizeSchemaForIdentity(schema)
if err != nil {
return nil, err
}
upperType := strings.ToUpper(objectType)
if upperType == "VIEW" {
source, err := s.getViewSource(schema, name)
if err != nil {
@@ -3282,8 +3450,12 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
// Unquoted Oracle identifiers are stored uppercase; quoted mixed-case names must stay exact.
// Try caller-provided identity first, then uppercase fallback (same pattern as column metadata).
dictionaryType := upperType
if dictionaryType == "PACKAGE_BODY" {
dictionaryType = "PACKAGE BODY"
}
for _, candidate := range oracleObjectIdentityNameCandidates(name) {
source, found, queryErr := s.loadObjectSourceText(schema, candidate, upperType)
source, found, queryErr := s.loadObjectSourceText(schema, candidate, dictionaryType)
if queryErr != nil {
return nil, queryErr
}
@@ -3399,8 +3571,12 @@ func (s *server) getTableDDL(schema, table, objectType string) (string, error) {
}
func (s *server) getTableDDLWithOptions(schema, table, objectType string, portable bool) (string, error) {
return s.getTableDDLWithIdentity(schema, table, objectType, portable, false)
}
func (s *server) getTableDDLWithIdentity(schema, table, objectType string, portable, exactIdentity bool) (string, error) {
var err error
schema, err = s.normalizeSchema(schema)
schema, err = s.metadataSchema(schema, exactIdentity)
if err != nil {
return "", err
}
@@ -3408,7 +3584,7 @@ func (s *server) getTableDDLWithOptions(schema, table, objectType string, portab
if err != nil {
return "", err
}
objectType, table, err = s.resolveDDLObject(schema, table, objectType)
objectType, table, err = s.resolveDDLObjectWithIdentity(schema, table, objectType, exactIdentity)
if err != nil {
return "", err
}
@@ -3447,6 +3623,12 @@ func (s *server) getTableDDLWithOptions(schema, table, objectType string, portab
return ddl, nil
}
if objectType == "TABLE" {
if exactIdentity {
if err != nil {
return "", err
}
return "", errors.New("exact table DDL is unavailable")
}
fallback, fallbackErr := s.buildTableDDL(schema, table)
if fallbackErr != nil {
return "", fallbackErr
@@ -3654,7 +3836,14 @@ func oracleStringLiteral(value string) string {
}
func (s *server) resolveDDLObject(schema, name, requested string) (string, string, error) {
return s.resolveDDLObjectWithIdentity(schema, name, requested, false)
}
func (s *server) resolveDDLObjectWithIdentity(schema, name, requested string, exactIdentity bool) (string, string, error) {
exact, uppercase, hasUppercaseFallback := oracleObjectNameCandidates(name)
if exactIdentity {
exact, hasUppercaseFallback = name, false
}
objectType := normalizeDDLObjectType(requested)
if objectType != "" {
return objectType, exact, nil
@@ -4218,14 +4407,30 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
var ownedRows *sql.Rows
defer func() { s.closeRows(ownedRows) }()
for attempt := 0; ; attempt++ {
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, opts.DeferLOBs)
// Only the first page may be retried: once rows have been returned the
// caller owns them and a second execution would duplicate data.
retrySQL := func(err error) (string, bool) {
if attempt != 0 || s.hasManualTransaction() || oracleSQLLocksRows(sqlText) {
return "", false
}
return oraclePagedRetrySQL(sqlText, s.loadOracleColumnMeta, err)
}
rows, err := s.queryRowsWithOracleValueRewriteIfNeeded(sqlText, opts.TimeoutSecs, deferLobsForQuery(sqlText, opts.DeferLOBs))
if err != nil {
if rewritten, ok := retrySQL(err); ok {
sqlText = rewritten
continue
}
return queryPageResult{}, nil, err
}
ownedRows = rows
columns, err := rows.Columns()
if err != nil {
s.closeRows(rows)
if rewritten, ok := retrySQL(err); ok {
sqlText = rewritten
continue
}
return queryPageResult{}, nil, err
}
columnTypes := columnTypeNames(rows)
@@ -4238,12 +4443,9 @@ func (s *server) runPagedOracleSelect(sqlText string, opts queryOptions, pageSiz
result.ExecutionTimeMS = time.Since(start).Milliseconds()
if err != nil {
s.closeRows(rows)
var panicErr oracleDriverPanicError
if attempt == 0 && !s.hasManualTransaction() && !oracleSQLLocksRows(sqlText) && errors.As(err, &panicErr) {
if placeholder, ok := oraclePlaceholderRetrySQL(sqlText, s.loadOracleColumnMeta); ok {
sqlText = placeholder
continue
}
if rewritten, ok := retrySQL(err); ok {
sqlText = rewritten
continue
}
return queryPageResult{}, nil, err
}
@@ -4267,6 +4469,33 @@ func oraclePlaceholderRetrySQL(sqlText string, loadColumns oracleColumnMetaLoade
return placeholder, true
}
// oraclePagedRetrySQL returns the statement to retry with when the first page
// of a paged Oracle SELECT fails while reading the response.
//
// A driver panic means the driver state for the scanned value is unknown, so it
// keeps the placeholder projection used by the non-paged fallback. Response
// reading failures such as go-ora's "TTC error: received code ..." mean a column
// value could not be decoded (SDO_GEOMETRY, XMLTYPE, ...); those retry with the
// value rewrite so the columns come back as WKT / serialized text instead of
// failing the whole page (issue #13179). The non-paged executeSelect path
// already performs this rewrite in executeOracleSelectWithXMLTypeRetry; without
// it the paged path (table data and paged query results) surfaced the raw TTC
// error and the table could not be opened.
func oraclePagedRetrySQL(sqlText string, loadColumns oracleColumnMetaLoader, err error) (string, bool) {
var panicErr oracleDriverPanicError
if errors.As(err, &panicErr) {
return oraclePlaceholderRetrySQL(sqlText, loadColumns)
}
if !shouldRetryOracleXMLTypeRewrite(err) {
return "", false
}
rewritten, rewriteErr := rewriteOracleSelectSQL(sqlText, loadColumns, false)
if rewriteErr != nil || rewritten == sqlText {
return "", false
}
return rewritten, true
}
func (s *server) fetchQueryPage(sessionID string, pageSize int) (queryPageResult, error) {
session := s.sessions[sessionID]
if session == nil {
@@ -4436,7 +4665,7 @@ func (s *server) executeQuery(opts queryOptions) (queryResult, error) {
maxRows = defaultMaxRows
}
if isQuerySQL(sqlText) {
result, err := s.executeSelect(sqlText, maxRows, opts.TimeoutSecs, opts.DeferLOBs)
result, err := s.executeSelect(sqlText, maxRows, opts.TimeoutSecs, deferLobsForQuery(sqlText, opts.DeferLOBs))
result.ExecutionTimeMS = time.Since(start).Milliseconds()
return result, err
}
@@ -4584,6 +4813,16 @@ func columnTypeNames(rows *sql.Rows) []string {
return result
}
// deferLobsForQuery keeps SQL-level large-value previews in charge. When the
// client already projected __DBX_LARGE_VALUE_BYTES_ marker columns (Oracle
// table-data previews truncate LOBs with DBMS_LOB.SUBSTR in the SQL), those
// expressions must pass through untouched and the nested value rewrite
// (geometry/XMLType/opaque conversions) keeps running as usual, so the
// deferred-LOB placeholder pass must not engage on top of them.
func deferLobsForQuery(sqlText string, requested bool) bool {
return requested && !strings.Contains(strings.ToUpper(sqlText), largeValueBytesColumnPrefix)
}
func (s *server) queryRowsWithOracleValueRewriteIfNeeded(sqlText string, timeoutSecs int, deferLOBs bool) (resultRows *sql.Rows, queryErr error) {
// A statement that keeps row locks while it runs is rewritten before the
// first attempt: when such a statement panics after the rows were locked,
+436 -25
View File
@@ -57,6 +57,9 @@ func TestHandshakeResponse(t *testing.T) {
if !contains(result.Capabilities, "query") || !contains(result.Capabilities, "metadata") {
t.Fatalf("expected query and metadata capabilities, got %v", result.Capabilities)
}
if !contains(result.Capabilities, "exact_metadata_identity_v1") {
t.Fatal("updated Oracle metadata must explicitly advertise exact identity")
}
}
func TestRuntimeHandshakeAdvertisesMultiSessionProtocol(t *testing.T) {
@@ -876,14 +879,14 @@ func TestListIndexesSeparatesQuotedCloneTableNamesByCase(t *testing.T) {
{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{schema, "ORDERS_COPY"},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION"},
rows: [][]driver.Value{{"ORDERS_COPY_IDX1", "STATUS", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1)}},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION", "OWNER"},
rows: [][]driver.Value{{"ORDERS_COPY_IDX1", "STATUS", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1), "HR"}},
},
{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{schema, "ORDERS_copy"},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION"},
rows: [][]driver.Value{{"ORDERS_copy_IDX1", "STATUS", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1)}},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION", "OWNER"},
rows: [][]driver.Value{{"ORDERS_copy_IDX1", "STATUS", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1), "HR"}},
},
})
s := newServer()
@@ -914,10 +917,10 @@ func TestListIndexesExposesExpressionDirectionAndVisibilityMetadata(t *testing.T
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{schema, table},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION"},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION", "OWNER"},
rows: [][]driver.Value{
{"IX_EVENT_EXPR", "SYS_NC00001$", "NONUNIQUE", int64(0), "FUNCTION-BASED NORMAL", "NO", "INVISIBLE", "DESC", `LOWER("EVENT_NAME")`, int64(1)},
{"IX_EVENT_EXPR", "EVENT_TS", "NONUNIQUE", int64(0), "FUNCTION-BASED NORMAL", "NO", "INVISIBLE", "ASC", nil, int64(2)},
{"IX_EVENT_EXPR", "SYS_NC00001$", "NONUNIQUE", int64(0), "FUNCTION-BASED NORMAL", "NO", "INVISIBLE", "DESC", `LOWER("EVENT_NAME")`, int64(1), " Index Owner "},
{"IX_EVENT_EXPR", "EVENT_TS", "NONUNIQUE", int64(0), "FUNCTION-BASED NORMAL", "NO", "INVISIBLE", "ASC", nil, int64(2), " Index Owner "},
},
}})
s := newServer()
@@ -943,6 +946,7 @@ func TestListIndexesExposesExpressionDirectionAndVisibilityMetadata(t *testing.T
t.Fatal(err)
}
for key, want := range map[string]any{
"owner": " Index Owner ",
"columns": []any{`LOWER("EVENT_NAME")`, "EVENT_TS"},
"key_is_expression": []any{true, false},
"key_directions": []any{"DESC", "ASC"},
@@ -968,8 +972,8 @@ func TestListIndexesMarksUnsupportedShapesIncompleteThroughRPC(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{"APP", "EVENTS"},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION"},
rows: [][]driver.Value{{"IX_EVENT", "EVENT_TS", "NONUNIQUE", int64(0), test.indexType, test.partitioned, "VISIBLE", "ASC", nil, int64(1)}},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION", "OWNER"},
rows: [][]driver.Value{{"IX_EVENT", "EVENT_TS", "NONUNIQUE", int64(0), test.indexType, test.partitioned, "VISIBLE", "ASC", nil, int64(1), "APP"}},
}})
s := newServer()
s.db = db
@@ -986,6 +990,28 @@ func TestListIndexesMarksUnsupportedShapesIncompleteThroughRPC(t *testing.T) {
}
}
func TestExactIndexesKeepSameNameDifferentOwnersSeparate(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
queryContains: "FROM ALL_INDEXES",
args: []driver.Value{" Parent ", " T(a) "},
columns: []string{"INDEX_NAME", "COLUMN_NAME", "UNIQUENESS", "IS_PRIMARY", "INDEX_TYPE", "PARTITIONED", "VISIBILITY", "DESCEND", "COLUMN_EXPRESSION", "COLUMN_POSITION", "OWNER"},
rows: [][]driver.Value{
{" Ix(a) ", "ID", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1), " Owner "},
{" Ix(a) ", "CODE", "NONUNIQUE", int64(0), "NORMAL", "NO", "VISIBLE", "ASC", nil, int64(1), "Other"},
},
}})
s := newServer()
s.db = db
response, _ := s.handleLine(`{"jsonrpc":"2.0","id":1,"method":"list_indexes","params":{"schema":" Parent ","table":" T(a) ","exact_identity":true}}`)
if response.Error != nil {
t.Fatal(response.Error)
}
indexes := response.Result.([]indexInfo)
if len(indexes) != 2 || indexes[0].Owner == nil || *indexes[0].Owner != " Owner " || indexes[1].Owner == nil || *indexes[1].Owner != "Other" || len(indexes[0].Columns) != 1 || len(indexes[1].Columns) != 1 {
t.Fatalf("owner identity was lost or merged: %#v", indexes)
}
}
func TestGetTableCommentPreservesQuotedObjectName(t *testing.T) {
const schema = "HR"
const table = "OrderDetails"
@@ -2321,35 +2347,231 @@ func TestListSessionUserTablesQueryUsesUserDictionary(t *testing.T) {
}
func TestListObjectsSQLUsesSplitDictionaryQuery(t *testing.T) {
sqlText := strings.ToUpper(oracleListObjectsSQL)
query := oracleListObjectsQuery("APP", metadataListConstraints{})
sqlText := strings.ToUpper(query.SQL)
if !strings.Contains(sqlText, "ALL_TABLES") || !strings.Contains(sqlText, "ALL_OBJECTS") {
t.Fatalf("object listing should split tables from other objects, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should split tables from other objects, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "UNION ALL") {
t.Fatalf("object listing should union object metadata, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should union object metadata, got: %s", query.SQL)
}
if strings.Contains(sqlText, "ALL_TAB_COMMENTS") {
t.Fatalf("object listing should not load comments during refresh, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should not load comments during refresh, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "'PACKAGE BODY'") || !strings.Contains(sqlText, "PACKAGE_BODY") {
t.Fatalf("object listing should include package bodies with normalized type, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should include package bodies with normalized type, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "'SEQUENCE'") {
t.Fatalf("object listing should include sequences, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should include sequences, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "'TYPE BODY'") || !strings.Contains(sqlText, "TYPE_BODY") || !strings.Contains(sqlText, "'TYPE'") {
t.Fatalf("object listing should include TYPE and normalize TYPE BODY, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "END, OBJECT_NAME, OBJECT_TYPE") {
t.Fatalf("same-name TYPE and TYPE BODY need deterministic ordering, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "ALL_SYNONYMS") || !strings.Contains(sqlText, "'SYNONYM' AS OBJECT_TYPE") {
t.Fatalf("object listing should include schema synonyms, got: %s", oracleListObjectsSQL)
t.Fatalf("object listing should include schema synonyms, got: %s", query.SQL)
}
}
func TestSchemaIndexObjectsPreserveBothOwnersThroughRPC(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "SYS_CONTEXT('USERENV', 'SESSION_USER')", args: []driver.Value{}, rows: [][]driver.Value{{"APP"}}},
{queryContains: "USER_INDEXES", args: []driver.Value{"INDEX", int64(2), int64(0)}, columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "PARENT_SCHEMA", "PARENT_NAME"}, rows: [][]driver.Value{{" Ix (a) ", "INDEX", nil, "Table Owner", " T(a) "}}},
})
s := newServer()
s.db = db
resp, _ := s.handleLine(`{"id":1,"method":"list_objects","params":{"schema":"APP","object_types":["INDEX"],"limit":2}}`)
if resp.Error != nil {
t.Fatalf("list_objects failed: %v", resp.Error)
}
data, err := json.Marshal(resp.Result)
if err != nil {
t.Fatal(err)
}
var objects []map[string]any
if err := json.Unmarshal(data, &objects); err != nil {
t.Fatal(err)
}
if len(objects) != 1 || objects[0]["schema"] != "APP" || objects[0]["name"] != " Ix (a) " || objects[0]["object_type"] != "INDEX" || objects[0]["parent_schema"] != "Table Owner" || objects[0]["parent_name"] != " T(a) " {
t.Fatalf("index identities were not preserved: %s", data)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("queries consumed = %d, want %d", scripted.next, len(scripted.steps))
}
}
func TestSchemaIndexObjectsDoNotReportPGAFailureAsEmpty(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "SYS_CONTEXT('USERENV', 'SESSION_USER')", args: []driver.Value{}, rows: [][]driver.Value{{"APP"}}},
{queryContains: "FROM USER_TABLES", args: []driver.Value{"INDEX"}, err: errors.New("ORA-04036: PGA memory used by the instance exceeds PGA_AGGREGATE_LIMIT")},
})
s := newServer()
s.db = db
resp, _ := s.handleLine(`{"id":1,"method":"list_objects","params":{"schema":"APP","object_types":["INDEX"]}}`)
if resp.Error == nil || !strings.Contains(resp.Error.Message, "ORA-04036") {
t.Fatalf("dictionary failure must remain an error, got %+v", resp)
}
}
func TestExactSchemaIndexAllDictionaryPagePreservesMissingParent(t *testing.T) {
owner := " Index Owner "
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "SYS_CONTEXT('USERENV', 'SESSION_USER')", args: []driver.Value{}, rows: [][]driver.Value{{"Other Owner"}}},
{queryContains: "ALL_INDEXES", args: []driver.Value{owner, owner, owner, owner, "INDEX", int64(3), int64(1)}, columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "PARENT_SCHEMA", "PARENT_NAME"}, rows: [][]driver.Value{{" Ix (a) ", "INDEX", nil, nil, nil}}},
})
s := newServer()
s.db = db
resp, _ := s.handleLine(`{"id":1,"method":"list_objects","params":{"schema":" Index Owner ","object_types":["INDEX"],"limit":2,"offset":1,"exact_identity":true}}`)
if resp.Error != nil {
t.Fatalf("exact ALL dictionary page failed: %v", resp.Error)
}
objects := resp.Result.([]objectInfo)
if len(objects) != 1 || objects[0].Schema != owner || objects[0].Name != " Ix (a) " || objects[0].ParentName != nil || objects[0].ParentSchema != nil || scripted.next != 2 {
t.Fatalf("page must preserve raw/unknown identities without applying offset twice: %+v", objects)
}
}
func TestExactMetadataRPCNeverNormalizesOrRetriesANearbyName(t *testing.T) {
for _, method := range []string{"get_columns", "list_indexes", "get_table_comment", "list_foreign_keys", "list_constraints", "list_triggers"} {
t.Run(method, func(t *testing.T) {
query := map[string]string{"get_columns": "FROM ALL_TAB_COLUMNS", "list_indexes": "FROM ALL_INDEXES", "get_table_comment": "FROM ALL_TAB_COMMENTS", "list_foreign_keys": "FROM ALL_CONSTRAINTS", "list_constraints": "FROM ALL_CONSTRAINTS", "list_triggers": "FROM ALL_TRIGGERS"}[method]
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{queryContains: query, args: []driver.Value{" Mixed Owner ", " T(a) "}}})
s := newServer()
s.db = db
resp, _ := s.handleLine(fmt.Sprintf(`{"id":1,"method":%q,"params":{"schema":" Mixed Owner ","table":" T(a) ","exact_identity":true}}`, method))
if resp.Error != nil {
t.Fatalf("exact read failed: %v", resp.Error)
}
if scripted.next != 1 {
t.Fatalf("exact missing target must not retry a nearby name; queries=%d", scripted.next)
}
})
}
}
func TestExactTableDDLDoesNotBuildAnotherTargetAfterMetadataFailure(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{queryContains: "DBMS_METADATA.GET_DDL", args: []driver.Value{"TABLE", " T(a) ", " Mixed Owner "}, err: errors.New("ORA-01031: insufficient privileges")}})
s := newServer()
s.db = db
resp, _ := s.handleLine(`{"id":1,"method":"get_table_ddl","params":{"schema":" Mixed Owner ","table":" T(a) ","object_type":"TABLE","exact_identity":true}}`)
if resp.Error == nil || !strings.Contains(resp.Error.Message, "ORA-01031") || scripted.next != 1 {
t.Fatalf("must preserve exact identity failure without fallback, got %+v after %d queries", resp, scripted.next)
}
}
func TestListSessionUserObjectsSQLIncludesSequences(t *testing.T) {
sqlText := strings.ToUpper(oracleListObjectsSessionUserBaseSQL)
if !strings.Contains(sqlText, "FROM USER_OBJECTS") || !strings.Contains(sqlText, "'SEQUENCE'") {
if !strings.Contains(sqlText, "FROM USER_OBJECTS") || !strings.Contains(sqlText, "'SEQUENCE'") || !strings.Contains(sqlText, "'TYPE BODY'") || !strings.Contains(sqlText, "'TYPE'") {
t.Fatalf("session-user object listing should include sequences, got: %s", oracleListObjectsSessionUserBaseSQL)
}
}
func TestGetOracleTypeSourceUsesExactIdentityAndCollectionDDLFallback(t *testing.T) {
const ddl = `CREATE TYPE "App"."MixedType" AS TABLE OF VARCHAR2(20)`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "DBMS_XMLGEN.CONVERT", args: []driver.Value{"App", "MixedType", "TYPE"}, rows: [][]driver.Value{{nil}}},
{queryContains: "DBMS_METADATA.GET_DDL(:1, :2, :3)", args: []driver.Value{"TYPE", "MixedType", "App"}, rows: [][]driver.Value{{ddl}}},
})
s := newServer()
s.db = db
result, err := s.getObjectSource("App", "MixedType", "TYPE")
if err != nil {
t.Fatal(err)
}
if result["source"] != ddl || result["editable"] != false || result["name"] != "MixedType" {
t.Fatalf("unexpected type source: %#v", result)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestGetOracleTypeBodyReadsLongQuotedNameAsTypeBodyReadonly(t *testing.T) {
const name = "Mixed Type"
source := "TYPE BODY \"Mixed Type\" AS\n" + strings.Repeat(" MEMBER PROCEDURE P IS BEGIN NULL; END;\n", 1200) + "END;"
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "DBMS_XMLGEN.CONVERT", args: []driver.Value{"App", name, "TYPE BODY"}, rows: [][]driver.Value{{source}}},
})
s := newServer()
s.db = db
result, err := s.getObjectSource("App", name, "TYPE_BODY")
if err != nil {
t.Fatal(err)
}
if result["source"] != source || result["editable"] != false || result["object_type"] != "TYPE_BODY" {
t.Fatalf("unexpected body source: %#v", result)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected exact identity query only; got %d queries", scripted.next)
}
}
func TestGetOracleTypeRejectsEmptySourceWhenMetadataFallbackFails(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "DBMS_XMLGEN.CONVERT", args: []driver.Value{"APP", "EMPTY_T", "TYPE"}, rows: [][]driver.Value{{nil}}},
{queryContains: "DBMS_METADATA.GET_DDL(:1, :2, :3)", args: []driver.Value{"TYPE", "EMPTY_T", "APP"}, err: errors.New("metadata unavailable")},
})
s := newServer()
s.db = db
result, err := s.getObjectSource("APP", "EMPTY_T", "TYPE")
if err == nil || result != nil {
t.Fatalf("expected empty/missing type source to fail, got result=%#v err=%v", result, err)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestGetOracleTypeBodyFallsBackAfterDictionaryErrorsWithExactPaddedIdentity(t *testing.T) {
const owner = " Mixed Owner "
const name = " Mixed Body "
const sourceErr = "dictionary denied"
const ddl = `CREATE TYPE BODY " Mixed Owner " . " Mixed Body " AS END;`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "DBMS_XMLGEN.CONVERT", args: []driver.Value{owner, name, "TYPE BODY"}, err: errors.New(sourceErr)},
{queryContains: "SELECT TEXT", args: []driver.Value{owner, name, "TYPE BODY"}, err: errors.New("line view denied")},
{queryContains: "DBMS_METADATA.GET_DDL(:1, :2, :3)", args: []driver.Value{"TYPE_BODY", name, owner}, rows: [][]driver.Value{{ddl}}},
})
s := newServer()
s.db = db
result, err := s.getObjectSource(owner, name, "TYPE_BODY")
if err != nil {
t.Fatal(err)
}
if result["schema"] != owner || result["name"] != name || result["source"] != ddl || result["editable"] != false {
t.Fatalf("type-body identity/source changed: %#v", result)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestGetOracleTypeFallsBackAfterDictionaryErrorsWithExactPaddedIdentity(t *testing.T) {
const owner = " Mixed Owner "
const name = " Mixed Type "
const ddl = `CREATE TYPE " Mixed Owner " . " Mixed Type " AS OBJECT ( VALUE NUMBER )`
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{queryContains: "DBMS_XMLGEN.CONVERT", args: []driver.Value{owner, name, "TYPE"}, err: errors.New("ALL_SOURCE denied")},
{queryContains: "SELECT TEXT", args: []driver.Value{owner, name, "TYPE"}, err: errors.New("ALL_SOURCE lines denied")},
{queryContains: "DBMS_METADATA.GET_DDL(:1, :2, :3)", args: []driver.Value{"TYPE", name, owner}, rows: [][]driver.Value{{ddl}}},
})
s := newServer()
s.db = db
result, err := s.getObjectSource(owner, name, "TYPE")
if err != nil {
t.Fatal(err)
}
if result["schema"] != owner || result["name"] != name || result["source"] != ddl || result["editable"] != false {
t.Fatalf("type identity/source changed: %#v", result)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestListObjectsQueryAppliesMetadataConstraints(t *testing.T) {
query := oracleListObjectsQuery("APP", metadataListConstraints{
Filter: "pkg%",
@@ -2358,19 +2580,19 @@ func TestListObjectsQueryAppliesMetadataConstraints(t *testing.T) {
})
sqlText := strings.ToUpper(query.SQL)
if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :4 ESCAPE '\\'") {
if !strings.Contains(sqlText, "UPPER(OBJECT_NAME) LIKE :5 ESCAPE '\\'") {
t.Fatalf("object listing should push filter predicate, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "OBJECT_TYPE IN (:5,:6)") {
if !strings.Contains(sqlText, "OBJECT_TYPE IN (:6,:7)") {
t.Fatalf("object listing should push object type predicate, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "ROWNUM <= :7") || !strings.Contains(sqlText, "DBX_RN > :8") {
if !strings.Contains(sqlText, "ROWNUM <= :8") || !strings.Contains(sqlText, "DBX_RN > :9") {
t.Fatalf("object listing should use rownum pagination, got: %s", query.SQL)
}
if len(query.Args) != 8 {
if len(query.Args) != 9 {
t.Fatalf("unexpected args: %#v", query.Args)
}
if query.Args[0] != "APP" || query.Args[1] != "APP" || query.Args[2] != "APP" || query.Args[3] != "%P%K%G%\\%%" || query.Args[4] != "FUNCTION" || query.Args[5] != "PACKAGE" || query.Args[6] != 25 || query.Args[7] != 0 {
if query.Args[0] != "APP" || query.Args[1] != "APP" || query.Args[2] != "APP" || query.Args[3] != "APP" || query.Args[4] != "%P%K%G%\\%%" || query.Args[5] != "FUNCTION" || query.Args[6] != "PACKAGE" || query.Args[7] != 25 || query.Args[8] != 0 {
t.Fatalf("object constraints args were not normalized: %#v", query.Args)
}
}
@@ -2424,14 +2646,44 @@ func TestListObjectsQuerySupportsSynonymOnlyFiltering(t *testing.T) {
if !strings.Contains(sqlText, "FROM ALL_SYNONYMS") || !strings.Contains(sqlText, "S.OWNER = :3") {
t.Fatalf("cross-schema synonym listing should use the selected owner, got: %s", query.SQL)
}
if !strings.Contains(sqlText, "OBJECT_TYPE IN (:5)") {
if !strings.Contains(sqlText, "OBJECT_TYPE IN (:6)") {
t.Fatalf("synonym filtering should be pushed into the constrained query, got: %s", query.SQL)
}
if len(query.Args) != 7 || query.Args[2] != "AP" || query.Args[3] != "%S%P%E%C%" || query.Args[4] != "SYNONYM" || query.Args[5] != 30 || query.Args[6] != 20 {
if len(query.Args) != 8 || query.Args[2] != "AP" || query.Args[3] != "AP" || query.Args[4] != "%S%P%E%C%" || query.Args[5] != "SYNONYM" || query.Args[6] != 30 || query.Args[7] != 20 {
t.Fatalf("unexpected synonym query args: %#v", query.Args)
}
}
func TestListObjectsPagesSameNameTypeAndBodyUnderExactQuotedOwner(t *testing.T) {
const owner = " Mixed Owner "
steps := []oracleViewSourceQueryStep{
{queryContains: "END, OBJECT_NAME, OBJECT_TYPE", columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "PARENT_SCHEMA", "PARENT_NAME"}, args: []driver.Value{"MIXED OWNER", "MIXED OWNER", "MIXED OWNER", owner, "%T%Y%P%E%", "TYPE", "TYPE_BODY", int64(1), int64(0)}, rows: [][]driver.Value{{"Mixed Type", "TYPE", nil, nil, nil}}},
{queryContains: "END, OBJECT_NAME, OBJECT_TYPE", columns: []string{"OBJECT_NAME", "OBJECT_TYPE", "COMMENTS", "PARENT_SCHEMA", "PARENT_NAME"}, args: []driver.Value{"MIXED OWNER", "MIXED OWNER", "MIXED OWNER", owner, "%T%Y%P%E%", "TYPE", "TYPE_BODY", int64(2), int64(1)}, rows: [][]driver.Value{{"Mixed Type", "TYPE_BODY", nil, nil, nil}}},
}
db, scripted := openOracleViewSourceTestDB(t, steps)
s := newServer()
s.db = db
constraints := metadataListConstraints{Filter: "type", Limit: 1, ObjectTypes: []string{"TYPE", "TYPE_BODY"}}
specs, err := s.listObjects(owner, constraints)
if err != nil {
t.Fatal(err)
}
constraints.Offset = 1
bodies, err := s.listObjects(owner, constraints)
if err != nil {
t.Fatal(err)
}
if len(specs) != 1 || len(bodies) != 1 || specs[0].Name != bodies[0].Name || specs[0].ObjectType != "TYPE" || bodies[0].ObjectType != "TYPE_BODY" {
t.Fatalf("same-name TYPE/BODY page identities lost: specs=%#v bodies=%#v", specs, bodies)
}
if specs[0].Schema != owner || bodies[0].Schema != owner {
t.Fatalf("exact quoted owner not retained: %#v %#v", specs[0], bodies[0])
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d list queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestOracleListTriggersSQLLoadsSourceWithoutLongColumns(t *testing.T) {
sqlText := strings.ToUpper(oracleListTriggersSQL)
@@ -3416,6 +3668,39 @@ func TestGetObjectSourceAggregatesProcedureSourceInOracle(t *testing.T) {
}
}
func TestStrictPackageSourceReportsDictionaryOriginAndUsesExactDictionaryKind(t *testing.T) {
for _, kind := range []string{"PACKAGE", "PACKAGE_BODY"} {
for _, strict := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/strict=%t", kind, strict), func(t *testing.T) {
dictionaryKind := strings.ReplaceAll(kind, "_", " ")
source := dictionaryKind + " \"Pkg.With.Dot\" AS END;"
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{{
queryContains: "DBMS_XMLGEN.CONVERT",
args: []driver.Value{"DBX_TEST", "Pkg.With.Dot", dictionaryKind},
rows: [][]driver.Value{{source}},
}})
s := newServer()
s.db = db
response, shutdown := s.handleLine(fmt.Sprintf(`{"id":1,"method":"get_object_source","params":{"schema":"DBX_TEST","name":"Pkg.With.Dot","object_type":%q,"include_source_origin":%t}}`, kind, strict))
if shutdown || response.Error != nil {
t.Fatalf("unexpected source response: %#v", response)
}
result := response.Result.(map[string]any)
if result["source"] != source || result["object_type"] != kind {
t.Fatalf("wrong exact source: %#v", result)
}
origin, exists := result["source_origin"]
if exists != strict || (strict && origin != "oracle-dictionary") {
t.Fatalf("wrong source provenance: %#v", result)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("unexpected queries: %d", scripted.next)
}
})
}
}
}
func TestGetObjectSourceReturnsEmptyWhenAggregatedSourceIsNull(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
@@ -3579,6 +3864,7 @@ type oracleViewSourceQueryStep struct {
columns []string
rows [][]driver.Value
err error
nextErr error
exec bool
panicText string
nextPanicText string
@@ -3635,7 +3921,7 @@ func (c *oracleViewSourceConn) QueryContext(
values[index] = arg.Value
}
if !reflect.DeepEqual(values, step.args) {
return nil, errors.New("unexpected query arguments")
return nil, fmt.Errorf("unexpected query arguments: got %#v, want %#v (query %q)", values, step.args, query)
}
if step.err != nil {
return nil, step.err
@@ -3647,6 +3933,7 @@ func (c *oracleViewSourceConn) QueryContext(
return &oracleViewSourceRows{
columns: columns,
values: step.rows,
nextErr: step.nextErr,
nextPanicText: step.nextPanicText,
columnsPanicText: step.columnsPanicText,
columnsPanicCall: step.columnsPanicCall,
@@ -3683,6 +3970,7 @@ type oracleViewSourceRows struct {
columns []string
values [][]driver.Value
next int
nextErr error
nextPanicText string
columnsPanicText string
columnsPanicCall int
@@ -3705,6 +3993,9 @@ func (r *oracleViewSourceRows) Next(dest []driver.Value) error {
if r.nextPanicText != "" {
panic(r.nextPanicText)
}
if r.nextErr != nil {
return r.nextErr
}
if r.next >= len(r.values) {
return io.EOF
}
@@ -4086,6 +4377,110 @@ func TestExecuteQueryPageSurfacesPanicWhenPlaceholderNotApplicable(t *testing.T)
}
}
func TestExecuteQueryPageRetriesWithWKTRewriteOnTTCError(t *testing.T) {
// go-ora raises "TTC error: received code ... during response reading" when a
// response contains a value it cannot decode, e.g. SDO_GEOMETRY. The paged
// path must retry with the value rewrite (geometry as WKT) instead of
// surfacing the raw error and leaving the table unreadable (issue #13179).
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "SELECT * FROM SDE.TEST_GEOM",
args: []driver.Value{},
columns: []string{"ID", "GEOM"},
rows: [][]driver.Value{{int64(1), "raw geometry bytes"}},
nextErr: errors.New("TTC error: received code 3 during response reading"),
},
{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{
{"ID", "NUMBER", nil},
{"GEOM", "SDO_GEOMETRY", "PUBLIC"},
},
},
{
queryContains: `SDO_UTIL.TO_WKTGEOMETRY("GEOM") AS "GEOM"`,
args: []driver.Value{},
columns: []string{"ID", "GEOM"},
rows: [][]driver.Value{{int64(1), "POINT (1 2)"}},
},
})
s := newServer()
s.db = db
result, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
if err != nil {
t.Fatalf("expected the WKT retry to serve the first page, got %v", err)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
if len(result.Rows) != 1 {
t.Fatalf("expected one rewritten row, got %v", result.Rows)
}
want := []any{int64(1), "POINT (1 2)"}
if !reflect.DeepEqual(result.Rows[0], want) {
t.Fatalf("unexpected row: %v, want %v", result.Rows[0], want)
}
if result.SessionID != nil || result.HasMore {
t.Fatalf("fully drained page must not keep a session: %v %v", result.SessionID, result.HasMore)
}
}
func TestExecuteQueryPageKeepsTTCErrorWhenNoRewriteApplies(t *testing.T) {
db, scripted := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
queryContains: "SELECT * FROM SDE.TEST_GEOM",
args: []driver.Value{},
columns: []string{"ID"},
rows: [][]driver.Value{{int64(1)}},
nextErr: errors.New("TTC error: received code 3 during response reading"),
},
{
queryContains: "ALL_TAB_COLUMNS",
args: []driver.Value{"SDE", "TEST_GEOM"},
columns: []string{"COLUMN_NAME", "DATA_TYPE", "DATA_TYPE_OWNER"},
rows: [][]driver.Value{{"ID", "NUMBER", nil}},
},
})
s := newServer()
s.db = db
_, err := s.executeQueryPage(queryOptions{SQL: "SELECT * FROM SDE.TEST_GEOM"}, 10)
if err == nil || !strings.Contains(err.Error(), "TTC error: received code 3") {
t.Fatalf("expected the original TTC error, got %v", err)
}
if scripted.next != len(scripted.steps) {
t.Fatalf("expected %d queries, got %d", len(scripted.steps), scripted.next)
}
}
func TestOraclePagedRetrySQLChoosesRewriteByFailureKind(t *testing.T) {
loadColumns := func(string, string) ([]oracleColumnMeta, error) {
return []oracleColumnMeta{{Name: "GEOM", DataType: "SDO_GEOMETRY", DataTypeOwner: "PUBLIC"}}, nil
}
ttcErr := errors.New("TTC error: received code 3 during response reading")
rewritten, ok := oraclePagedRetrySQL("SELECT * FROM TEST_GEOM", loadColumns, ttcErr)
if !ok {
t.Fatal("expected a TTC-error retry")
}
if !strings.Contains(rewritten, `SDO_UTIL.TO_WKTGEOMETRY("GEOM") AS "GEOM"`) {
t.Fatalf("TTC retry must read geometry as WKT, got %s", rewritten)
}
placeholder, ok := oraclePagedRetrySQL("SELECT * FROM TEST_GEOM", loadColumns, oracleDriverPanicError{value: "decode panic"})
if !ok {
t.Fatal("expected a panic retry")
}
if !strings.Contains(placeholder, "'<SDO_GEOMETRY>'") {
t.Fatalf("panic retry must use the placeholder projection, got %s", placeholder)
}
if _, ok := oraclePagedRetrySQL("SELECT * FROM TEST_GEOM", loadColumns, errors.New("ORA-00942: table or view does not exist")); ok {
t.Fatal("unrelated errors must not be retried")
}
}
func TestRuntimeHandleLineRecoversRequestPanic(t *testing.T) {
db, _ := openOracleViewSourceTestDB(t, []oracleViewSourceQueryStep{
{
@@ -4594,6 +4989,22 @@ func TestRewriteOracleOpaqueObjectInDeferredProjection(t *testing.T) {
}
}
func TestDeferLobsForQueryDefersToSQLPreviewMarkers(t *testing.T) {
plain := `SELECT "ID", "BODY" FROM "MESPRD"."DOCUMENTS"`
if !deferLobsForQuery(plain, true) {
t.Fatal("defer should engage for plain projections")
}
if deferLobsForQuery(plain, false) {
t.Fatal("defer must honor the request flag")
}
preview := `SELECT "ID", "BODY", "__DBX_LARGE_VALUE_BYTES_T_1" FROM (SELECT "ID", ` +
`DBMS_LOB.SUBSTR("BODY", 201, 1) AS "BODY", 'T:200:' || DBMS_LOB.GETLENGTH("BODY") AS "__DBX_LARGE_VALUE_BYTES_T_1" ` +
`FROM "MESPRD"."DOCUMENTS") WHERE ROWNUM <= 100`
if deferLobsForQuery(preview, true) {
t.Fatal("defer must not engage when SQL-level preview markers are present")
}
}
func TestParseSingleOracleTableRefReadsDatabaseLinkAndAlias(t *testing.T) {
cases := []struct {
name string
+40
View File
@@ -342,3 +342,43 @@ func TestGetTopicConfigPreservesArgumentTypes(t *testing.T) {
t.Fatalf("unexpected argument values %#v", configs)
}
}
func TestDecodeDeliveryPayloadTextDecodesThriftBinaryProtocol(t *testing.T) {
// Strict TBinaryProtocol v1 CALL: method="ping", seqId=7, field 1 (string)="ok", field 2 (i32)=42, STOP
strictCall := []byte{
0x80, 0x01, 0x00, 0x01,
0x00, 0x00, 0x00, 0x04, 'p', 'i', 'n', 'g',
0x00, 0x00, 0x00, 0x07,
0x0b, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 'o', 'k',
0x08, 0x00, 0x02, 0x00, 0x00, 0x00, 0x2a,
0x00,
}
decoded, ok := decodeDeliveryPayloadText(strictCall)
if !ok {
t.Fatal("expected strict TBinaryProtocol payload to decode")
}
parsed := mustObject(t, decoded)
if parsed["method"] != "ping" || parsed["type"] != "CALL" {
t.Fatalf("unexpected decoded thrift envelope %#v", parsed)
}
fields := objectOrNil(parsed, "fields")
if fields["1"] != "ok" || fields["2"].(json.Number).String() != "42" {
t.Fatalf("unexpected decoded thrift fields %#v", fields)
}
// Raw TSerializer struct (valid UTF-8 bytes < 0x80 with NUL/control bytes): field 1 (string)="hello", STOP
rawStruct := []byte{0x0b, 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o', 0x00}
decodedStruct, ok := decodeDeliveryPayloadText(rawStruct)
if !ok {
t.Fatal("expected raw TSerializer struct to decode")
}
structObj := mustObject(t, decodedStruct)
if structObj["1"] != "hello" {
t.Fatalf("unexpected decoded struct %#v", structObj)
}
// Plain text stays untouched.
if plain, ok := decodeDeliveryPayloadText([]byte("hello world")); !ok || plain != "hello world" {
t.Fatalf("unexpected plain payload %q (%v)", plain, ok)
}
}
+2 -3
View File
@@ -11,7 +11,6 @@ import (
"regexp"
"sort"
"strings"
"unicode/utf8"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -1276,8 +1275,8 @@ func peekedMessageFromDelivery(queue string, index int64, delivery amqp.Delivery
if !delivery.Timestamp.IsZero() {
message["timestamp"] = delivery.Timestamp.UnixMilli()
}
if utf8.Valid(delivery.Body) {
message["payloadText"] = string(delivery.Body)
if text, ok := decodeDeliveryPayloadText(delivery.Body); ok {
message["payloadText"] = text
}
return message
}
+436
View File
@@ -0,0 +1,436 @@
package main
import (
"bytes"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"math"
"strconv"
"strings"
"unicode/utf8"
)
const (
thriftVersionMask uint32 = 0xffff0000
thriftVersion1 uint32 = 0x80010000
thriftTypeMask uint32 = 0x000000ff
thriftMaxDepth = 32
thriftMaxContainerItems = 10000
thriftMaxMethodNameBytes = 256
)
const (
thriftStop byte = 0
thriftVoid byte = 1
thriftBool byte = 2
thriftByte byte = 3
thriftDouble byte = 4
thriftI16 byte = 6
thriftI32 byte = 8
thriftI64 byte = 10
thriftString byte = 11
thriftStruct byte = 12
thriftMap byte = 13
thriftSet byte = 14
thriftList byte = 15
thriftUUID byte = 16
)
var errInvalidThrift = errors.New("invalid thrift binary payload")
type thriftReader struct {
data []byte
pos int
}
func (r *thriftReader) remaining() int {
return len(r.data) - r.pos
}
func (r *thriftReader) atEnd() bool {
return r.pos >= len(r.data)
}
func (r *thriftReader) take(n int) ([]byte, error) {
if n < 0 || r.pos+n > len(r.data) {
return nil, errInvalidThrift
}
slice := r.data[r.pos : r.pos+n]
r.pos += n
return slice, nil
}
func (r *thriftReader) u8() (byte, error) {
b, err := r.take(1)
if err != nil {
return 0, err
}
return b[0], nil
}
func (r *thriftReader) i16() (int16, error) {
b, err := r.take(2)
if err != nil {
return 0, err
}
return int16(binary.BigEndian.Uint16(b)), nil
}
func (r *thriftReader) i32() (int32, error) {
b, err := r.take(4)
if err != nil {
return 0, err
}
return int32(binary.BigEndian.Uint32(b)), nil
}
func (r *thriftReader) u32() (uint32, error) {
b, err := r.take(4)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b), nil
}
func (r *thriftReader) i64() (int64, error) {
b, err := r.take(8)
if err != nil {
return 0, err
}
return int64(binary.BigEndian.Uint64(b)), nil
}
func (r *thriftReader) f64() (float64, error) {
b, err := r.take(8)
if err != nil {
return 0, err
}
v := math.Float64frombits(binary.BigEndian.Uint64(b))
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0, errInvalidThrift
}
return v, nil
}
func isPrintableUTF8(data []byte) bool {
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if (r < 0x20 && r != '\t' && r != '\n' && r != '\r') || r == 0x7f {
return false
}
}
return true
}
func isValidThriftMethodName(name string) bool {
if len(name) == 0 || len(name) > thriftMaxMethodNameBytes {
return false
}
for i := 0; i < len(name); i++ {
if name[i] <= 0x20 || name[i] >= 0x7f {
return false
}
}
return true
}
func thriftMessageTypeName(code byte) (string, bool) {
switch code {
case 1:
return "CALL", true
case 2:
return "REPLY", true
case 3:
return "EXCEPTION", true
case 4:
return "ONEWAY", true
default:
return "", false
}
}
func decodeThriftBinaryOrString(data []byte, depth int) any {
if len(data) == 0 {
return ""
}
if isPrintableUTF8(data) {
return string(data)
}
if depth < thriftMaxDepth {
if nested, err := decodeThriftRawStruct(data, depth+1); err == nil {
return nested
}
}
return map[string]any{"$bytes": hex.EncodeToString(data)}
}
func readThriftValue(r *thriftReader, fieldType byte, depth int) (any, error) {
if depth > thriftMaxDepth {
return nil, errInvalidThrift
}
switch fieldType {
case thriftVoid:
return nil, nil
case thriftBool:
b, err := r.u8()
if err != nil || (b != 0 && b != 1) {
return nil, errInvalidThrift
}
return b == 1, nil
case thriftByte:
b, err := r.u8()
if err != nil {
return nil, err
}
return int8(b), nil
case thriftDouble:
return r.f64()
case thriftI16:
return r.i16()
case thriftI32:
return r.i32()
case thriftI64:
return r.i64()
case thriftString:
length, err := r.i32()
if err != nil || length < 0 {
return nil, errInvalidThrift
}
raw, err := r.take(int(length))
if err != nil {
return nil, err
}
return decodeThriftBinaryOrString(raw, depth), nil
case thriftStruct:
return readThriftStruct(r, depth+1)
case thriftMap:
keyType, err := r.u8()
if err != nil {
return nil, err
}
valType, err := r.u8()
if err != nil {
return nil, err
}
size, err := r.i32()
if err != nil || size < 0 || int(size) > thriftMaxContainerItems || int(size) > r.remaining() {
return nil, errInvalidThrift
}
out := make(map[string]any, int(size))
for i := 0; i < int(size); i++ {
k, err := readThriftValue(r, keyType, depth+1)
if err != nil {
return nil, err
}
v, err := readThriftValue(r, valType, depth+1)
if err != nil {
return nil, err
}
keyBytes, err := json.Marshal(k)
if err != nil {
return nil, errInvalidThrift
}
keyStr := string(keyBytes)
if len(keyStr) >= 2 && keyStr[0] == '"' && keyStr[len(keyStr)-1] == '"' {
if unquoted, err := strconv.Unquote(keyStr); err == nil {
keyStr = unquoted
}
}
out[keyStr] = v
}
return out, nil
case thriftSet, thriftList:
elemType, err := r.u8()
if err != nil {
return nil, err
}
size, err := r.i32()
if err != nil || size < 0 || int(size) > thriftMaxContainerItems || int(size) > r.remaining() {
return nil, errInvalidThrift
}
items := make([]any, 0, int(size))
for i := 0; i < int(size); i++ {
item, err := readThriftValue(r, elemType, depth+1)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
case thriftUUID:
raw, err := r.take(16)
if err != nil {
return nil, err
}
h := hex.EncodeToString(raw)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32], nil
default:
return nil, errInvalidThrift
}
}
func readThriftStruct(r *thriftReader, depth int) (map[string]any, error) {
if depth > thriftMaxDepth {
return nil, errInvalidThrift
}
fields := make(map[string]any)
for {
fieldType, err := r.u8()
if err != nil {
return nil, err
}
if fieldType == thriftStop {
break
}
fieldID, err := r.i16()
if err != nil || fieldID < 0 {
return nil, errInvalidThrift
}
key := strconv.Itoa(int(fieldID))
if _, exists := fields[key]; exists {
return nil, errInvalidThrift
}
val, err := readThriftValue(r, fieldType, depth)
if err != nil {
return nil, err
}
fields[key] = val
}
return fields, nil
}
func decodeThriftStrictMessage(data []byte) (map[string]any, error) {
r := &thriftReader{data: data}
versionAndType, err := r.u32()
if err != nil || (versionAndType&thriftVersionMask) != thriftVersion1 || (versionAndType&0x0000ff00) != 0 {
return nil, errInvalidThrift
}
msgType, ok := thriftMessageTypeName(byte(versionAndType & thriftTypeMask))
if !ok {
return nil, errInvalidThrift
}
nameLen, err := r.i32()
if err != nil || nameLen <= 0 || int(nameLen) > thriftMaxMethodNameBytes {
return nil, errInvalidThrift
}
nameBytes, err := r.take(int(nameLen))
if err != nil || !isPrintableUTF8(nameBytes) {
return nil, errInvalidThrift
}
method := string(nameBytes)
if !isValidThriftMethodName(method) {
return nil, errInvalidThrift
}
seqID, err := r.i32()
if err != nil {
return nil, err
}
fields, err := readThriftStruct(r, 0)
if err != nil || !r.atEnd() {
return nil, errInvalidThrift
}
return map[string]any{
"method": method,
"type": msgType,
"seqId": seqID,
"fields": fields,
}, nil
}
func decodeThriftNonStrictMessage(data []byte) (map[string]any, error) {
r := &thriftReader{data: data}
nameLen, err := r.i32()
if err != nil || nameLen <= 0 || int(nameLen) > thriftMaxMethodNameBytes {
return nil, errInvalidThrift
}
nameBytes, err := r.take(int(nameLen))
if err != nil || !isPrintableUTF8(nameBytes) {
return nil, errInvalidThrift
}
method := string(nameBytes)
if !isValidThriftMethodName(method) {
return nil, errInvalidThrift
}
typeByte, err := r.u8()
if err != nil {
return nil, err
}
msgType, ok := thriftMessageTypeName(typeByte)
if !ok {
return nil, errInvalidThrift
}
seqID, err := r.i32()
if err != nil {
return nil, err
}
fields, err := readThriftStruct(r, 0)
if err != nil || !r.atEnd() {
return nil, errInvalidThrift
}
return map[string]any{
"method": method,
"type": msgType,
"seqId": seqID,
"fields": fields,
}, nil
}
func decodeThriftRawStruct(data []byte, depth int) (map[string]any, error) {
r := &thriftReader{data: data}
fields, err := readThriftStruct(r, depth)
if err != nil || !r.atEnd() || len(fields) == 0 {
return nil, errInvalidThrift
}
return fields, nil
}
func decodeThriftUnframed(data []byte) (map[string]any, error) {
if len(data) >= 4 && (binary.BigEndian.Uint32(data[:4])&thriftVersionMask) == thriftVersion1 {
return decodeThriftStrictMessage(data)
}
if msg, err := decodeThriftNonStrictMessage(data); err == nil {
return msg, nil
}
return decodeThriftRawStruct(data, 0)
}
func decodeThriftBinaryJSON(data []byte) (string, bool) {
if len(data) == 0 {
return "", false
}
value, err := decodeThriftUnframed(data)
if err != nil && len(data) > 4 {
frameLen := binary.BigEndian.Uint32(data[:4])
if frameLen > 0 && int(frameLen) == len(data)-4 {
value, err = decodeThriftUnframed(data[4:])
}
}
if err != nil {
return "", false
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(value); err != nil {
return "", false
}
return strings.TrimSuffix(buf.String(), "\n"), true
}
func decodeDeliveryPayloadText(body []byte) (string, bool) {
if len(body) > 0 && isPrintableUTF8(body) {
return string(body), true
}
if decoded, ok := decodeThriftBinaryJSON(body); ok {
return decoded, true
}
if utf8.Valid(body) {
return string(body), true
}
return "", false
}
+85 -7
View File
@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::env;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
@@ -65,6 +66,78 @@ fn integration_params() -> Option<Value> {
}))
}
fn wait_for_database_ready(mut query: impl FnMut() -> Value, timeout: Duration) -> Result<(), String> {
let started = Instant::now();
loop {
let result = query();
let columns = result["columns"].as_array().expect("VGroup columns");
// Servers before 3.3.8 do not expose the is_ready column.
let Some(ready_column) = columns.iter().position(|column| column == "is_ready") else {
return Ok(());
};
let rows = result["rows"].as_array().expect("VGroup rows");
if !rows.is_empty() && rows.iter().all(|row| row[ready_column] == true) {
return Ok(());
}
if started.elapsed() >= timeout {
return Err(format!("VGroups did not become ready: {result}"));
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn create_database(agent: &mut AgentProcess, session_id: &str, database: &str) {
agent.call(
"execute_batch",
json!({"agentSessionId": session_id, "statements": [format!("CREATE DATABASE IF NOT EXISTS {database}")]}),
);
wait_for_database_ready(
|| {
agent.call(
"execute_query",
json!({
"agentSessionId": session_id,
"schema": database,
"sql": "SHOW VGROUPS",
"maxRows": 1000,
"timeoutSecs": 5
}),
)
},
Duration::from_secs(30),
)
.unwrap_or_else(|error| panic!("database {database}: {error}"));
}
#[test]
fn database_readiness_waits_for_every_vgroup() {
let mut calls = 0;
wait_for_database_ready(
|| {
calls += 1;
json!({"columns": ["vgid", "is_ready"], "rows": [[2, true], [3, calls > 1]]})
},
Duration::from_secs(1),
)
.unwrap();
assert_eq!(calls, 2);
}
#[test]
fn database_readiness_rejects_empty_or_offline_vgroups() {
for rows in [json!([]), json!([[2, false]])] {
let error = wait_for_database_ready(|| json!({"columns": ["vgid", "is_ready"], "rows": rows}), Duration::ZERO)
.unwrap_err();
assert!(error.contains("VGroups did not become ready"), "{error}");
}
}
#[test]
fn database_readiness_preserves_older_server_support() {
wait_for_database_ready(|| json!({"columns": ["vgid", "v1_status"], "rows": [[2, "leader"]]}), Duration::ZERO)
.unwrap();
}
#[test]
fn tdengine_websocket_live_compatibility() {
let Some(connect_params) = integration_params() else {
@@ -83,12 +156,12 @@ fn tdengine_websocket_live_compatibility() {
open_params["agentSessionId"] = json!(session_id);
assert_eq!(agent.call("open_session", open_params), json!({"ok": true}));
create_database(&mut agent, session_id, &database);
agent.call(
"execute_batch",
json!({
"agentSessionId": session_id,
"statements": [
format!("CREATE DATABASE IF NOT EXISTS {database}"),
format!("CREATE STABLE IF NOT EXISTS {database}.meters (ts TIMESTAMP, current FLOAT, voltage INT, active BOOL, note NCHAR(32)) TAGS (location NCHAR(64), group_id INT)"),
format!("CREATE TABLE IF NOT EXISTS {database}.d1001 USING {database}.meters TAGS ('California.SanFrancisco', 2)"),
format!("INSERT INTO {database}.d1001 VALUES (1785808800001, 10.1, 220, true, 'first') (1785808800002, 10.2, 221, false, 'second') (1785808800003, 10.3, 222, true, 'third')")
@@ -242,10 +315,9 @@ fn tdengine_websocket_live_paged_table_list_matches_the_full_list() {
open_params["agentSessionId"] = json!(session_id);
assert_eq!(agent.call("open_session", open_params), json!({"ok": true}));
let mut statements = vec![
format!("CREATE DATABASE IF NOT EXISTS {database}"),
format!("CREATE STABLE IF NOT EXISTS {database}.st_paged (ts TIMESTAMP, v INT) TAGS (gid INT)"),
];
create_database(&mut agent, session_id, &database);
let mut statements =
vec![format!("CREATE STABLE IF NOT EXISTS {database}.st_paged (ts TIMESTAMP, v INT) TAGS (gid INT)")];
// Enough child tables that the sidebar's paging walks several pages.
for index in 0..300 {
statements.push(format!(
@@ -274,7 +346,10 @@ fn tdengine_websocket_live_paged_table_list_matches_the_full_list() {
.expect("table list")
.iter()
.map(|table| {
(table["name"].as_str().unwrap_or_default().to_string(), table["table_type"].as_str().unwrap_or_default().to_string())
(
table["name"].as_str().unwrap_or_default().to_string(),
table["table_type"].as_str().unwrap_or_default().to_string(),
)
})
.collect()
}
@@ -300,6 +375,9 @@ fn tdengine_websocket_live_paged_table_list_matches_the_full_list() {
}
assert_eq!(seen, full, "paging dropped or invented tables");
agent.call("execute_batch", json!({"agentSessionId": session_id, "statements": [format!("DROP DATABASE IF EXISTS {database}")]}));
agent.call(
"execute_batch",
json!({"agentSessionId": session_id, "statements": [format!("DROP DATABASE IF EXISTS {database}")]}),
);
agent.shutdown();
}
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
+2 -2
View File
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
+43 -13
View File
@@ -19,13 +19,40 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,24 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute Gradle
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+41 -41
View File
@@ -1,58 +1,58 @@
{
"access": "0.1.81",
"bigquery": "0.1.85",
"cache": "0.1.30",
"access": "0.1.82",
"bigquery": "0.1.86",
"cache": "0.1.31",
"cassandra": "0.1.54",
"dameng": "0.1.95",
"databend": "0.1.72",
"databricks": "0.1.79",
"db2": "0.1.85",
"dameng": "0.1.96",
"databend": "0.1.73",
"databricks": "0.1.80",
"db2": "0.1.86",
"duckdb": "0.1.34",
"exasol": "0.1.80",
"firebird": "0.1.82",
"gbase8a": "0.1.79",
"gbase8c": "0.1.2",
"gbase8s": "0.1.74",
"goldendb": "0.1.85",
"h2": "0.1.85",
"h2-legacy": "0.1.54",
"exasol": "0.1.81",
"firebird": "0.1.84",
"gbase8a": "0.1.80",
"gbase8c": "0.1.3",
"gbase8s": "0.1.76",
"goldendb": "0.1.86",
"h2": "0.1.86",
"h2-legacy": "0.1.55",
"hive": "0.1.60",
"argo": "0.1.4",
"ignite": "0.1.37",
"ignite3": "0.1.37",
"spark": "0.1.56",
"informix": "0.1.85",
"kingbase": "0.1.67",
"kylin": "0.1.84",
"ignite": "0.1.38",
"ignite3": "0.1.38",
"spark": "0.1.57",
"informix": "0.1.86",
"kingbase": "0.1.69",
"kylin": "0.1.85",
"neo4j": "0.1.53",
"nebula": "0.1.3",
"oceanbase-oracle": "0.1.81",
"oracle": "0.1.71",
"oracle-oci": "0.1.4",
"snowflake": "0.1.84",
"oceanbase-oracle": "0.1.82",
"oracle": "0.1.72",
"oracle-oci": "0.1.5",
"snowflake": "0.1.85",
"sqlite-worker": "0.1.7",
"spanner": "0.1.37",
"saphana": "0.1.80",
"sundb": "0.1.85",
"teradata": "0.1.79",
"tdengine": "0.1.53",
"trino": "0.1.84",
"transwarp": "0.1.15",
"spanner": "0.1.38",
"saphana": "0.1.81",
"sundb": "0.1.86",
"teradata": "0.1.80",
"tdengine": "0.1.54",
"trino": "0.1.85",
"transwarp": "0.1.16",
"vastbase": "0.1.56",
"vertica": "0.1.79",
"mongodb": "0.1.88",
"highgo": "0.1.82",
"uxdb": "0.1.51",
"yashandb": "0.1.80",
"oscar": "0.1.59",
"vertica": "0.1.80",
"mongodb": "0.1.89",
"highgo": "0.1.83",
"uxdb": "0.1.52",
"yashandb": "0.1.81",
"oscar": "0.1.60",
"xugu": "0.1.51",
"iris": "0.1.78",
"iris": "0.1.79",
"iotdb": "0.1.46",
"etcd": "0.1.46",
"etcd2": "0.1.4",
"zookeeper": "0.1.34",
"kafka": "0.1.19",
"kafka": "0.1.20",
"rocketmq": "0.1.17",
"rabbitmq": "0.1.15",
"sqlserver-legacy": "0.1.59"
"sqlserver-legacy": "0.1.60"
}
+142 -71
View File
@@ -74,7 +74,7 @@ import { useVisibilityChange } from "@/composables/useVisibilityChange";
import { useExternalSqlFileChanges } from "@/composables/useExternalSqlFileChanges";
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
import { useSnippetAutoUpload } from "@/composables/useSnippetAutoUpload";
import { readSyncMethod, readWebDavAutoUploadConfig, readWebDavBackupSelection, recordWebDavSyncSuccess } from "@/lib/webdav/webdavAutoUploadConfig";
import { readSyncMethod, readWebDavAutoUploadConfig, readWebDavBackupSelection, recordWebDavSyncSuccess, writeWebDavLastUploadAt, webDavSyncTarget } from "@/lib/webdav/webdavAutoUploadConfig";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import { appShellMinSizeStyleForScale, shouldDrawDesktopWindowFrame, shouldDrawLinuxFloatingFrame, useWindowControls } from "@/composables/useWindowControls";
import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup";
@@ -101,18 +101,20 @@ import {
type SqlObjectNavigationTarget,
} from "@/lib/sql/sqlNavigation";
import { isObjectDependencySection, type ObjectDependencyTarget } from "@/lib/database/objectDependencyDetails";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { assertSourceIdentity, beginSourceSave } from "@/lib/table/objectSourceIdentity";
import { buildEditableObjectSource } from "@/lib/table/objectSourceEditor";
import { isOracleFamilyTypeSource } from "@/lib/database/databaseObjectCapabilities";
import { oracleTypeManagementRequest } from "@/lib/table/oracleTypeManagementRequest";
import { saveObjectSourceTab } from "@/lib/table/objectSourceTabSave";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
import { schemaAfterConnectionSwitch } from "@/lib/schema/connectionSchemaInitialization";
import { resolveHistorySqlRestoreTarget } from "@/lib/history/historyRestoreTarget";
import { canReuseTabForHistoryRestore, resolveHistorySqlRestoreTarget } from "@/lib/history/historyRestoreTarget";
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { uuid } from "@/lib/common/utils";
import { getPlatform, isMacOS, isWindows } from "@/lib/backend/platform";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { defaultSavedQueryFileName, externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, queryEditorOpenFileAccept, queryEditorOpenFileFilters, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import { defaultSavedQueryFileName, externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, queryEditorOpenFileAccept, queryEditorOpenFileFilters, queryTabExternalFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TableInfoTab, TabOutputView, TreeNode } from "@/types/database";
import { OPEN_PLUGIN_SETTINGS, type PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink";
@@ -140,6 +142,7 @@ import {
isDisconnectAllActiveConnectionsShortcut,
isEditTableStructureShortcut,
isExecuteSqlInNewResultTabShortcut,
isExecuteAllSqlShortcut,
isExecuteSqlShortcut,
isDefaultQueryShortcutTarget,
isFocusSearchShortcut,
@@ -168,9 +171,11 @@ import {
isToggleZenModeShortcut,
isZoomInShortcut,
isZoomOutShortcut,
saveShortcutSurface,
switchToTabIndexFromShortcut,
tabSwitcherDirectionFromShortcut,
} from "@/lib/editor/keyboardShortcuts";
import { DBX_VIM_ACTION_EVENT, type DbxVimActionEvent } from "@/lib/editor/vimDbxActions";
import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget";
import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher";
import { createModifierDoubleTapShortcutController, isModifierDoubleTapShortcut, matchesModifierDoubleTapShortcut } from "@/lib/editor/modifierDoubleTapShortcut";
@@ -186,7 +191,6 @@ import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "@/li
import { resolveAiSqlTargetTabId, type AiSqlApplyTargetMode } from "@/lib/ai/aiSqlTargetTab";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { normalizeSqlExecutionTarget, sqlExecutionTargetCapabilities } from "@/lib/database/sqlExecutionTargetCapabilities";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
@@ -206,7 +210,7 @@ import { dismissOpenModalSurface } from "@/lib/ui/modalSurface";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTargets";
import { aiTargetFromTab, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
import type { AiExternalContextRequest } from "@/lib/ai/aiExternalContext";
import { queryErrorSelection, type AiExternalContextRequest } from "@/lib/ai/aiExternalContext";
import { isSchemaAware, isSingleDatabase, supportsConnectionQueryActions, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { canFormatSqlForDatabaseType, formatSqlForEditing, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
@@ -248,6 +252,7 @@ const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-op
const TabSwitcherDialog = defineAsyncComponent(() => import("@/components/tabs/TabSwitcherDialog.vue"));
const QueryEditorDdlViewDialog = defineAsyncComponent(() => import("@/components/objects/DdlViewDialog.vue"));
const QueryEditorObjectSourceDialog = defineAsyncComponent(() => import("@/components/objects/ObjectSourceDialog.vue"));
const OracleTypeManagementDialog = defineAsyncComponent(() => import("@/components/objects/OracleTypeManagementDialog.vue"));
type AiAssistantHandle = {
openPluginConversation: (request: AiPluginConversationRequest) => void;
@@ -479,7 +484,16 @@ const isAiPanelMaximized = ref(false);
const isHistoryPanelMaximized = ref(false);
const isZenMode = ref(false);
const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true");
const sqlFileLocateRequest = ref<{ id: number; path: string } | null>(null);
let sqlFileLocateRequestId = 0;
const showSqlFilePanel = ref(safeLocalStorageGet("dbx-sql-file-panel-open") === "true");
watch(
showSqlFilePanel,
(open) => {
if (!open) sqlFileLocateRequest.value = null;
},
{ flush: "sync" },
);
const rightSidebarPanelRefs: Record<RightSidebarPanelId, typeof showAiPanel> = {
ai: showAiPanel,
history: showHistory,
@@ -990,21 +1004,26 @@ async function refreshAgentDriverUpdateCount() {
}
function restoreHistorySql(sql: string, entry: HistoryEntry) {
const tab = activeTab.value;
if (tab?.mode === "query") {
queryStore.updateSql(tab.id, sql);
return;
}
const target = resolveHistorySqlRestoreTarget({
entry,
activeTab: tab,
activeTab: activeTab.value,
firstConnectionId: connectionStore.connections[0]?.id,
connections: connectionStore.connections,
getConfig: (connectionId) => connectionStore.getConfig(connectionId),
});
if (!target) return;
const tabId = queryStore.createTab(target.connectionId, target.database, t("tabs.sql"), "query", target.schema);
queryStore.updateSql(tabId, sql);
const tab = activeTab.value;
if (tab && canReuseTabForHistoryRestore(tab, target)) {
queryStore.updateSql(tab.id, sql);
} else {
queryStore.createTab(target.connectionId, target.database, undefined, "query", target.schema, sql, undefined, { forceNew: true });
}
if (target.connectionId) {
connectionStore.activeConnectionId = target.connectionId;
connectionStore.ensureConnected(target.connectionId).catch(() => {});
}
}
const executableSql = computed(() => {
@@ -1112,6 +1131,14 @@ function requestActiveEditorExecute(source?: "pointer" | "keyboard", tabId?: str
void tryExecute(undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
function requestActiveEditorExecuteAll(_source?: "pointer" | "keyboard", tabId?: string) {
pendingToolbarExecutionSnapshot.value = undefined;
const targetTabId = tabId ?? activeTab.value?.id;
if (contentAreaRef.value?.requestQueryEditorExecuteAll?.(targetTabId)) return;
const tab = targetTabId ? queryStore.tabs.find((t) => t.id === targetTabId) : activeTab.value;
void tryExecute(tab?.sql ?? undefined, targetTabId ? { tabId: targetTabId } : undefined);
}
function requestActiveEditorExecuteInNewResultTab(source?: "pointer" | "keyboard", tabId?: string) {
const snapshot = pendingToolbarExecutionSnapshot.value;
pendingToolbarExecutionSnapshot.value = undefined;
@@ -1172,6 +1199,7 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
connectionRequiredSignalFor: (tabId: string) => (connectionRequiredTabId.value === tabId ? connectionRequiredSignal.value : 0),
captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,
toolbarExecute: requestActiveEditorExecute,
toolbarExecuteAll: requestActiveEditorExecuteAll,
toolbarExecuteInNewResultTab: requestActiveEditorExecuteInNewResultTab,
cancelExecution: (tabId: string) => cancelActiveExecution(tabId),
explain: (tabId: string) => tryExplain(undefined, { tabId }),
@@ -1346,6 +1374,9 @@ async function openImmediateSync() {
const includeSecrets = Boolean((selection?.includeSecrets ?? secretsStatus.enabled) && secretsStatus.enabled && secretsStatus.hasSavedPassphrase);
const summary = await api.webdavSyncUpload(webDavConfig, settingsStore.editorSettings, undefined, includeSecrets, selection);
recordWebDavSyncSuccess(webDavConfig);
const currentConfig = readWebDavAutoUploadConfig();
const isCurrent = Boolean(currentConfig.enabled && currentConfig.webDavConfig && webDavSyncTarget(currentConfig.webDavConfig) === webDavSyncTarget(webDavConfig));
writeWebDavLastUploadAt(webDavConfig, Date.now(), isCurrent);
toast(t("settings.syncUploadSuccess", { bytes: summary.bytes, path: summary.remotePath }), 3000);
} catch (error: any) {
toast(error?.message || String(error), 5000);
@@ -2072,10 +2103,14 @@ function editorAiTarget(tabId?: string): AiConversationBinding | null {
}
function fixWithAi(tabId: string, errorMessage: string) {
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
const selection = queryErrorSelection(tab);
const target = aiTargetFromTab(queryStore.activeResultExecutionTarget(tabId) ?? tab, (connectionId) => !!connectionStore.getConfig(connectionId));
openRightSidebarPanel("ai");
invokeWhenAiReady((handle) =>
handle.openExternalContext({
target: editorAiTarget(tabId),
target,
selections: selection ? [selection] : [],
action: "fix",
instruction: errorMessage,
unresolvedKey: "ai.externalTargetUnavailable",
@@ -2714,54 +2749,7 @@ async function openSaveSqlDialog(tabId?: string) {
}
async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
const connection = connectionStore.getConfig(tab.connectionId);
const source = tab.objectSource;
if (!connection || !source) return false;
try {
const identity = queryStore.getObjectSourceIdentitySnapshot(tab)!;
assertSourceIdentity(identity);
const { connectionId, database } = tab;
const schema = source.schema || tab.schema || database;
const sql = tab.sql;
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection.db_type;
const statements = await buildExecutableObjectSourceStatements({
databaseType,
objectType: source.objectType,
schema,
name: source.name,
source: sql,
});
const execute = async () => {
if (!queryStore.tabs.includes(tab) || tab.objectSource !== source || tab.connectionId !== connectionId || tab.database !== database || (source.schema || tab.schema || database) !== schema) return false;
const release = beginSourceSave(identity);
try {
await executeObjectSourceSave(connectionId, database, databaseType, statements, schema);
return true;
} finally {
release();
}
};
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection,
database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute,
});
if (!saved) return false;
} else {
if (!(await execute())) return false;
}
if (tab.sql === sql) queryStore.markTabClean(tab);
toast(t("objects.sourceSaved"), 2000);
return true;
} catch (e: any) {
toast(t("objects.sourceSaveFailed", { message: e?.message || String(e) }), 5000);
return false;
}
return saveObjectSourceTab(tab, { connection: connectionStore.getConfig(tab.connectionId), queryStore, t, toast });
}
function saveSqlFolderDisplayName(id: string) {
@@ -3331,6 +3319,7 @@ async function newQuery(options: NewQueryOptions = {}) {
identifierQuote: connectionStore.connectionIdentifierQuote?.(target.connectionId),
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase,
});
const tabId = queryStore.createTab(conn.id, target.database, undefined, "query", target.schema, initialSql, target.catalog, { schemaQualifiesSql: target.schemaQualifiesSql === true });
if (initialSql) {
@@ -3472,6 +3461,12 @@ function tableTargetFromActiveTab(table: string | SqlObjectNavigationTarget, con
}
async function onClickTable(table: SqlObjectNavigationTarget, context?: SqlObjectNavigationContext) {
if (table.type === "index") {
const origin = context ?? activeTab.value;
if (!origin || !table.parentName || !table.parentSchema || !table.schema) return;
queryStore.openObjectProperties({ connectionId: origin.connectionId, database: origin.database, catalog: origin.catalog, schema: table.parentSchema, name: table.parentName, objectType: "TABLE", initialTab: "indexes", focusObject: { owner: table.schema, name: table.name, type: "INDEX" } });
return;
}
// Procedures/functions/packages open source (same as sidebar view-source), not table data/DDL.
if (isSqlObjectNavigationRoutineType(table.type)) {
await onOpenObjectSource(table, false, context);
@@ -3680,6 +3675,18 @@ function setSidebarOpen(open: boolean) {
safeLocalStorageSet("dbx-sidebar-open", open ? "true" : "false");
}
function locateSqlFile(tab: QueryTab) {
const path = queryTabExternalFilePath(tab);
if (!path || isDetachedWindowContext) return;
isZenMode.value = false;
openRightSidebarPanel("sqlFile");
sqlFileLocateRequest.value = { id: ++sqlFileLocateRequestId, path };
}
function finishSqlFileLocate(requestId: number) {
if (sqlFileLocateRequest.value?.id === requestId) sqlFileLocateRequest.value = null;
}
async function locateTabInSidebar(tab: QueryTab) {
setSidebarOpen(true);
await nextTick();
@@ -4158,6 +4165,10 @@ async function handleQuickOpenSelect(item: any) {
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(item.connectionId));
if (!databaseType) throw new Error("Connection type is unavailable.");
const objectName = item.objectName || item.tableName;
if (isOracleFamilyTypeSource(databaseType, objectType)) {
queryStore.openObjectSourceTabPending({ connectionId: item.connectionId, database: item.database, schema, title: `Source - ${objectName}`, initialEditing: false, request: { name: objectName, objectType, signature: item.signature } });
return;
}
const { editableSource, objectType: resolvedType } = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId: item.connectionId,
database: item.database,
@@ -4572,6 +4583,35 @@ async function closeActiveSurface() {
queryStore.closeTab(queryStore.activeTabId);
}
}
// App-scoped `:dbx` actions. Editor-scoped ones are handled (and stopped) in
// QueryEditor before the event bubbles up to window.
function handleVimDbxAppAction(event: Event) {
const { action } = (event as DbxVimActionEvent).detail;
switch (action) {
case "newQuery":
void newQuery();
break;
case "closeTab":
void closeActiveSurface();
break;
case "closeOtherTabs":
appTabBarRef.value?.closeOtherActiveTabs();
break;
case "closeAllTabs":
appTabBarRef.value?.closeAllTabs();
break;
case "reopenClosedTab":
queryStore.reopenClosedTab();
break;
case "openSettings":
openSettings();
break;
default:
return;
}
event.preventDefault();
}
async function handleKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
@@ -4776,11 +4816,30 @@ async function handleKeydown(e: KeyboardEvent) {
if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) {
return;
}
if (activeTab.value?.mode === "query" && !showSaveSqlDialog.value && isSaveShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
void openSaveSqlDialog();
return;
if (isSaveShortcut(e, shortcuts)) {
// The settings page, plugin center and driver store keep the previously active
// query tab activated, so the active tab alone cannot decide who Ctrl+S belongs
// to. Without the surface check the shortcut popped the save-SQL dialog on top of
// the settings page and silently dropped the edited draft (#13020).
const saveShortcutTarget = saveShortcutSurface({
settingsPageOpen: showSettingsPage.value,
pluginCenterOpen: showPluginCenter.value,
driverStoreOpen: showDriverStore.value,
activeTabMode: activeTab.value?.mode ?? null,
saveSqlDialogOpen: showSaveSqlDialog.value,
});
if (saveShortcutTarget === "settings") {
e.preventDefault();
e.stopPropagation();
void settingsPageRef.value?.applySettingsFromShortcut();
return;
}
if (saveShortcutTarget === "query") {
e.preventDefault();
e.stopPropagation();
void openSaveSqlDialog();
return;
}
}
// CodeMirror ignores keydown events after an IME composition changes the
// document, so this app-level fallback must share the editor shortcut guard.
@@ -4797,6 +4856,12 @@ async function handleKeydown(e: KeyboardEvent) {
if (!e.repeat && !activeTab.value.isExecuting && !contentAreaRef.value?.shouldBlockQueryEditorExecutionShortcut?.(e)) requestActiveEditorExecuteInNewResultTab();
return;
}
if (activeTab.value?.mode === "query" && isExecuteAllSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
if (!e.repeat && !activeTab.value.isExecuting && !contentAreaRef.value?.shouldBlockQueryEditorExecutionShortcut?.(e)) requestActiveEditorExecuteAll();
return;
}
if (activeTab.value?.mode === "query" && isExecuteSqlShortcut(e, shortcuts) && e.target instanceof Element && e.target.closest("[data-query-editor-root]")) {
e.preventDefault();
e.stopPropagation();
@@ -5123,6 +5188,7 @@ onMounted(async () => {
window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.addEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
window.addEventListener("keydown", handleKeydown);
window.addEventListener(DBX_VIM_ACTION_EVENT, handleVimDbxAppAction);
window.addEventListener("keyup", handleKeyup, true);
window.addEventListener("blur", handleTabSwitcherWindowBlur);
document.addEventListener("visibilitychange", handleTabSwitcherVisibilityChange);
@@ -5219,6 +5285,7 @@ onUnmounted(() => {
window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true);
window.removeEventListener("keydown", handleAuxiliarySearchKeydownCapture, true);
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(DBX_VIM_ACTION_EVENT, handleVimDbxAppAction);
window.removeEventListener("keyup", handleKeyup, true);
window.removeEventListener("blur", handleTabSwitcherWindowBlur);
document.removeEventListener("visibilitychange", handleTabSwitcherVisibilityChange);
@@ -5313,6 +5380,7 @@ onUnmounted(() => {
@start-resize="startSidebarResize"
@collapse="setSidebarOpen(false)"
@open-settings="(initialTab) => openSettings(initialTab ?? 'appearance')"
@open-connection-settings="(connectionId) => openConnectionSettings(connectionId, 'advanced')"
@add-to-ai="addToAi"
@open-dependency-object="onOpenDependencyObject"
@mousedown="rememberSidebarSearchSurface"
@@ -5446,6 +5514,7 @@ onUnmounted(() => {
ref="contentAreaRef"
:content-suppressed="activeTab?.mode === 'plugin-workbench'"
@locate-tab="locateTabInSidebar"
@locate-sql-file="locateSqlFile"
@close-tab="
(tabId: string) => {
if (tabId === queryStore.activeTabId) void closeActiveSurface();
@@ -5563,6 +5632,7 @@ onUnmounted(() => {
schema: tab.schema,
catalog: tab.catalog,
tableName: tab.structureTableName || '',
exactIdentity: tab.structureExactIdentity,
},
commentChanged,
!!createdTableName,
@@ -5691,7 +5761,7 @@ onUnmounted(() => {
>
<div class="panel-resize-handle panel-resize-handle--left" @pointerdown="startSqlFilePanelResize" />
<div class="h-full min-h-0 overflow-hidden rounded-[inherit]">
<SqlFilePanel @close="closeRightSidebarPanel('sqlFile')" />
<SqlFilePanel :locate-request="sqlFileLocateRequest" @locate-handled="finishSqlFileLocate" @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" />
@@ -5800,6 +5870,7 @@ onUnmounted(() => {
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
<AiRunsClosePromptDialog v-if="isDesktop && showAiRunsClosePrompt" v-model:open="showAiRunsClosePrompt" :count="blockingAiRunCount" @cancel="cancelPendingAppClose" @quit="confirmQuitWithActiveAiRuns" />
<QuickOpenDialog :open="showQuickOpen" :initial-content-mode="quickOpenForceContent" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
<OracleTypeManagementDialog v-if="oracleTypeManagementRequest" />
<TabSwitcherDialog :open="showTabSwitcher" :tabs="tabSwitcherTabs" :selected-index="tabSwitcherIndex" :shortcut-hint="tabSwitcherShortcutHint" @update:open="handleTabSwitcherOpenChange" @update:selected-index="tabSwitcherIndex = $event" @select="handleTabSwitcherSelect" />
</div>
<Teleport to="body">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Copy, Loader2, PlugZap, RefreshCcw, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,8 @@ import { processListSelectionAfterClick } from "@/lib/database/processListSelect
import { oracleMajorVersion } from "@/lib/database/oracleSessionManagement";
import { useTabUiState } from "@/lib/tabs/tabUiState";
const SessionDiagnostics = defineAsyncComponent(() => import("./SessionDiagnostics.vue"));
const props = defineProps<{
connection: ConnectionConfig;
}>();
@@ -90,6 +92,7 @@ let managedClientSession: ManagedClientSession | null = null;
// Full-text preview for long cells (SQL statement / info), opened by clicking them.
const previewText = ref<string | null>(null);
const diagnosticRow = ref<ProcessRow | null>(null);
const lockTarget = ref<ProcessRow | null>(null);
const lockColumns = ref<string[]>([]);
const lockRows = ref<unknown[][]>([]);
@@ -105,6 +108,11 @@ function openPreview(value: string | number | null) {
previewText.value = String(value);
}
function showDiagnostics(row: ProcessRow) {
if (!driver.value?.diagnosticEngine || actionsLocked.value || refreshing.value) return;
diagnosticRow.value = { ...row };
}
function closeManagedSession(session: ManagedClientSession) {
if (!session.closeRequested || session.active > 0) return;
session.closeRequested = false;
@@ -672,6 +680,7 @@ watch(
lockTruncated.value = false;
loadingLocks.value = false;
lockRequestGeneration++;
diagnosticRow.value = null;
search.value = "";
sortKey.value = driver.value?.defaultSortKey ?? "time";
sortDir.value = "desc";
@@ -819,6 +828,10 @@ onBeforeUnmount(() => {
</td>
<td class="px-3 py-1.5 text-right">
<span class="inline-flex items-center justify-end gap-1">
<Button v-if="driver?.diagnosticEngine" variant="ghost" size="sm" class="h-6 gap-1 px-1.5 text-[11px]" :disabled="actionsLocked || refreshing" @click="showDiagnostics(row)">
<Activity class="h-3.5 w-3.5" />
{{ t("sessionDiagnostics.open") }}
</Button>
<Button v-if="driver?.buildLockDetailsSql" variant="ghost" size="sm" class="h-6 gap-1 px-1.5 text-[11px]" :disabled="actionsLocked || refreshing || loadingLocks" :title="t('processList.oracleLockDetails')" @click="showLocks(row)">
<Search class="h-3.5 w-3.5" />
{{ t("processList.oracleLockDetails") }}
@@ -859,6 +872,8 @@ onBeforeUnmount(() => {
</table>
</div>
<SessionDiagnostics v-if="diagnosticRow && driver?.diagnosticEngine" :connection="connection" :row="diagnosticRow" :engine="driver.diagnosticEngine" :version-major="versionMajor ?? undefined" :database="driver.database ?? ''" @close="diagnosticRow = null" />
<Dialog
:open="cancelTarget !== null"
@update:open="
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Copy, Loader2, RefreshCcw } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSessionDiagnostics } from "@/composables/useSessionDiagnostics";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { ConnectionConfig, QueryResult } from "@/types/database";
import type { ProcessRow } from "@/lib/database/processListDrivers";
import type { DiagnosticEngine, DiagnosticSection } from "@/lib/database/sessionDiagnostics";
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const props = defineProps<{ connection: ConnectionConfig; row: ProcessRow; engine: DiagnosticEngine; database: string; versionMajor?: number }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const { toast } = useToast();
const context = computed(() => ({ connection: props.connection, row: props.row, engine: props.engine, database: props.database, versionMajor: props.versionMajor }));
const { snapshot, busy, error, cleanupFailed, selection, refresh, stop } = useSessionDiagnostics(context);
const activeTab = ref("sql");
const section = computed<DiagnosticSection<unknown>>(() => (activeTab.value === "statistics" ? snapshot.value.metrics : activeTab.value === "longOperations" ? snapshot.value.longOperations : activeTab.value === "plan" ? snapshot.value.plan : snapshot.value.sql));
const tabs = ["sql", "plan", "statistics", "longOperations"];
const tables = computed(() => (activeTab.value === "longOperations" ? [{ key: "longOperations", section: snapshot.value.longOperations }] : [{ key: "waits", section: snapshot.value.waits }]));
const columnLabels: Record<string, string> = {
SCOPE: "scope",
WAIT_KIND: "waitKind",
STATE: "state",
EVENT: "event",
WAIT_CLASS: "waitClass",
SECONDS_IN_WAIT: "secondsInWait",
BLOCKING_INSTANCE: "blockingInstance",
BLOCKING_SESSION: "blockingSession",
SID: "sid",
SERIAL_NUMBER: "serial",
INSTANCE_ID: "instance",
OPNAME: "operation",
TARGET: "target",
SOFAR: "sofar",
TOTALWORK: "totalWork",
UNITS: "units",
START_TIME: "startTime",
LAST_UPDATE_TIME: "lastUpdateTime",
ELAPSED_SECONDS: "elapsedSeconds",
TIME_REMAINING: "remainingSeconds",
MESSAGE: "message",
SQL_ID: "sqlId",
SQL_EXEC_ID: "sqlExecId",
SQL_EXEC_START: "sqlExecStart",
SVR_IP: "serverIp",
SVR_PORT: "serverPort",
TRACE_ID: "traceId",
PROGRESS_PERCENT: "progress",
};
function formattedTime(value?: string) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function formattedMicros(value: string) {
if (!/^\d+$/.test(value)) return value;
const millis = Number(BigInt(value) / 1000n);
return Number.isSafeInteger(millis) ? formattedTime(new Date(millis).toString()) : value;
}
function formatValue(value: unknown, column = "") {
if (value === null || value === undefined) return "—";
if ((column === "SCOPE" || column === "WAIT_KIND") && ["cursor_cumulative", "plan_cumulative", "request", "session_sample", "execution", "current_wait", "last_wait", "session_trace", "request_trace"].includes(String(value))) return t(`sessionDiagnostics.scopes.${value}`);
return String(value);
}
function sectionStatus(value: DiagnosticSection<unknown>, longOperations = false) {
if (value.state === "loading") return t("common.loading");
if (value.state === "cancelled" || value.state === "timeout") return "";
if (value.state === "empty" && longOperations) return t("sessionDiagnostics.longOperationsEmpty");
if (value.reason) return t(`sessionDiagnostics.reasons.${value.reason}`);
if (value.state === "failed") return t("sessionDiagnostics.reasons.failed");
return "";
}
function columnLabel(column: string) {
return columnLabels[column] ? t(`sessionDiagnostics.columns.${columnLabels[column]}`) : column;
}
async function copySql() {
const text = snapshot.value.sql.data?.text;
if (!text) return;
try {
await copyToClipboard(text);
toast(t("processList.copied"), 1500);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 3000);
}
}
function chooseRequest(event: Event) {
void refresh({ requestKey: (event.target as HTMLSelectElement).value || undefined });
}
function choosePlan(event: Event) {
void refresh({ ...selection.value, planKey: (event.target as HTMLSelectElement).value || undefined });
}
function tableRows(result?: QueryResult) {
return result?.rows ?? [];
}
</script>
<template>
<Dialog :open="true" @update:open="(open) => !open && emit('close')">
<DialogContent class="flex h-[min(82vh,780px)] max-w-6xl flex-col gap-0 p-0" :aria-busy="busy">
<DialogHeader class="shrink-0 space-y-2 border-b px-5 pb-4 pt-5 pr-12">
<DialogTitle>{{ t("sessionDiagnostics.title", { id: row.id }) }}</DialogTitle>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{{ connection.name }}</Badge>
<span>{{ engine === "oracle" ? "Oracle" : "OceanBase Oracle" }}</span>
<span v-if="snapshot.identity.sqlId" class="font-mono">SQL_ID {{ snapshot.identity.sqlId }}</span>
</div>
<details class="text-xs text-muted-foreground">
<summary class="w-fit cursor-pointer rounded focus-visible:outline focus-visible:outline-ring">{{ t("sessionDiagnostics.sampleContext") }}</summary>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="(value, key) in snapshot.identity" :key="key">
<template v-if="value"
><dt>{{ t(`sessionDiagnostics.identity.${key}`) }}</dt>
<dd class="break-all font-mono">{{ value }}</dd></template
>
</template>
<dt>{{ t("sessionDiagnostics.sampleWindow") }}</dt>
<dd>{{ formattedTime(snapshot.startedAt) }} — {{ formattedTime(snapshot.finishedAt) }}</dd>
</dl>
</details>
</DialogHeader>
<div v-if="snapshot.stale" class="shrink-0 border-b bg-muted/30 px-5 py-2 text-xs text-muted-foreground" role="status">{{ t("sessionDiagnostics.stale") }}</div>
<p v-if="snapshot.interrupted" class="shrink-0 border-b px-5 py-2 text-xs text-muted-foreground" role="status">{{ t(`sessionDiagnostics.states.${snapshot.interrupted}`) }}</p>
<p v-if="error" class="shrink-0 border-b bg-destructive/10 px-5 py-2 text-xs text-destructive" role="alert">{{ error }}</p>
<p v-if="cleanupFailed" class="shrink-0 border-b px-5 py-2 text-xs text-muted-foreground" role="status">{{ t("sessionDiagnostics.cleanupFailed") }}</p>
<label v-if="snapshot.requestChoices.length > 1" class="flex shrink-0 flex-wrap items-center gap-2 border-b px-5 py-3 text-sm">
{{ t("sessionDiagnostics.chooseRequest") }}
<select :value="selection.requestKey ?? ''" class="h-8 max-w-full rounded-md border bg-background px-2 text-xs focus-visible:outline focus-visible:outline-ring" :disabled="busy" @change="chooseRequest">
<option value="" disabled>{{ t("sessionDiagnostics.selectRequest") }}</option>
<option v-for="choice in snapshot.requestChoices" :key="choice.key" :value="choice.key">{{ t("sessionDiagnostics.requestOption", { ...choice.identity, requestTime: formattedMicros(choice.identity.requestTime) }) }}</option>
</select>
</label>
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<TabsList variant="line" class="mx-4 mt-2 shrink-0">
<TabsTrigger v-for="tab in tabs" :key="tab" :value="tab">{{ t(`sessionDiagnostics.tabs.${tab}`) }}</TabsTrigger>
</TabsList>
<div class="shrink-0 space-y-1 border-b px-5 py-2 text-xs text-muted-foreground" aria-live="polite">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span v-if="section.source" class="break-all font-mono">{{ section.source }}</span
><span v-if="section.sampledAt">{{ formattedTime(section.sampledAt) }}</span>
</div>
<p v-if="sectionStatus(section, activeTab === 'longOperations')" class="flex items-center gap-2"><Loader2 v-if="section.state === 'loading'" class="h-3.5 w-3.5 animate-spin" />{{ sectionStatus(section, activeTab === "longOperations") }}</p>
<p v-if="section.incomplete">{{ t("sessionDiagnostics.incomplete") }}</p>
<p v-if="section.error" class="break-words text-destructive" role="alert">{{ section.error }}</p>
<p v-if="section.data && section.dataSampledAt && section.dataSampledAt !== section.sampledAt">{{ t("sessionDiagnostics.previousSample", { time: formattedTime(section.dataSampledAt) }) }}</p>
</div>
<TabsContent value="sql" class="min-h-0 overflow-auto p-5">
<template v-if="snapshot.sql.data">
<div class="mb-3 flex items-center justify-between gap-3">
<Badge variant="outline">{{ t(`sessionDiagnostics.completeness.${snapshot.sql.data.completeness}`) }}</Badge
><Button variant="outline" size="sm" class="gap-1.5" :disabled="!snapshot.sql.data.text" @click="copySql"><Copy class="h-3.5 w-3.5" />{{ t(snapshot.sql.data.completeness === "fragment" ? "sessionDiagnostics.copyFragment" : "sessionDiagnostics.copyText") }}</Button>
</div>
<pre class="select-text whitespace-pre-wrap break-words rounded-md border bg-muted/20 p-4 font-mono text-xs leading-5" tabindex="0">{{ snapshot.sql.data.text }}</pre>
</template>
</TabsContent>
<TabsContent value="plan" class="flex min-h-0 flex-col overflow-hidden">
<label v-if="snapshot.planChoices.length > 1 || (snapshot.planChoices.length > 0 && snapshot.plan.reason === 'target_changed')" class="flex shrink-0 flex-wrap items-center gap-2 px-5 py-3 text-sm"
>{{ t("sessionDiagnostics.choosePlan")
}}<select :value="selection.planKey ?? ''" class="h-8 max-w-full rounded-md border bg-background px-2 text-xs focus-visible:outline focus-visible:outline-ring" :disabled="busy" @change="choosePlan">
<option value="" disabled>{{ t("sessionDiagnostics.selectPlan") }}</option>
<option v-for="choice in snapshot.planChoices" :key="choice.key" :value="choice.key">{{ t("sessionDiagnostics.planOption", choice.identity) }}</option>
</select></label
>
<template v-if="snapshot.plan.data"
><p class="shrink-0 px-5 py-2 text-xs text-muted-foreground">{{ t(`sessionDiagnostics.planBindings.${snapshot.plan.data.binding}`) }}</p>
<div class="min-h-0 flex-1"><ExplainPlanViewer :plan="snapshot.plan.data.plan" :table-result="snapshot.plan.data.table" default-view="table" /></div
></template>
</TabsContent>
<TabsContent value="statistics" class="min-h-0 overflow-auto p-5">
<div v-if="snapshot.metrics.data?.length" class="overflow-auto rounded-md border">
<table class="w-full text-left text-xs">
<thead class="bg-muted/30">
<tr>
<th v-for="key in ['metric', 'value', 'unit', 'scope', 'source']" :key="key" class="whitespace-nowrap px-3 py-2 font-medium">{{ t(`sessionDiagnostics.columns.${key}`) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="metric in snapshot.metrics.data" :key="metric.source" class="border-t">
<td class="px-3 py-2">{{ t(`sessionDiagnostics.metrics.${metric.name}`) }}</td>
<td class="whitespace-nowrap px-3 py-2 font-mono">{{ formatValue(metric.value) }}</td>
<td class="px-3 py-2">{{ t(`sessionDiagnostics.units.${metric.unit}`) }}</td>
<td class="whitespace-nowrap px-3 py-2">{{ t(`sessionDiagnostics.scopes.${metric.scope}`) }}</td>
<td class="break-all px-3 py-2 font-mono text-muted-foreground">{{ metric.source }}</td>
</tr>
</tbody>
</table>
</div>
<h3 class="mb-2 mt-5 text-sm font-medium">{{ t("sessionDiagnostics.waits") }}</h3>
<div v-for="table in tables" :key="table.key" class="space-y-2">
<p class="text-xs text-muted-foreground">{{ table.section.source }} · {{ formattedTime(table.section.sampledAt) }}</p>
<p v-if="sectionStatus(table.section)" class="text-xs text-muted-foreground">{{ sectionStatus(table.section) }}</p>
<p v-if="table.section.data && table.section.dataSampledAt && table.section.dataSampledAt !== table.section.sampledAt" class="text-xs text-muted-foreground">{{ t("sessionDiagnostics.previousSample", { time: formattedTime(table.section.dataSampledAt) }) }}</p>
<p v-if="table.section.incomplete" class="text-xs text-muted-foreground">{{ t("sessionDiagnostics.incomplete") }}</p>
<p v-if="table.section.error" class="break-words text-xs text-destructive">{{ table.section.error }}</p>
<div v-if="table.section.data" class="overflow-auto rounded-md border">
<table class="w-full text-left text-xs">
<thead class="bg-muted/30">
<tr>
<th v-for="column in table.section.data.columns" :key="column" class="whitespace-nowrap px-3 py-2 font-medium" :title="column">{{ columnLabel(column) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(values, index) in tableRows(table.section.data)" :key="index" class="border-t">
<td v-for="(value, column) in values" :key="column" class="whitespace-nowrap px-3 py-2 font-mono">{{ formatValue(value, table.section.data.columns[column]) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</TabsContent>
<TabsContent value="longOperations" class="min-h-0 overflow-auto p-5">
<p v-if="engine === 'oceanbase-oracle'" class="mb-3 text-xs text-muted-foreground">{{ t("sessionDiagnostics.obLongOperations") }}</p>
<div v-if="snapshot.longOperations.data?.rows.length" class="overflow-auto rounded-md border">
<table class="w-max min-w-full text-left text-xs">
<thead class="bg-muted/30">
<tr>
<th v-for="column in snapshot.longOperations.data.columns" :key="column" class="whitespace-nowrap px-3 py-2 font-medium" :title="column">{{ columnLabel(column) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(values, index) in snapshot.longOperations.data.rows" :key="index" class="border-t">
<td
v-for="(value, column) in values"
:key="column"
class="px-3 py-2"
:class="snapshot.longOperations.data.columns[column] === 'MESSAGE' ? 'w-80 max-w-80 whitespace-normal break-words font-mono' : snapshot.longOperations.data.columns[column] === 'SCOPE' ? 'whitespace-nowrap' : 'whitespace-nowrap font-mono'"
>
{{ formatValue(value, snapshot.longOperations.data.columns[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</TabsContent>
</Tabs>
<DialogFooter class="shrink-0 items-center border-t px-5 py-3">
<Button v-if="busy" variant="outline" @click="stop">{{ t("sessionDiagnostics.stop") }}</Button>
<Button variant="outline" class="gap-1.5" :disabled="busy" @click="refresh()"><RefreshCcw class="h-3.5 w-3.5" />{{ t("grid.refresh") }}</Button>
<Button variant="secondary" @click="emit('close')">{{ t("common.close") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -13,6 +13,9 @@ const mocks = vi.hoisted(() => ({
executeQuery: vi.fn(),
executeMulti: vi.fn(),
closeClientConnectionSession: vi.fn(),
cancelQuery: vi.fn(),
readLargeValueChunk: vi.fn(),
diagnosticGate: null as null | (() => Promise<void>),
contextGuard: vi.fn(),
contextGuardGate: null as null | (() => Promise<void>),
toast: vi.fn(),
@@ -82,7 +85,8 @@ function dialogSurface(): Component {
});
}
vi.mock("vue-i18n", () => ({
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) => {
const message = mocks.localeMessages?.[key.split(".")[0]!] as Record<string, unknown> | undefined;
@@ -97,7 +101,7 @@ vi.mock("vue-i18n", () => ({
}));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return { Activity: Icon, AlertTriangle: Icon, ArrowDown: Icon, ArrowUp: Icon, Ban: Icon, Copy: Icon, Loader2: Icon, PlugZap: Icon, RefreshCcw: Icon, Search: Icon };
return { Activity: Icon, AlertTriangle: Icon, ArrowDown: Icon, ArrowUp: Icon, Ban: Icon, Copy: Icon, Loader2: Icon, PlugZap: Icon, RefreshCcw: Icon, Search: Icon, AlertCircle: Icon, Braces: Icon, Check: Icon, Download: Icon, GitBranch: Icon, Table2: Icon, FileText: Icon, Workflow: Icon };
});
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
@@ -113,7 +117,7 @@ vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, executeMulti: mocks.executeMulti, closeClientConnectionSession: mocks.closeClientConnectionSession }));
vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, executeMulti: mocks.executeMulti, closeClientConnectionSession: mocks.closeClientConnectionSession, cancelQuery: mocks.cancelQuery, readLargeValueChunk: mocks.readLargeValueChunk }));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionSqlGuard: (options: { execute: () => Promise<unknown> }) => options.execute(),
executeWithProductionContextGuard: async (options: { execute: () => Promise<unknown> }) => {
@@ -193,7 +197,7 @@ let oracleTerminated = false;
let oracleTerminateError = "";
function oracleListResult() {
const rows: unknown[][] = [["5", "30", "1", "3", "APP", "ACTIVE", "localhost", "DBX", "selfsql", null, null, null, "500", "2026-10-20 09:00:00"]];
if (!oracleTerminated) rows.push(["21", "42", "2", "3", "OTHER", "INACTIVE", "apphost", "worker", null, "SQL*Net message from client", null, null, "700", "2026-10-20 08:00:00"]);
if (!oracleTerminated) rows.push(["21", "42", "2", "3", "OTHER", "INACTIVE", "apphost", "worker", "0123456789abc", "SQL*Net message from client", null, null, "700", "2026-10-20 08:00:00"]);
return { columns: [], rows };
}
@@ -229,6 +233,51 @@ function postgresListResult() {
};
}
function diagnosticResult(row: Record<string, unknown>) {
return { columns: Object.keys(row), rows: [Object.values(row)], affected_rows: 0, execution_time_ms: 1 };
}
async function diagnosticReply(sql: string) {
if (sql.includes("FROM GV$SESSION_LONGOPS") || sql.includes("FROM SYS.GV$SESSION_LONGOPS")) return { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 };
if (sql.includes("FROM GV$SESSION s WHERE") || sql.includes("FROM SYS.GV$OB_PROCESSLIST WHERE")) {
const gate = mocks.diagnosticGate;
mocks.diagnosticGate = null;
if (gate) await gate();
return diagnosticResult(
sql.includes("OB_PROCESSLIST")
? { SID: "21", SESSION_USER: "OTHER", TENANT: "tenant_a", SVR_IP: "127.0.0.1", SVR_PORT: "2882", PROXY_SESSID: null, DB_NAME: "db", SQL_ID: "A".repeat(32), TRACE_ID: "trace-1", STATE: "ACTIVE", SQL_TEXT: "SELECT 2" }
: { SQL_ID: "0123456789abc", CHILD_NUMBER: "1", SQL_EXEC_ID: "2", SQL_EXEC_START: "2026-10-21T20:00:00", STATE: "WAITING", EVENT: "fixture wait", WAIT_CLASS: "Other", SECONDS_IN_WAIT: "0" },
);
}
if (sql.includes(" AS NOW_US")) return diagnosticResult({ NOW_US: "1792593313000000" });
if (sql.includes("FROM GV$SQL_PLAN_STATISTICS_ALL")) return diagnosticResult({ STARTS: "1", OUTPUT_ROWS: "2" });
if (sql.includes("FROM GV$SQL_PLAN p")) return diagnosticResult({ ID: "0", PARENT_ID: null, OPERATION: "SELECT STATEMENT", COST: "1", ESTIMATED_ROWS: "2" });
if (sql.includes("FROM GV$SQL s")) return diagnosticResult({ SQL_ID: "0123456789abc", CHILD_NUMBER: "1", CHILD_ADDRESS: "000000001234ABCD", FIRST_LOAD_TIME: "2026-10-21/19:00:00", PLAN_HASH: "9", SQL_TEXT: "SELECT 2", SOURCE_LENGTH: "8", EXECUTIONS: "2", ELAPSED_TIME: "0" });
if (sql.includes("FROM SYS.GV$OB_SQL_AUDIT"))
return diagnosticResult({
TENANT_ID: "7",
TENANT_NAME: "tenant_a",
SVR_IP: "127.0.0.1",
SVR_PORT: "2882",
SID: "21",
TRACE_ID: "trace-1",
SQL_ID: "A".repeat(32),
REQUEST_ID: "99",
REQUEST_TIME: "1792593313123456",
SQL_EXEC_ID: "2",
PLAN_ID: "9",
RET_CODE: "0",
QUERY_SQL: "SELECT 2",
ELAPSED_TIME: "0",
RETURN_ROWS: "2",
STATE: "WAITED SHORT TIME",
EVENT: "fixture request wait",
WAIT_CLASS: "Other",
});
if (sql.includes("FROM SYS.GV$OB_PLAN_CACHE_PLAN_STAT")) return diagnosticResult({ TENANT_ID: "7", SVR_IP: "127.0.0.1", SVR_PORT: "2882", PLAN_ID: "9", SQL_ID: "A".repeat(32), FIRST_LOAD_TIME: "2026-10-21 20:00:00 +08:00", PLAN_HASH: "10", EXECUTIONS: "2" });
if (sql.includes("FROM SYS.GV$OB_PLAN_CACHE_PLAN_EXPLAIN")) return diagnosticResult({ PLAN_DEPTH: "0", PLAN_LINE_ID: "0", OPERATOR: "PHY_EXPR_VALUES", NAME: "NULL", COST: "0", ESTIMATED_ROWS: "2" });
}
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
@@ -289,10 +338,13 @@ beforeEach(() => {
mocks.oceanbaseTerminated = true;
return { columns: [], rows: [] };
}
return { columns: [], rows: [] };
return (await diagnosticReply(sql)) ?? { columns: [], rows: [] };
});
mocks.executeMulti.mockReset().mockResolvedValue([{ columns: [], rows: [] }]);
mocks.closeClientConnectionSession.mockReset().mockResolvedValue(undefined);
mocks.cancelQuery.mockReset().mockResolvedValue(true);
mocks.readLargeValueChunk.mockReset();
mocks.diagnosticGate = null;
mocks.contextGuard.mockReset();
mocks.contextGuardGate = null;
mocks.toast.mockReset();
@@ -300,7 +352,7 @@ beforeEach(() => {
oracleTerminateError = "";
mocks.oceanbaseRows = [
["5", "APP", "tenant_a", "localhost", "db", "Query", "0", "active", "SELECT 1", null, "127.0.0.1", "2882"],
["21", "OTHER", "tenant_a", "apphost", "db", "Sleep", "4", "idle", "SELECT 2", null, "127.0.0.1", "2882"],
["21", "OTHER", "tenant_a", "apphost", "db", "Sleep", "4", "idle", "SELECT 2", null, "127.0.0.1", "2882", "A".repeat(32), "trace-1"],
["22", "PROXY", "tenant_a", "proxyhost", "db", "Query", "5", "active", "SELECT 3", "99", "127.0.0.2", "2882"],
];
mocks.oceanbaseTerminated = false;
@@ -987,3 +1039,206 @@ describe("ProcessListPanel batch terminate", () => {
expect(root?.textContent).not.toContain("processList.transactionCount:0");
});
});
describe("ProcessListPanel linked diagnostics", () => {
it("labels retained waits with their original sample time while refreshed metrics succeed", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
const oldTime = "2026-10-21T10:00:00.000Z",
newTime = "2026-10-21T11:00:00.000Z";
const clock = vi.spyOn(Date.prototype, "toISOString").mockReturnValue(oldTime);
try {
await mountOraclePanel();
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false), { timeout: 5000 });
const tab = [...(root?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])].find((value) => value.textContent?.trim() === "统计与等待");
tab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
await vi.waitFor(() => expect(root?.textContent).toContain("fixture wait"));
clock.mockReturnValue(newTime);
const execute = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation(async (...args: unknown[]) => {
if (String(args[2]).includes("FROM GV$SESSION s WHERE")) throw new Error("ORA-01031: waits denied");
return execute(...args);
});
findDialogButton("刷新")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("ORA-01031: waits denied"));
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false));
const panel = root?.querySelector('[role="tabpanel"][data-state="active"]');
expect(panel?.textContent).toContain("fixture wait");
expect(panel?.textContent).toContain("游标累计");
expect(panel?.textContent).toContain(zhCN.sessionDiagnostics.previousSample.replace("{time}", new Date(oldTime).toLocaleString()));
expect(panel?.textContent).not.toContain(zhCN.sessionDiagnostics.previousSample.replace("{time}", new Date(newTime).toLocaleString()));
} finally {
clock.mockRestore();
}
});
it.each([
["Oracle", oracleConnection, "游标累计"],
["OceanBase Oracle", oceanbaseConnection, "计划缓存累计"],
])("opens the complete %s diagnostic workflow from the existing session row", async (_label, connection, cumulativeLabel) => {
mocks.localeMessages = zhCN as Record<string, unknown>;
if (connection.db_type === "oracle") await mountOraclePanel();
else await mountOceanbasePanel();
const open = [...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断");
expect(open?.disabled).toBe(false);
open?.click();
await vi.waitFor(() => expect(root?.querySelector("[data-dialog] pre")?.textContent, root?.textContent ?? "").toBe("SELECT 2"), { timeout: 5000 });
expect(root?.textContent).toContain("会话 21 诊断");
const selectTab = async (label: string) => {
const button = [...(root?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])].find((tab) => tab.textContent?.trim() === label);
expect(button).toBeDefined();
button?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
await nextTick();
};
await selectTab("已有计划");
await vi.waitFor(() => expect(root?.textContent).toContain(connection.db_type === "oracle" ? "已有游标计划" : "已关联原请求的缓存计划"));
await vi.waitFor(() => expect(root?.textContent).toContain(connection.db_type === "oracle" ? "ORACLE" : "OCEANBASE-ORACLE"), { timeout: 5000 });
expect(root?.textContent).not.toContain("MYSQL");
await selectTab("统计与等待");
await vi.waitFor(() => expect(root?.textContent).toContain(cumulativeLabel));
expect(root?.textContent).toContain("等待与会话状态");
await selectTab("长操作");
await vi.waitFor(() => expect(root?.textContent).toContain("本次未观察到长操作"));
expect(root?.textContent).not.toContain("SQL 已完成");
expect(mocks.executeMulti).not.toHaveBeenCalled();
const calls = mocks.executeQuery.mock.calls.filter((call) => String((call[5] as { clientSessionId?: string })?.clientSessionId).startsWith("session-diagnostics:"));
expect(calls.length).toBeGreaterThan(4);
expect(calls.every((call) => String(call[2]).startsWith("SELECT "))).toBe(true);
expect(calls.some((call) => call[2] === "SELECT 2")).toBe(false);
findDialogButton("关闭")?.click();
await vi.waitFor(() => expect(root?.textContent).not.toContain("会话 21 诊断"));
expect(processRow("21").textContent).toContain("OTHER");
});
it("offers no diagnostic action for an unrelated engine", async () => {
await mountPanel(mysqlConnection);
expect(gridButtons().some((button) => button.textContent?.trim() === "sessionDiagnostics.open")).toBe(false);
expect(findGridButton("processList.batchTerminate")).toBeDefined();
});
it("treats the exact-request placeholder as a prompt rather than an action that clears a bound request", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
const execute = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation(async (...args: unknown[]) => {
const value = await execute(...args);
if (!String(args[2]).includes("FROM SYS.GV$OB_SQL_AUDIT")) return value;
const other = [...value.rows[0]];
other[value.columns.indexOf("REQUEST_ID")] = "100";
return { ...value, rows: [...value.rows, other] };
});
await mountOceanbasePanel();
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(root?.querySelector("select")?.options.length).toBe(3), { timeout: 5000 });
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false));
const select = root?.querySelector("select")!;
expect(select.options[0]?.disabled).toBe(true);
select.value = select.options[1]!.value;
select.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(root?.textContent).toContain("SYS.GV$OB_SQL_AUDIT.QUERY_SQL"));
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false));
expect(root?.querySelector("select")?.options[0]?.disabled).toBe(true);
expect(root?.querySelector("select")?.value).toBe(select.options[1]!.value);
});
it("keeps explicit plan selection available when the captured audit narrows earlier cache candidates", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
let auditAvailable = false;
const execute = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation(async (...args: unknown[]) => {
const sql = String(args[2]);
if (sql.includes("FROM SYS.GV$OB_SQL_AUDIT") && !auditAvailable) return { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 };
if (sql.includes("FROM SYS.GV$OB_SESS_TIME_MODEL")) return diagnosticResult({ TENANT_ID: "7" });
const value = await execute(...args);
if (!sql.includes("FROM SYS.GV$OB_PLAN_CACHE_PLAN_STAT")) return value;
const original = Object.fromEntries(value.columns.map((column: string, index: number) => [column, value.rows[0][index]]));
const alternative = { ...original, PLAN_ID: "10", PLAN_HASH: "11" };
if (sql.includes("AND PLAN_ID=10")) return diagnosticResult(alternative);
if (sql.includes("AND PLAN_ID=9") || auditAvailable) return diagnosticResult(original);
const other = diagnosticResult(alternative);
return { ...value, rows: [...other.rows, ...value.rows] };
});
await mountOceanbasePanel();
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(root?.querySelector('[role="tab"]')).not.toBeNull(), { timeout: 5000 });
const tab = [...(root?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])].find((value) => value.textContent?.trim() === "已有计划");
tab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
await vi.waitFor(() => expect(root?.querySelector("select")?.options.length).toBe(3));
expect(root?.querySelector("select")?.options[0]?.disabled).toBe(true);
const choose = (id: string) => {
const select = root?.querySelector("select")!;
select.value = [...select.options].find((option) => option.textContent?.startsWith(`计划 ${id} ·`))!.value;
select.dispatchEvent(new Event("change", { bubbles: true }));
};
choose("10");
await vi.waitFor(() => expect(root?.textContent).toContain("同 SQL 的缓存计划,未证实用于本次执行"));
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false));
auditAvailable = true;
findDialogButton("刷新")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("目标身份或缓存代际已变化"));
await vi.waitFor(() => expect(findDialogButton("刷新")?.disabled).toBe(false));
expect(root?.querySelector("select")?.options.length).toBe(2);
expect(root?.textContent).not.toContain("已关联原请求的缓存计划");
choose("9");
await vi.waitFor(() => expect(root?.textContent).toContain("已关联原请求的缓存计划"));
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("shows the original missing SID and trace-level association for an OB long operation", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
const execute = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation(async (...args: unknown[]) =>
String(args[2]).includes("FROM SYS.GV$SESSION_LONGOPS") ? diagnosticResult({ SID: "-1", TRACE_ID: "trace-1", SVR_IP: "127.0.0.1", SVR_PORT: "2882", OPNAME: "create index", TARGET: "table_id=42", ELAPSED_SECONDS: "2", TIME_REMAINING: "3" }) : execute(...args),
);
await mountOceanbasePanel();
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("会话 21 诊断"), { timeout: 5000 });
const tab = [...(root?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [])].find((value) => value.textContent?.trim() === "长操作");
tab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
await vi.waitFor(() => expect(root?.textContent).toContain("请求 trace 关联(未提供会话 ID)"));
const panel = root?.querySelector('[role="tabpanel"][data-state="active"]');
expect(panel?.textContent).toContain("-1");
expect(panel?.textContent).toContain("create index");
expect(panel?.textContent).not.toContain("%");
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("stops local reading from the actual dialog while cancellation remains pending", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
let release!: () => void;
mocks.diagnosticGate = () =>
new Promise<void>((resolve) => {
release = resolve;
});
mocks.cancelQuery.mockReturnValue(new Promise(() => {}));
await mountOraclePanel();
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(findDialogButton("停止读取")).toBeDefined());
findDialogButton("停止读取")?.click();
await vi.waitFor(() => expect(findDialogButton("停止读取")).toBeUndefined());
expect(root?.textContent).toContain("已停止本地读取");
expect(findDialogButton("刷新")?.disabled).toBe(false);
expect(mocks.cancelQuery).toHaveBeenCalledOnce();
release();
await nextTick();
const calls = mocks.executeQuery.mock.calls.filter((call) => String((call[5] as { clientSessionId?: string })?.clientSessionId).startsWith("session-diagnostics:"));
expect(calls).toHaveLength(1);
});
it("closes diagnostics on connection changes without sampling the old row on the new connection", async () => {
mocks.localeMessages = zhCN as Record<string, unknown>;
let release!: () => void;
mocks.diagnosticGate = () =>
new Promise<void>((resolve) => {
release = resolve;
});
const connection = await mountReactivePanel(oracleConnection);
[...processRow("21").querySelectorAll("button")].find((button) => button.textContent?.trim() === "诊断")?.click();
await vi.waitFor(() => expect(findDialogButton("停止读取")).toBeDefined());
connection.id = "oracle-next";
await vi.waitFor(() => expect(root?.textContent).not.toContain("会话 21 诊断"));
release();
await nextTick();
expect(mocks.executeQuery.mock.calls.filter((call) => String((call[5] as { clientSessionId?: string })?.clientSessionId).startsWith("session-diagnostics:")).every((call) => call[0] === "oracle-1")).toBe(true);
expect(mocks.cancelQuery).toHaveBeenCalledOnce();
});
});
@@ -238,11 +238,16 @@ watch(
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.compression") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input :checked="draft.outputCompression === 'gzip'" type="checkbox" class="h-4 w-4 accent-primary" @change="draft.outputCompression = ($event.target as HTMLInputElement).checked ? 'gzip' : 'none'" />
{{ t("databaseBackup.compressionGzip") }}
</label>
<Label>{{ t("databaseBackup.backupFormat") }}</Label>
<Select v-model="draft.outputCompression">
<SelectTrigger data-backup-output-format class="w-full sm:w-65"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ t("databaseBackup.formatSql") }}</SelectItem>
<SelectItem value="gzip">{{ t("databaseBackup.compressionGzip") }}</SelectItem>
<SelectItem value="nb3">{{ t("databaseBackup.formatNb3") }}</SelectItem>
</SelectContent>
</Select>
<p v-if="draft.outputCompression === 'nb3'" data-backup-nb3-hint class="text-xs text-muted-foreground">{{ t("databaseBackup.formatNb3Hint") }}</p>
</div>
<div v-if="multipleConnectionsSelected" data-backup-multi-connection-hint class="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, Download, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, Download, FileInput, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
@@ -44,7 +44,27 @@ const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, heartbeat, destinationRoot, error: backupError, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, runOneShotBatch, cancelRun } = useScheduledDatabaseBackups();
const {
schedules,
runs,
activeScheduleIds,
activeRunIds,
cancellingRunIds,
activeRuns,
heartbeat,
destinationRoot,
error: backupError,
saveSchedule,
setScheduleEnabled,
deleteSchedule,
deleteRuns,
importBackupFiles,
renameRun,
runSchedule,
runOneShot,
runOneShotBatch,
cancelRun,
} = useScheduledDatabaseBackups();
const desktop = isTauriRuntime();
const backgroundEnabled = ref(false);
const backgroundBusy = ref(false);
@@ -117,7 +137,7 @@ const selectedRunIds = reactive(new Set<string>());
const historyConnectionId = ref("");
const historyConnectionPickerOpen = ref(false);
const historyConnectionSearch = ref("");
const historyBackupMethod = ref<"all" | "manual" | "scheduled" | "one-shot">("all");
const historyBackupMethod = ref<"all" | ReturnType<typeof runBackupMethod>>("all");
const historyStatus = ref<"all" | DatabaseBackupRun["status"]>("all");
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
@@ -126,7 +146,8 @@ const sortedRuns = computed(() => [...runs.value].sort((left, right) => Date.par
const historyConnections = computed(() => {
const connectionById = new Map<string, string>();
for (const run of sortedRuns.value) {
if (!connectionById.has(run.connectionId)) connectionById.set(run.connectionId, run.connectionName || connectionName(run.connectionId));
// Imported backup files are not tied to a saved connection.
if (run.connectionId && !connectionById.has(run.connectionId)) connectionById.set(run.connectionId, run.connectionName || connectionName(run.connectionId));
}
return [...connectionById].map(([id, name]) => ({ id, name })).sort((left, right) => left.name.localeCompare(right.name, locale.value));
});
@@ -348,11 +369,18 @@ function selectHistoryConnection(connectionId: string) {
historyConnectionSearch.value = "";
}
function runBackupMethod(run: DatabaseBackupRun): "manual" | "scheduled" | "one-shot" {
if (run.source === "one-shot") return "one-shot";
function runBackupMethod(run: DatabaseBackupRun): "manual" | "scheduled" | "one-shot" | "imported" {
if (run.source === "one-shot" || run.source === "imported") return run.source;
return run.trigger;
}
function runBackupMethodLabel(run: DatabaseBackupRun): string {
const method = runBackupMethod(run);
if (method === "one-shot") return t("databaseBackup.oneShotTrigger");
if (method === "imported") return t("databaseBackup.importedTrigger");
return method === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger");
}
function formatDate(value?: string): string {
if (!value || !Number.isFinite(Date.parse(value))) return t("databaseBackup.never");
return new Intl.DateTimeFormat(locale.value, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
@@ -802,6 +830,46 @@ async function revealBackup(file: DatabaseBackupFile) {
}
}
const BACKUP_FILE_FILTERS = [{ name: "Database backup", extensions: ["sql", "gz", "nb3"] }];
async function pickBackupFiles(multiple: boolean): Promise<string[]> {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ multiple, filters: BACKUP_FILE_FILTERS });
return Array.isArray(selected) ? selected : selected ? [selected] : [];
}
/** Restores a backup file created anywhere, without adding it to the history. */
async function restoreFromFile() {
try {
// The web SQL file dialog uploads the file itself; the desktop picks a local path first.
const [filePath] = desktop ? await pickBackupFiles(false) : [undefined];
if (desktop && !filePath) return;
connectionStore.sqlFileSource = { connectionId: "", database: "", ...(filePath ? { filePath } : {}) };
} catch (error) {
toast(translateBackendError(t, error), 5000);
}
}
async function importBackups() {
try {
const files = await pickBackupFiles(true);
if (files.length === 0) return;
const imported = await importBackupFiles(files);
toast(t("databaseBackup.backupsImported", { count: imported.length }), 2500);
} catch (error) {
toast(translateBackendError(t, error), 5000);
}
}
const pendingDeleteMessage = computed(() => {
const pending = pendingDeleteRunIds.value.map((runId) => runs.value.find((run) => run.id === runId)).filter((run): run is DatabaseBackupRun => !!run);
const owned = pending.filter((run) => run.source !== "imported");
if (owned.length === 0) return t("databaseBackup.deleteImportedConfirm", { count: pending.length });
const files = owned.reduce((total, run) => total + run.files.length, 0);
const message = pending.length === 1 ? t("databaseBackup.deleteBackupConfirm", { count: files }) : t("databaseBackup.deleteBackupsConfirm", { count: pending.length, files });
return owned.length === pending.length ? message : `${message} ${t("databaseBackup.importedFilesKept")}`;
});
async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
try {
const prepared = desktop ? file.filePath : await api.prepareDatabaseBackupRestore(run.id, run.files.indexOf(file));
@@ -825,6 +893,14 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button data-backup-restore-from-file variant="outline" size="sm" :title="t('databaseBackup.restoreFromFileHint')" @click="restoreFromFile">
<RotateCcw class="mr-2 h-4 w-4" />
{{ t("databaseBackup.restoreFromFile") }}
</Button>
<Button v-if="desktop" data-backup-import variant="outline" size="sm" :title="t('databaseBackup.importBackupHint')" @click="importBackups">
<FileInput class="mr-2 h-4 w-4" />
{{ t("databaseBackup.importBackup") }}
</Button>
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.oneShotBackup') : t('databaseBackup.noSupportedConnections')" @click="openOneShotBackup">
<Play class="mr-2 h-4 w-4" />
{{ t("databaseBackup.oneShotBackup") }}
@@ -941,6 +1017,7 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<SelectItem value="manual">{{ t("databaseBackup.manualTrigger") }}</SelectItem>
<SelectItem value="scheduled">{{ t("databaseBackup.scheduledTrigger") }}</SelectItem>
<SelectItem value="one-shot">{{ t("databaseBackup.oneShotTrigger") }}</SelectItem>
<SelectItem value="imported">{{ t("databaseBackup.importedTrigger") }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="historyStatus">
@@ -987,10 +1064,10 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ displayedRunStatusLabel(run) }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.source === "one-shot" ? t("databaseBackup.oneShotTrigger") : run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
<Badge variant="outline" class="font-normal">{{ runBackupMethodLabel(run) }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ run.connectionName || connectionName(run.connectionId) }}</span>
<span>{{ run.source === "imported" ? t("databaseBackup.externalFile") : run.connectionName || connectionName(run.connectionId) }}</span>
<span>{{ formatDate(run.startedAt) }}</span>
<span>{{ t("databaseBackup.fileCount", { count: run.files.length }) }}</span>
<span v-if="run.error" class="break-all text-destructive">{{ translateBackendError(t, run.error) }}</span>
@@ -1208,13 +1285,7 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<DialogHeader
><DialogTitle>{{ t("databaseBackup.deleteBackup") }}</DialogTitle></DialogHeader
>
<p class="text-sm text-muted-foreground">
{{
pendingDeleteRunIds.length === 1
? t("databaseBackup.deleteBackupConfirm", { count: runs.find((run) => run.id === pendingDeleteRunIds[0])?.files.length || 0 })
: t("databaseBackup.deleteBackupsConfirm", { count: pendingDeleteRunIds.length, files: pendingDeleteRunIds.reduce((total, runId) => total + (runs.find((run) => run.id === runId)?.files.length || 0), 0) })
}}
</p>
<p data-backup-delete-message class="text-sm text-muted-foreground">{{ pendingDeleteMessage }}</p>
<DialogFooter>
<Button variant="outline" @click="deleteRunDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button variant="destructive" @click="confirmDeleteRuns">{{ t("databaseBackup.delete") }}</Button>
@@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
deleteRuns: vi.fn(),
importBackupFiles: vi.fn(),
renameRun: vi.fn(),
runSchedule: vi.fn(),
runOneShot: vi.fn(),
@@ -75,6 +76,7 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
deleteRuns: mocks.deleteRuns,
importBackupFiles: mocks.importBackupFiles,
renameRun: mocks.renameRun,
runSchedule: mocks.runSchedule,
runOneShot: mocks.runOneShot,
@@ -154,6 +156,9 @@ function destinationInput(): HTMLInputElement {
return input;
}
// The backup format select precedes the table scope select in the config fields.
const TABLE_SCOPE_SELECT = 1;
async function selectDialogOption(triggerIndex: number, optionText: string) {
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
@@ -167,7 +172,7 @@ async function selectDialogOption(triggerIndex: number, optionText: string) {
}
async function setTablePattern(pattern: string) {
await selectDialogOption(0, String(i18n.global.t("databaseBackup.includeTables")));
await selectDialogOption(TABLE_SCOPE_SELECT, String(i18n.global.t("databaseBackup.includeTables")));
const input = currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`);
if (!input) throw new Error("Table pattern input not found");
input.value = pattern;
@@ -252,7 +257,9 @@ async function showDatabaseOptions() {
}
function buttonWithText(text: string): HTMLButtonElement {
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
// Prefer an exact label so "Restore" does not match "Restore from file".
const buttons = Array.from(document.body.querySelectorAll("button"));
const button = buttons.find((item) => item.textContent?.trim() === text) ?? buttons.find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
@@ -346,6 +353,7 @@ afterEach(() => {
mocks.saveSchedule.mockClear();
mocks.deleteRun.mockClear();
mocks.deleteRuns.mockClear();
mocks.importBackupFiles.mockReset();
mocks.renameRun.mockReset();
mocks.cancelRun.mockClear();
mocks.runSchedule.mockReset();
@@ -381,6 +389,57 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
else expect(mocks.prepareDatabaseBackupRestore).toHaveBeenCalledWith("restore-run", 0);
});
it("imports outside backup files into the history and restores any file without importing it", async () => {
mocks.openDirectory.mockResolvedValueOnce(["/downloads/shop.nb3", "/downloads/old.sql.gz"]);
mocks.importBackupFiles.mockResolvedValue([{ id: "imported-1" }, { id: "imported-2" }]);
await mountSettings();
document.body.querySelector<HTMLButtonElement>("[data-backup-import]")!.click();
await flush();
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ multiple: true }));
expect(mocks.importBackupFiles).toHaveBeenCalledWith(["/downloads/shop.nb3", "/downloads/old.sql.gz"]);
expect(mocks.toast).toHaveBeenCalledWith(String(i18n.global.t("databaseBackup.backupsImported", { count: 2 })), 2500);
mocks.openDirectory.mockResolvedValueOnce("/downloads/navicat.nb3");
document.body.querySelector<HTMLButtonElement>("[data-backup-restore-from-file]")!.click();
await flush();
expect(mocks.sqlFileSource).toEqual({ connectionId: "", database: "", filePath: "/downloads/navicat.nb3" });
expect(mocks.importBackupFiles).toHaveBeenCalledTimes(1);
});
it("opens the uploading SQL file dialog to restore from a file on the web, where import is unavailable", async () => {
mocks.desktop = false;
await mountSettings();
expect(document.body.querySelector("[data-backup-import]")).toBeNull();
document.body.querySelector<HTMLButtonElement>("[data-backup-restore-from-file]")!.click();
await flush();
expect(mocks.openDirectory).not.toHaveBeenCalled();
expect(mocks.sqlFileSource).toEqual({ connectionId: "", database: "" });
});
it("labels imported backups and says deleting them keeps the files", async () => {
mocks.runs.push({
id: "imported-run",
scheduleName: "shop.nb3",
connectionId: "",
connectionName: "",
trigger: "manual",
source: "imported",
status: "success",
startedAt: "2026-09-08T02:00:00.000Z",
files: [{ database: "shop", schema: "sales", displayName: "shop.sales", filePath: "/downloads/shop.nb3" }],
});
await mountSettings();
expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.importedTrigger")));
expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.externalFile")));
expect(document.body.textContent).not.toContain(String(i18n.global.t("databaseBackup.missingConnection")));
buttonWithTitle(String(i18n.global.t("databaseBackup.deleteBackup"))).click();
await flush();
expect(currentDialog().querySelector("[data-backup-delete-message]")?.textContent).toBe(String(i18n.global.t("databaseBackup.deleteImportedConfirm", { count: 1 })));
});
it("shows a backup display name and saves edits through the rename dialog", async () => {
mocks.runs.push({
id: "renamed-run",
@@ -705,7 +764,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
database.checked = true;
database.dispatchEvent(new Event("change", { bubbles: true }));
await flush();
await selectDialogOption(0, String(i18n.global.t("databaseBackup.exactTables")));
await selectDialogOption(TABLE_SCOPE_SELECT, String(i18n.global.t("databaseBackup.exactTables")));
await flush();
const start = Array.from(currentDialog().querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("databaseBackup.startBackup")))!;
expect(start.disabled).toBe(true);
@@ -824,7 +883,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
const database = Array.from(currentDialog().querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find((item) => item.parentElement?.textContent?.trim() === "app")!;
database.checked = true;
database.dispatchEvent(new Event("change", { bubbles: true }));
await selectDialogOption(0, String(i18n.global.t("databaseBackup.exactTables")));
await selectDialogOption(TABLE_SCOPE_SELECT, String(i18n.global.t("databaseBackup.exactTables")));
await flush();
expect(currentDialog().querySelector("[data-backup-table-selector]")).not.toBeNull();
@@ -835,7 +894,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(currentDialog().querySelector("[data-backup-database-search]")).toBeNull();
expect(currentDialog().querySelector("[data-backup-table-selector]")).toBeNull();
expect(startBackupButton().disabled).toBe(false);
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[0]!;
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[TABLE_SCOPE_SELECT]!;
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
await flush();
const options = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).map((item) => item.textContent?.trim());
@@ -7,13 +7,18 @@ const { t } = useI18n();
withDefaults(
defineProps<{
compact?: boolean;
/** Icon-only marker for dense rows: same shape in every row, no text. */
marker?: boolean;
}>(),
{ compact: false },
{ compact: false, marker: false },
);
</script>
<template>
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
<span v-if="marker" data-production-marker class="inline-flex h-4 w-4 shrink-0 items-center justify-center text-red-600 dark:text-red-300" role="img" :aria-label="t('production.title')" :title="t('production.title')">
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
</span>
<span v-else class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
<span>PROD</span>
</span>
@@ -183,8 +183,7 @@ async function applyDriverStoreDir(kind: DriverStoreDirKind, newDir: string | nu
};
toast(t("driverStore.driverStoreDirSuccess", { target }));
// Restart the app to use the new paths
const { relaunch } = await import("@tauri-apps/plugin-process");
relaunch();
await api.relaunchApp();
} catch (e: any) {
toast(t("driverStore.driverStoreDirMigrationFailed", { error: backendError(e) }), 5000);
} finally {
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ConnectionDetailRow } from "@/lib/connection/connectionDetailRows";
defineProps<{ rows: ConnectionDetailRow[] }>();
</script>
<template>
<div class="w-max min-w-40 max-w-[min(28rem,calc(100vw-24px))] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-lg">
<div class="space-y-1">
<div v-for="row in rows" :key="row.label" class="grid grid-cols-[max-content_minmax(0,1fr)] gap-2 text-xs leading-5">
<span class="shrink-0 text-muted-foreground">{{ row.label }}</span>
<template v-if="row.values">
<div class="flex flex-col gap-0.5 font-mono text-foreground/90">
<span v-for="(value, index) in row.values" :key="index" class="break-all">{{ value }}</span>
</div>
</template>
<button
v-else-if="row.action"
type="button"
class="w-fit rounded bg-primary/10 px-1 font-mono text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="row.actionLabel"
:title="row.actionLabel"
@click.stop="row.action()"
>
{{ row.value }}
</button>
<span v-else-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">{{ row.value }}</span>
<span v-else class="truncate font-mono text-foreground/90" :title="row.value">{{ row.value }}</span>
</div>
</div>
</div>
</template>
@@ -32,6 +32,7 @@ import type {
PluginFormField,
PluginFormFieldValue,
ProxyTunnelConfig,
SshAgentIdentity,
SshConfigHostEntry,
SshTunnelConfig,
TransportLayerConfig,
@@ -50,6 +51,7 @@ import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSum
import { insertSqliteRemoteTransportLayer, isSqliteRemoteTransportLayerType, sqliteRemoteTransportError } from "@/lib/connection/sqliteRemoteTransport";
import { sanitizeConnectionCredentials } from "@/lib/connection/credentialSanitizer";
import { applySshAuthMethod, inferSshAuthMethod } from "@/lib/connection/sshAuthMethod";
import { editableSshLocalPort, normalizeSshLocalPort, sshLocalPortState, sshLocalPortSupportedForConnection } from "@/lib/connection/sshLocalPort";
import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts";
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync";
import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDraft as persistConnectionNoteVisibilityDraftState, resetConnectionNoteVisibilityDraft, setConnectionNoteVisibilityDraft, syncConnectionNoteVisibilityDraft } from "./connectionNoteVisibilityDraft";
@@ -96,7 +98,16 @@ import {
} from "@/lib/connection/timeoutLimits";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection";
import { applyConnectionDeepLinkUpdate, resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate";
import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseConnectionDeepLinkUpdate, parseServiceConnectionUrl, type ConnectionDeepLinkDraft, type ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink";
import {
connectionDeepLinkServiceHydrationValue,
parseConnectionDeepLink,
parseConnectionDeepLinkUpdate,
parseServiceConnectionUrl,
shouldAutoConfirmConnectionDeepLink,
shouldAutoConfirmConnectionPrefill,
type ConnectionDeepLinkDraft,
type ConnectionDeepLinkUpdate,
} from "@/lib/connection/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { parseGaussdbHosts, serializeGaussdbHosts, type GaussdbHostEntry } from "@/lib/connection/gaussdbHosts";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
@@ -174,7 +185,7 @@ import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatab
import { resolveVisibleDatabaseSaveAction, visibleDatabasePatternsWithoutMatchAll } from "@/components/sidebar/visibleDatabasesDialogState";
import { MATCH_ALL_VISIBLE_DATABASE_PATTERN, canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware, isSingleDatabase, supportsDataDictionary, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
import { normalizeConnectionIdentityFields, normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
import { databaseConnectionFormKind, databaseManifestEntry } from "@/lib/database/databaseDriverManifest";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
import CloudflareD1ConnectionFields from "@/components/connection/CloudflareD1ConnectionFields.vue";
@@ -186,6 +197,8 @@ import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudfla
import { hasSpannerResourcePath, isSpannerConnection, normalizeSpannerConnection } from "@/lib/connection/spannerResourcePath";
import {
buildElasticsearchExternalConfig,
elasticsearchAuthTypeFromConfig,
type ElasticsearchAuthType,
elasticsearchConnectionModeFromConfig,
elasticsearchConnectivityCheckDisabledFromConfig,
elasticsearchConnectivityCheckPathFromConfig,
@@ -489,6 +502,7 @@ function resetCassandraTlsFields(externalConfig: unknown) {
Object.assign(cassandraTls, cassandraTlsConfigFromExternalConfig(externalConfig));
}
const elasticsearchAuthType = ref<ElasticsearchAuthType>("basic");
const elasticsearchConnectionMode = ref<ElasticsearchConnectionMode>("direct");
const elasticsearchKibanaBasePath = ref("");
const elasticsearchConnectivityCheckPath = ref("");
@@ -498,7 +512,15 @@ const elasticsearchConnectionPorts = ref<Record<ElasticsearchConnectionMode, num
direct: 9200,
kibana: 5601,
});
function switchElasticsearchAuthType(authType: ElasticsearchAuthType) {
if (authType === elasticsearchAuthType.value) return;
elasticsearchAuthType.value = authType;
form.value.password = "";
resetTestState();
}
function resetElasticsearchProxyFields(externalConfig?: unknown) {
elasticsearchAuthType.value = elasticsearchAuthTypeFromConfig(externalConfig);
const mode = elasticsearchConnectionModeFromConfig(externalConfig);
elasticsearchConnectionMode.value = mode;
elasticsearchKibanaBasePath.value = elasticsearchKibanaBasePathFromConfig(externalConfig);
@@ -534,6 +556,7 @@ function defaultSshTunnel(): SshTunnelConfig {
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
ssh_agent_key_fingerprint: "",
auth_method: "password",
allow_exec_channel_proxy: false,
proxy_command: "",
@@ -553,8 +576,10 @@ function normalizeSshTunnel(hop: Partial<SshTunnelConfig>): SshTunnelConfig {
key_passphrase: hop.key_passphrase || "",
connect_timeout_secs: Number(hop.connect_timeout_secs) || 5,
expose_lan: !!hop.expose_lan,
local_port: hop.enabled === false ? normalizeSshLocalPort(hop.local_port) : editableSshLocalPort(hop.local_port),
use_ssh_agent: !!hop.use_ssh_agent,
ssh_agent_sock_path: hop.ssh_agent_sock_path || "",
ssh_agent_key_fingerprint: hop.ssh_agent_key_fingerprint || "",
auth_method: hop.auth_method || inferSshAuthMethod(hop),
allow_exec_channel_proxy: !!hop.allow_exec_channel_proxy,
proxy_command: hop.proxy_command || "",
@@ -3124,6 +3149,12 @@ function switchGbaseProfile(profile: GbaseProfile) {
let applyingConnectionUpdate = false;
let appliedConnectionUpdate: ConnectionDeepLinkUpdate | null = null;
/**
* 免确认保存的去重键。必须早于下面这个 `immediate` watcher 声明——更新链接的
* 自动保存就在该 watcher 里触发,晚声明会命中 TDZ。
*/
const submittedAutoCreatePrefillKey = ref<string | null>(null);
function finishApplyingConnectionUpdate() {
void nextTick(() => {
applyingConnectionUpdate = false;
@@ -3315,7 +3346,10 @@ watch(
customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : "";
dialogStep.value = "config";
configTab.value = initialConfigTab();
if (props.updatePrefill) finishApplyingConnectionUpdate();
if (props.updatePrefill) {
finishApplyingConnectionUpdate();
submitUpdateIfAutoConfirmed(props.updatePrefill);
}
// Form/profile watchers normalize derived fields in this flush. Capture
// the saved baseline afterwards so those initial changes are not treated
// as user edits that invalidate persisted database metadata.
@@ -4888,6 +4922,7 @@ function connectionConfigForSshTunnelTest(id: string): ConnectionConfig {
function connectionConfigForSubmit(id: string, generatedName = "", validatePluginRequired = true): ConnectionConfig {
let config: LegacyConnectionConfig;
let preserveExistingShowDatabaseLinks = false;
if (isPluginConnection.value) {
const entry = selectedPluginProvider.value;
if (!entry) throw new Error(pluginLoadError.value || t("connection.pluginProviderUnavailable"));
@@ -4947,6 +4982,9 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
}
config.database_info = undefined;
config.database = normalizeStoredConnectionDatabase(config.db_type, config.database);
// Plugin providers own their field semantics; trim only the built-in form where a
// hostname/user name/database can never carry meaningful surrounding whitespace.
if (!isPluginConnection.value) normalizeConnectionIdentityFields(config);
config.note = config.note?.trim() || undefined;
if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) {
Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value));
@@ -4986,6 +5024,18 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.host = serialized.host;
config.port = serialized.port;
}
// Hostnames and usernames cannot contain leading/trailing whitespace, so a
// stray space (typically from copy-pasting) can only be a mistake — but it
// still reaches the driver and fails in a way the error message does not
// make visible. Passwords are left untouched because surrounding spaces can
// be significant, and file-path hosts (SQLite/DuckDB/H2 file mode) keep
// their exact value for the same reason.
const usesLocalFilePathHost = isLocalFileTypeDb(config.db_type) && (config.db_type !== "h2" || h2ConnectionModeForConfig(config) === "file");
if (!usesLocalFilePathHost) {
config.host = config.host.trim();
}
config.username = config.username.trim();
config.database = config.database?.trim() || undefined;
if (isCloudflareD1Connection(config)) {
normalizeCloudflareD1Connection(config);
if (!hasCloudflareD1Credentials(config)) {
@@ -5199,7 +5249,11 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
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);
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value, elasticsearchAuthType.value);
if (elasticsearchAuthType.value === "apiKey") {
config.username = "";
config.password = config.password.trim();
}
} else if (config.db_type === "meilisearch") {
config.username = "";
config.password = config.password.trim();
@@ -5446,8 +5500,18 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.connection_string = config.connection_string?.trim() || JDBCX_DEFAULT_URL;
config.jdbc_driver_class = config.jdbc_driver_class?.trim() || JDBCX_JDBC_DRIVER_CLASS;
} else {
config.host = "";
config.port = 0;
const keepsExistingTarget = editingId.value === id && props.editConfig?.id === id && config.db_type === props.editConfig.db_type && config.driver_profile === props.editConfig.driver_profile && config.connection_string === props.editConfig.connection_string;
if (!keepsExistingTarget) {
config.host = "";
config.port = 0;
} else if (props.editConfig.production_badge_scope == null && config.production_badge_scope === sidebarProductionBadgeScope(props.editConfig)) {
config.production_badge_scope = undefined;
}
preserveExistingShowDatabaseLinks = keepsExistingTarget && !supportsOracleDatabaseLinks(config.db_type);
if (preserveExistingShowDatabaseLinks) {
// Generic JDBC has no DB-link toggle; retain the saved target setting.
config.show_database_links = props.editConfig?.show_database_links;
}
config.connection_string = config.connection_string?.trim() || "";
}
} else if (config.db_type === "prestosql") {
@@ -5521,7 +5585,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
}
if (!config.show_system_schemas) config.show_system_schemas = undefined;
if (config.show_database_links !== false) config.show_database_links = undefined;
if (config.show_database_links !== false && !preserveExistingShowDatabaseLinks) config.show_database_links = undefined;
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
@@ -6337,9 +6401,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
resetTestState();
}
const submittedOneTimePrefillKey = ref<string | null>(null);
function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) {
function prefillDraftKey(draft: ConnectionDeepLinkDraft) {
return JSON.stringify([
draft.name,
draft.dbType,
@@ -6360,11 +6422,31 @@ function oneTimePrefillKey(draft: ConnectionDeepLinkDraft) {
]);
}
function submitOneTimePrefill(draft: ConnectionDeepLinkDraft) {
if (!draft.oneTime) return;
const key = oneTimePrefillKey(draft);
if (submittedOneTimePrefillKey.value === key) return;
submittedOneTimePrefillKey.value = key;
/**
* 免确认保存连接深链。
*
* `one_time` 临时连接一直是自动提交的;设置项 `deepLinkConnectionConfirmMode`
* 打开对应形态后,新建(`create`)与带 `id` 的更新(`update`)链接也走同一条路径:
* 弹窗完成预填后立刻保存,用户不需要再点确认。
*
* 仍然复用弹窗自己的 `save()`,因此驱动安装、URL 解析、校验等逻辑与手动保存完全一致;
* 一旦校验失败,`save()` 返回 false 且弹窗保持打开,用户可以继续手动补全。
*/
function submitPrefillIfAutoConfirmed(draft: ConnectionDeepLinkDraft) {
if (!shouldAutoConfirmConnectionPrefill(draft, settingsStore.editorSettings.deepLinkConnectionConfirmMode)) return;
const key = prefillDraftKey(draft);
if (submittedAutoCreatePrefillKey.value === key) return;
submittedAutoCreatePrefillKey.value = key;
void nextTick(() => save());
}
/** 带 `id` 的更新链接:预填完成后按设置决定是否直接保存。 */
function submitUpdateIfAutoConfirmed(update: ConnectionDeepLinkUpdate) {
if (!shouldAutoConfirmConnectionDeepLink("update", settingsStore.editorSettings.deepLinkConnectionConfirmMode)) return;
const key = `update:${update.connectionId}:${JSON.stringify(update.patch)}`;
if (submittedAutoCreatePrefillKey.value === key) return;
submittedAutoCreatePrefillKey.value = key;
// 让表单 watcher 先跑完一轮派生字段归一化,再按当前表单内容保存。
void nextTick(() => save());
}
@@ -6434,7 +6516,7 @@ function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
function applyConnectionPrefill(draft: ConnectionDeepLinkDraft) {
resetForm();
applyConnectionDraftToForm(draft);
submitOneTimePrefill(draft);
submitPrefillIfAutoConfirmed(draft);
}
watch(
@@ -6442,7 +6524,7 @@ watch(
(value) => {
if (!value) {
const draftId = editingId.value ? null : draftTestConnectionId.value;
submittedOneTimePrefillKey.value = null;
submittedAutoCreatePrefillKey.value = null;
resetForm();
if (draftId) {
void api.disconnectDb(draftId).catch(() => undefined);
@@ -6616,6 +6698,7 @@ function addHttpTunnel() {
function duplicateTransportLayer(layer: TransportLayerConfig) {
if (sqliteRemoteTransportRestricted.value && !isSqliteRemoteTransportLayerType(layer.type)) return;
const next = normalizeTransportLayer({ ...layer, id: uuid(), name: layer.name ? `${layer.name} copy` : "" });
if (next.type === "ssh") delete next.local_port;
form.value.transport_layers = sqliteRemoteTransportRestricted.value ? insertSqliteRemoteTransportLayer(transportLayers.value, next) : [...transportLayers.value, next];
selectedTransportLayerId.value = next.id;
resetTestState();
@@ -6701,11 +6784,19 @@ function updateSelectedSshAuthMethod(value: unknown) {
resetTestState();
}
function updateSelectedSshLocalPort(value: unknown) {
const layer = selectedSshLayer.value;
if (!layer) return;
layer.local_port = editableSshLocalPort(value);
resetTestState();
}
function validateTransportLayers(config: LegacyConnectionConfig) {
const layers = config.transport_layers || [];
if (config.db_type === "sqlite" && sqliteRemoteTransportError(layers)) {
throw new Error(t("connection.sqliteTransportSshOnly"));
}
const lastEnabledIndex = layers.reduce((last, layer, index) => (layer.enabled === false ? last : index), -1);
layers.forEach((layer, index) => {
if (layer.enabled === false) return;
// Profile-referencing layers are stubs: the shared profile supplies the
@@ -6735,6 +6826,10 @@ function validateTransportLayers(config: LegacyConnectionConfig) {
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 300) {
throw new Error(t("connection.sshHopInvalidTimeout", { hop: label }));
}
const localPortState = sshLocalPortState(layer.local_port);
if (localPortState === "invalid") throw new Error(t("connection.sshHopInvalidLocalPort", { hop: label }));
if (localPortState === "valid" && index !== lastEnabledIndex) throw new Error(t("connection.sshHopLocalPortNotLast", { hop: label }));
if (localPortState === "valid" && !sshLocalPortSupportedForConnection(config, mqSystemKind.value, selectedPluginProvider.value?.contribution.proxy_route)) throw new Error(t("connection.sshHopLocalPortUnsupported", { hop: label }));
}
});
}
@@ -6918,6 +7013,58 @@ async function browseSshKeyPath(target?: SshTunnelConfig | null) {
}
}
const SSH_AGENT_ALL_IDENTITIES = "__all__";
const sshAgentIdentities = ref<SshAgentIdentity[]>([]);
const sshAgentIdentitiesLoading = ref(false);
const sshAgentIdentitiesError = ref("");
let sshAgentIdentitiesTimer: ReturnType<typeof setTimeout> | undefined;
let sshAgentIdentitiesRequest = 0;
const sshAgentIdentityOptions = computed(() => {
const fingerprint = selectedSshLayer.value?.ssh_agent_key_fingerprint || "";
if (!fingerprint || sshAgentIdentities.value.some((identity) => identity.fingerprint === fingerprint)) {
return sshAgentIdentities.value;
}
return [{ fingerprint, algorithm: "", comment: "" }, ...sshAgentIdentities.value];
});
function sshAgentIdentityLabel(identity: SshAgentIdentity) {
if (!identity.comment) return identity.fingerprint;
return identity.algorithm ? `${identity.comment} (${identity.algorithm})` : identity.comment;
}
function updateSelectedSshAgentKeyFingerprint(value: unknown) {
if (!selectedSshLayer.value) return;
const fingerprint = String(value);
selectedSshLayer.value.ssh_agent_key_fingerprint = fingerprint === SSH_AGENT_ALL_IDENTITIES ? "" : fingerprint;
}
async function loadSshAgentIdentities(sshAgentSockPath: string) {
const request = ++sshAgentIdentitiesRequest;
sshAgentIdentitiesLoading.value = true;
sshAgentIdentitiesError.value = "";
try {
const identities = await api.listSshAgentIdentities(sshAgentSockPath);
if (request === sshAgentIdentitiesRequest) sshAgentIdentities.value = identities;
} catch (cause) {
if (request === sshAgentIdentitiesRequest) {
sshAgentIdentitiesError.value = t("connection.sshAgentKeyLoadFailed", { error: cause instanceof Error ? cause.message : String(cause) });
}
} finally {
if (request === sshAgentIdentitiesRequest) sshAgentIdentitiesLoading.value = false;
}
}
watch(
() => (selectedSshLayer.value?.auth_method === "agent" ? selectedSshLayer.value.ssh_agent_sock_path || "" : null),
(sshAgentSockPath) => {
if (sshAgentSockPath === null) return;
clearTimeout(sshAgentIdentitiesTimer);
sshAgentIdentitiesTimer = setTimeout(() => void loadSshAgentIdentities(sshAgentSockPath), 400);
},
{ immediate: true },
);
async function browseCaCertPath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -9381,14 +9528,22 @@ function openExternalUrl(url: string) {
<Input v-model="form.informix_server" class="col-span-3" placeholder="ol_informix1170" />
</div>
<div v-if="form.db_type !== 'meilisearch' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.mqAuth") }}</Label>
<div class="col-span-3 flex items-center gap-2">
<Button type="button" size="sm" :variant="elasticsearchAuthType === 'basic' ? 'default' : 'outline'" :aria-pressed="elasticsearchAuthType === 'basic'" @click="switchElasticsearchAuthType('basic')">{{ t("connection.mqAuthBasic") }}</Button>
<Button type="button" size="sm" :variant="elasticsearchAuthType === 'apiKey' ? 'default' : 'outline'" :aria-pressed="elasticsearchAuthType === 'apiKey'" @click="switchElasticsearchAuthType('apiKey')">{{ t("connection.mqAuthApiKey") }}</Button>
</div>
</div>
<div v-if="form.db_type !== 'meilisearch' && form.db_type !== 'spanner' && !(form.db_type === 'elasticsearch' && elasticsearchAuthType === 'apiKey')" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div v-if="form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "meilisearch" ? t("connection.mqAuthApiKey") : t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
<Label :class="connectionLabelClass">{{ form.db_type === "meilisearch" || (form.db_type === "elasticsearch" && elasticsearchAuthType === "apiKey") ? t("connection.mqAuthApiKey") : t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="form.db_type === 'elasticsearch' && elasticsearchAuthType === 'apiKey' ? 'Base64(id:api_key)' : undefined" />
</div>
<SavePasswordCheckbox v-if="form.db_type !== 'spanner'" v-model="form.save_password" />
@@ -10916,6 +11071,35 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentSockPath") }}</Label>
<Input v-model="selectedSshLayer.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" :disabled="selectedSshLayer.enabled === false" />
</div>
<div v-if="selectedSshLayer.auth_method === 'agent'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshAgentKey") }}</Label>
<div class="col-span-3 flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Select :model-value="selectedSshLayer.ssh_agent_key_fingerprint || SSH_AGENT_ALL_IDENTITIES" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshAgentKeyFingerprint">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="SSH_AGENT_ALL_IDENTITIES">{{ t("connection.sshAgentKeyAll") }}</SelectItem>
<SelectItem v-for="identity in sshAgentIdentityOptions" :key="identity.fingerprint" :value="identity.fingerprint" :title="identity.fingerprint">
{{ sshAgentIdentityLabel(identity) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :title="t('connection.sshAgentKeyRefresh')" :disabled="selectedSshLayer.enabled === false || sshAgentIdentitiesLoading" @click="loadSshAgentIdentities(selectedSshLayer.ssh_agent_sock_path || '')">
<Loader2 v-if="sshAgentIdentitiesLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
</Button>
</div>
</div>
<div v-if="selectedSshLayer.auth_method === 'agent'" class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 text-xs text-muted-foreground">
<p>{{ t("connection.sshAgentKeyHint") }}</p>
<p v-if="sshAgentIdentitiesError" class="mt-1">{{ sshAgentIdentitiesError }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 cursor-pointer">
@@ -10923,6 +11107,13 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.sshExposeLan") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sshLocalPort") }}</Label>
<div class="col-span-3 space-y-1">
<Input :model-value="selectedSshLayer.local_port ?? ''" type="number" min="1" max="65535" step="1" :placeholder="t('connection.sshLocalPortPlaceholder')" :disabled="selectedSshLayer.enabled === false" @update:model-value="updateSelectedSshLocalPort" />
<p class="text-xs text-muted-foreground">{{ t("connection.sshLocalPortHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<label class="col-span-3 flex items-start gap-2 cursor-pointer">
@@ -22,9 +22,11 @@ const props = withDefaults(
triggerClass?: HTMLAttributes["class"];
triggerIconClass?: HTMLAttributes["class"];
listClass?: HTMLAttributes["class"];
hideTitle?: boolean;
}>(),
{
disabled: false,
hideTitle: false,
triggerIconClass: "size-4 text-muted-foreground",
},
);
@@ -154,7 +156,7 @@ defineExpose({
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button type="button" variant="ghost" :disabled="disabled" :title="selectedLabel" :class="cn('h-6 w-auto max-w-56 min-w-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
<Button type="button" variant="ghost" :disabled="disabled" :title="hideTitle ? undefined : selectedLabel" :class="cn('h-6 w-auto max-w-56 min-w-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
<slot name="trigger-label" :value="modelValue" :label="selectedLabel">
<span class="truncate">{{ selectedLabel }}</span>
</slot>
@@ -7,7 +7,7 @@ import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Loader2, Plus, Trash2, FolderOpen } from "@lucide/vue";
import { Loader2, Plus, Trash2, FolderOpen, RefreshCw } from "@lucide/vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
@@ -16,7 +16,7 @@ import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/l
import { applySshAuthMethod } from "@/lib/connection/sshAuthMethod";
import { stripInvisibleCharactersFromLayer } from "@/lib/connection/credentialSanitizer";
import * as api from "@/lib/backend/api";
import type { SshConfigHostEntry, TunnelProfile } from "@/types/database";
import type { SshAgentIdentity, SshConfigHostEntry, TunnelProfile } from "@/types/database";
import { translateBackendError } from "@/i18n/backend-errors";
const { t } = useI18n();
@@ -81,6 +81,58 @@ function updateSelectedSshHost(value: string | number) {
void loadSshConfigHosts();
const SSH_AGENT_ALL_IDENTITIES = "__all__";
const sshAgentIdentities = ref<SshAgentIdentity[]>([]);
const sshAgentIdentitiesLoading = ref(false);
const sshAgentIdentitiesError = ref("");
let sshAgentIdentitiesTimer: ReturnType<typeof setTimeout> | undefined;
let sshAgentIdentitiesRequest = 0;
const sshAgentIdentityOptions = computed(() => {
const fingerprint = selectedSsh.value?.ssh_agent_key_fingerprint || "";
if (!fingerprint || sshAgentIdentities.value.some((identity) => identity.fingerprint === fingerprint)) {
return sshAgentIdentities.value;
}
return [{ fingerprint, algorithm: "", comment: "" }, ...sshAgentIdentities.value];
});
function sshAgentIdentityLabel(identity: SshAgentIdentity) {
if (!identity.comment) return identity.fingerprint;
return identity.algorithm ? `${identity.comment} (${identity.algorithm})` : identity.comment;
}
function updateSelectedSshAgentKeyFingerprint(value: unknown) {
if (!selectedSsh.value) return;
const fingerprint = String(value);
selectedSsh.value.ssh_agent_key_fingerprint = fingerprint === SSH_AGENT_ALL_IDENTITIES ? "" : fingerprint;
}
async function loadSshAgentIdentities(sshAgentSockPath: string) {
const request = ++sshAgentIdentitiesRequest;
sshAgentIdentitiesLoading.value = true;
sshAgentIdentitiesError.value = "";
try {
const identities = await api.listSshAgentIdentities(sshAgentSockPath);
if (request === sshAgentIdentitiesRequest) sshAgentIdentities.value = identities;
} catch (cause) {
if (request === sshAgentIdentitiesRequest) {
sshAgentIdentitiesError.value = t("connection.sshAgentKeyLoadFailed", { error: cause instanceof Error ? cause.message : String(cause) });
}
} finally {
if (request === sshAgentIdentitiesRequest) sshAgentIdentitiesLoading.value = false;
}
}
watch(
() => (selectedSsh.value?.auth_method === "agent" ? selectedSsh.value.ssh_agent_sock_path || "" : null),
(sshAgentSockPath) => {
if (sshAgentSockPath === null) return;
clearTimeout(sshAgentIdentitiesTimer);
sshAgentIdentitiesTimer = setTimeout(() => void loadSshAgentIdentities(sshAgentSockPath), 400);
},
{ immediate: true },
);
function invalidateProfileTest() {
testGuard.invalidate();
isTesting.value = false;
@@ -311,6 +363,35 @@ async function testSelected() {
<Label class="text-xs">{{ t("connection.sshAgentSockPath") }}</Label>
<Input v-model="selectedSsh.ssh_agent_sock_path" class="col-span-3" :placeholder="t('connection.sshAgentSockPathPlaceholder')" />
</div>
<div v-if="selectedSsh.auth_method === 'agent'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-xs">{{ t("connection.sshAgentKey") }}</Label>
<div class="col-span-3 flex min-w-0 items-center gap-1">
<div class="min-w-0 flex-1">
<Select :model-value="selectedSsh.ssh_agent_key_fingerprint || SSH_AGENT_ALL_IDENTITIES" @update:model-value="updateSelectedSshAgentKeyFingerprint">
<SelectTrigger class="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="SSH_AGENT_ALL_IDENTITIES">{{ t("connection.sshAgentKeyAll") }}</SelectItem>
<SelectItem v-for="identity in sshAgentIdentityOptions" :key="identity.fingerprint" :value="identity.fingerprint" :title="identity.fingerprint">
{{ sshAgentIdentityLabel(identity) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" :title="t('connection.sshAgentKeyRefresh')" :disabled="sshAgentIdentitiesLoading" @click="loadSshAgentIdentities(selectedSsh.ssh_agent_sock_path || '')">
<Loader2 v-if="sshAgentIdentitiesLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
</Button>
</div>
</div>
<div v-if="selectedSsh.auth_method === 'agent'" class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 text-xs text-muted-foreground">
<p>{{ t("connection.sshAgentKeyHint") }}</p>
<p v-if="sshAgentIdentitiesError" class="mt-1">{{ sshAgentIdentitiesError }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex cursor-pointer items-center gap-2">
@@ -1,14 +1,15 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ConnectionConfig } from "@/types/database";
import { parseConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink";
import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink";
const visibleTab = vi.hoisted(() => ({ value: "connection" }));
const { store, settings, backend } = vi.hoisted(() => ({
const { store, settings, backend, actualStoreHolder } = vi.hoisted(() => ({
store: {
connectionGroupOptions: [],
newConnectionGroupId: null,
@@ -26,9 +27,11 @@ const { store, settings, backend } = vi.hoisted(() => ({
applyGlobalTimeouts: vi.fn((config) => config),
getConfig: vi.fn(),
},
actualStoreHolder: { value: null as unknown },
settings: {
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0, connectTimeoutInheritConnectionIds: [], queryTimeoutInheritConnectionIds: [], deepLinkConnectionConfirmMode: "always" as "always" | "create" | "update" | "all" },
rememberedDatabaseForConnection: vi.fn(() => ""),
initEditorSettings: vi.fn().mockResolvedValue(undefined),
persistEditorSettings: vi.fn(),
updateEditorSettings: vi.fn(),
updateEditorSettingsAndPersist: vi.fn(),
@@ -36,6 +39,8 @@ const { store, settings, backend } = vi.hoisted(() => ({
backend: {
connectDb: vi.fn(),
disconnectDb: vi.fn(),
redisListDatabases: vi.fn(),
listSchemas: vi.fn(),
testConnectionWithInfo: vi.fn(),
listDatabases: vi.fn(),
listPlugins: vi.fn(),
@@ -43,12 +48,23 @@ const { store, settings, backend } = vi.hoisted(() => ({
listJdbcMavenBundles: vi.fn(),
listJdbcLocalBundles: vi.fn(),
listSshConfigHosts: vi.fn(),
listInstalledAgents: vi.fn(),
listInstalledAgentsLocal: vi.fn(),
listenAgentInstallProgress: vi.fn(),
loadConnections: vi.fn().mockResolvedValue([]),
saveConnections: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadTableVGroups: vi.fn().mockResolvedValue({}),
saveTableVGroups: vi.fn().mockResolvedValue(undefined),
loadSidebarLayout: vi.fn().mockResolvedValue({ groups: [], order: [] }),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => actualStoreHolder.value ?? 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 }));
@@ -193,10 +209,34 @@ async function mountDialog(config: ConnectionConfig, url: string, initiallyOpen
return props;
}
async function mountNewDialog(url?: string) {
const props = reactive({ open: true, prefillConfig: url ? parseConnectionDeepLink(url) : null });
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;
}
beforeEach(() => {
vi.clearAllMocks();
actualStoreHolder.value = null;
visibleTab.value = "connection";
i18n.global.locale.value = "en";
settings.editorSettings.deepLinkConnectionConfirmMode = "always";
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
store.updateConnection.mockResolvedValue(undefined);
@@ -206,9 +246,52 @@ beforeEach(() => {
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
(actualStoreHolder.value as { $dispose?: () => void } | null)?.$dispose?.();
actualStoreHolder.value = null;
});
describe("ConnectionDialog deep-link edit confirmation", () => {
it("preserves a protected generic JDBC target when only its name changes", async () => {
const saved = savedConnection({
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: "JDBC",
host: "db.example.test",
port: 2881,
username: "saved-user",
password: "__DBX_SAVED_CREDENTIAL__:synthetic-password",
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-url",
jdbc_driver_class: "example.Driver",
jdbc_driver_paths: ["driver.jar"],
jdbc_dialect: "oceanbase-oracle",
production_badge_scope: undefined,
});
await mountDialog(saved, "");
const name = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.value === saved.name);
expect(name).toBeTruthy();
name!.value = "Renamed JDBC";
name!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(store.updateConnection).toHaveBeenCalledTimes(1);
const submitted = store.updateConnection.mock.calls[0][0];
expect(submitted.name).toBe("Renamed JDBC");
expect(submitted.production_badge_scope).toBeUndefined();
expect(submitted).toMatchObject({
db_type: saved.db_type,
driver_profile: saved.driver_profile,
host: saved.host,
port: saved.port,
username: saved.username,
password: saved.password,
connection_string: saved.connection_string,
jdbc_driver_class: saved.jdbc_driver_class,
jdbc_driver_paths: saved.jdbc_driver_paths,
});
});
it("shows the global Redis preview limit in advanced settings and persists it", async () => {
visibleTab.value = "advanced";
settings.editorSettings.redisValuePreviewLimitKiB = 64;
@@ -311,6 +394,222 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
expect(store.connect).not.toHaveBeenCalled();
});
it("preserves a protected generic JDBC target when only its name changes", async () => {
const saved = savedConnection({
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: "JDBC",
host: "db.example.test",
port: 2881,
username: "saved-user",
password: "__DBX_SAVED_CREDENTIAL__:synthetic-password",
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-url",
jdbc_driver_class: "example.Driver",
jdbc_driver_paths: ["driver.jar"],
jdbc_dialect: "oceanbase-oracle",
production_badge_scope: undefined,
});
await mountDialog(saved, "");
const name = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.value === saved.name);
expect(name).toBeTruthy();
name!.value = "Renamed JDBC";
name!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(store.updateConnection).toHaveBeenCalledTimes(1);
const submitted = store.updateConnection.mock.calls[0][0];
expect(submitted.name).toBe("Renamed JDBC");
expect(submitted.production_badge_scope).toBeUndefined();
expect(submitted).toMatchObject({
db_type: saved.db_type,
driver_profile: saved.driver_profile,
host: saved.host,
port: saved.port,
username: saved.username,
password: saved.password,
connection_string: saved.connection_string,
jdbc_driver_class: saved.jdbc_driver_class,
jdbc_driver_paths: saved.jdbc_driver_paths,
});
});
it.each([true, false] as const)("preserves protected target settings through real Store load and Dialog save (%s DB links)", async (showDatabaseLinks) => {
setActivePinia(createPinia());
const saved = savedConnection({
id: "loaded-protected-jdbc",
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: undefined,
host: "db.example.test",
port: 2881,
username: "saved-user",
password: "__DBX_SAVED_CREDENTIAL__:synthetic-password",
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-url",
jdbc_driver_class: "example.Driver",
jdbc_driver_paths: ["driver.jar"],
url_params: undefined,
show_database_links: showDatabaseLinks,
production_badge_scope: undefined,
});
const untouched = savedConnection({
id: "untouched-protected-jdbc",
name: "Untouched JDBC",
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: undefined,
host: "schema.example.test",
port: 2882,
username: "schema-user",
password: "__DBX_SAVED_CREDENTIAL__:synthetic-schema-password",
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-schema-url",
jdbc_driver_class: "example.SchemaDriver",
jdbc_driver_paths: ["schema-driver.jar"],
url_params: undefined,
show_database_links: undefined,
});
vi.mocked(backend.loadConnections).mockResolvedValue([saved, untouched]);
vi.mocked(backend.loadSidebarLayout).mockResolvedValue({ groups: [], order: [] });
const { useConnectionStore } = await vi.importActual<typeof import("@/stores/connectionStore")>("@/stores/connectionStore");
const actualStore = useConnectionStore();
actualStoreHolder.value = actualStore;
await actualStore.initFromDisk();
const loaded = actualStore.getConfig(saved.id)!;
expect(loaded.production_badge_scope).toBeUndefined();
expect(loaded.driver_label).toBe("jdbc");
expect(loaded.url_params).toBe("");
expect(loaded.show_database_links).toBe(showDatabaseLinks);
expect(actualStore.getConfig(untouched.id)?.show_database_links).toBeUndefined();
await mountDialog(loaded, "");
const name = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.value === loaded.name);
expect(name).toBeTruthy();
name!.value = "Renamed JDBC";
name!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(backend.saveConnections).toHaveBeenCalledOnce();
const persisted = vi.mocked(backend.saveConnections).mock.calls[0][0][0];
const persistedUntouched = vi.mocked(backend.saveConnections).mock.calls[0][0].find((config) => config.id === untouched.id)!;
expect(persisted.name).toBe("Renamed JDBC");
expect(persisted.production_badge_scope).toBeUndefined();
expect(persisted.host).toBe("db.example.test");
expect(persisted.port).toBe(2881);
expect(persisted.connection_string).toBe(saved.connection_string);
expect(persisted.password).toBe(saved.password);
expect(persisted.show_database_links).toBe(showDatabaseLinks);
expect(persistedUntouched).toMatchObject({
id: untouched.id,
db_type: untouched.db_type,
driver_profile: untouched.driver_profile,
host: untouched.host,
port: untouched.port,
username: untouched.username,
password: untouched.password,
connection_string: untouched.connection_string,
jdbc_driver_class: untouched.jdbc_driver_class,
jdbc_driver_paths: untouched.jdbc_driver_paths,
});
expect(persistedUntouched.show_database_links).toBeUndefined();
});
it("keeps an explicitly selected badge scope on an unchanged generic JDBC target", async () => {
visibleTab.value = "advanced";
const saved = savedConnection({
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: "JDBC",
host: "db.example.test",
port: 2881,
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-url",
jdbc_driver_class: "example.Driver",
jdbc_driver_paths: ["driver.jar"],
production_badge_scope: undefined,
});
await mountDialog(saved, "");
const trigger = document.querySelector<HTMLButtonElement>("#production-badge-scope");
expect(trigger?.textContent).toContain("Database / schema (default)");
trigger!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((element) => element.textContent?.trim() === "Table / object");
expect(option).toBeTruthy();
option!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ production_badge_scope: "table", host: "db.example.test", port: 2881 }));
});
it("keeps the existing generic JDBC normalization when its URL is replaced", async () => {
const saved = savedConnection({
db_type: "jdbc",
driver_profile: "jdbc",
driver_label: "JDBC",
host: "db.example.test",
port: 2881,
connection_string: "__DBX_SAVED_CREDENTIAL__:synthetic-url",
jdbc_driver_class: "example.Driver",
jdbc_driver_paths: ["driver.jar"],
});
await mountDialog(saved, "");
const url = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.value === saved.connection_string);
expect(url).toBeTruthy();
url!.value = "jdbc:example://new.example.test:9999/db";
url!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
save!.click();
await settle();
expect(store.updateConnection).toHaveBeenCalledWith(
expect.objectContaining({
connection_string: "jdbc:example://new.example.test:9999/db",
host: "",
port: 0,
}),
);
});
it("keeps the generic JDBC new-connection endpoint normalization", async () => {
await mountNewDialog();
const jdbcCategory = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "JDBC connection");
expect(jdbcCategory).toBeTruthy();
jdbcCategory!.click();
await settle();
const jdbcOption = document.querySelector<HTMLButtonElement>('[title="JDBC"]');
expect(jdbcOption).toBeTruthy();
jdbcOption!.click();
await settle();
jdbcOption!.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await settle();
const url = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.placeholder.startsWith("jdbc:"));
expect(url).toBeTruthy();
url!.value = "jdbc:example://host/db";
url!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
expect(save).toBeTruthy();
save!.click();
await settle();
expect(store.addConnection).toHaveBeenCalledWith(
expect.objectContaining({
db_type: "jdbc",
connection_string: "jdbc:example://host/db",
host: "",
port: 0,
}),
null,
);
});
it("applies explicit empty passwords and false TLS values without create defaults", async () => {
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=&ssl=false");
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
@@ -470,4 +769,22 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
}),
);
});
it("applies an id update link without waiting for confirmation when the mode allows updates", async () => {
// 「仅带 id 的更新链接」/「所有连接链接」两种模式下,带 id 的深链不再停在编辑弹窗。
settings.editorSettings.deepLinkConnectionConfirmMode = "update";
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&host=new.example.test");
await settle();
expect(store.updateConnection).toHaveBeenCalledTimes(1);
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ id: "saved-production", host: "new.example.test" }));
});
it("keeps an id update link open for confirmation in the default mode", async () => {
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&host=new.example.test");
await settle();
expect(store.updateConnection).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
// @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/tabs", async () => {
const { defineComponent, h } = await import("vue");
const pass = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
});
const TabsContent = defineComponent({
props: { value: String },
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[] = [];
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();
}
function inputByValue(value: string): HTMLInputElement | null {
return [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.value === value) ?? null;
}
function setInputValue(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event("input"));
}
function saveButton(): HTMLButtonElement | null {
return [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Save") ?? null;
}
async function saveDialog() {
saveButton()?.click();
for (let i = 0; i < 12; i++) await nextTick();
}
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 = "";
});
function postgresConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "pg-1",
name: "PostgreSQL",
db_type: "postgresql",
host: "original.local",
port: 5432,
username: "dbx",
password: "secret-pass",
...overrides,
} as ConnectionConfig;
}
describe("ConnectionDialog whitespace trimming", () => {
it("trims stray whitespace around host and username before saving", async () => {
await mountDialog(postgresConnection());
const hostInput = inputByValue("original.local");
expect(hostInput, "host input should be rendered").toBeTruthy();
setInputValue(hostInput!, " docker.for.mac.host.internal ");
await settle();
await saveDialog();
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ host: "docker.for.mac.host.internal" }));
});
it("keeps the password untouched so surrounding spaces stay significant", async () => {
await mountDialog(postgresConnection({ password: " padded secret " }));
await saveDialog();
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ password: " padded secret " }));
});
it("does not trim the host for file-path based connections", async () => {
await mountDialog({
id: "sqlite-1",
name: "SQLite",
db_type: "sqlite",
host: "/tmp/original.db",
port: 0,
username: "",
password: "",
} as ConnectionConfig);
const pathInput = inputByValue("/tmp/original.db");
expect(pathInput, "file path input should be rendered").toBeTruthy();
setInputValue(pathInput!, "/tmp/ spaced name.db ");
await settle();
await saveDialog();
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ host: "/tmp/ spaced name.db " }));
});
});
@@ -100,10 +100,11 @@ vi.mock("@/components/ui/PasswordInput.vue", async () => {
return {
default: defineComponent({
props: ["modelValue"],
emits: ["update:modelValue"],
setup:
(props, { attrs }) =>
(props, { attrs, emit }) =>
() =>
h("input", { ...attrs, type: "password", value: props.modelValue }),
h("input", { ...attrs, type: "password", value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
}),
};
});
@@ -276,3 +277,46 @@ describe("ConnectionDialog save-password checkbox", () => {
expect(savePasswordCheckbox()).toBeNull();
});
});
function elasticsearchConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return { id: "es-1", name: "Elasticsearch", db_type: "elasticsearch", host: "search.example.com", port: 9243, username: "elastic", password: "basic-password", ssl: true, save_password: true, ...overrides } as ConnectionConfig;
}
function buttonNamed(name: string): HTMLButtonElement {
const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === name);
expect(button, `Expected button ${name}`).toBeTruthy();
return button!;
}
describe("Elasticsearch API key authentication", () => {
it("clears the previous credential on switch, hides username and submits the key in the secret slot", async () => {
await mountDialog(elasticsearchConnection());
expect(buttonNamed("Basic").getAttribute("aria-pressed")).toBe("true");
buttonNamed("API Key").click();
await settle();
const input = document.querySelector<HTMLInputElement>('input[placeholder="Base64(id:api_key)"]')!;
expect(input.type).toBe("password");
expect(input.value).toBe("");
expect(Array.from(document.querySelectorAll("label")).some((label) => label.textContent?.trim() === "User")).toBe(false);
input.value = " c3ludGhldGljOmtleQ== ";
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
buttonNamed("Test").click();
await settle();
expect(backend.testConnectionWithInfo).toHaveBeenCalledWith(expect.objectContaining({ username: "", password: "c3ludGhldGljOmtleQ==", external_config: { authType: "apiKey" } }));
buttonNamed("Save").click();
await settle();
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ username: "", password: "c3ludGhldGljOmtleQ==", external_config: { authType: "apiKey" }, save_password: true }));
});
it("restores the API key mode when editing and clears the key when switching back to Basic", async () => {
await mountDialog(elasticsearchConnection({ username: "", password: "encoded-key", external_config: { authType: "apiKey" }, save_password: false }));
expect(buttonNamed("API Key").getAttribute("aria-pressed")).toBe("true");
expect(document.querySelector<HTMLInputElement>('input[placeholder="Base64(id:api_key)"]')!.value).toBe("encoded-key");
expect(savePasswordCheckbox()!.checked).toBe(false);
buttonNamed("Basic").click();
await settle();
expect(document.querySelector<HTMLInputElement>('input[type="password"]')!.value).toBe("");
expect(document.querySelector('input[placeholder="Base64(id:api_key)"]')).toBeNull();
});
});
@@ -0,0 +1,404 @@
// @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, InstalledPlugin, SshTunnelConfig, TransportLayerConfig } 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 }) =>
() =>
props.open ? h("section", 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 === "transport" ? 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 sshLayer(id: string, extras: Partial<SshTunnelConfig> = {}): TransportLayerConfig {
return { type: "ssh", id, name: `Layer ${id}`, host: `${id}.example.com`, port: 22, user: "root", enabled: true, ...extras };
}
const proxyLayer: TransportLayerConfig = { type: "proxy", id: "proxy", name: "Corporate proxy", enabled: true, proxy_type: "socks5", host: "proxy.example.com", port: 1080 };
function tunnelled(layers: TransportLayerConfig[], overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return { id: "pg-1", name: "Postgres", db_type: "postgres", driver_profile: "postgres", driver_label: "PostgreSQL", host: "db.internal", port: 5432, username: "app", password: "secret", database: "app", save_password: true, transport_layers: layers, ...overrides };
}
function pluginWithProvider(proxyRoute: boolean): InstalledPlugin {
return {
manifest: {
id: "sample.kafka",
name: "Sample Kafka",
drivers: [],
contributions: [{ type: "connection-provider", id: "sample.connection", label: "Sample Kafka", database_type: "sample", fields: [], capabilities: ["connect"], proxy_route: proxyRoute }],
},
compatibility: { compatible: true },
};
}
function pluginConnection(layers: TransportLayerConfig[]): ConnectionConfig {
return { id: "plugin-1", name: "Sample", db_type: "plugin", plugin_id: "sample.kafka", plugin_connection_provider: "sample.connection", plugin_connection_type: "sample", host: "", port: 0, username: "", password: "", transport_layers: layers };
}
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();
}
function localPortInput(): HTMLInputElement | null {
const label = [...document.querySelectorAll("label")].find((element) => element.textContent?.trim() === "Local Port");
return label?.parentElement?.querySelector("input") ?? null;
}
async function typeLocalPort(value: string) {
const input = localPortInput();
expect(input, "the selected SSH layer should render the Local Port input").toBeTruthy();
input!.value = value;
input!.dispatchEvent(new Event("input"));
await settle();
}
async function save() {
[...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save")!.click();
await settle();
}
function savedLayers(): TransportLayerConfig[] {
expect(store.updateConnection).toHaveBeenCalledTimes(1);
return store.updateConnection.mock.calls[0][0].transport_layers;
}
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 SSH fixed local port", () => {
it("shows the saved port with an Auto placeholder and keeps it on save", async () => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })]));
expect(localPortInput()?.value).toBe("4059");
expect(localPortInput()?.getAttribute("placeholder")).toBe("Auto");
await save();
expect(savedLayers()).toEqual([expect.objectContaining({ id: "bastion", local_port: 4059 })]);
});
it.each([
["typing a new port", undefined, "4060", 4060],
["changing the port", 4059, "4060", 4060],
["clearing the port", 4059, "", undefined],
])("%s saves a numeric local_port or leaves it unset", async (_name, initial, typed, expected) => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: initial })]));
await typeLocalPort(typed);
await save();
const layer = savedLayers()[0] as SshTunnelConfig;
expect(layer.local_port).toBe(expected);
expect("local_port" in JSON.parse(JSON.stringify(layer))).toBe(expected !== undefined);
});
it.each([
["NaN", Number.NaN],
["text", "abc"],
])("saves no port for a saved non-numeric %s value instead of failing validation", async (_name, saved) => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: saved as number })]));
await save();
expect(document.body.textContent).not.toContain("Local port must be between 1 and 65535");
const layer = savedLayers()[0] as SshTunnelConfig;
expect(layer.local_port).toBeUndefined();
expect("local_port" in JSON.parse(JSON.stringify(layer))).toBe(false);
});
it("saves no port when the field emits a non-numeric value", async () => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })]));
// A number input sanitizes text to "", so the raw string only reaches the handler through a text input.
localPortInput()!.type = "text";
await typeLocalPort("abc");
await save();
expect(document.body.textContent).not.toContain("Local port must be between 1 and 65535");
expect((savedLayers()[0] as SshTunnelConfig).local_port).toBeUndefined();
});
it.each([
["70000", "70000"],
["0", "0"],
["4059.5", "4059.5"],
])("refuses to save %s as the port instead of dropping it", async (_name, typed) => {
await mountDialog(tunnelled([sshLayer("bastion")]));
await typeLocalPort(typed);
await save();
expect(store.updateConnection).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Layer bastion: Local port must be between 1 and 65535");
});
it.each([
["an earlier enabled layer", [sshLayer("first", { local_port: 4059 }), sshLayer("second")]],
["a layer followed by a proxy", [sshLayer("first", { local_port: 4059 }), proxyLayer]],
])("refuses a port on %s", async (_name, layers) => {
await mountDialog(tunnelled(layers));
await save();
expect(store.updateConnection).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Layer first: Local port only applies to the last enabled layer");
});
it("accepts the port on the last enabled layer when later layers are disabled", async () => {
await mountDialog(tunnelled([sshLayer("first", { local_port: 4059 }), sshLayer("second", { enabled: false })]));
await save();
expect(savedLayers()).toEqual([expect.objectContaining({ id: "first", local_port: 4059 }), expect.objectContaining({ id: "second", enabled: false })]);
});
it("drops an invalid port once its layer is disabled so the backend never receives it", async () => {
await mountDialog(tunnelled([sshLayer("first"), sshLayer("second")]));
await typeLocalPort("70000");
document.querySelector<HTMLInputElement>('.connection-transport-layer-option input[type="checkbox"]')!.click();
await settle();
await save();
const [first] = savedLayers() as SshTunnelConfig[];
expect(first.enabled).toBe(false);
expect(first.local_port).toBeUndefined();
});
it("does not copy the port when a layer is duplicated", async () => {
await mountDialog(tunnelled([sshLayer("first", { local_port: 4059 })]));
[...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Duplicate")!.click();
await settle();
expect(localPortInput()?.value).toBe("");
document.querySelector<HTMLElement>(".connection-transport-layer-option")!.click();
await settle();
expect(localPortInput()?.value).toBe("4059");
});
it.each([
["ClickHouse", { db_type: "clickhouse", driver_profile: "clickhouse", driver_label: "ClickHouse", port: 8123 }],
["Redis Cluster", { db_type: "redis", driver_profile: "redis", driver_label: "Redis", port: 6379, redis_connection_mode: "cluster", redis_cluster_nodes: "10.0.0.1:6379" }],
] as [string, Partial<ConnectionConfig>][])("refuses a port on a %s connection, whose primary path never binds it", async (_name, overrides) => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })], overrides));
await save();
expect(store.updateConnection).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Layer bastion: Local port is not supported for this connection type");
});
it("saves the same connection type without a port", async () => {
await mountDialog(tunnelled([sshLayer("bastion")], { db_type: "clickhouse", driver_profile: "clickhouse", driver_label: "ClickHouse", port: 8123 }));
await save();
expect((savedLayers()[0] as SshTunnelConfig).local_port).toBeUndefined();
});
it("accepts a port on a Redis standalone connection", async () => {
await mountDialog(tunnelled([sshLayer("bastion", { local_port: 4059 })], { db_type: "redis", driver_profile: "redis", driver_label: "Redis", port: 6379, redis_connection_mode: "standalone" }));
await save();
expect(savedLayers()).toEqual([expect.objectContaining({ id: "bastion", local_port: 4059 })]);
});
it("refuses a port on a plugin connection whose provider declares proxy_route", async () => {
backend.listPlugins.mockResolvedValue([pluginWithProvider(true)]);
await mountDialog(pluginConnection([sshLayer("bastion", { local_port: 4059 })]));
await save();
expect(store.updateConnection).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Layer bastion: Local port is not supported for this connection type");
});
it("saves a plugin connection with proxy_route when the layer has no port", async () => {
backend.listPlugins.mockResolvedValue([pluginWithProvider(true)]);
await mountDialog(pluginConnection([sshLayer("bastion")]));
await save();
expect((savedLayers()[0] as SshTunnelConfig).local_port).toBeUndefined();
});
it("accepts a port on a plugin connection whose provider does not declare proxy_route", async () => {
backend.listPlugins.mockResolvedValue([pluginWithProvider(false)]);
await mountDialog(pluginConnection([sshLayer("bastion", { local_port: 4059 })]));
await save();
expect(savedLayers()).toEqual([expect.objectContaining({ id: "bastion", local_port: 4059 })]);
});
it("does not offer a port on a layer that is not SSH", async () => {
await mountDialog(tunnelled([proxyLayer]));
expect(localPortInput()).toBeNull();
});
});
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
import { countCompareSelection, isCompareRowSelected, setCompareSelection, toggleCompareSelection } from "@/lib/dataGrid/dataCompareSelection";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -13,7 +16,7 @@ import { useToast } from "@/composables/useToast";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import { inferCompareKeyColumns, normalizeKeyColumns, sameKeyColumns, type CompareKeyColumnOption, type DataCompareCellValue, type DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare";
import { inferCompareKeyColumns, normalizeKeyColumns, sameKeyColumns, type CompareKeyColumnOption, type DataCompareCellValue, type DataCompareRow, type DataCompareModifiedRow, type DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare";
import {
buildDataCompareSyncPlanTables,
emptyDataCompareSyncPlan,
@@ -25,8 +28,6 @@ import {
type DataCompareTableStatus,
type DataCompareTableTask,
type DiffKind,
type SelectableDataCompareModifiedRow,
type SelectableDataCompareRow,
} from "@/composables/useDataCompareSession";
import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue";
import DataCompareConfigSelector from "@/components/diff/DataCompareConfigSelector.vue";
@@ -40,6 +41,10 @@ import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompar
const PREVIEW_LIMIT_OPTIONS = [50, 100, 200, 500];
const SYNC_EXECUTE_BATCH_SIZE = 500;
const DIFF_ROW_MIN_HEIGHT = 64;
const DIFF_LIST_MAX_HEIGHT = 288;
const SQL_PREVIEW_MAX_CHARACTERS = 8192;
const SQL_PREVIEW_MAX_LINES = 200;
const { t } = useI18n();
const { toast } = useToast();
@@ -82,8 +87,10 @@ const targetSchemas = ref<string[]>([]);
const targetTables = ref<string[]>([]);
const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1]));
const batchResults = ref<DataCompareTableResult[]>([]);
const syncPlan = ref<DataCompareSyncPlan>(emptyDataCompareSyncPlan());
const batchResults = shallowRef<DataCompareTableResult[]>([]);
const syncPlan = shallowRef<DataCompareSyncPlan>(emptyDataCompareSyncPlan());
const sqlPreview = computed(() => createBoundedTextPreview(syncPlan.value.syncSql, { maxCharacters: SQL_PREVIEW_MAX_CHARACTERS, maxLines: SQL_PREVIEW_MAX_LINES }));
const sqlPreviewText = computed(() => (sqlPreview.value.truncated ? `${sqlPreview.value.head}\n…\n${sqlPreview.value.tail}` : sqlPreview.value.head));
const comparing = ref(false);
const planningSync = ref(false);
const compareProgressCurrent = ref(0);
@@ -810,7 +817,7 @@ function hasDiffRows(table: DataCompareTableResult, kind: DiffKind): boolean {
}
function selectedRows(table: DataCompareTableResult, kind: DiffKind): number {
return table.diff[kind].filter((row) => row.selected).length;
return countCompareSelection(table.selection[kind], table.diff[kind].length);
}
function rowsForDisplay(table: DataCompareTableResult, kind: DiffKind) {
@@ -833,37 +840,34 @@ function toggleShowAll(table: DataCompareTableResult, kind: DiffKind) {
function setDiffSelection(kind: DiffKind, selected: boolean) {
if (executionLocked.value) return;
batchResults.value.forEach((table) => {
if (!table.syncEligible) return;
table.diff[kind].forEach((row) => {
row.selected = selected;
});
if (table.syncEligible) setCompareSelection(table.selection[kind], selected);
});
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
}
function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, selected: boolean) {
if (executionLocked.value || !table.syncEligible) return;
table.diff[kind].forEach((row) => {
row.selected = selected;
});
setCompareSelection(table.selection[kind], selected);
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
}
function clearAllSelections() {
if (executionLocked.value) return;
(["added", "removed", "modified"] as DiffKind[]).forEach((kind) => {
batchResults.value.forEach((table) => {
table.diff[kind].forEach((row) => {
row.selected = false;
});
});
batchResults.value.forEach((table) => {
for (const kind of ["added", "removed", "modified"] as DiffKind[]) setCompareSelection(table.selection[kind], false);
});
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
}
function toggleRowSelection(table: DataCompareTableResult, row: SelectableDataCompareRow | SelectableDataCompareModifiedRow) {
// Diff arrays retain their order. A missing target can have duplicate match keys,
// so both virtual items and selection use row positions instead of SQL keys.
function diffRowKey(_row: DataCompareRow | DataCompareModifiedRow, index: number): number {
return index;
}
function toggleRowSelection(table: DataCompareTableResult, kind: DiffKind, index: number) {
if (executionLocked.value || !table.syncEligible) return;
row.selected = !row.selected;
toggleCompareSelection(table.selection[kind], index);
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
}
@@ -977,7 +981,9 @@ async function executeSql() {
database,
sql: syncPlan.value.syncSql,
source: t("production.sourceDataCompare"),
analyzeInBackground: true,
execute: async () => {
if (componentUnmounted || executionInterrupted) return undefined;
syncErrors.value = [];
executeTotal.value = statements.length;
executedCount.value = 0;
@@ -1111,7 +1117,7 @@ function formatRowValues(values: Record<string, DataCompareCellValue>): string {
);
}
function formatModifiedSummary(row: SelectableDataCompareModifiedRow): string {
function formatModifiedSummary(row: DataCompareModifiedRow): string {
return truncateText(row.changes.map((change) => `${change.column}: ${formatValue(change.target)} -> ${formatValue(change.source)}`).join(", "), 220);
}
@@ -1643,25 +1649,22 @@ onBeforeUnmount(() => {
</Button>
</div>
<div class="max-h-72 overflow-auto divide-y">
<button
v-for="row in rowsForDisplay(item, kind)"
:key="`${item.sourceTable}:${kind}:${row.key}`"
type="button"
class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!item.syncEligible"
@click="toggleRowSelection(item, row)"
>
<CheckSquare v-if="row.selected" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<div class="min-w-0 flex-1">
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
<div class="mt-1 text-muted-foreground break-words">
{{ kind === "modified" ? formatModifiedSummary(row as SelectableDataCompareModifiedRow) : formatRowValues((row as SelectableDataCompareRow).values) }}
</div>
</div>
</button>
</div>
<DynamicScroller :items="rowsForDisplay(item, kind)" :min-item-size="DIFF_ROW_MIN_HEIGHT" :key-field="diffRowKey" class="max-h-72 overflow-auto" :style="{ height: `${Math.min(rowsForDisplay(item, kind).length * DIFF_ROW_MIN_HEIGHT, DIFF_LIST_MAX_HEIGHT)}px` }">
<template #default="{ item: row, active, index }">
<DynamicScrollerItem :item="row" :index="index" :active="active" :data-index="index">
<button type="button" class="flex w-full items-start gap-3 px-3 py-2 text-left text-xs hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-60" :disabled="!item.syncEligible" @click="toggleRowSelection(item, kind, index)">
<CheckSquare v-if="isCompareRowSelected(item.selection[kind], index)" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<div class="min-w-0 flex-1">
<div class="font-mono">{{ formatKeyValues(row.keyValues) }}</div>
<div class="mt-1 text-muted-foreground break-words">
{{ kind === "modified" ? formatModifiedSummary(row as DataCompareModifiedRow) : formatRowValues((row as DataCompareRow).values) }}
</div>
</div>
</button>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<div v-if="remainingRows(item, kind) > 0 && !item.showAll[kind]" class="border-t px-3 py-2 text-xs text-muted-foreground">
{{ t("dataCompare.remainingRows", { count: remainingRows(item, kind) }) }}
@@ -1675,8 +1678,9 @@ onBeforeUnmount(() => {
{{ t("dataCompare.planningSync") }}
</div>
<div v-else-if="syncPlan.syncSql.trim()" class="space-y-1">
<p v-if="sqlPreview.truncated" class="text-xs text-muted-foreground">{{ t("dangerDialog.previewTruncated", { lines: sqlPreview.omittedLines.toLocaleString(), characters: sqlPreview.omittedCharacters.toLocaleString() }) }}</p>
<Label class="text-xs font-medium">{{ t("diff.generatedSql") }}</Label>
<textarea :value="syncPlan.syncSql" readonly class="w-full h-48 rounded-[6px] border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring" />
<textarea :value="sqlPreviewText" readonly class="w-full h-48 rounded-[6px] border bg-muted/20 p-3 font-mono text-xs resize-none focus:outline-none focus:ring-1 focus:ring-ring" />
</div>
<div v-else-if="differentTableCount === 0 && failedTableCount === 0" class="text-sm text-muted-foreground">
{{ t("dataCompare.noDifferences") }}
@@ -19,18 +19,20 @@ import {
reconcileSchemaDiffRoutineMappings,
reconcileSchemaDiffSelectedRoutines,
schemaDiffRoutineKey,
schemaDiffRoutineKeyFromFunction,
SCHEMA_DIFF_UNRESTRICTED_ROUTINE_LIMIT,
type SchemaDiffRoutineMatch,
} from "@/lib/schema/schemaDiffRoutine";
import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
import { schemaDiffRoutineObjectTypes, schemaDiffRoutineObjectTypesIntersection, supportsSchemaDiffRoutines } from "@/lib/database/databaseObjectCapabilities";
import { schemaDiffRoutineObjectTypes, schemaDiffRoutineObjectTypesIntersection, supportsSchemaDiffRoutines, schemaDiffRoutineProductType } from "@/lib/database/databaseObjectCapabilities";
import { schemaDiffEngineDatabaseType } from "@/lib/schema/schemaDiff";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, Trash2, X } from "@lucide/vue";
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping, OracleRoutineKind, OracleRoutineIdentity } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
@@ -75,6 +77,7 @@ const emit = defineEmits<{
(e: "deleteHistoryConfig", configId: string): void;
(e: "update:selectedTables", value?: string[]): void;
(e: "update:selectedRoutines", value?: string[]): void;
(e: "update:routineSelection", value: { objectKinds: OracleRoutineKind[]; selectedObjects?: Pick<OracleRoutineIdentity, "name" | "kind">[] }): void;
(e: "update:compareScope", value: { tables?: boolean; views?: boolean; functions?: boolean }): void;
}>();
@@ -266,7 +269,12 @@ const tableListCoordinator = createSchemaDiffTableListCoordinator({
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
const tablesCompareEnabled = computed(() => !!props.options.tables);
const routinesCompareEnabled = computed(() => !!props.options.functions);
const canEnableRoutinesCompare = computed(() => schemaDiffRoutineObjectTypesIntersection(sourceDbType.value as DatabaseType | undefined, targetDbType.value as DatabaseType | undefined).length > 0);
const sourceRoutineType = computed(() => schemaDiffRoutineProductType(sourceDbType.value, schemaDiffEngineDatabaseType(sourceConfig.value)));
const targetRoutineType = computed(() => schemaDiffRoutineProductType(targetDbType.value, schemaDiffEngineDatabaseType(targetConfig.value)));
const availableRoutineKinds = computed(() => schemaDiffRoutineObjectTypesIntersection(sourceRoutineType.value, targetRoutineType.value, ["PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"]));
const routineKinds = computed(() => props.options.routineObjectKinds ?? schemaDiffRoutineObjectTypesIntersection(sourceRoutineType.value, targetRoutineType.value));
const typedRoutines = computed(() => props.options.routineObjectKinds !== undefined);
const canEnableRoutinesCompare = computed(() => availableRoutineKinds.value.length > 0);
/** User preference AND supported pair — drives compare/canCompare/Switch display. */
const effectiveRoutinesEnabled = computed(() => routinesCompareEnabled.value && canEnableRoutinesCompare.value);
@@ -295,8 +303,59 @@ const targetRoutineListLoaded = ref(false);
const restrictRoutines = ref(false);
const localSelectedRoutines = ref<string[]>([]);
const loadingRoutines = ref(false);
type ProgramSelection = Pick<OracleRoutineIdentity, "name" | "kind">;
const routineIdentities = { source: ref<Record<string, ProgramSelection>>({}), target: ref<Record<string, ProgramSelection>>({}) };
const routineRequests = { source: 0, target: 0 };
for (const side of ["source", "target"] as const) {
watch(
() => [side === "source" ? props.sourceConnectionId : props.targetConnectionId, side === "source" ? props.sourceDatabase : props.targetDatabase, side === "source" ? props.sourceSchema : props.targetSchema, JSON.stringify(props.options.routineObjectKinds)],
() => {
routineRequests[side]++;
routineIdentities[side].value = {};
if (side === "source") sourceRoutineList.value = [];
else {
targetRoutineList.value = [];
targetRoutineListLoaded.value = false;
}
},
{ flush: "sync" },
);
}
const exactRoutineKey = (identity: ProgramSelection) => schemaDiffRoutineKeyFromFunction({ name: identity.name, function_type: identity.kind, arguments: "" }, true);
const selectedRoutineKeys = computed(() => (typedRoutines.value ? props.options.selectedRoutineObjects?.map(exactRoutineKey) : props.selectedRoutines));
const routineLabels = computed<Record<string, string>>(() => {
if (!typedRoutines.value) return {};
const identities = { ...routineIdentities.target.value, ...routineIdentities.source.value };
for (const identity of props.options.selectedRoutineObjects ?? []) identities[exactRoutineKey(identity)] = identity;
return Object.fromEntries(Object.entries(identities).map(([key, identity]) => [key, `${identity.kind.replaceAll("_", " ")} ${identity.name}`]));
});
const canConfigureRoutineSelection = computed(() => isTableIdentityReady("source") && supportsSchemaDiffRoutines(sourceDbType.value as DatabaseType));
function emitRoutineSelection(keys: string[] | undefined) {
if (!typedRoutines.value) {
emit("update:selectedRoutines", keys);
return;
}
emit("update:routineSelection", {
objectKinds: [...routineKinds.value],
selectedObjects: keys?.flatMap((key) => {
const identity = routineIdentities.source.value[key];
return identity ? [{ ...identity }] : [];
}),
});
}
function toggleRoutineKind(kind: OracleRoutineKind, enabled: boolean) {
const kinds = availableRoutineKinds.value.filter((candidate) => (candidate === kind ? enabled : routineKinds.value.includes(candidate)));
const selectedObjects = restrictRoutines.value
? localSelectedRoutines.value.flatMap((key) => {
const identity = routineIdentities.source.value[key];
return identity && kinds.includes(identity.kind) ? [{ ...identity }] : [];
})
: undefined;
emit("update:routineSelection", { objectKinds: kinds, selectedObjects });
}
const canConfigureRoutineSelection = computed(() => isTableIdentityReady("source") && supportsSchemaDiffRoutines(sourceRoutineType.value));
const activeRoutineMappings = computed(() => identitySchemaDiffRoutineMappings(props.options?.routineMappings ?? []));
const routineMatches = computed<SchemaDiffRoutineMatch[]>(() => buildSchemaDiffRoutineMatches(localSelectedRoutines.value, targetRoutineList.value, activeRoutineMappings.value));
const matchedRoutineCount = computed(() => routineMatches.value.filter((match) => match.targetRoutine).length);
@@ -313,7 +372,7 @@ function reconcileRoutineMappings(selectedRoutines: string[], targetRoutines = t
}
watch(
() => props.selectedRoutines,
() => selectedRoutineKeys.value,
(value) => {
restrictRoutines.value = Array.isArray(value);
localSelectedRoutines.value = value && Array.isArray(value) ? [...value] : [];
@@ -333,7 +392,7 @@ watch(
function handleToggleRestrictRoutines(enabled: boolean) {
restrictRoutines.value = enabled;
emit("update:selectedRoutines", enabled ? [...localSelectedRoutines.value] : undefined);
emitRoutineSelection(enabled ? [...localSelectedRoutines.value] : undefined);
if (!enabled) emitRoutineMappings([]);
else reconcileRoutineMappings(localSelectedRoutines.value);
}
@@ -341,7 +400,7 @@ function handleToggleRestrictRoutines(enabled: boolean) {
function handleUpdateSelectedRoutines(value: string[]) {
localSelectedRoutines.value = value;
if (!restrictRoutines.value) return;
emit("update:selectedRoutines", [...value]);
emitRoutineSelection([...value]);
reconcileRoutineMappings(value);
}
@@ -350,24 +409,31 @@ function handleRemoveSelectedRoutine(sourceRoutine: string) {
}
function clearUnavailableRoutineSelection() {
routineRequests.source++;
routineRequests.target++;
routineIdentities.source.value = {};
routineIdentities.target.value = {};
sourceRoutineList.value = [];
targetRoutineList.value = [];
targetRoutineListLoaded.value = false;
if (props.selectedRoutines === undefined && !restrictRoutines.value && localSelectedRoutines.value.length === 0) {
if (selectedRoutineKeys.value === undefined && !restrictRoutines.value && localSelectedRoutines.value.length === 0) {
emitRoutineMappings([]);
return;
}
restrictRoutines.value = false;
localSelectedRoutines.value = [];
emit("update:selectedRoutines", undefined);
emitRoutineSelection(undefined);
emitRoutineMappings([]);
}
async function loadRoutineList(side: "source" | "target") {
const request = ++routineRequests[side];
const exactKinds = typedRoutines.value;
const requestedKinds = exactKinds ? [...routineKinds.value] : undefined;
const connectionId = side === "source" ? props.sourceConnectionId : props.targetConnectionId;
const database = side === "source" ? props.sourceDatabase : props.targetDatabase;
const schema = side === "source" ? props.sourceSchema : props.targetSchema;
const dbType = side === "source" ? sourceDbType.value : targetDbType.value;
const dbType = side === "source" ? sourceRoutineType.value : targetRoutineType.value;
if (!isTableIdentityReady(side) || !supportsSchemaDiffRoutines(dbType as DatabaseType)) {
if (side === "source") sourceRoutineList.value = [];
else {
@@ -376,23 +442,32 @@ async function loadRoutineList(side: "source" | "target") {
}
return;
}
const isCurrent = metadataRequestIsCurrent(side);
const endpointIsCurrent = metadataRequestIsCurrent(side);
const isCurrent = () => endpointIsCurrent() && request === routineRequests[side] && JSON.stringify(requestedKinds) === JSON.stringify(typedRoutines.value ? routineKinds.value : undefined);
try {
loadingRoutines.value = true;
await store.ensureConnected(connectionId);
if (!isCurrent()) return;
// Names/signatures only — avoid listFunctions N+1 source fetch on the config step.
const kinds = schemaDiffRoutineObjectTypes(dbType as DatabaseType);
const kinds = schemaDiffRoutineObjectTypes(dbType as DatabaseType, requestedKinds);
const objects = kinds.length === 0 ? [] : await api.listObjects(connectionId, database, schema, kinds);
if (!isCurrent()) return;
const keys = objects.map((object) => schemaDiffRoutineKey(object.name, object.signature ?? ""));
const identities: Record<string, ProgramSelection> = {};
const keys = objects.map((object) => {
const kind = (object.object_type ?? "").replaceAll(" ", "_").toUpperCase() as OracleRoutineKind;
if (exactKinds && !kinds.includes(kind)) throw new Error("Program listing returned an unexpected object kind");
const key = exactKinds ? exactRoutineKey({ name: object.name, kind }) : schemaDiffRoutineKey(object.name, object.signature ?? "");
if (kinds.includes(kind)) identities[key] = { name: object.name, kind };
return key;
});
routineIdentities[side].value = identities;
if (side === "source") {
sourceRoutineList.value = keys;
if (restrictRoutines.value) {
const next = reconcileSchemaDiffSelectedRoutines(localSelectedRoutines.value, keys);
if (next.length !== localSelectedRoutines.value.length || next.some((key, index) => key !== localSelectedRoutines.value[index])) {
localSelectedRoutines.value = next;
emit("update:selectedRoutines", [...next]);
emitRoutineSelection([...next]);
}
}
} else {
@@ -413,7 +488,7 @@ async function loadRoutineList(side: "source" | "target") {
}
watch(
() => [restrictRoutines.value, effectiveRoutinesEnabled.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value] as const,
() => [restrictRoutines.value, effectiveRoutinesEnabled.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceRoutineType.value, JSON.stringify(props.options.routineObjectKinds)] as const,
([restrictEnabled, routinesEnabled]) => {
if (!canConfigureRoutineSelection.value) {
clearUnavailableRoutineSelection();
@@ -426,9 +501,9 @@ watch(
);
watch(
() => [restrictRoutines.value, localSelectedRoutines.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value] as const,
() => [restrictRoutines.value, localSelectedRoutines.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetRoutineType.value, JSON.stringify(props.options.routineObjectKinds)] as const,
([enabled, selectedCount]) => {
if (!enabled || selectedCount === 0 || !isTableIdentityReady("target") || !supportsSchemaDiffRoutines(targetDbType.value as DatabaseType)) {
if (!enabled || selectedCount === 0 || !isTableIdentityReady("target") || !supportsSchemaDiffRoutines(targetRoutineType.value)) {
if (!enabled) {
targetRoutineList.value = [];
targetRoutineListLoaded.value = false;
@@ -443,10 +518,10 @@ watch(
watch(
() => ({
source: [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] as const,
selectedRoutines: props.selectedRoutines,
selectedRoutines: selectedRoutineKeys.value,
}),
(current, previous) => {
if (!isTableIdentityReady("source") || !supportsSchemaDiffRoutines(sourceDbType.value as DatabaseType)) {
if (!isTableIdentityReady("source") || !supportsSchemaDiffRoutines(sourceRoutineType.value)) {
clearUnavailableRoutineSelection();
return;
}
@@ -471,7 +546,7 @@ function handleRoutinesCompareEnabled(enabled: boolean) {
const unrestrictedRoutineLoadTooLarge = computed(() => {
if (!effectiveRoutinesEnabled.value || restrictRoutines.value) return false;
return isSchemaDiffUnrestrictedRoutineLoadTooLarge(sourceRoutineList.value.length, props.selectedRoutines);
return isSchemaDiffUnrestrictedRoutineLoadTooLarge(sourceRoutineList.value.length, selectedRoutineKeys.value);
});
const canCompare = computed(() => {
@@ -480,7 +555,7 @@ const canCompare = computed(() => {
if (!tablesEnabled && !routinesEnabled) return false;
if (unrestrictedRoutineLoadTooLarge.value) return false;
const hasSelectedTables = !tablesEnabled || props.selectedTables === undefined || props.selectedTables.length > 0;
const hasSelectedRoutines = !routinesEnabled || props.selectedRoutines === undefined || props.selectedRoutines.length > 0;
const hasSelectedRoutines = !routinesEnabled || (routineKinds.value.length > 0 && (selectedRoutineKeys.value === undefined || selectedRoutineKeys.value.length > 0));
// Unmapped source tables are not a blocker: they flow through the comparison as
// source-only tables (CREATE TABLE in the target), matching the pre-mapping behavior.
const hasValidTableMappings = !tablesEnabled || !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target");
@@ -945,7 +1020,13 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
<div class="space-y-1.5 border-t border-border/60 pt-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<Switch size="sm" :model-value="effectiveRoutinesEnabled" :disabled="!canEnableRoutinesCompare" :aria-label="t('diff.compareRoutinesSwitch')" @update:model-value="handleRoutinesCompareEnabled(Boolean($event))" />
<Switch
size="sm"
:model-value="typedRoutines ? routinesCompareEnabled : effectiveRoutinesEnabled"
:disabled="!canEnableRoutinesCompare && !(typedRoutines && routinesCompareEnabled)"
:aria-label="t('diff.compareRoutinesSwitch')"
@update:model-value="handleRoutinesCompareEnabled(Boolean($event))"
/>
<Label class="text-xs font-medium" :class="!canEnableRoutinesCompare ? 'text-muted-foreground' : ''">{{ t("diff.routineSelection") }}</Label>
</div>
<Button v-if="effectiveRoutinesEnabled && (restrictRoutines || canConfigureRoutineSelection)" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="loadingRoutines" @click="handleToggleRestrictRoutines(!restrictRoutines)">
@@ -959,6 +1040,12 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
{{ t("diff.routineCompareDisabled") }}
</div>
<template v-else>
<div v-if="availableRoutineKinds.includes('PACKAGE')" class="flex flex-wrap gap-x-4 gap-y-2">
<label v-for="kind in availableRoutineKinds" :key="kind" class="flex items-center gap-2 text-xs">
<input type="checkbox" class="accent-primary" :data-program-kind="kind" :checked="routineKinds.includes(kind)" :disabled="loadingRoutines" @change="toggleRoutineKind(kind, ($event.target as HTMLInputElement).checked)" />
{{ kind.replaceAll("_", " ") }}
</label>
</div>
<div v-if="!restrictRoutines" class="text-[11px] text-muted-foreground">
{{ !isTableIdentityReady("source") ? t("diff.routineSelectionNeedSource") : t("diff.routineSelectionUnrestricted") }}
</div>
@@ -971,6 +1058,8 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
:model-value="localSelectedRoutines"
@update:model-value="handleUpdateSelectedRoutines"
:tables="sourceRoutineList"
:disabled="loadingRoutines"
:labels="typedRoutines ? routineLabels : undefined"
:title="t('diff.sourceRoutines')"
:search-placeholder="t('diff.searchSourceRoutines')"
:empty-text="t('diff.noRoutines')"
@@ -998,9 +1087,9 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
</thead>
<tbody>
<tr v-for="match in routineMatches" :key="match.sourceRoutine" class="border-b last:border-b-0">
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.sourceRoutine">{{ match.sourceRoutine }}</td>
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.targetRoutine || undefined">
<span v-if="match.targetRoutine">{{ match.targetRoutine }}</span>
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="routineLabels[match.sourceRoutine] ?? match.sourceRoutine">{{ routineLabels[match.sourceRoutine] ?? match.sourceRoutine }}</td>
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.targetRoutine ? (routineLabels[match.targetRoutine] ?? match.targetRoutine) : undefined">
<span v-if="match.targetRoutine">{{ routineLabels[match.targetRoutine] ?? match.targetRoutine }}</span>
<span v-else class="text-muted-foreground">—</span>
</td>
<td class="px-2 py-1.5" :class="matchStatusClass(match.kind)">
@@ -9,6 +9,11 @@ import { useTheme } from "@/composables/useTheme";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { Splitpanes, Pane } from "splitpanes";
import { oracleRoutineActiveStep, oracleRoutineDeploymentEntries, routineIdentityKey, type OracleRoutineDeployment } from "@/lib/schema/oracleRoutineDeploy";
import { oracleRoutineOperationVerified } from "@/lib/table/oracleRoutineWrite";
import { scalarPositionToUtf16Offset } from "@/lib/sql/errorPosition";
import type { OracleRoutineOperation, OracleRoutinePlanDraft } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
import {
schemaDiffObjectSelectionState,
schemaDiffReviewAlert,
@@ -54,6 +59,9 @@ const props = defineProps<{
showIgnoreForeignKeyChecks?: boolean;
/** Skip failed statements and keep running the rest of the deploy script. */
continueOnError?: boolean;
databaseType?: DatabaseType;
routineDeployment?: OracleRoutineDeployment | null;
packageTransfer?: boolean;
}>();
const emit = defineEmits<{
@@ -63,12 +71,104 @@ const emit = defineEmits<{
"update:continueOnError": [enabled: boolean];
back: [];
deploy: [];
"select-routine": [objectId: string];
"validate-routines": [];
"refresh-routines": [];
"observe-routine": [operationId: string];
"recover-routine": [operationId: string];
"include-package-companion": [objectId: string, source: "source" | "target"];
"update-package-options": [objectId: string, options: Pick<OracleRoutinePlanDraft, "packageBodySource" | "packageContract" | "confirmPackagePair">];
"review-package-target": [objectId: string];
}>();
const editorContainer = ref<HTMLDivElement>();
const editorView = shallowRef<any>(null);
const isEditorReady = ref(false);
const selectedObjectId = ref<string | null>(null);
let editorDisposed = false;
let readOnlyCompartment: any;
const routineDraft = computed(() => props.routineDeployment?.drafts.find((draft) => draft.objectId === props.routineDeployment?.selectedObjectId));
const routineStep = computed(() => (props.routineDeployment ? oracleRoutineActiveStep(props.routineDeployment) : undefined));
const routineEntries = computed(() => (props.routineDeployment ? oracleRoutineDeploymentEntries(props.routineDeployment) : []));
const routineIdentity = computed(() => routineStep.value?.identity ?? routineDraft.value?.identity);
const routineOriginals = computed(() => {
if (routineStep.value) return { source: routineStep.value.sourceOriginal, target: routineStep.value.targetOriginal };
const draft = routineDraft.value;
const source = props.routineDeployment?.source;
const target = props.routineDeployment?.target;
return {
source: source?.objects.find((object) => object.version.identity.owner === source.endpoint.schema && object.version.identity.name === (draft?.sourceName ?? draft?.identity.name) && object.version.identity.kind === draft?.identity.kind)?.source,
target: target?.objects.find((object) => draft && routineIdentityKey(object.version.identity) === routineIdentityKey(draft.identity))?.source,
};
});
const packageDraft = computed(() => (!props.routineDeployment?.recoveryPlan && !editorReadOnly.value && (routineDraft.value?.identity.kind === "PACKAGE" || routineDraft.value?.identity.kind === "PACKAGE_BODY") ? routineDraft.value : undefined));
const packagePeer = computed(() =>
props.routineDeployment?.drafts.find(
(draft) => draft.included && draft.identity.owner === packageDraft.value?.identity.owner && draft.identity.name === packageDraft.value?.identity.name && draft.identity.kind !== packageDraft.value?.identity.kind && (draft.identity.kind === "PACKAGE" || draft.identity.kind === "PACKAGE_BODY"),
),
);
function packageMaterial(source: "source" | "target", kind: "PACKAGE" | "PACKAGE_BODY") {
const snapshot = props.routineDeployment?.[source];
const name = source === "source" ? (packageDraft.value?.sourceName ?? packageDraft.value?.identity.name) : packageDraft.value?.identity.name;
return snapshot?.objects.find((object) => object.version.identity.owner === snapshot.endpoint.schema && object.version.identity.name === name && object.version.identity.kind === kind && object.sourceComplete && object.source);
}
const routineRecord = computed(() => (props.routineDeployment?.recoveryPlan ? routineStep.value?.operation : routineDraft.value?.operation));
const routineCompanion = computed(() => routineRecord.value?.readback?.packageCompanion);
const routineCompanionStatus = computed(() => {
const companion = routineCompanion.value;
if (!companion) return "";
if (companion.exists === false) return t("objects.routineReadbackMissing");
if (companion.state === "invalid") return t("objects.invalid");
if (companion.issues.length || (companion.state !== "present" && companion.state !== "verified")) return t("diff.routineUnverified");
return companion.version?.status ?? t("diff.routineUnverified");
});
const routineHistory = computed(() => {
const records = props.routineDeployment?.records.filter((record) => routineDraft.value && routineIdentityKey(record.identity) === routineIdentityKey(routineDraft.value.identity)) ?? [];
return routineRecord.value && !records.some((record) => record.operationId === routineRecord.value?.operationId) ? [...records, routineRecord.value] : records;
});
const editorReadOnly = computed(() => !!props.routineDeployment && (props.executing || !!props.routineDeployment.recoveryPlan || (!!routineDraft.value?.operation && oracleRoutineOperationVerified(routineDraft.value.operation))));
function routineStatus(record?: OracleRoutineOperation | null, compact = false): string {
if (!record) return t("diff.routineNeedsValidation");
if (props.routineDeployment?.pendingOperationId === record.operationId || record.lifecycle === "running" || record.lifecycle === "unproven") return t("diff.routinePending");
if (oracleRoutineOperationVerified(record)) return t("diff.routineVerified");
if (record.lifecycle === "prepared") return t("diff.routinePrepared");
if (record.readback?.state === "invalid") return t(compact ? "objects.invalid" : "objects.routineReadbackInvalid");
return t("diff.routineUnverified");
}
async function locateOffset(offset: number) {
if (!editorView.value) return;
const { EditorView } = await import("@codemirror/view");
if (!editorView.value || editorDisposed) return;
editorView.value.dispatch({ selection: { anchor: offset }, effects: EditorView.scrollIntoView(offset, { y: "center" }) });
editorView.value.focus();
}
function locateIssue(line: number, column: number) {
void locateOffset(scalarPositionToUtf16Offset(effectiveSql.value, line, column));
}
function compilerOffset(line: number, column: number): number | undefined {
if (line < 1 || column < 1) return;
if (!routineStep.value || effectiveSql.value !== routineStep.value.sql || routineRecord.value?.readback?.definitionMatches !== true) return;
const source = routineRecord.value.readback.source;
if (!source) return;
// ALL_ERRORS positions start at the stored program unit, not CREATE.
// Only map the known generated prefix; retain the database coordinates when a
// more complicated representation cannot be aligned safely.
const prefix = /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?/i.exec(source);
if (!prefix) return;
const unit = source.slice(prefix[0].length).trimEnd();
const terminated = unit.endsWith(";") ? unit : `${unit};`;
const offset = scalarPositionToUtf16Offset(terminated, line, column);
// The stored unit can omit the declaration's owner while the approved draft
// includes it. Map only the unchanged suffix, never guess inside that header.
let suffix = 0;
while (suffix < Math.min(terminated.length, effectiveSql.value.length) && terminated[terminated.length - suffix - 1] === effectiveSql.value[effectiveSql.value.length - suffix - 1]) suffix++;
if (offset < terminated.length - suffix) return;
return effectiveSql.value.length - terminated.length + offset;
}
// Parse object positions in deploySql
const objectPositions = computed(() => {
@@ -94,6 +194,10 @@ const objectPositions = computed(() => {
});
async function handleSelectObject(obj: SchemaDiffObject) {
if (props.routineDeployment) {
emit("select-routine", obj.id);
return;
}
selectedObjectId.value = obj.id;
if (editorView.value) {
const pos = objectPositions.value.get(obj.id);
@@ -108,6 +212,7 @@ async function handleSelectObject(obj: SchemaDiffObject) {
// Filter top-level selected objects (exclude children and none)
const topLevelObjects = computed(() => {
if (props.routineDeployment) return routineEntries.value.map((entry): SchemaDiffObject => ({ id: entry.objectId, name: entry.identity.name, routineType: entry.identity.kind, objectKind: "function", operationType: entry.action === "replace" ? "modify" : entry.action, selected: true }));
const operationOrder: Record<DiffOperationType, number> = { create: 0, modify: 1, delete: 2, none: 3 };
return props.selectedObjects
.filter((o) => {
@@ -118,10 +223,10 @@ const topLevelObjects = computed(() => {
});
const operationCounts = computed(() => {
return summarizeSchemaDiffOperations(props.selectedObjects);
return summarizeSchemaDiffOperations(props.routineDeployment ? topLevelObjects.value : props.selectedObjects);
});
const selectedObjectCount = computed(() => selectedSchemaDiffObjects(props.selectedObjects).length);
const selectedObjectCount = computed(() => (props.routineDeployment ? topLevelObjects.value.length : selectedSchemaDiffObjects(props.selectedObjects).length));
const compatibilityWarningCount = computed(() => props.compatibilityWarnings?.length ?? 0);
const reviewAlert = computed(() => schemaDiffReviewAlert(props.destructiveStatementCount ?? 0, compatibilityWarningCount.value));
@@ -152,13 +257,16 @@ const operationColors: Record<DiffOperationType, string> = {
};
// Initialize CodeMirror editor
let syncingEditorSql = false;
async function initEditor() {
if (!editorContainer.value) return;
const [{ EditorView }, { EditorState, Compartment }, langSql, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]);
if (editorDisposed || !editorContainer.value) return;
const themeComp = new Compartment();
const fontComp = new Compartment();
readOnlyCompartment = new Compartment();
const editorTheme = settingsStore.editorSettings.theme;
const appAppearance = isDark.value ? "dark" : "light";
@@ -166,9 +274,10 @@ async function initEditor() {
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance);
if (editorDisposed || !editorContainer.value) return;
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, "postgres");
const dialect = createDbxCodeMirrorSqlDialect(langSql, props.databaseType === "oracle" || props.databaseType === "oceanbase-oracle" ? "oracle" : "postgres");
const state = EditorState.create({
doc: effectiveSql.value,
@@ -177,8 +286,9 @@ async function initEditor() {
langSql.sql({ dialect }),
themeComp.of(themeExt),
fontComp.of(fontExt),
readOnlyCompartment.of(EditorState.readOnly.of(editorReadOnly.value)),
EditorView.updateListener.of((update: any) => {
if (update.docChanged) {
if (update.docChanged && !syncingEditorSql) {
emit("update:deploySql", update.state.doc.toString());
}
}),
@@ -194,18 +304,31 @@ watch(
() => effectiveSql.value,
(newVal) => {
if (editorView.value && editorView.value.state.doc.toString() !== newVal) {
editorView.value.dispatch({
changes: { from: 0, to: editorView.value.state.doc.length, insert: newVal },
});
syncingEditorSql = true;
try {
editorView.value.dispatch({
changes: { from: 0, to: editorView.value.state.doc.length, insert: newVal },
});
} finally {
syncingEditorSql = false;
}
}
},
);
watch(editorReadOnly, async (readOnly) => {
if (!editorView.value || !readOnlyCompartment) return;
const { EditorState } = await import("@codemirror/state");
if (!editorView.value || editorDisposed) return;
editorView.value.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(readOnly)) });
});
onMounted(() => {
initEditor();
});
onUnmounted(() => {
editorDisposed = true;
editorView.value?.destroy();
editorView.value = null;
});
@@ -314,7 +437,7 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b shrink-0">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="$emit('back')">
<Button v-if="!packageTransfer" variant="ghost" size="sm" class="h-7 px-2 text-xs gap-1" @click="$emit('back')">
<ArrowLeft class="w-3.5 h-3.5" />
{{ t("diff.backToResult") }}
</Button>
@@ -355,14 +478,26 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<Splitpanes class="flex-1 min-h-0">
<Pane size="30" min-size="20">
<div class="h-full overflow-auto p-2 space-y-0.5">
<div v-for="obj in topLevelObjects" :key="obj.id" class="flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-accent/50 cursor-pointer" :class="{ 'bg-primary/10': selectedObjectId === obj.id }" @click="handleSelectObject(obj)">
<div
v-for="obj in topLevelObjects"
:key="obj.id"
role="button"
tabindex="0"
class="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-accent/50 cursor-pointer focus-visible:ring-2 focus-visible:ring-ring"
:class="{ 'bg-primary/10': (routineDeployment?.selectedObjectId ?? selectedObjectId) === obj.id }"
@click="handleSelectObject(obj)"
@keydown.enter.prevent="handleSelectObject(obj)"
@keydown.space.prevent="handleSelectObject(obj)"
>
<component :is="operationIcons[obj.operationType]" class="w-3.5 h-3.5 shrink-0" :class="operationColors[obj.operationType]" />
<component :is="getObjectIcon(obj.objectKind)" class="w-3.5 h-3.5 shrink-0" :class="getObjectIconColor(obj.objectKind)" />
<span class="truncate">{{ obj.name }}</span>
<span v-if="obj.routineType === 'PACKAGE' || obj.routineType === 'PACKAGE_BODY'" class="text-muted-foreground">{{ obj.routineType.replaceAll("_", " ") }}</span>
<span v-if="obj.children?.length" class="text-[10px] text-muted-foreground shrink-0">{{ schemaDiffSelectionTargets(obj).filter((child) => child.selected).length }}/{{ schemaDiffSelectionTargets(obj).length }}</span>
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">
{{ getOperationLabel(obj.operationType) }}
</span>
<span v-if="routineDeployment" class="basis-full pl-5 text-xs text-muted-foreground">{{ routineStatus(routineEntries.find((entry) => entry.objectId === obj.id)?.operation, true) }}</span>
</div>
<div v-if="topLevelObjects.length === 0" class="text-xs text-muted-foreground text-center py-4">
{{ t("diff.noObjectsSelected") }}
@@ -371,7 +506,116 @@ function getObjectIconColor(kind: DiffObjectKind): string {
</Pane>
<Pane size="70" min-size="40">
<div ref="editorContainer" class="deploy-sql-editor h-full w-full" />
<div class="flex h-full min-w-0 flex-col">
<div v-if="routineDeployment" class="max-h-[50%] shrink-0 space-y-2 overflow-y-auto border-b px-3 py-2 text-xs">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span v-if="routineIdentity" class="break-all font-mono">{{ routineIdentity.owner }}.{{ routineIdentity.name }} · {{ routineIdentity.kind }}</span>
<span class="ml-auto" aria-live="polite">{{ routineDeployment.validating ? t("diff.routineChecking") : routineStatus(routineRecord) }}</span>
</div>
<p v-if="routineDeployment.recoveryPlan" class="text-amber-700 dark:text-amber-300">{{ t("diff.routineRecoveryNote") }}</p>
<p v-else-if="editorReadOnly && !executing" class="text-muted-foreground">{{ t("diff.routineReadOnlyCompleted") }}</p>
<div v-if="packageDraft" class="space-y-2">
<div v-if="routineDeployment && routineDeployment.source.endpoint.databaseType !== routineDeployment.target.endpoint.databaseType" class="space-y-1 text-amber-700 dark:text-amber-300">
<p>{{ routineDeployment.source.endpoint.databaseType }} → {{ routineDeployment.target.endpoint.databaseType }} · {{ t("diff.packageCrossProduct") }}</p>
<Button v-if="!packageDraft.reviewedTargetDraft && packageDraft.sql?.trim()" variant="outline" size="sm" :disabled="executing" @click="emit('review-package-target', packageDraft.objectId)">{{ t("diff.packageReviewTarget") }}</Button>
</div>
<div v-if="packageDraft.identity.kind === 'PACKAGE' && packageDraft.action !== 'delete'" class="flex flex-wrap gap-2">
<Button v-if="packageMaterial('source', 'PACKAGE_BODY') && (!packageTransfer || !packageMaterial('target', 'PACKAGE_BODY'))" variant="outline" size="sm" :disabled="executing" @click="emit('include-package-companion', packageDraft.objectId, 'source')">{{
t("diff.packageUseSourceBody")
}}</Button>
<Button v-if="!packageTransfer && packageMaterial('target', 'PACKAGE_BODY')" variant="outline" size="sm" :disabled="executing" @click="emit('include-package-companion', packageDraft.objectId, 'target')">{{ t("diff.packageRebuildTargetBody") }}</Button>
</div>
<div v-if="packageDraft.identity.kind === 'PACKAGE_BODY'" class="space-y-2">
<Button
v-if="!packagePeer && packageMaterial(packageDraft.action === 'delete' ? 'target' : 'source', 'PACKAGE') && (!packageTransfer || !packageMaterial('target', 'PACKAGE'))"
variant="outline"
size="sm"
:disabled="executing"
@click="emit('include-package-companion', packageDraft.objectId, 'source')"
>{{ t(packageDraft.action === "delete" ? "diff.packageDeleteWhole" : "diff.packageAddSpecification") }}</Button
>
<div v-if="packageDraft.action !== 'delete'" class="flex flex-wrap gap-3" role="radiogroup" :aria-label="t('diff.packageContract')">
<label v-for="contract in ['source', 'target'] as const" :key="contract" class="flex items-center gap-1.5">
<input type="radio" name="package-contract" :value="contract" :checked="(packageDraft.packageContract ?? 'source') === contract" :disabled="executing" @change="emit('update-package-options', packageDraft.objectId, { packageContract: contract })" />
{{ t(contract === "source" ? "diff.packageSourceContract" : "diff.packageTargetContract") }}
</label>
</div>
</div>
<label v-if="packageDraft.identity.kind === 'PACKAGE' && (packageDraft.action !== 'create' || packageMaterial('target', 'PACKAGE_BODY') || routineStep?.packageImpact?.bodyPending)" class="flex items-start gap-2 text-amber-700 dark:text-amber-300">
<input type="checkbox" class="mt-0.5" :checked="packageDraft.confirmPackagePair ?? false" :disabled="executing" @change="emit('update-package-options', packageDraft.objectId, { confirmPackagePair: ($event.target as HTMLInputElement).checked })" />
{{ t(packageDraft.action === "delete" ? "diff.packageConfirmDrop" : "diff.packageConfirmImpact") }}
</label>
</div>
<p v-if="routineStep?.packageImpact?.bodyPending" class="text-amber-700 dark:text-amber-300">{{ t("diff.packageSpecificationOnly") }}</p>
<p v-if="routineStep?.packageImpact?.mayInitialize" class="text-muted-foreground">{{ t("diff.packageInitialization") }}</p>
<details v-if="routineStep?.packageImpact?.grants" class="max-h-32 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.packageGrantBackup") }}</summary>
<p v-if="!routineStep.packageImpact.grants.complete" class="text-amber-700 dark:text-amber-300">{{ routineStep.packageImpact.grants.issue }}</p>
<p v-for="(grant, index) in routineStep.packageImpact.grants.grants" :key="index" class="mt-1 break-all font-mono">{{ grant.grantor }} → {{ grant.grantee }} · {{ grant.privilege }} · GRANTABLE={{ grant.grantable }}</p>
</details>
<div v-if="routineDeployment.error || routineStep?.issues.length" role="alert" tabindex="-1" class="max-h-40 space-y-1 overflow-auto text-destructive">
<p v-if="routineDeployment.error" class="whitespace-pre-wrap break-words">{{ routineDeployment.error }}</p>
<button v-for="(issue, index) in routineStep?.issues ?? []" :key="index" type="button" class="block w-full text-left hover:underline focus-visible:ring-2 focus-visible:ring-ring" @click="locateIssue(issue.line, issue.column)">
{{ issue.line }}:{{ issue.column }} — {{ issue.message }}
</button>
</div>
<details v-if="routineOriginals.source" class="max-h-40 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.sourceDdl") }}</summary>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyToClipboard(routineOriginals.source)">{{ t("diff.copySourceDdl") }}</Button>
<pre class="mt-1 whitespace-pre-wrap break-words font-mono">{{ routineOriginals.source }}</pre>
</details>
<details v-if="routineOriginals.target" class="max-h-40 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.targetDdl") }}</summary>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyToClipboard(routineOriginals.target)">{{ t("diff.copyTargetDdl") }}</Button>
<pre class="mt-1 whitespace-pre-wrap break-words font-mono">{{ routineOriginals.target }}</pre>
</details>
<details v-if="routineStep?.incomingCallers.length" class="max-h-32 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.routineCallers") }} ({{ routineStep.incomingCallers.length }})</summary>
<p v-for="(caller, index) in routineStep.incomingCallers" :key="index" class="mt-1 break-all font-mono">{{ caller.owner }}.{{ caller.name }} · {{ caller.kind }}</p>
</details>
<div v-if="routineRecord" class="max-h-56 space-y-1 overflow-auto break-words" aria-live="polite">
<p v-if="routineRecord.dispatched">{{ routineRecord.acknowledged ? t("objects.routineWriteAcknowledged") : t("diff.routineDispatched") }}</p>
<p v-if="routineRecord.error" class="whitespace-pre-wrap text-destructive">{{ routineRecord.error }}</p>
<p v-for="(issue, index) in routineRecord.readback?.issues ?? []" :key="index" class="text-destructive">{{ issue.message }}</p>
<button
v-for="(message, index) in routineRecord.readback?.compilerMessages ?? []"
:key="index"
type="button"
class="block w-full text-left"
:class="message.attribute === 'ERROR' ? 'text-destructive' : 'text-amber-700 dark:text-amber-300'"
:disabled="compilerOffset(message.line, message.position) === undefined"
@click="locateOffset(compilerOffset(message.line, message.position)!)"
>
{{ message.attribute }} {{ message.line }}:{{ message.position }} — {{ message.text }}
</button>
<details v-if="routineRecord.readback?.source" class="max-h-40 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.targetDdl") }} · {{ routineRecord.identity.kind.replaceAll("_", " ") }}</summary>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyToClipboard(routineRecord.readback.source)">{{ t("diff.copyTargetDdl") }}</Button>
<pre class="mt-1 whitespace-pre-wrap break-words font-mono">{{ routineRecord.readback.source }}</pre>
</details>
<div v-if="routineCompanion" class="space-y-1 border-t pt-2">
<p class="break-all font-mono">{{ routineCompanion.identity.kind.replaceAll("_", " ") }} {{ routineCompanion.identity.name }} · {{ routineCompanionStatus }}</p>
<p v-for="(issue, index) in routineCompanion.issues" :key="index" class="text-destructive">{{ issue.message }}</p>
<p v-for="(message, index) in routineCompanion.compilerMessages" :key="index" :class="message.attribute === 'ERROR' ? 'text-destructive' : 'text-amber-700 dark:text-amber-300'">{{ message.attribute }} {{ message.line }}:{{ message.position }} — {{ message.text }}</p>
<details v-if="routineCompanion.source" class="max-h-40 overflow-auto text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.targetDdl") }} · {{ routineCompanion.identity.kind.replaceAll("_", " ") }}</summary>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="copyToClipboard(routineCompanion.source)">{{ t("diff.copyTargetDdl") }}</Button>
<pre class="mt-1 whitespace-pre-wrap break-words font-mono">{{ routineCompanion.source }}</pre>
</details>
</div>
<Button v-if="routineRecord.lifecycle !== 'prepared'" variant="outline" size="sm" class="h-7 text-xs" :disabled="executing || routineDeployment.validating" @click="emit('observe-routine', routineRecord.operationId)">{{ t("diff.routineObserve") }}</Button>
<Button v-if="routineRecord.lifecycle === 'terminal' && routineRecord.dispatched" variant="ghost" size="sm" class="h-7 text-xs" :disabled="executing || routineDeployment.validating" @click="emit('recover-routine', routineRecord.operationId)">{{
t("diff.routineRecoveryPreview")
}}</Button>
</div>
<p v-if="routineDeployment.stopRequested" class="text-muted-foreground">{{ t("diff.routineStopped") }}</p>
<details v-if="routineHistory.length" class="text-muted-foreground">
<summary class="cursor-pointer">{{ t("diff.routineHistory", { count: routineHistory.length }) }}</summary>
<p v-for="record in routineHistory" :key="record.operationId" class="mt-1 break-all font-mono">{{ record.operationId }} — {{ routineStatus(record, true) }}</p>
</details>
</div>
<div ref="editorContainer" class="deploy-sql-editor min-h-0 w-full flex-1" />
</div>
</Pane>
</Splitpanes>
@@ -398,20 +642,22 @@ function getObjectIconColor(kind: DiffObjectKind): string {
<input type="checkbox" class="accent-primary" :checked="ignoreForeignKeyChecks === true" :disabled="executing" @change="emit('update:ignoreForeignKeyChecks', ($event.target as HTMLInputElement).checked)" />
{{ t("diff.ignoreForeignKeyChecks") }}
</label>
<label class="flex items-center gap-1.5 text-xs" :class="executing ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'" :title="t('diff.continueOnErrorHint')">
<label v-if="!routineDeployment" class="flex items-center gap-1.5 text-xs" :class="executing ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'" :title="t('diff.continueOnErrorHint')">
<input type="checkbox" class="accent-primary" :checked="continueOnError === true" :disabled="executing" @change="emit('update:continueOnError', ($event.target as HTMLInputElement).checked)" />
{{ t("diff.continueOnError") }}
</label>
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-7 text-xs gap-1" @click="$emit('back')">
<Button v-if="routineDeployment" variant="outline" size="sm" class="h-7 text-xs" :disabled="executing || routineDeployment.validating" @click="emit('refresh-routines')">{{ t("diff.routineRefreshBaseline") }}</Button>
<Button v-if="routineDeployment" variant="outline" size="sm" class="h-7 text-xs" :disabled="executing || routineDeployment.validating" @click="emit('validate-routines')">{{ t("diff.routineValidate") }}</Button>
<Button v-if="!packageTransfer" variant="ghost" size="sm" class="h-7 text-xs gap-1" @click="$emit('back')">
<ArrowLeft class="w-3.5 h-3.5" />
{{ t("diff.backToResult") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="$emit('back')">
<Button v-if="!packageTransfer" variant="ghost" size="sm" class="h-7 text-xs" @click="$emit('back')">
{{ t("diff.cancel") }}
</Button>
<Button size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing || canExecute === false" @click="handleDeploy">
<Button v-if="!packageTransfer" size="sm" class="h-7 text-xs gap-1" :disabled="topLevelObjects.length === 0 || executing || canExecute === false" @click="handleDeploy">
<Loader2 v-if="executing" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
{{ t("diff.deployToServer") }}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount, nextTick } from "vue";
import { computed, reactive, ref, watch, onBeforeUnmount, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -27,11 +27,27 @@ import { buildContinueOnErrorDeployLog, buildDeployTxResult, type DeployTxResult
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjectPhase, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { createSchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
import { countSchemaDiffActionableObjects, partitionSchemaDiffObjectsByResultTab, swapSchemaDiffRoutineMappings } from "@/lib/schema/schemaDiffRoutine";
import { schemaDiffRoutineObjectTypesIntersection } from "@/lib/database/databaseObjectCapabilities";
import { schemaDiffRoutineObjectTypesIntersection, schemaDiffRoutineProductType } from "@/lib/database/databaseObjectCapabilities";
import { loadObjectSourceWithRoutineFallback } from "@/lib/table/objectSourceLoad";
import { copyToClipboard } from "@/lib/common/clipboard";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping } from "@/types/schemaDiff";
import {
createOracleRoutineDeployment,
editOracleRoutineDraft,
editOraclePackageOptions,
includeOraclePackageCompanion,
reviewOraclePackageTargetDraft,
executeOracleRoutineDeployment,
observeOracleRoutineDeployment,
oracleRoutineActiveStep,
oracleRoutinePlanSql,
prepareOracleRoutineRecovery,
refreshOracleRoutineDeployment,
selectOracleRoutineDraft,
synchronizeOracleRoutineSelection,
validateOracleRoutineDeployment,
} from "@/lib/schema/oracleRoutineDeploy";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping, SchemaDiffRoutineMapping, OracleRoutineKind, OracleRoutineIdentity } from "@/types/schemaDiff";
import { getSchemaDiffSession, schemaDiffSessionObjects, startSchemaDiffSession, type SchemaDiffSession } from "@/composables/useSchemaDiffSession";
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
import {
@@ -141,6 +157,7 @@ const activeSessionId = ref<string | null>(props.sessionId ?? null);
let appliedSessionResult: SchemaDiffPreparation | null = null;
let shownSessionError = "";
let componentUnmounted = false;
let suppressTargetIdentityReset = false;
const diffObjects = ref<SchemaDiffObject[]>([]);
const tableDiffGroups = ref<OperationGroup[]>([]);
const routineDiffGroups = ref<OperationGroup[]>([]);
@@ -175,13 +192,81 @@ const tableActionableCount = computed(() => countSchemaDiffActionableObjects(tab
const routineActionableCount = computed(() => countSchemaDiffActionableObjects(routineDiffObjects.value));
const tablesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.tables);
const routinesCompareEnabled = computed(() => !!schemaDiffPanelOptions.value.functions);
const canEnableRoutinesCompare = computed(() => schemaDiffRoutineObjectTypesIntersection(sourceDbType.value as DatabaseType | undefined, targetDbType.value as DatabaseType | undefined).length > 0);
const canEnableRoutinesCompare = computed(
() => schemaDiffRoutineObjectTypesIntersection(schemaDiffRoutineProductType(sourceDbType.value, sourceEngineDbType.value), schemaDiffRoutineProductType(targetDbType.value, targetEngineDbType.value), schemaDiffPanelOptions.value.routineObjectKinds).length > 0,
);
const effectiveRoutinesEnabled = computed(() => routinesCompareEnabled.value && canEnableRoutinesCompare.value);
const showTableResultTab = computed(() => tablesCompareEnabled.value);
const showRoutineResultTab = computed(() => effectiveRoutinesEnabled.value);
const showResultTabList = computed(() => showTableResultTab.value && showRoutineResultTab.value);
const showNoDifferences = computed(() => showTableResultTab.value && tableDiffObjects.value.length === 0);
const showRoutineNoDifferences = computed(() => showRoutineResultTab.value && routineDiffObjects.value.length === 0);
const routineInventoriesComplete = computed(() => !!comparisonSession.value?.routineSnapshots?.source?.enumerationComplete && !!comparisonSession.value?.routineSnapshots?.target?.enumerationComplete);
const showRoutineNoDifferences = computed(() => showRoutineResultTab.value && routineDiffObjects.value.length === 0 && (!isOracleRoutineComparison.value || routineInventoriesComplete.value));
const comparisonSession = computed(() => getSchemaDiffSession(activeSessionId.value));
const isOracleRoutineComparison = computed(() => {
const snapshots = comparisonSession.value?.routineSnapshots;
return !!snapshots?.source && !!snapshots.target && [snapshots.source.endpoint.databaseType, snapshots.target.endpoint.databaseType].every((type) => type === "oracle" || type === "oceanbase-oracle");
});
const isOracleRoutineDeploy = computed(() => resultTab.value === "routines" && isOracleRoutineComparison.value);
const routineDeployment = computed(() => comparisonSession.value?.routineDeployment);
const routineTargetIsCurrent = computed(() => {
const snapshots = comparisonSession.value?.routineSnapshots;
return (
!!snapshots?.source &&
!!snapshots.target &&
snapshots.source.endpoint.connectionId === sourceConnectionId.value &&
snapshots.source.endpoint.database === sourceDatabase.value &&
snapshots.source.endpoint.schema === sourceSchema.value &&
snapshots.target.endpoint.connectionId === targetConnectionId.value &&
snapshots.target.endpoint.database === targetDatabase.value &&
snapshots.target.endpoint.schema === targetSchema.value &&
sourceEngineDbType.value === snapshots.source.endpoint.databaseType &&
targetEngineDbType.value === snapshots.target.endpoint.databaseType
);
});
const deployIsBusy = computed(() => executing.value || !!routineDeployment.value?.executing || !!routineDeployment.value?.validating);
const routineReadIssues = computed(() => {
if (!isOracleRoutineComparison.value) return [];
return (["source", "target"] as const).flatMap((side) => {
const snapshot = comparisonSession.value?.routineSnapshots?.[side];
const selected = selectedObject.value;
const name = side === "source" ? (selected?.sourceName ?? selected?.name) : (selected?.targetName ?? selected?.name);
const object = snapshot?.objects.find((object) => object.version.identity.name === name && object.version.identity.kind === selected?.routineType);
return [...(snapshot?.issues ?? []), ...(object?.issues ?? [])].map((issue) => ({ side, message: issue.message }));
});
});
const deployEditorSql = computed({
get: () => {
if (!isOracleRoutineDeploy.value || !routineDeployment.value) return selectedDeploySql.value;
const state = routineDeployment.value;
return state.recoveryPlan ? (oracleRoutineActiveStep(state)?.sql ?? "") : (state.drafts.find((draft) => draft.objectId === state.selectedObjectId)?.sql ?? "");
},
set: (sql: string) => {
if (!isOracleRoutineDeploy.value || !routineDeployment.value) {
selectedDeploySql.value = sql;
return;
}
if (!routineDeployment.value.recoveryPlan && routineDeployment.value.selectedObjectId) editOracleRoutineDraft(routineDeployment.value, routineDeployment.value.selectedObjectId, sql);
},
});
let routineConfirmRevision: number | null = null;
function ensureRoutineDeployment() {
const session = comparisonSession.value;
if (!isOracleRoutineComparison.value || !session?.routineSnapshots?.source || !session.routineSnapshots.target) return;
session.routineDeployment ??= reactive(createOracleRoutineDeployment(session.routineSnapshots.source, session.routineSnapshots.target));
synchronizeOracleRoutineSelection(session.routineDeployment, routineDiffObjects.value);
return session.routineDeployment;
}
function leaveDeployReview() {
const state = routineDeployment.value;
if (state) {
state.stopRequested = true;
state.reviewOpen = false;
}
step.value = "result";
}
// Phase 4 result fields
const rollbackSql = ref("");
@@ -260,8 +345,8 @@ const dialogStyle = computed(() => {
}
if (step.value === "result") {
return {
width: savedSize?.width || "1100px",
height: savedSize?.height || "820px",
width: parseFloat(savedSize?.width) > 0 ? savedSize.width : "1100px",
height: parseFloat(savedSize?.height) > 0 ? savedSize.height : "820px",
maxWidth: "calc(100vw - 2rem)",
maxHeight: "calc(100vh - 2rem)",
};
@@ -301,6 +386,7 @@ function setupResizeObserver() {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width <= 0 || height <= 0) continue;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = window.setTimeout(() => {
localStorage.setItem(
@@ -339,6 +425,7 @@ watch(
onBeforeUnmount(() => {
componentUnmounted = true;
if (routineDeployment.value) routineDeployment.value.stopRequested = true;
teardownResizeObserver();
});
@@ -363,7 +450,14 @@ const selectedTreeObject = computed(() => {
return findSchemaDiffObject(diffObjects.value, selectedObjectId.value);
});
const canDeployRoutines = computed(() => isSchemaDiffPostgresLike(targetDbType.value));
const canDeployRoutines = computed(() => {
if (!isOracleRoutineComparison.value) return isSchemaDiffPostgresLike(targetDbType.value);
if (!routineTargetIsCurrent.value) return false;
const snapshots = comparisonSession.value!.routineSnapshots!;
if (snapshots.source!.endpoint.databaseType === snapshots.target!.endpoint.databaseType) return true;
const kinds = comparisonSession.value!.config.options.routineObjectKinds;
return !!kinds?.length && kinds.every((kind) => kind === "PACKAGE" || kind === "PACKAGE_BODY") && routineDiffObjects.value.every((object) => object.routineType === "PACKAGE" || object.routineType === "PACKAGE_BODY");
});
const canDeploy = computed(() => {
// MySQL/SQL Server routine sync is deferred (copy DDL only). Postgres-family
@@ -470,12 +564,12 @@ function restoreSchemaDiffSession(session: SchemaDiffSession): void {
targetSchema: config.targetSchema,
});
updateActiveConfigOptions(config.options);
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(config.sourceDbType, config.targetDbType, config.options);
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(schemaDiffRoutineProductType(config.sourceDbType, config.sourceEngineDbType), schemaDiffRoutineProductType(config.targetDbType, config.targetEngineDbType), config.options);
}
function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
if (!session) return;
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(session.config.sourceDbType, session.config.targetDbType, session.config.options);
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(schemaDiffRoutineProductType(session.config.sourceDbType, session.config.sourceEngineDbType), schemaDiffRoutineProductType(session.config.targetDbType, session.config.targetEngineDbType), session.config.options);
if (session.status === "running") {
loading.value = true;
step.value = "compare";
@@ -505,7 +599,13 @@ function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
compatibilityWarnings.value = session.result.compatibilityWarnings ?? [];
permissionDiffs.value = session.result.permissionDiffs ?? [];
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(session.result.dependencyGraph);
diffObjects.value = schemaDiffSessionObjects(session.result);
diffObjects.value = schemaDiffSessionObjects(session.result, session.routineDeployment);
if (session.routineDeployment) {
for (const object of diffObjects.value) {
const saved = session.routineDeployment.drafts.find((draft) => draft.objectId === object.id);
if (saved) object.selected = saved.included;
}
}
rebuildDiffGroups();
const hasTableActions = showTableResultTab.value && countSchemaDiffActionableObjects(tableDiffObjects.value) > 0;
const hasRoutineActions = showRoutineResultTab.value && countSchemaDiffActionableObjects(routineDiffObjects.value) > 0;
@@ -530,6 +630,7 @@ function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
deploySqlMode.value = "forward";
void regenerateSelectedDeploySql();
void regenerateFocusedDeploySql();
if (session.routineDeployment?.reviewOpen) step.value = "deploy-review";
}
// A Compare session deliberately outlives this v-if-mounted dialog. Closing the
@@ -537,7 +638,10 @@ function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
watch(
[() => open.value, () => props.sessionId],
([isOpen]) => {
if (!isOpen) return;
if (!isOpen) {
if (routineDeployment.value) routineDeployment.value.stopRequested = true;
return;
}
const session = getSchemaDiffSession(props.sessionId);
activeSessionId.value = session?.id ?? null;
appliedSessionResult = null;
@@ -593,9 +697,6 @@ watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, tar
});
});
/** Suppress target-identity resets while restoring session/history/swap. */
let suppressTargetIdentityReset = false;
function runWithoutTargetIdentityReset(action: () => void): void {
suppressTargetIdentityReset = true;
try {
@@ -607,6 +708,10 @@ function runWithoutTargetIdentityReset(action: () => void): void {
}
}
watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, targetDatabase, targetSchema], () => {
if (!suppressTargetIdentityReset && routineDeployment.value) routineDeployment.value.stopRequested = true;
});
function handleSourceConnectionChange(connectionId: string): void {
if (connectionId === sourceConnectionId.value) return;
sourceConnectionId.value = connectionId;
@@ -727,6 +832,12 @@ function handleSelectedRoutinesUpdate(value?: string[]) {
}
}
function handleRoutineSelectionUpdate(value: { objectKinds: OracleRoutineKind[]; selectedObjects?: Pick<OracleRoutineIdentity, "name" | "kind">[] }) {
if (activeConfig.value) {
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, routineObjectKinds: value.objectKinds, selectedRoutineObjects: value.selectedObjects, selectedRoutines: undefined, routineMappings: [] }, getDbType()));
}
}
function handleCompareScopeUpdate(value: { tables?: boolean; views?: boolean; functions?: boolean }) {
if (activeConfig.value) {
const next = { ...activeConfig.value.options, ...value };
@@ -765,7 +876,7 @@ function handleCompare(): void {
appliedSessionResult = null;
shownSessionError = "";
resetComparisonResultState();
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(sourceDbType, targetDbType, options);
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjectPhase(schemaDiffRoutineProductType(sourceDbType, sourceEngineDbType.value), schemaDiffRoutineProductType(targetDbType, targetEngineDbType.value), options);
const session = startSchemaDiffSession(
{
sourceConnectionId: sourceConnectionId.value,
@@ -875,6 +986,10 @@ async function regenerateSelectedDeploySql() {
if (!result) {
return;
}
if (isOracleRoutineDeploy.value) {
ensureRoutineDeployment();
return;
}
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType());
const input = selectSchemaDiffInput(result, currentTabDiffObjects.value);
@@ -901,6 +1016,7 @@ async function regenerateFocusedDeploySql(objectId: string | null = selectedObje
focusedForwardDeploySql.value = "";
focusedRollbackSql.value = "";
focusedDeploySql.value = "";
if (isOracleRoutineDeploy.value) return;
if (!result || !objectId || !findSchemaDiffObject(diffObjects.value, objectId)) {
return;
}
@@ -936,6 +1052,10 @@ function switchDeploySqlMode(mode: "forward" | "rollback") {
}
const canExecuteDeploy = computed(() => {
if (isOracleRoutineDeploy.value) {
const state = routineDeployment.value;
return routineTargetIsCurrent.value && !!(state?.recoveryPlan ?? state?.plan)?.ready && !deployIsBusy.value;
}
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
return false;
}
@@ -945,7 +1065,7 @@ const canExecuteDeploy = computed(() => {
const destructiveStatements = computed(() => {
const databaseType = store.getConfig(targetConnectionId.value)?.db_type;
return detectDestructiveSchemaDiffStatements(selectedDeploySql.value, databaseType);
return detectDestructiveSchemaDiffStatements(isOracleRoutineDeploy.value && routineDeployment.value ? oracleRoutinePlanSql(routineDeployment.value) : selectedDeploySql.value, databaseType);
});
function applyRename(rc: RenameCandidate) {
@@ -995,6 +1115,10 @@ function ignoreRename(index: number) {
}
async function handleExecuteScript() {
if (isOracleRoutineDeploy.value) {
await handleDeployReview();
return;
}
if (!selectedDeploySql.value.trim() || selectedDeploySql.value.trim() === "-- No objects selected") {
toast(t("diff.noObjectsSelected"), 3000);
return;
@@ -1008,6 +1132,20 @@ async function handleExecuteScript() {
}
async function executeDeploySql() {
if (isOracleRoutineDeploy.value && routineDeployment.value) {
const state = routineDeployment.value;
if (!routineTargetIsCurrent.value) return;
await executeOracleRoutineDeployment(state, (sql, execute) =>
executeWithProductionSqlGuard({
connection: store.getConfig(state.target.endpoint.connectionId),
database: state.target.endpoint.database,
sql,
source: t("production.sourceSchemaDiff"),
execute,
}),
);
return;
}
executing.value = true;
try {
const targetConnection = store.getConfig(targetConnectionId.value);
@@ -1079,8 +1217,11 @@ async function handleSelectObject(reviewObject: SchemaDiffObject) {
}
if (obj.objectKind !== "function") return;
// Strict snapshots retain incomplete reads and their issues; an ordinary
// fallback must not silently replace that evidence with an unverified source.
if (isOracleRoutineComparison.value) return;
const preferredKind: ObjectSourceKind = obj.routineType === "PROCEDURE" ? "PROCEDURE" : "FUNCTION";
const preferredKind: ObjectSourceKind = obj.routineType ?? "FUNCTION";
async function fetchRoutineDdl(connectionId: string, database: string, schema: string, name: string): Promise<string | undefined> {
try {
@@ -1262,7 +1403,7 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
}
}
function handleDeployReview() {
async function handleDeployReview() {
if (resultTab.value === "routines" && !canDeployRoutines.value) return;
const selectedObjects = selectedSchemaDiffObjects(currentTabDiffObjects.value);
if (selectedObjects.length === 0) {
@@ -1270,10 +1411,21 @@ function handleDeployReview() {
return;
}
step.value = "deploy-review";
if (isOracleRoutineDeploy.value) {
const state = ensureRoutineDeployment();
if (state) {
state.reviewOpen = true;
if (!state.plan && !state.recoveryPlan && !state.executing) await validateOracleRoutineDeployment(state);
}
}
fetchDbVersion(targetConnectionId.value, targetDatabase.value, targetSchema.value);
}
async function handleDeploy() {
if (isOracleRoutineDeploy.value) {
if (!canExecuteDeploy.value) return;
routineConfirmRevision = routineDeployment.value?.revision ?? null;
}
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 5000);
return;
@@ -1283,6 +1435,7 @@ async function handleDeploy() {
async function onConfirmDeploy() {
showConfirmDialog.value = false;
if (isOracleRoutineDeploy.value && (routineConfirmRevision !== routineDeployment.value?.revision || !canExecuteDeploy.value)) return;
if (deploySqlMode.value === "rollback" && rollbackCompleteness.value === "incomplete") {
toast(t("diff.rollbackIncompleteBlocked"), 5000);
return;
@@ -1292,6 +1445,10 @@ async function onConfirmDeploy() {
}
const deployStats = computed(() => {
if (isOracleRoutineDeploy.value && routineDeployment.value) {
const steps = (routineDeployment.value.recoveryPlan ?? routineDeployment.value.plan)?.steps ?? [];
return { create: steps.filter((step) => step.action === "create").length, modify: steps.filter((step) => step.action === "replace").length, delete: steps.filter((step) => step.action === "delete").length, total: steps.length };
}
const counts = summarizeSchemaDiffOperations(currentTabDiffObjects.value);
return {
create: counts.create,
@@ -1373,6 +1530,7 @@ const targetConnectionInfo = computed(() => {
@update:routine-mappings="handleRoutineMappingsUpdate"
@update:selected-tables="handleSelectedTablesUpdate"
@update:selected-routines="handleSelectedRoutinesUpdate"
@update:routine-selection="handleRoutineSelectionUpdate"
@update:compare-scope="handleCompareScopeUpdate"
@open-field-mapping="showFieldMappingDialog = true"
/>
@@ -1493,13 +1651,24 @@ const targetConnectionInfo = computed(() => {
</TabsContent>
<TabsContent v-if="showRoutineResultTab" value="routines" class="mt-0 flex min-h-0 flex-1 flex-col outline-none data-[state=inactive]:hidden">
<div v-if="routineReadIssues.length || (selectedObject?.operationType === 'none' && selectedObject.changes?.length)" role="status" class="mx-3 mt-2 max-h-32 shrink-0 space-y-1 overflow-auto text-xs text-amber-700 dark:text-amber-300">
<p v-for="(issue, index) in routineReadIssues" :key="index">{{ t(issue.side === "source" ? "diff.sourceObject" : "diff.targetObject") }} — {{ issue.message }}</p>
<p v-for="(change, index) in selectedObject?.operationType === 'none' ? (selectedObject.changes ?? []) : []" :key="`change-${index}`">{{ change }}</p>
</div>
<div v-if="showRoutineNoDifferences" class="mx-3 mt-2 shrink-0 rounded-md border border-dashed bg-muted/20 px-3 py-2 text-center text-xs text-muted-foreground">
{{ t("diff.noDifferences") }}
</div>
<Splitpanes horizontal class="min-h-0 flex-1" @resized="handleSplitpanesResized">
<Pane :size="splitpanesSize" min-size="20">
<div class="h-full overflow-auto">
<SchemaDiffRoutineList :objects="routineDiffObjects" :viewing-object-id="selectedRoutineObjectId" :selectable="canDeployRoutines" :empty-text="t('diff.noDifferences')" @toggle-selection="handleToggleObjectSelection" @view-diff="handleViewRoutineDiff" />
<SchemaDiffRoutineList
:objects="routineDiffObjects"
:viewing-object-id="selectedRoutineObjectId"
:selectable="canDeployRoutines"
:empty-text="t(isOracleRoutineComparison && !routineInventoriesComplete ? 'diff.routineUnreadable' : 'diff.noDifferences')"
@toggle-selection="handleToggleObjectSelection"
@view-diff="handleViewRoutineDiff"
/>
</div>
</Pane>
<Pane :size="100 - splitpanesSize" min-size="20">
@@ -1533,7 +1702,7 @@ const targetConnectionInfo = computed(() => {
<!-- Deploy Review Step -->
<template v-else-if="step === 'deploy-review'">
<SchemaDiffDeployStep
v-model:deploy-sql="selectedDeploySql"
v-model:deploy-sql="deployEditorSql"
v-model:ignore-foreign-key-checks="ignoreForeignKeyChecks"
v-model:continue-on-error="continueOnError"
:show-ignore-foreign-key-checks="isMysqlDeployTarget"
@@ -1541,8 +1710,10 @@ const targetConnectionInfo = computed(() => {
:target-connection-id="targetConnectionId"
:target-database="targetDatabase"
:target-schema="targetSchema"
:executing="executing"
:rollback-sql="rollbackSql"
:executing="deployIsBusy"
:database-type="targetEngineDbType"
:routine-deployment="isOracleRoutineDeploy ? routineDeployment : null"
:rollback-sql="isOracleRoutineDeploy ? undefined : rollbackSql"
:deploy-sql-mode="deploySqlMode"
:compatibility-warnings="selectedCompatibilityWarnings"
:rename-candidates="renameCandidates"
@@ -1551,8 +1722,20 @@ const targetConnectionInfo = computed(() => {
:can-execute="canExecuteDeploy"
:destructive-statement-count="destructiveStatements.length"
@update:deploy-sql-mode="switchDeploySqlMode"
@back="step = 'result'"
@back="leaveDeployReview"
@deploy="handleDeploy"
@select-routine="
(id) => {
if (routineDeployment) selectOracleRoutineDraft(routineDeployment, id);
}
"
@validate-routines="routineDeployment && validateOracleRoutineDeployment(routineDeployment)"
@include-package-companion="(id, source) => routineDeployment && includeOraclePackageCompanion(routineDeployment, id, source)"
@update-package-options="(id, options) => routineDeployment && editOraclePackageOptions(routineDeployment, id, options)"
@review-package-target="(id) => routineDeployment && reviewOraclePackageTargetDraft(routineDeployment, id)"
@refresh-routines="routineDeployment && refreshOracleRoutineDeployment(routineDeployment)"
@observe-routine="(id) => routineDeployment && observeOracleRoutineDeployment(routineDeployment, id)"
@recover-routine="(id) => routineDeployment && prepareOracleRoutineRecovery(routineDeployment, id)"
/>
</template>
</div>
@@ -1575,8 +1758,8 @@ const targetConnectionInfo = computed(() => {
<p v-if="showRoutineDeployDeferredHint" class="max-w-md text-right text-[11px] text-muted-foreground">
{{ t("diff.routineSyncDeferredHint") }}
</p>
<Button v-else size="sm" :disabled="!canDeploy || executing" @click="handleDeployReview">
<Loader2 v-if="executing" class="w-3.5 h-3.5 mr-1 animate-spin" />
<Button v-else size="sm" :disabled="!canDeploy || deployIsBusy" @click="handleDeployReview">
<Loader2 v-if="deployIsBusy" class="w-3.5 h-3.5 mr-1 animate-spin" />
<Play v-else class="w-3.5 h-3.5 mr-1" />
{{ deployButtonLabel }}
</Button>
@@ -1625,7 +1808,7 @@ const targetConnectionInfo = computed(() => {
</ul>
</div>
<label class="flex items-start gap-2 rounded border border-yellow-300 bg-yellow-50 p-3 text-xs text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200" :class="executing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'">
<label v-if="!isOracleRoutineDeploy" class="flex items-start gap-2 rounded border border-yellow-300 bg-yellow-50 p-3 text-xs text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200" :class="executing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'">
<input v-model="continueOnError" type="checkbox" class="mt-0.5 accent-primary" :disabled="executing" />
<span>
<span class="font-medium">{{ t("diff.continueOnError") }}</span>
@@ -1636,8 +1819,8 @@ const targetConnectionInfo = computed(() => {
<DialogFooter>
<Button variant="outline" @click="showConfirmDialog = false">{{ t("diff.cancel") }}</Button>
<Button variant="destructive" :disabled="executing" @click="onConfirmDeploy">
<Loader2 v-if="executing" class="w-3.5 h-3.5 mr-1 animate-spin" />
<Button variant="destructive" :disabled="deployIsBusy || (isOracleRoutineDeploy && !canExecuteDeploy)" @click="onConfirmDeploy">
<Loader2 v-if="deployIsBusy" class="w-3.5 h-3.5 mr-1 animate-spin" />
{{ t("diff.confirmDeploy") }}
</Button>
</DialogFooter>
@@ -137,11 +137,15 @@ function displayObjectName(object: SchemaDiffObject): string {
}
function sourceObjectName(object: SchemaDiffObject): string {
return object.sourceName ?? displayObjectName(object);
const name = object.sourceName ?? displayObjectName(object);
if (object.routineType === "PACKAGE" || object.routineType === "PACKAGE_BODY") return `${object.routineType.replaceAll("_", " ")} ${name}`;
return name;
}
function targetObjectName(object: SchemaDiffObject): string {
return object.targetName ?? displayObjectName(object);
const name = object.targetName ?? displayObjectName(object);
if (object.routineType === "PACKAGE" || object.routineType === "PACKAGE_BODY") return `${object.routineType.replaceAll("_", " ")} ${name}`;
return name;
}
</script>
@@ -38,6 +38,7 @@ const rows = computed(() =>
function displayName(object: SchemaDiffObject, side: "source" | "target"): string {
const name = side === "source" ? (object.sourceName ?? object.name) : (object.targetName ?? object.name);
if (object.routineType === "PACKAGE" || object.routineType === "PACKAGE_BODY") return `${object.routineType.replaceAll("_", " ")} ${name}`;
return schemaDiffRoutineKey(name, object.arguments ?? "");
}
@@ -46,6 +47,7 @@ function hasStats(stats: SchemaDiffRoutineTextDiffStats): boolean {
}
function onCheckboxChange(object: SchemaDiffObject, event: Event) {
if (object.operationType === "none") return;
emit("toggle-selection", object, (event.target as HTMLInputElement).checked);
}
@@ -79,7 +81,17 @@ function onRowActivate(object: SchemaDiffObject) {
@keydown.enter.prevent="onRowActivate(row.object)"
@keydown.space.prevent="onRowActivate(row.object)"
>
<input v-if="showSelection" type="checkbox" class="accent-primary justify-self-center" :checked="row.selection.checked" :indeterminate="row.selection.indeterminate" @click.stop @change="onCheckboxChange(row.object, $event)" />
<input
v-if="showSelection"
type="checkbox"
class="accent-primary justify-self-center"
:checked="row.selection.checked"
:indeterminate="row.selection.indeterminate"
:disabled="row.object.operationType === 'none'"
:aria-label="displayName(row.object, 'source') || displayName(row.object, 'target')"
@click.stop
@change="onCheckboxChange(row.object, $event)"
/>
<div class="min-w-0 truncate font-mono" :title="row.sourceLabel || undefined">
<span v-if="row.sourceLabel" :class="row.object.operationType === 'create' ? 'text-green-600 dark:text-green-400' : ''">{{ row.sourceLabel }}</span>
<span v-else class="text-muted-foreground">—</span>
@@ -89,7 +101,10 @@ function onRowActivate(object: SchemaDiffObject) {
<span v-else class="text-muted-foreground">—</span>
</div>
<div class="min-w-0 truncate tabular-nums" :title="hasStats(row.stats) ? t('diff.routineDiffStats', { added: row.stats.added, removed: row.stats.removed, modified: row.stats.modified }) : undefined">
<template v-if="hasStats(row.stats)">
<span v-if="row.object.routineComparisonState === 'equal'" class="text-muted-foreground">{{ t("diff.routineEqual") }}</span>
<span v-else-if="row.object.routineComparisonState === 'unreadable'" class="text-amber-700 dark:text-amber-300">{{ t("diff.routineUnreadable") }}</span>
<span v-else-if="row.object.routineComparisonState === 'conflict'" class="text-destructive">{{ t("diff.routineConflict") }}</span>
<template v-else-if="hasStats(row.stats)">
<span class="text-green-600 dark:text-green-400">+{{ row.stats.added }}</span>
<span class="text-muted-foreground"> </span>
<span class="text-red-500 dark:text-red-400">−{{ row.stats.removed }}</span>
@@ -2,12 +2,33 @@
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDataCompareSelection, isCompareRowSelected } from "@/lib/dataGrid/dataCompareSelection";
import i18n from "@/i18n";
import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
import type { DataCompareSession } from "@/composables/useDataCompareSession";
vi.mock("vue-virtual-scroller", () => ({
DynamicScroller: defineComponent({
props: ["items"],
setup:
(props, { slots }) =>
() =>
h(
"div",
props.items.map((item: unknown, index: number) => slots.default?.({ item, index, active: true })),
),
}),
DynamicScrollerItem: defineComponent({
setup:
(_, { slots }) =>
() =>
h("div", slots.default?.()),
}),
}));
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
copyToClipboard: vi.fn().mockResolvedValue(undefined),
ensureConnected: vi.fn().mockResolvedValue(undefined),
listDatabases: vi.fn().mockResolvedValue([]),
listSchemas: vi.fn().mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]),
@@ -21,6 +42,8 @@ const mocks = vi.hoisted(() => ({
rollbackManualTransaction: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
const sessionMocks = vi.hoisted(() => ({
@@ -132,10 +155,11 @@ function completedSession(): DataCompareSession {
rowBudget: 100_000,
syncEligible: true,
databaseType: "oracle",
selection: { added: createDataCompareSelection(true), removed: createDataCompareSelection(true), modified: createDataCompareSelection(true) },
diff: {
added: [
{ key: "1", keyValues: { ID: 1 }, values: { ID: 1, NAME: "first" }, selected: true },
{ key: "2", keyValues: { ID: 2 }, values: { ID: 2, NAME: "second" }, selected: true },
{ key: "1", keyValues: { ID: 1 }, values: { ID: 1, NAME: "first" } },
{ key: "2", keyValues: { ID: 2 }, values: { ID: 2, NAME: "second" } },
],
removed: [],
modified: [],
@@ -483,7 +507,7 @@ describe("DataCompareDialog session restore", () => {
});
await flushAsyncSetup();
expect(session.batchResults[0]?.diff.added[0]?.selected).toBe(false);
expect(isCompareRowSelected(session.batchResults[0]!.selection.added, 0)).toBe(false);
expect(session.syncPlan.syncStatements).toEqual(["INSERT 2"]);
expect(session.syncPlan.syncSql).toBe("INSERT 2;");
});
@@ -536,3 +560,20 @@ describe("DataCompareDialog truncation warning", () => {
expect(document.body.textContent ?? "").not.toContain(i18n.global.t("dataCompare.truncatedTitle"));
});
});
it("bounds the SQL preview while executing the complete plan", async () => {
const session = completedSession();
const statements = Array.from({ length: 1000 }, (_, index) => `UPDATE orders SET name = '${"x".repeat(32)}' WHERE id = ${index};`);
session.syncPlan = { insertCount: 0, updateCount: 1000, deleteCount: 0, statementCount: statements.length, syncStatements: statements, syncSql: statements.join("\n") };
mountSessionDialog(session);
await flushAsyncSetup();
const preview = document.querySelector<HTMLTextAreaElement>("textarea[readonly]")!.value;
expect(preview.length).toBeLessThan(9000);
expect(preview).toContain("WHERE id = 999;");
buttonFor("diff.copySql").click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(session.syncPlan.syncSql));
mocks.executeBatch.mockResolvedValue(undefined);
buttonFor("diff.executeSync").click();
await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(2));
expect(mocks.executeBatch.mock.calls.flatMap((call) => call[2])).toEqual(statements);
});
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive, type App } from "vue";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { getDefaultOptionsForDbType, normalizeSchemaDiffCompareOptions, type OracleRoutineKind, type OracleRoutineIdentity, type SchemaDiffCompareOptions } from "@/types/schemaDiff";
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
const backend = vi.hoisted(() => ({ listObjects: vi.fn(), loadSchemaDiffRoutines: vi.fn() }));
vi.mock("@/stores/connectionStore", () => {
const connections = [
{ id: "source", name: "Source", db_type: "oceanbase-oracle" },
{ id: "target", name: "Target", db_type: "oceanbase-oracle" },
{ id: "mysql", name: "MySQL", db_type: "mysql" },
];
return { useConnectionStore: () => ({ connections, sidebarLayout: { groups: [], order: [] }, getConfig: (id: string) => connections.find((connection) => connection.id === id), ensureConnected: vi.fn().mockResolvedValue(undefined) }) };
});
vi.mock("@/lib/backend/api", () => ({ ...backend, listDatabases: vi.fn().mockResolvedValue(["S", "T"]), listSchemas: vi.fn().mockResolvedValue(["S", "T"]), executeQuery: vi.fn().mockResolvedValue({ rows: [] }) }));
const name = 'Pkg.With.Dot,"中文😀';
const rows = (kinds: string[]) => kinds.map((object_type) => ({ name: object_type.startsWith("PACKAGE") ? name : object_type === "FUNCTION" ? "F" : "P", object_type }));
const apps: App[] = [];
beforeEach(() => {
vi.clearAllMocks();
backend.listObjects.mockImplementation(async (_id, _db, _schema, kinds: string[]) => rows(kinds));
});
afterEach(() => {
apps.splice(0).forEach((app) => app.unmount());
document.body.textContent = "";
});
async function flush() {
for (let index = 0; index < 8; index++) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function mountConfig(options: Partial<SchemaDiffCompareOptions> = {}, targetConnectionId = "target") {
const state = reactive({ options: { ...getDefaultOptionsForDbType("oceanbase-oracle"), tables: false, views: false, functions: true, ...options } });
const container = document.createElement("div");
document.body.append(container);
const app = createApp({
setup: () => () =>
h(SchemaDiffConfigStep, {
sourceConnectionId: "source",
sourceDatabase: "S",
sourceSchema: "S",
targetConnectionId,
targetDatabase: "T",
targetSchema: "T",
configs: [],
activeConfigId: "",
ignoreComments: false,
options: state.options,
selectedRoutines: state.options.selectedRoutines,
loading: false,
recentConfigs: [],
tableListLoader: { load: vi.fn().mockResolvedValue([]) },
"onUpdate:routineSelection": (selection: { objectKinds: OracleRoutineKind[]; selectedObjects?: Pick<OracleRoutineIdentity, "name" | "kind">[] }) => {
state.options = normalizeSchemaDiffCompareOptions({ ...state.options, routineObjectKinds: selection.objectKinds, selectedRoutineObjects: selection.selectedObjects, selectedRoutines: undefined, routineMappings: [] });
},
"onUpdate:selectedRoutines": (selectedRoutines?: string[]) => {
state.options = { ...state.options, selectedRoutines };
},
"onUpdate:compareScope": (scope: { functions?: boolean }) => {
state.options = { ...state.options, ...scope };
},
}),
});
apps.push(app);
app.use(i18n);
app.mount(container);
return { state, container };
}
it("keeps the legacy listing request unchanged and lets BODY-only selection use an opaque kind key with a readable label", async () => {
const { state, container } = mountConfig();
await flush();
expect(backend.listObjects).toHaveBeenCalledWith("source", "S", "S", ["PROCEDURE", "FUNCTION"]);
expect(state.options.routineObjectKinds).toBeUndefined();
container.querySelector<HTMLInputElement>('[data-program-kind="PACKAGE_BODY"]')!.click();
await flush();
expect(state.options.routineObjectKinds).toEqual(["PROCEDURE", "FUNCTION", "PACKAGE_BODY"]);
[...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === i18n.global.t("diff.chooseRoutines"))!.click();
await flush();
const key = JSON.stringify(["PACKAGE_BODY", name, ""]);
const body = [...container.querySelectorAll<HTMLButtonElement>("[data-table-name]")].find((button) => button.dataset.tableName === key)!;
expect(body.textContent).toContain(`PACKAGE BODY ${name}`);
expect(body.textContent).not.toContain('["PACKAGE_BODY"');
body.click();
await flush();
expect(state.options.selectedRoutineObjects).toEqual([{ name, kind: "PACKAGE_BODY" }]);
expect(state.options.selectedRoutines).toBeUndefined();
container.querySelector<HTMLInputElement>('[data-program-kind="PACKAGE"]')!.click();
await flush();
expect(state.options.selectedRoutineObjects).toEqual([{ name, kind: "PACKAGE_BODY" }]);
[...container.querySelectorAll<HTMLButtonElement>("[data-table-name]")].find((button) => button.dataset.tableName === JSON.stringify(["PACKAGE", name, ""]))!.click();
await flush();
expect(state.options.selectedRoutineObjects).toEqual([
{ name, kind: "PACKAGE_BODY" },
{ name, kind: "PACKAGE" },
]);
expect(backend.loadSchemaDiffRoutines).not.toHaveBeenCalled();
});
it("does not let an older kind-list response remove a newer restored package selection", async () => {
const { state, container } = mountConfig();
await flush();
let resolveOld!: (value: ReturnType<typeof rows>) => void;
backend.listObjects.mockImplementation(async (_id, _db, _schema, kinds: string[]) =>
kinds.includes("PACKAGE_BODY")
? new Promise((resolve) => {
resolveOld = resolve;
})
: rows(kinds),
);
container.querySelector<HTMLInputElement>('[data-program-kind="PACKAGE_BODY"]')!.click();
await vi.waitFor(() => expect(resolveOld).toBeTypeOf("function"));
state.options = normalizeSchemaDiffCompareOptions({ ...state.options, routineObjectKinds: ["PACKAGE"], selectedRoutineObjects: [{ name, kind: "PACKAGE" }], selectedRoutines: undefined });
await flush();
resolveOld(rows(["PACKAGE_BODY"]));
await flush();
expect(state.options.selectedRoutineObjects).toEqual([{ name, kind: "PACKAGE" }]);
expect([...container.querySelectorAll<HTMLButtonElement>("[data-table-name]")].map((button) => button.dataset.tableName)).toContain(JSON.stringify(["PACKAGE", name, ""]));
expect(container.querySelector<HTMLInputElement>('[data-program-kind="PACKAGE_BODY"]')!.checked).toBe(false);
});
it("retains an explicit legacy function selection when package kinds are enabled", async () => {
const { state, container } = mountConfig({ selectedRoutines: ["F"] });
await flush();
container.querySelector<HTMLInputElement>('[data-program-kind="PACKAGE"]')!.click();
await flush();
expect(state.options.selectedRoutineObjects).toEqual([{ name: "F", kind: "FUNCTION" }]);
expect(state.options.selectedRoutines).toBeUndefined();
});
it("lets an unsupported restored package scope be turned off explicitly without silently filtering it", async () => {
const { state, container } = mountConfig({ tables: true, routineObjectKinds: ["PACKAGE"] }, "mysql");
await flush();
const toggle = [...container.querySelectorAll<HTMLButtonElement>('[role="switch"]')].find((button) => button.getAttribute("aria-label") === i18n.global.t("diff.compareRoutinesSwitch"))!;
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(toggle.disabled).toBe(false);
toggle.click();
await flush();
expect(state.options.functions).toBe(false);
expect(state.options.routineObjectKinds).toEqual(["PACKAGE"]);
expect(backend.loadSchemaDiffRoutines).not.toHaveBeenCalled();
});
@@ -3,8 +3,14 @@
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
import { createOracleRoutineDeployment, editOraclePackageOptions, includeOraclePackageCompanion, reviewOraclePackageTargetDraft, selectOracleRoutineDraft, synchronizeOracleRoutineSelection } from "@/lib/schema/oracleRoutineDeploy";
import type { OracleRoutineOperation, OracleRoutineKind } from "@/types/schemaDiff";
const editor = vi.hoisted(() => ({ focus: vi.fn(), dispatch: vi.fn(), readOnly: vi.fn((_value: boolean) => []), dialect: vi.fn((_language: unknown, _dialect: string) => undefined), copy: vi.fn() }));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: editor.copy }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/i18n", () => ({ default: { global: { t: (key: string) => key } } }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { theme: "default", fontSize: 12, fontFamily: "monospace" } }),
@@ -19,7 +25,7 @@ vi.mock("@/lib/editor/editorThemes", () => ({
editorFontTheme: () => [],
}));
vi.mock("@/lib/editor/codemirrorSqlDialect", () => ({ createDbxCodeMirrorSqlDialect: () => undefined }));
vi.mock("@/lib/editor/codemirrorSqlDialect", () => ({ createDbxCodeMirrorSqlDialect: editor.dialect }));
// The deploy script preview is a CodeMirror view; the option under test lives in
// the footer, so the editor is replaced with the smallest view that still accepts
@@ -32,19 +38,24 @@ vi.mock("@codemirror/view", () => ({
constructor(config: { state: { doc: { toString: () => string; length: number } } }) {
this.state = config.state;
}
dispatch() {}
dispatch = editor.dispatch;
focus = editor.focus;
destroy() {}
},
}));
vi.mock("@codemirror/state", () => ({
EditorState: {
readOnly: { of: editor.readOnly },
create: (config: { doc: string }) => ({ doc: { toString: () => config.doc, length: config.doc.length } }),
},
Compartment: class {
of() {
return [];
}
reconfigure() {
return [];
}
},
}));
@@ -130,6 +141,327 @@ describe("SchemaDiffDeployStep continue-on-error option", () => {
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.clearAllMocks();
});
function routineFixture(kind: OracleRoutineKind = "FUNCTION") {
const endpoint = { connectionId: "target", database: "tenant", schema: "Owner.With.Dot", databaseType: "oceanbase-oracle" as const, generation: 1 };
const snapshot = { endpoint, state: "complete" as const, enumerationComplete: true, objects: [], issues: [] };
const state = reactive(createOracleRoutineDeployment(snapshot, snapshot));
const object = { id: "func-F", name: "F", routineType: kind, objectKind: "function" as const, operationType: "create" as const, selected: true };
synchronizeOracleRoutineSelection(state, [object]);
const identity = state.drafts[0].identity;
const sql = kind === "FUNCTION" ? 'CREATE FUNCTION "Owner.With.Dot"."F" RETURN NUMBER AS BEGIN RETURN 1; END;' : `CREATE ${kind.replaceAll("_", " ")} "Owner.With.Dot"."F" AS END;`;
state.drafts[0].sql = sql;
state.plan = {
target: endpoint,
ready: false,
steps: [{ identity, action: "create", sql, sourceOriginal: "raw source", targetOriginal: null, recoverySql: null, recoveryComplete: true, references: [], incomingCallers: [], operation: null, issues: [{ code: "unsupported", message: "Use a static reference", line: 1, column: 5 }] }],
};
return { state, object, sql, endpoint, identity };
}
describe("SchemaDiffDeployStep Oracle routine review", () => {
it("reuses package editing and validation for transfer without offering a second execution or replacement of skipped kinds", async () => {
const { state, object, sql, identity } = routineFixture("PACKAGE_BODY");
const spec = { ...identity, kind: "PACKAGE" as const };
const material = { version: { identity: spec, objectId: "spec", status: "VALID", lastDdlTime: "1" }, source: "complete specification", sourceComplete: true, dependencies: [], dependenciesComplete: true, issues: [] };
state.source = { ...state.source, objects: [material] };
state.target = { ...state.target, objects: [material] };
const validate = vi.fn();
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oracle", routineDeployment: state, packageTransfer: true, onValidateRoutines: validate });
expect(container.textContent).not.toContain("diff.deployToServer");
expect(container.textContent).not.toContain("diff.backToResult");
expect(container.textContent).not.toContain("diff.packageAddSpecification");
const buttons = [...container.querySelectorAll<HTMLButtonElement>("button")];
buttons.find((button) => button.textContent?.trim() === "diff.routineValidate")!.click();
expect(validate).toHaveBeenCalledOnce();
expect(container.querySelector('input[type="radio"][value="target"]')).not.toBeNull();
state.target = { ...state.target, objects: [] };
await nextTick();
expect(container.textContent).toContain("diff.packageAddSpecification");
});
it("offers an explicit manual target-draft review for cross-product packages without enabling deployment", async () => {
const { state, object, sql } = routineFixture("PACKAGE");
state.source = { ...state.source, endpoint: { ...state.source.endpoint, databaseType: "oracle" } };
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oceanbase-oracle", routineDeployment: state, canExecute: false, onReviewPackageTarget: (id: string) => reviewOraclePackageTargetDraft(state, id) });
expect(container.textContent).toContain("oracle → oceanbase-oracle");
expect(container.textContent).toContain("diff.packageCrossProduct");
const review = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("diff.packageReviewTarget"))!;
expect(state.drafts[0].reviewedTargetDraft).not.toBe(true);
review.click();
await nextTick();
expect(state.drafts[0].reviewedTargetDraft).toBe(true);
expect(state.plan).toBeNull();
const deploy = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("diff.deployToServer"))!;
expect(deploy.disabled).toBe(true);
});
it("requires an explicit body choice, retains target body as a draft, and invalidates confirmation when its contract changes", async () => {
const { state, object, sql, identity } = routineFixture("PACKAGE");
const bodyIdentity = { ...identity, kind: "PACKAGE_BODY" as const };
state.drafts[0].action = "replace";
state.target = { ...state.target, objects: [{ version: { identity: bodyIdentity, objectId: "2", status: "VALID", lastDdlTime: "1" }, source: "complete target body", sourceComplete: true, dependencies: [], dependenciesComplete: true, issues: [] }] };
const { container } = await mountDeployStep({
deploySql: sql,
selectedObjects: [object],
databaseType: "oceanbase-oracle",
routineDeployment: state,
onIncludePackageCompanion: (id: string, source: "source" | "target") => includeOraclePackageCompanion(state, id, source),
onUpdatePackageOptions: (id: string, options: Parameters<typeof editOraclePackageOptions>[2]) => editOraclePackageOptions(state, id, options),
});
expect(state.drafts).toHaveLength(1);
const confirm = [...container.querySelectorAll<HTMLLabelElement>("label")].find((label) => label.textContent?.includes("diff.packageConfirmImpact"))!.querySelector<HTMLInputElement>("input")!;
expect(confirm.checked).toBe(false);
confirm.checked = true;
confirm.dispatchEvent(new Event("change"));
await nextTick();
expect(state.drafts[0].confirmPackagePair).toBe(true);
const retain = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("diff.packageRebuildTargetBody"))!;
retain.click();
await nextTick();
expect(state.drafts).toHaveLength(2);
expect(state.drafts[1]).toMatchObject({ identity: bodyIdentity, packageBodySource: "target", action: "replace" });
expect(state.drafts[0].confirmPackagePair).toBe(false);
expect(state.plan).toBeNull();
const original = [...container.querySelectorAll("details")].find((detail) => detail.querySelector("summary")?.textContent === "diff.targetDdl")!;
expect(original.querySelector("pre")?.textContent).toBe("complete target body");
original.querySelector("button")!.click();
expect(editor.copy).toHaveBeenCalledWith("complete target body");
const targetContract = container.querySelector<HTMLInputElement>('input[type="radio"][value="target"]')!;
targetContract.checked = true;
targetContract.dispatchEvent(new Event("change"));
await nextTick();
expect(state.drafts[1].packageContract).toBe("target");
});
it("renders the Core package order and exposes recovery-only body source in the same review", async () => {
const { state, object, sql, identity } = routineFixture("PACKAGE");
const bodyIdentity = { ...identity, kind: "PACKAGE_BODY" as const };
const specification = state.plan!.steps[0];
const body = { ...specification, identity: bodyIdentity, action: "delete" as const, sql: 'DROP PACKAGE BODY "Owner.With.Dot"."F"', sourceOriginal: "complete original body" };
state.recoveryPlan = { ...state.plan!, steps: [body, specification] };
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oceanbase-oracle", routineDeployment: state, onSelectRoutine: (id: string) => selectOracleRoutineDraft(state, id) });
const rows = [...container.querySelectorAll<HTMLElement>('[role="button"]')];
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain("PACKAGE BODY");
expect(rows[1].textContent).not.toContain("PACKAGE BODY");
rows[0].click();
await nextTick();
expect(state.recoveryPlan).not.toBeNull();
expect(state.drafts).toHaveLength(1);
expect(container.textContent).toContain("Owner.With.Dot.F · PACKAGE_BODY");
const source = [...container.querySelectorAll("details")].find((detail) => detail.querySelector("summary")?.textContent === "diff.sourceDdl")!;
expect(source.querySelector("pre")?.textContent).toBe("complete original body");
source.querySelector("button")!.click();
expect(editor.copy).toHaveBeenCalledWith("complete original body");
});
it("shows specification success separately from an invalid body, preserves its full readback, and does not locate body errors in the specification editor", async () => {
const { state, object, sql, endpoint, identity } = routineFixture("PACKAGE");
const bodyIdentity = { ...identity, kind: "PACKAGE_BODY" as const };
const body = 'CREATE OR REPLACE PACKAGE BODY "Owner.With.Dot"."F" AS -- 中文😀\nEND;';
const record: OracleRoutineOperation = {
operationId: "spec-success",
target: endpoint,
identity,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
error: null,
readback: {
identity,
state: "verified",
exists: true,
version: null,
source: sql,
definitionMatches: true,
issues: [],
compilerMessages: [],
packageCompanion: {
identity: bodyIdentity,
state: "invalid",
exists: true,
version: { identity: bodyIdentity, objectId: "12", status: "INVALID", lastDdlTime: "1" },
source: body,
definitionMatches: null,
issues: [],
compilerMessages: [
{ attribute: "ERROR", line: 3, position: 12, text: "PLS-00323" },
{ attribute: "WARNING", line: 4, position: 2, text: "unused package state" },
],
},
},
};
state.drafts[0].operation = record;
state.plan!.steps[0].operation = record;
state.plan!.steps[0].issues = [];
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oceanbase-oracle", routineDeployment: state, canExecute: false });
expect(container.textContent).toContain("diff.routineVerified");
expect(container.textContent).toContain("PACKAGE BODY F · objects.invalid");
expect(container.textContent).toContain("ERROR 3:12 — PLS-00323");
expect(container.textContent).toContain("WARNING 4:2 — unused package state");
expect([...container.querySelectorAll("button")].some((button) => button.textContent?.includes("PLS-00323"))).toBe(false);
const details = [...container.querySelectorAll("details")].find((detail) => detail.querySelector("summary")?.textContent === "diff.targetDdl · PACKAGE BODY")!;
expect(details.querySelector("pre")?.textContent).toBe(body);
details.querySelector("button")!.click();
expect(editor.copy).toHaveBeenCalledWith(body);
state.drafts[0].operation!.readback!.packageCompanion = { ...record.readback!.packageCompanion!, state: "missing", exists: false, version: null, source: null, compilerMessages: [] };
await nextTick();
expect(container.textContent).toContain("PACKAGE BODY F · objects.routineReadbackMissing");
});
it("does not present a VALID catalog status as complete companion verification when its source readback failed", async () => {
const { state, object, sql, endpoint, identity } = routineFixture("PACKAGE");
const companion = { ...identity, kind: "PACKAGE_BODY" as const };
state.drafts[0].operation = {
operationId: "pair-read-failed",
target: endpoint,
identity,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
error: null,
readback: {
identity,
state: "readback-failed",
exists: true,
source: sql,
version: null,
definitionMatches: true,
compilerMessages: [],
issues: [],
packageCompanion: {
identity: companion,
state: "readback-failed",
exists: true,
version: { identity: companion, objectId: "12", status: "VALID", lastDdlTime: "1" },
source: null,
definitionMatches: null,
compilerMessages: [],
issues: [{ stage: "source", code: "incomplete", message: "companion source incomplete" }],
},
},
};
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], routineDeployment: state });
expect(container.textContent).toContain("PACKAGE BODY F · diff.routineUnverified");
expect(container.textContent).toContain("companion source incomplete");
expect(container.textContent).not.toContain("PACKAGE BODY F · VALID");
});
it("locates a package-body compiler position after Unicode and an omitted stored owner in the body editor", async () => {
const { state, object, endpoint, identity } = routineFixture("PACKAGE_BODY");
const stored = "PACKAGE BODY \"F\" AS FUNCTION f RETURN NUMBER IS BEGIN RETURN '😀' + missing_identifier; END f; END;";
const sql = `CREATE OR REPLACE ${stored.replace('"F"', '"Owner.With.Dot"."F"')}`;
state.drafts[0].sql = sql;
state.plan!.steps[0].sql = sql;
state.plan!.steps[0].issues = [];
state.drafts[0].operation = {
operationId: "invalid-body",
target: endpoint,
identity,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
error: null,
readback: {
identity,
state: "invalid",
exists: true,
source: `CREATE OR REPLACE ${stored}`,
version: null,
definitionMatches: true,
issues: [],
compilerMessages: [{ attribute: "ERROR", line: 1, position: Array.from(stored.slice(0, stored.indexOf("missing_identifier"))).length + 1, text: "package member missing" }],
},
};
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], routineDeployment: state, databaseType: "oceanbase-oracle" });
const message = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("package member missing"))!;
expect(message.disabled).toBe(false);
message.click();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(editor.dispatch).toHaveBeenCalledWith(expect.objectContaining({ selection: { anchor: sql.indexOf("missing_identifier") } }));
});
it("keeps the editable draft and positioned issues in the existing editor, with explicit revalidation", async () => {
const { state, object, sql } = routineFixture();
const validate = vi.fn();
const update = vi.fn();
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oceanbase-oracle", routineDeployment: state, canExecute: false, onValidateRoutines: validate, "onUpdate:deploySql": update });
expect(editor.dialect.mock.calls[0]?.[1]).toBe("oracle");
expect(continueOnErrorCheckbox(container)).toBeNull();
const issue = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("1:5 — Use a static reference"));
expect(issue).toBeDefined();
issue!.click();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(editor.dispatch).toHaveBeenCalledWith(expect.objectContaining({ selection: { anchor: 4 } }));
expect(editor.focus).toHaveBeenCalled();
const buttons = [...container.querySelectorAll<HTMLButtonElement>("button")];
buttons.find((button) => button.textContent?.trim() === "diff.routineValidate")!.click();
expect(validate).toHaveBeenCalledTimes(1);
expect(buttons.find((button) => button.textContent?.trim() === "diff.deployToServer")!.disabled).toBe(true);
expect(update).not.toHaveBeenCalled();
});
it("displays ACK and compiler evidence without a success label, and only locks verified definitions", async () => {
const { state, object, sql, endpoint, identity } = routineFixture();
const record: OracleRoutineOperation = {
operationId: "original-invalid",
target: endpoint,
identity,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
error: null,
readback: { identity, state: "invalid", exists: true, version: null, source: sql, definitionMatches: true, issues: [], compilerMessages: [{ attribute: "ERROR", line: 1, position: 20, text: "invalid identifier" }] },
};
state.drafts[0].operation = record;
state.plan!.steps[0].operation = record;
const observe = vi.fn();
const { container } = await mountDeployStep({ deploySql: sql, selectedObjects: [object], databaseType: "oracle", routineDeployment: state, onObserveRoutine: observe });
expect(container.textContent).toContain("objects.routineWriteAcknowledged");
expect(container.textContent).toContain("ERROR 1:20 — invalid identifier");
expect(container.textContent).not.toContain("diff.routineVerified");
expect(editor.readOnly).toHaveBeenLastCalledWith(false);
[...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "diff.routineObserve")!.click();
expect(observe).toHaveBeenCalledWith("original-invalid");
state.drafts[0].operation = { ...record, readback: { ...record.readback!, state: "verified", compilerMessages: [] } };
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(editor.readOnly).toHaveBeenLastCalledWith(true);
expect(container.textContent).toContain("diff.routineReadOnlyCompleted");
});
it("maps a compiler position past an omitted stored owner to the same token in the target draft", async () => {
const { state, object, sql, endpoint, identity } = routineFixture();
const draftSql = sql.replace("RETURN 1;", "RETURN missing_identifier;");
const storedUnit = 'FUNCTION "F" RETURN NUMBER AS BEGIN RETURN missing_identifier; END';
const position = storedUnit.indexOf("missing_identifier") + 1;
const record: OracleRoutineOperation = {
operationId: "invalid-short-header",
target: endpoint,
identity,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
error: null,
readback: { identity, state: "invalid", exists: true, version: null, source: `CREATE OR REPLACE ${storedUnit}`, definitionMatches: true, issues: [], compilerMessages: [{ attribute: "ERROR", line: 1, position, text: "unknown identifier" }] },
};
state.drafts[0].sql = draftSql;
state.drafts[0].operation = record;
state.plan!.steps[0].sql = draftSql;
state.plan!.steps[0].operation = record;
const { container } = await mountDeployStep({ deploySql: draftSql, selectedObjects: [object], databaseType: "oracle", routineDeployment: state });
const message = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("unknown identifier"))!;
expect(message.disabled).toBe(false);
message.click();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(editor.dispatch).toHaveBeenCalledWith(expect.objectContaining({ selection: { anchor: draftSql.indexOf("missing_identifier") } }));
});
});
describe("SchemaDiffDeployStep foreign key check option", () => {
@@ -0,0 +1,554 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive, type App } from "vue";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue";
import SchemaDiffDeployStep from "@/components/diff/SchemaDiffDeployStep.vue";
import { startSchemaDiffSession, removeSchemaDiffSession } from "@/composables/useSchemaDiffSession";
import type { OracleRoutineKind, OracleRoutineOperation, OracleRoutinePlanInput, OracleRoutineSnapshot } from "@/types/schemaDiff";
const calls = vi.hoisted(() => ({ load: vi.fn(), compare: vi.fn(), prepare: vi.fn(), execute: vi.fn(), observe: vi.fn(), genericPlan: vi.fn(), confirm: vi.fn(), copy: vi.fn(), transaction: vi.fn(), databaseType: "oceanbase-oracle", sourceDatabaseType: "" }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/i18n", () => ({ default: { global: { t: (key: string) => key } } }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/composables/useDialogSources", () => ({ openSchemaDiffSession: vi.fn() }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => ({ addSchemaDiffTask: vi.fn(), updateCompareTask: vi.fn() }) }));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: calls.confirm }) }));
vi.mock("@/stores/connectionStore", () => {
const connections = () => ["source", "target"].map((id) => ({ id, name: id, db_type: id === "source" ? calls.sourceDatabaseType || calls.databaseType : calls.databaseType, is_production: id === "target", host: "localhost", port: 1 }));
return {
useConnectionStore: () => ({
get connections() {
return connections();
},
getConfig: (id: string) => connections().find((connection) => connection.id === id),
ensureConnected: vi.fn().mockResolvedValue(undefined),
}),
};
});
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { theme: "default", fontSize: 12, fontFamily: "monospace" } }) }));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: async () => [], editorFontTheme: () => [] }));
vi.mock("@/lib/editor/codemirrorSqlDialect", () => ({ createDbxCodeMirrorSqlDialect: () => undefined }));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: calls.copy }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({ invalidateObjectDdlCache: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@/lib/table/objectBrowserRowsCache", () => ({ invalidateObjectBrowserRowsCache: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({
loadSchemaDiffRoutines: calls.load,
prepareSchemaDiff: calls.compare,
prepareOracleRoutinePlan: calls.prepare,
updateOracleRoutineOperationReferences: vi.fn(async () => {}),
executeOracleRoutineOperation: calls.execute,
observeOracleRoutineOperation: calls.observe,
generateSchemaSyncPlan: calls.genericPlan,
executeQuery: vi.fn().mockResolvedValue({ rows: [] }),
listFunctions: vi.fn().mockResolvedValue([]),
executeScriptWith2pc: calls.transaction,
}));
// Substitute only CodeMirror's DOM boundary. The production dialog, session,
// deployment controller, buttons and production guard run unchanged.
vi.mock("@codemirror/state", () => ({
EditorState: { readOnly: { of: () => [] }, create: (config: any) => ({ ...config, doc: { toString: () => config.doc, length: config.doc.length } }) },
Compartment: class {
of() {
return [];
}
reconfigure() {
return [];
}
},
}));
vi.mock("@codemirror/view", () => ({
EditorView: class {
static updateListener = { of: (callback: unknown) => callback };
static scrollIntoView = () => ({});
state: any;
input: HTMLTextAreaElement;
listener: any;
constructor(config: any) {
this.state = config.state;
this.listener = config.state.extensions.find((extension: unknown) => typeof extension === "function");
this.input = document.createElement("textarea");
this.input.value = this.state.doc.toString();
this.input.addEventListener("input", () => this.setText(this.input.value));
config.parent.append(this.input);
}
setText(text: string) {
this.state.doc = { toString: () => text, length: text.length };
this.input.value = text;
this.listener?.({ docChanged: true, state: this.state });
}
dispatch(change: any) {
if (change.changes) this.setText(change.changes.insert);
}
focus() {
this.input.focus();
}
destroy() {
this.input.remove();
}
},
}));
vi.mock("@codemirror/lang-sql", () => ({ sql: () => [] }));
vi.mock("codemirror", () => ({ basicSetup: [] }));
const apps: App[] = [];
const sessions: string[] = [];
const records = new Map<string, OracleRoutineOperation>();
function snapshot(connectionId: string): OracleRoutineSnapshot {
const owner = connectionId === "source" ? "Source" : "Target";
const product = connectionId === "source" ? calls.sourceDatabaseType || calls.databaseType : calls.databaseType;
return { endpoint: { connectionId, database: "tenant", schema: owner, databaseType: product === "oracle" ? "oracle" : "oceanbase-oracle", generation: 1 }, state: "complete", enumerationComplete: true, objects: [], issues: [] };
}
const sourceSql = 'CREATE OR REPLACE FUNCTION "F" RETURN NUMBER AS BEGIN RETURN 1; END';
const sourceFunction = { name: "F", function_type: "FUNCTION", definition: sourceSql, arguments: "", data_type: "NUMBER" };
beforeEach(() => {
vi.clearAllMocks();
records.clear();
calls.databaseType = "oceanbase-oracle";
calls.sourceDatabaseType = "";
calls.load.mockImplementation(async (id: string) => snapshot(id));
calls.compare.mockResolvedValue({ diffs: [], functionDiffs: [{ type: "added", name: "F", source: sourceFunction }], syncSql: "", renameCandidates: [] });
calls.confirm.mockResolvedValue(true);
calls.genericPlan.mockResolvedValue({ syncSql: "CREATE FUNCTION f() RETURNS int AS 'SELECT 1' LANGUAGE SQL;", rollbackCompleteness: "complete", missingRollbackObjects: [] });
calls.transaction.mockResolvedValue({ status: "committed", executed_count: 1, statement_count: 1 });
calls.prepare.mockImplementation(async (input: OracleRoutinePlanInput) => ({
target: input.target.endpoint,
ready: true,
steps: input.drafts.map((draft) => {
const operation: OracleRoutineOperation = { operationId: `approval-${calls.prepare.mock.calls.length}`, target: input.target.endpoint, identity: draft.identity, lifecycle: "prepared", dispatched: false, acknowledged: false, error: null, readback: null };
records.set(operation.operationId, operation);
return {
identity: draft.identity,
action: draft.action,
sql: draft.sql ?? 'CREATE FUNCTION "Target"."F" RETURN NUMBER AS BEGIN RETURN 1; END;',
sourceOriginal: sourceSql,
targetOriginal: null,
recoverySql: null,
recoveryComplete: true,
references: [],
incomingCallers: [],
issues: [],
operation,
};
}),
}));
calls.observe.mockImplementation(async (id: string) => ({ operation: records.get(id), lifecycle: records.get(id)?.lifecycle, error: null }));
calls.execute.mockImplementation(async (id: string) => {
const operation: OracleRoutineOperation = {
...records.get(id)!,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
readback: { identity: records.get(id)!.identity, state: "invalid", exists: true, version: null, source: sourceSql, definitionMatches: true, issues: [], compilerMessages: [{ attribute: "ERROR", line: 1, position: 20, text: "invalid identifier" }] },
};
records.set(id, operation);
return operation;
});
});
async function flush() {
for (let index = 0; index < 8; index++) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function button(text: string) {
const found = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === text);
expect(found, text).toBeDefined();
return found!;
}
async function mountComparison(databaseType: "oracle" | "oceanbase-oracle" | "postgres" | "mysql" | "sqlserver" = "oceanbase-oracle", sourceType = databaseType, kinds?: OracleRoutineKind[]) {
calls.databaseType = databaseType;
calls.sourceDatabaseType = sourceType;
const session = startSchemaDiffSession(
{
sourceConnectionId: "source",
sourceDatabase: "tenant",
sourceSchema: "Source",
sourceDbType: sourceType,
targetConnectionId: "target",
targetDatabase: "tenant",
targetSchema: "Target",
targetDbType: databaseType,
options: { tables: false, views: false, functions: true, sequences: false, rules: false, owners: false, ...(kinds ? { routineObjectKinds: kinds } : {}) },
ignoreComments: false,
label: "test",
},
{ tableListLoader: { load: vi.fn().mockResolvedValue([]) } },
);
sessions.push(session.id);
await flush();
expect(session.status).toBe("completed");
const visible = reactive({ open: true });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
setup: () => () =>
h(SchemaDiffDialog, {
open: visible.open,
sessionId: session.id,
"onUpdate:open": (open: boolean) => {
visible.open = open;
},
}),
});
apps.push(app);
app.mount(host);
await flush();
return { session, visible };
}
afterEach(() => {
apps.splice(0).forEach((app) => app.unmount());
sessions.splice(0).forEach(removeSchemaDiffSession);
document.body.innerHTML = "";
localStorage.removeItem("dbx-schema-diff-size");
});
it("keeps external SQL and rollback previews separate from actual user edits, including clearing", async () => {
const state = reactive({ sql: "original specification", mode: "forward" });
const edits: string[] = [];
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
setup: () => () =>
h(SchemaDiffDeployStep, {
deploySql: state.sql,
deploySqlMode: state.mode as "forward" | "rollback",
rollbackSql: "original recovery",
selectedObjects: [],
targetConnectionId: "target",
targetDatabase: "tenant",
targetSchema: "Target",
executing: false,
"onUpdate:deploySql": (sql: string) => edits.push(sql),
}),
});
apps.push(app);
app.mount(host);
await flush();
const input = host.querySelector<HTMLTextAreaElement>("textarea")!;
state.sql = "";
await flush();
expect(input.value).toBe("");
state.sql = "prepared target body";
await flush();
state.mode = "rollback";
await flush();
expect(input.value).toBe("original recovery");
state.mode = "forward";
await flush();
expect(input.value).toBe("prepared target body");
expect(edits).toEqual([]);
input.value = "manual body";
input.dispatchEvent(new Event("input"));
input.value = "";
input.dispatchEvent(new Event("input"));
expect(edits).toEqual(["manual body", ""]);
});
it.each([
[{ width: "0px", height: "0px" }, "1100px", "820px"],
[{ width: "1120px", height: "840px" }, "1120px", "840px"],
])("opens readable results while preserving a positive saved dialog size: %j", async (saved, width, height) => {
localStorage.setItem("dbx-schema-diff-size", JSON.stringify(saved));
await mountComparison();
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
expect(dialog.style.width).toBe(width);
expect(dialog.style.height).toBe(height);
});
it("does not persist a hidden dialog's zero-sized resize observation", async () => {
let resize: ResizeObserverCallback | undefined;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(private callback: ResizeObserverCallback) {}
observe(target: Element) {
if (target.matches(".dbx-schema-diff-dialog")) resize = this.callback;
}
unobserve() {}
disconnect() {}
},
);
try {
await mountComparison();
await vi.waitFor(() => expect(resize).toBeDefined());
resize!([{ contentRect: { width: 1120, height: 840 } } as ResizeObserverEntry], {} as ResizeObserver);
await vi.waitFor(() => expect(JSON.parse(localStorage.getItem("dbx-schema-diff-size")!)).toEqual({ width: "1120px", height: "840px" }));
resize!([{ contentRect: { width: 0, height: 0 } } as ResizeObserverEntry], {} as ResizeObserver);
await new Promise((resolve) => setTimeout(resolve, 550));
expect(JSON.parse(localStorage.getItem("dbx-schema-diff-size")!)).toEqual({ width: "1120px", height: "840px" });
} finally {
vi.unstubAllGlobals();
}
});
it("opens the real Oracle comparison, retains draft on both confirmations' cancellation, then displays INVALID and corrects only that step", async () => {
const { session } = await mountComparison();
button("diff.nextStepDeployRoutines").click();
await flush();
expect(calls.genericPlan).not.toHaveBeenCalled();
expect(session.routineDeployment?.plan?.ready).toBe(true);
expect(button("diff.deployToServer").disabled).toBe(false);
button("diff.deployToServer").click();
await flush();
const cancel = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "diff.cancel").at(-1)!;
cancel.click();
await flush();
expect(calls.execute).not.toHaveBeenCalled();
expect(session.routineDeployment?.drafts[0].sql).toContain('"Target"."F"');
calls.confirm.mockResolvedValueOnce(false);
button("diff.deployToServer").click();
await flush();
button("diff.confirmDeploy").click();
await flush();
expect(calls.confirm).toHaveBeenCalledTimes(1);
expect(calls.execute).not.toHaveBeenCalled();
button("diff.deployToServer").click();
await flush();
button("diff.confirmDeploy").click();
await flush();
expect(calls.execute).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("ERROR 1:20 — invalid identifier");
expect(button("diff.deployToServer").disabled).toBe(true);
const editor = document.querySelector<HTMLTextAreaElement>(".deploy-sql-editor textarea")!;
editor.value = 'CREATE FUNCTION "Target"."F" RETURN NUMBER AS BEGIN RETURN 77; END;';
editor.dispatchEvent(new Event("input"));
await flush();
button("diff.routineValidate").click();
await flush();
expect(calls.prepare.mock.calls.at(-1)![0].drafts[0]).toMatchObject({ action: "replace", baselineOperationId: "approval-1", sql: editor.value });
expect(button("diff.deployToServer").disabled).toBe(false);
calls.execute.mockImplementationOnce(async (id: string) => ({
...records.get(id)!,
lifecycle: "terminal",
dispatched: true,
acknowledged: true,
readback: { identity: records.get(id)!.identity, state: "verified", exists: true, version: null, source: editor.value, definitionMatches: true, compilerMessages: [], issues: [] },
}));
button("diff.deployToServer").click();
await flush();
button("diff.confirmDeploy").click();
await flush();
expect(calls.execute.mock.calls.map((call) => call[0])).toEqual(["approval-1", "approval-2"]);
expect(session.routineDeployment?.records).toHaveLength(2);
expect(document.body.textContent).toContain("diff.routineReadOnlyCompleted");
});
it("keeps a newly included target body automatic until the user actually edits it", async () => {
calls.load.mockImplementation(async (id: string) => {
const result = snapshot(id);
result.objects = (["PACKAGE", "PACKAGE_BODY"] as const).map((kind) => ({
version: { identity: { owner: result.endpoint.schema!, name: "P", kind }, objectId: `${id}-${kind}`, status: "VALID", lastDdlTime: "1" },
source: kind === "PACKAGE" ? 'CREATE PACKAGE "P" AS FUNCTION f RETURN NUMBER; END;' : 'CREATE PACKAGE BODY "P" AS FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; END;',
sourceComplete: true,
dependencies: [],
dependenciesComplete: true,
issues: [],
}));
return result;
});
const spec = { ...sourceFunction, name: "P", function_type: "PACKAGE", definition: 'CREATE PACKAGE "P" AS g NUMBER; FUNCTION f RETURN NUMBER; END;' };
calls.compare.mockResolvedValue({ diffs: [], functionDiffs: [{ type: "modified", name: "P", programKind: "PACKAGE", source: spec, target: { ...spec, definition: spec.definition.replace("g NUMBER; ", "") } }], syncSql: "", renameCandidates: [] });
const ordinaryPrepare = calls.prepare.getMockImplementation()!;
calls.prepare.mockImplementation(async (input: OracleRoutinePlanInput) => {
const plan = await ordinaryPrepare(input);
for (const step of plan.steps) step.sql = input.drafts.find((draft) => draft.identity.kind === step.identity.kind)?.sql ?? (step.identity.kind === "PACKAGE" ? spec.definition : 'CREATE PACKAGE BODY "Target"."P" AS FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; END;');
return plan;
});
const { session } = await mountComparison("oceanbase-oracle", "oceanbase-oracle", ["PACKAGE"]);
button("diff.nextStepDeployRoutines").click();
await flush();
button("diff.packageRebuildTargetBody").click();
await flush();
const body = session.routineDeployment!.drafts.find((draft) => draft.identity.kind === "PACKAGE_BODY")!;
expect(body.packageBodySource).toBe("target");
expect(body.sql).toBeUndefined();
button("diff.routineValidate").click();
await flush();
const request = calls.prepare.mock.calls.at(-1)![0] as OracleRoutinePlanInput;
expect(request.drafts.find((draft) => draft.identity.kind === "PACKAGE_BODY")).toMatchObject({ packageBodySource: "target", sql: undefined });
expect(body.sql).toContain('PACKAGE BODY "Target"."P"');
const input = document.querySelector<HTMLTextAreaElement>(".deploy-sql-editor textarea")!;
input.value = "";
input.dispatchEvent(new Event("input"));
await flush();
expect(body.sql).toBe("");
expect(session.routineDeployment!.plan).toBeNull();
});
it("restores the same draft and operation after closing and reopening the actual dialog", async () => {
const { session, visible } = await mountComparison();
button("diff.nextStepDeployRoutines").click();
await flush();
const editor = document.querySelector<HTMLTextAreaElement>(".deploy-sql-editor textarea")!;
editor.value = "retained manual draft";
editor.dispatchEvent(new Event("input"));
await flush();
visible.open = false;
await flush();
visible.open = true;
await flush();
expect(document.querySelector<HTMLTextAreaElement>(".deploy-sql-editor textarea")?.value).toBe("retained manual draft");
expect(session.routineDeployment?.records[0].operationId).toBe("approval-1");
expect(calls.execute).not.toHaveBeenCalled();
});
it("retains a late unknown result across close/reopen and only observes its original request", async () => {
const { session, visible } = await mountComparison();
button("diff.nextStepDeployRoutines").click();
await flush();
let reject!: (error: Error) => void;
calls.execute.mockReturnValueOnce(
new Promise((_resolve, fail) => {
reject = fail;
}),
);
button("diff.deployToServer").click();
await flush();
button("diff.confirmDeploy").click();
await flush();
expect(calls.execute).toHaveBeenCalledTimes(1);
visible.open = false;
await flush();
const unknown = { ...records.get("approval-1")!, lifecycle: "unproven" as const, dispatched: true };
records.set("approval-1", unknown);
reject(new Error("client response lost"));
await flush();
visible.open = true;
await flush();
expect(session.routineDeployment?.drafts[0].operation?.operationId).toBe("approval-1");
expect(button("diff.deployToServer").disabled).toBe(true);
button("diff.routineObserve").click();
await flush();
expect(calls.observe).toHaveBeenLastCalledWith("approval-1", true);
button("diff.routineValidate").click();
await flush();
expect(calls.prepare).toHaveBeenCalledTimes(1);
expect(calls.execute).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("termination is not proven");
});
it("retains all four comparison states and copies an equal object's exact raw source", async () => {
const raw = sourceSql.replace("RETURN 1", "RETURN 'two spaces'");
calls.compare.mockResolvedValue({
diffs: [],
syncSql: "",
functionDiffs: [
{ type: "added", name: "Added", source: { ...sourceFunction, name: "Added" } },
{ type: "modified", name: "Modified", source: { ...sourceFunction, name: "Modified" }, target: { ...sourceFunction, name: "Modified", definition: "old" } },
{ type: "removed", name: "Removed", target: { ...sourceFunction, name: "Removed" } },
{ type: "equal", name: "Equal", source: { ...sourceFunction, name: "Equal", definition: raw }, target: { ...sourceFunction, name: "Equal", definition: raw } },
],
});
await mountComparison();
const rows = [...document.querySelectorAll<HTMLElement>('[role="button"]')].filter((row) => row.querySelector('input[type="checkbox"]'));
expect(rows).toHaveLength(4);
const equal = rows.find((row) => row.textContent?.includes("Equal"))!;
expect(equal.textContent).toContain("diff.routineEqual");
expect(equal.querySelector<HTMLInputElement>("input")?.disabled).toBe(true);
equal.click();
await flush();
const copy = document.querySelector<HTMLButtonElement>('button[title="diff.copySourceDdl"]')!;
expect(copy.disabled).toBe(false);
copy.click();
expect(calls.copy).toHaveBeenLastCalledWith(raw);
});
it.each(["mysql", "sqlserver"] as const)("keeps %s routine comparison copy-only", async (databaseType) => {
await mountComparison(databaseType);
expect(document.body.textContent).toContain("diff.routineSyncDeferredHint");
expect(document.body.textContent).not.toContain("diff.nextStepDeployRoutines");
expect(calls.prepare).not.toHaveBeenCalled();
expect(calls.execute).not.toHaveBeenCalled();
});
it("keeps PostgreSQL's existing plan, confirmation and transactional execution path", async () => {
await mountComparison("postgres");
button("diff.nextStepDeployRoutines").click();
await flush();
expect(calls.genericPlan).toHaveBeenCalled();
expect(calls.prepare).not.toHaveBeenCalled();
button("diff.deployToServer").click();
await flush();
button("diff.confirmDeploy").click();
await flush();
expect(calls.transaction).toHaveBeenCalledWith("target", "tenant", ["CREATE FUNCTION f() RETURNS int AS 'SELECT 1' LANGUAGE SQL;"], "Target", false);
expect(document.body.textContent).toContain("diff.deploySuccessMessage");
expect(calls.execute).not.toHaveBeenCalled();
});
it("does not label two failed inventories as an equal empty comparison", async () => {
calls.load.mockImplementation(async (id: string) => ({ ...snapshot(id), state: "permission-denied", enumerationComplete: false, issues: [{ stage: "inventory", code: "permission-denied", message: "Metadata visibility unavailable" }] }));
calls.compare.mockResolvedValue({ diffs: [], functionDiffs: [], syncSql: "" });
await mountComparison();
expect(document.body.textContent).toContain("Metadata visibility unavailable");
expect(document.body.textContent).toContain("diff.routineUnreadable");
expect(document.body.textContent).not.toContain("diff.noDifferences");
expect(button("diff.nextStepDeployRoutines").disabled).toBe(true);
});
it("opens a native Oracle target draft through the same controlled review", async () => {
const { session } = await mountComparison("oracle");
button("diff.nextStepDeployRoutines").click();
await flush();
expect(session.routineDeployment?.plan?.target.databaseType).toBe("oracle");
expect(button("diff.deployToServer").disabled).toBe(false);
expect(calls.genericPlan).not.toHaveBeenCalled();
});
it("keeps the old cross-product function selection outside the package deployment path", async () => {
const session = startSchemaDiffSession(
{
sourceConnectionId: "source",
sourceDatabase: "tenant",
sourceSchema: "Source",
sourceDbType: "oracle",
targetConnectionId: "target",
targetDatabase: "tenant",
targetSchema: "Target",
targetDbType: "oceanbase-oracle",
options: { tables: false, views: false, functions: true, sequences: false, rules: false, owners: false, routineObjectKinds: ["FUNCTION"] },
ignoreComments: false,
label: "cross-product function",
},
{ tableListLoader: { load: vi.fn() } },
);
sessions.push(session.id);
await flush();
expect(session.status).toBe("failed");
expect(session.error).toContain("cross-product comparison supports explicit package kinds only");
expect(calls.prepare).not.toHaveBeenCalled();
expect(calls.execute).not.toHaveBeenCalled();
});
it("opens a cross-product package in the actual review and requires explicit target-draft review before approval", async () => {
calls.compare.mockResolvedValue({ diffs: [], functionDiffs: [{ type: "added", name: "P", programKind: "PACKAGE", source: { ...sourceFunction, name: "P", function_type: "PACKAGE", definition: 'CREATE PACKAGE "P" AS n NUMBER; END;' } }], syncSql: "", renameCandidates: [] });
const ordinaryPrepare = calls.prepare.getMockImplementation()!;
calls.prepare.mockImplementation(async (input: OracleRoutinePlanInput) => {
const plan = await ordinaryPrepare(input);
plan.steps[0].sql = input.drafts[0].sql ?? 'CREATE PACKAGE "Target"."P" AS n NUMBER; END;';
if (!input.drafts[0].sql) {
plan.ready = false;
plan.steps[0].operation = null;
plan.steps[0].issues = [{ code: "review-required", message: "Review target draft", line: 1, column: 1 }];
}
return plan;
});
const { session } = await mountComparison("oceanbase-oracle", "oracle", ["PACKAGE"]);
button("diff.nextStepDeployRoutines").click();
await flush();
expect(session.routineDeployment?.plan?.ready).toBe(false);
expect(button("diff.deployToServer").disabled).toBe(true);
button("diff.packageReviewTarget").click();
await flush();
button("diff.routineValidate").click();
await flush();
expect(calls.prepare.mock.calls.at(-1)![0].drafts[0].sql).toContain('PACKAGE "Target"."P"');
expect(button("diff.deployToServer").disabled).toBe(false);
expect(calls.execute).not.toHaveBeenCalled();
});
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { createApp } from "vue";
import { expect, it, vi } from "vitest";
import type { SchemaDiffObject } from "@/lib/schema/schemaDiff";
import SchemaDiffRoutineList from "../SchemaDiffRoutineList.vue";
import SchemaDiffObjectTree from "../SchemaDiffObjectTree.vue";
import { groupDiffObjects } from "@/lib/schema/schemaDiff";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
it("distinguishes same-name package kinds without changing overload labels or selection identity", () => {
const objects = ["PACKAGE", "PACKAGE_BODY", "FUNCTION"].map((routineType, index) => ({
id: String(index),
name: "P",
sourceName: "P",
targetName: "P",
objectKind: "function",
routineType,
arguments: routineType === "FUNCTION" ? "integer" : "",
operationType: "modify",
selected: true,
})) as SchemaDiffObject[];
const selections: SchemaDiffObject[] = [];
const host = document.createElement("div");
const app = createApp(SchemaDiffRoutineList, { objects, selectable: true, onToggleSelection: (object: SchemaDiffObject) => selections.push(object) });
app.mount(host);
try {
const checkboxes = Array.from(host.querySelectorAll("input"));
expect(checkboxes.map((input) => input.getAttribute("aria-label"))).toEqual(["PACKAGE P", "PACKAGE BODY P", "P(integer)"]);
expect(host.textContent).toContain("PACKAGE BODY P");
for (const input of checkboxes) input.dispatchEvent(new Event("change"));
expect(selections).toEqual(objects);
} finally {
app.unmount();
}
const tree = createApp(SchemaDiffObjectTree, { groups: groupDiffObjects(objects) });
tree.mount(host);
try {
expect(host.textContent).toContain("PACKAGE P");
expect(host.textContent).toContain("PACKAGE BODY P");
} finally {
tree.unmount();
}
});
@@ -81,6 +81,7 @@ import {
Server,
ShieldCheck,
Table2,
Target,
Play,
Square,
Sparkles,
@@ -146,6 +147,7 @@ import {
type CustomPromptContext,
} from "@/lib/ai/ai";
import { activeAiRunBinding, aiContextTargetFor, bindingForSnapshot, resolveConversationBinding, resolveExternalSendTarget, sameConversationBinding, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
import { resolveComposerDatabaseReset, toggleSelectedDatabase, withDatabaseSelected, type AiComposerScope } from "@/lib/ai/aiComposerDatabases";
import type { AiExternalContextRequest } from "@/lib/ai/aiExternalContext";
import {
AI_IMAGE_ATTACHMENT_MAX_BYTES,
@@ -2470,44 +2472,92 @@ function syncSelectedDatabases() {
const active = selectedNamespace.value;
const available = dbSelectOptions.value.map((option) => option.database);
const retained = selectedDatabases.value.filter((database) => available.includes(database));
const next = retained.length ? retained : active ? [active] : [];
// The target is always part of the scope: the run executes on it even when the
// options list has not loaded yet, and @ mentions resolve against the selection
// (#12972).
const next = withDatabaseSelected(retained.length ? retained : active ? [active] : [], active);
removeMentionsOfDatabases(selectedDatabases.value.filter((database) => !next.includes(database)));
selectedDatabases.value = next;
}
function toggleDatabase(database: string) {
if (selectedDatabaseValues.value.has(database)) {
if (selectedDatabases.value.length === 1) return;
selectedDatabases.value = selectedDatabases.value.filter((item) => item !== database);
removeMentionsOfDatabases([database]);
} else {
selectedDatabases.value = [...selectedDatabases.value, database];
// Extra selected databases are read-only context. The target is set through
// `setDatabaseTarget` instead, so unchecking cannot silently move SQL elsewhere.
const next = toggleSelectedDatabase(selectedDatabases.value, database, selectedNamespace.value);
if (!next) return;
removeMentionsOfDatabases(selectedDatabases.value.filter((database) => !next.includes(database)));
selectedDatabases.value = next;
}
/** Drop the extra context databases. The target itself is never deselectable (#12972). */
function clearDatabaseSelection() {
const next = selectedNamespace.value ? [selectedNamespace.value] : [];
removeMentionsOfDatabases(selectedDatabases.value.filter((database) => !next.includes(database)));
selectedDatabases.value = next;
}
/** Whether `database` is the namespace this chat executes on. */
function isTargetNamespace(database: string): boolean {
return !!database && database === selectedNamespace.value;
}
/**
* Make `database` the database this chat executes on (#12972).
*
* The conversation binding is the single execution target (SQL actions, agent
* handoff, write confirmations), so retargeting writes it — it must not touch the
* visible editor tab (#9902). Dameng's top-level namespace is a schema, so there
* the target is the conversation schema, not its configured database.
*/
function setDatabaseTarget(database: string) {
const connection = boundConnection.value;
if (!connection || !database || isTargetNamespace(database)) return;
clearContextReferences();
selectedDatabases.value = withDatabaseSelected(selectedDatabases.value, database);
if (resolveAiNamespaceSelection({ database: boundDatabase.value, schema: boundSchema.value }, connection).kind === "schema") {
void rebindConversation(connection, boundDatabase.value, database);
return;
}
// A schema belongs to one database: the previous one may not exist (or may
// name a different namespace) in the database being retargeted, and it is used
// for metadata reads and SQL execution alike (#12972). Re-pick it instead.
void rebindConversation(connection, database, undefined);
}
const selectedNamespace = computed(() => (boundConnection.value ? resolveAiNamespaceSelection({ database: boundDatabase.value, schema: boundSchema.value }, boundConnection.value).value : ""));
// Reset the composer's database multi-select whenever the *namespace* changes.
/** Scope a selection belongs to; see `resolveComposerDatabaseReset`. */
const composerDatabaseScope = computed<AiComposerScope>(() => ({
conversationId: conversationId.value,
connectionId: boundConnectionId.value,
namespace: selectedNamespace.value,
}));
// Reset the composer's database multi-select when the *scope* moves: another
// conversation, another connection, or (on schema-scoped engines such as Dameng)
// another namespace.
//
// `selectedDatabases` is a single ref for the whole panel, and the key used to be
// `connection:tab` — so switching to another conversation kept the previous one's
// selection, i.e. the "bound database" leaked between chats (#9902). The
// conversation id belongs in the key; the namespace keeps a fresh, unsaved chat
// following the visible tab. A bound conversation's namespace no longer depends
// on the visible tab, so switching tabs inside it no longer resets the selection.
watch(
() => `${boundConnectionId.value}\u0000${boundDatabase.value}\u0000${boundSchema.value ?? ""}\u0000${conversationId.value}`,
() => {
const next = selectedNamespace.value ? [selectedNamespace.value] : [];
// A chip naming a database this namespace change just dropped out of the
// selection would be quoted in the message while the run refuses to read it
// (#11660) — the same rule the deselect handler applies, at the other place
// the selection changes. An unresolved namespace prunes nothing: the chip is
// inert and the request-time scope guard still refuses it.
if (next.length) removeMentionsOfDatabases(selectedDatabases.value.filter((database) => !next.includes(database)));
selectedDatabases.value = next;
},
);
// following the visible tab.
//
// A schema change on a database-scoped engine is deliberately NOT a scope change:
// the schema belongs to the target database, and resetting here used to move the
// chat back to the connection's default database (`postgres` on PostgreSQL) while
// the picker still showed the user's choice (#12972).
watch(composerDatabaseScope, (next, previous) => {
const reset = resolveComposerDatabaseReset(previous, next, selectedDatabases.value);
if (!reset) return;
// A chip naming a database this scope change just dropped out of the
// selection would be quoted in the message while the run refuses to read it
// (#11660) — the same rule the deselect handler applies, at the other place
// the selection changes. An unresolved namespace prunes nothing: the chip is
// inert and the request-time scope guard still refuses it.
if (reset.length) removeMentionsOfDatabases(selectedDatabases.value.filter((database) => !reset.includes(database)));
selectedDatabases.value = reset;
});
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type));
@@ -4071,6 +4121,11 @@ async function send() {
// A background auto-send has no composer of its own, so it runs on the
// conversation's own database.
const runDatabases = connection && tab && resolveAiNamespaceSelection(tab, connection).kind === "database" ? (auto || confirmationRetargets ? [runBinding.database] : [...selectedDatabases.value]) : [];
// Keep the submitted SQL paired with `text` even if another external fix
// replaces the composer while skills or prompt templates are loading.
// Like other composer context, selections cannot cross a confirmation target.
const selectionContexts = auto || confirmationRetargets ? [] : [...selectedEditorSelections.value];
const composerSelectionIds = new Set(selectedEditorSelections.value.map((selection) => selection.id));
const activeConfig = activeFullConfig.value;
if (!activeConfig) {
clearPendingWriteGrant();
@@ -4307,9 +4362,6 @@ async function send() {
const selectedSqlFiles = auto || confirmationRetargets || runPluginContext ? [] : [...selectedSqlFileMentions.value];
const csvAttachments = auto || confirmationRetargets ? [] : [...selectedCsvAttachments.value];
const imageAttachments = auto || confirmationRetargets ? [] : [...selectedImageAttachments.value];
// A selection describes the namespace the user picked it in, so it is dropped
// exactly like the other composer context when the run targets another one.
const selectionContexts = auto || confirmationRetargets ? [] : [...selectedEditorSelections.value];
const mentionedTables = [...selectedTableMentions, ...parseAiTableMentions(text, runDatabases)];
const modelInstruction = buildAiModelInstruction({
tableMentionRaws: selectedTableMentions.map((mention) => mention.raw),
@@ -4335,12 +4387,15 @@ async function send() {
}
historyIndex.value = -1;
draftBeforeHistory.value = "";
prompt.value = "";
// Consume only this send's selections. A later fix stays staged with its
// error, including when both failures have the same error text.
const pendingSelections = selectedEditorSelections.value.filter((selection) => !composerSelectionIds.has(selection.id));
if (prompt.value.trim() === text && pendingSelections.length === 0) prompt.value = "";
selectedMentions.value = [];
selectedSqlFileMentions.value = [];
selectedCsvAttachments.value = [];
selectedImageAttachments.value = [];
selectedEditorSelections.value = [];
selectedEditorSelections.value = pendingSelections;
}
if (autoSendVisible) scrollToBottom({ force: true });
@@ -4690,13 +4745,14 @@ async function send() {
// paying for buildAiContext() too; it can do real backend/schema work that
// would be entirely wasted on an already-abandoned request.
if (!generationCanContinue()) return;
const requestDatabase = runDatabases[0] ?? tab.database;
// The run's primary database is the chat's execution target (the picker's
// "target"), not the first database in the context selection: the model
// must answer for the database its SQL will actually run on (#12972).
const requestDatabase = tab.database;
const context = await buildAiContext(
{
...tab,
database: requestDatabase,
schema: runDatabases.length > 1 || requestDatabase !== tab.database ? undefined : tab.schema,
},
// The schema belongs to the target database; extra context databases must
// not drop it — they are only readable alongside the target.
{ ...tab, database: requestDatabase },
connection,
{
databases: runDatabases,
@@ -6531,6 +6587,9 @@ function openExternalContext(request: AiExternalContextRequest) {
}
applyDraftBinding(plan.binding);
if (!plan.binding.connectionId && request.unresolvedKey) toast(t(request.unresolvedKey), 5000);
// An explicit failed result replaces staged SQL selections, including a
// previous failed query. It must not be dropped by a full selection budget.
if (request.action === "fix" && request.selections?.some((selection) => selection.source === "query-error")) selectedEditorSelections.value = [];
for (const selection of request.selections ?? []) {
const budgetError = selectionContextBudgetError(selectedEditorSelections.value);
if (budgetError) {
@@ -7269,6 +7328,11 @@ async function openExternalUrl(url: string) {
<PopoverTrigger as-child>
<Button variant="ghost" :title="selectedDatabaseLabel" :aria-label="selectedDatabaseLabel" :class="['ai-database-selector-trigger h-5 min-w-0 max-w-64 justify-start gap-1 border-0 p-0 px-1 text-xs font-normal text-foreground/80 shadow-none', showAiSchemaSelector && 'flex-1']">
<Database class="ai-database-selector-icon h-3 w-3 shrink-0 text-foreground/40" />
<!-- Non-target databases are read-only context; the target is
where AI SQL actions run, so it is marked in the trigger (#12972). -->
<span v-if="selectedNamespace" class="flex shrink-0 items-center" :title="t('ai.databaseTarget')" data-ai-database-target-icon>
<Target class="h-3 w-3 text-primary/70" />
</span>
<span class="truncate">{{ selectedDatabaseLabel }}</span>
</Button>
</PopoverTrigger>
@@ -7277,12 +7341,16 @@ async function openExternalUrl(url: string) {
<Search class="pointer-events-none absolute left-3 h-3 w-3 text-muted-foreground" />
<input v-model="databaseSearchQuery" type="search" class="h-6 w-full bg-transparent pl-5 text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('ai.searchDatabases')" />
</div>
<button v-if="selectedDatabases.length > 1" type="button" class="mb-1 flex w-full items-center justify-end gap-1 border-b px-2 py-1 text-xs" @click.stop="selectedDatabases = []"><X class="h-3 w-3" />{{ t("ai.clearDatabaseSelection") }}</button>
<button v-if="selectedDatabases.length > 1" type="button" class="mb-1 flex w-full items-center justify-end gap-1 border-b px-2 py-1 text-xs" @click.stop="clearDatabaseSelection()"><X class="h-3 w-3" />{{ t("ai.clearDatabaseSelection") }}</button>
<div class="max-h-64 overflow-y-auto overscroll-contain">
<button v-for="option in filteredDbSelectOptions" :key="option.value" type="button" :title="option.label" class="flex w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
<Check :class="['h-4 w-4 shrink-0', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
</button>
<div v-for="option in filteredDbSelectOptions" :key="option.value" class="flex w-full min-w-0 items-center gap-1 rounded-sm pr-1 text-sm hover:bg-muted">
<button type="button" :title="option.label" class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left" @click="toggleDatabase(option.database)">
<Check :class="['h-4 w-4 shrink-0', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
</button>
<span v-if="isTargetNamespace(option.database)" class="shrink-0 rounded-sm bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary" data-ai-database-target>{{ t("ai.databaseTarget") }}</span>
<button v-else type="button" class="shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-foreground/5 hover:text-foreground" data-ai-database-target-action @click.stop="setDatabaseTarget(option.database)">{{ t("ai.setDatabaseTarget") }}</button>
</div>
<div v-if="!filteredDbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("ai.noDatabasesFound") }}</div>
</div>
</PopoverContent>
@@ -71,6 +71,7 @@ const backupSettingsCategoryLabelKeys: Record<BackupSettingsCategoryId, string>
const nonSyncableEditorSettingIds = new Set(["updateNotificationsEnabled", "autoDownloadUpdates", "autoUpdateApp", "autoUpdateDrivers", "autoUpdateJdbc", "autoUpdateMcp", "autoUpdatePlugins", "updateDownloadSource", "ignoredUpdateVersion"]);
const desktopSettingCategories: Record<string, BackupSettingsCategoryId> = {
show_tray_icon: "appearance",
linux_floating_frame_enabled: "appearance",
icon_theme: "appearance",
quit_on_close: "appearance",
close_action_prompted: "appearance",
@@ -131,7 +131,7 @@ async function copyFullCode() {
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[480px]" @open-auto-focus="onDangerDialogOpenAutoFocus" @close-auto-focus="onDangerDialogCloseAutoFocus">
<DialogContent :class="$slots.preview ? 'flex h-[85vh] min-h-0 flex-col sm:max-w-[1120px]' : 'sm:max-w-[480px]'" @open-auto-focus="onDangerDialogOpenAutoFocus" @close-auto-focus="onDangerDialogCloseAutoFocus">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-destructive">
<AlertTriangle class="h-5 w-5" />
@@ -139,29 +139,31 @@ async function copyFullCode() {
</DialogTitle>
</DialogHeader>
<div class="py-4 min-w-0">
<div class="py-4 min-w-0" :class="{ 'flex min-h-0 flex-1 flex-col': $slots.preview }">
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line max-h-40 overflow-auto">{{ detailsText }}</p>
<slot name="options" />
<component :is="detailsLabel ? 'details' : 'div'" v-if="code" data-testid="danger-code-container" class="min-w-0">
<summary v-if="detailsLabel" class="mb-2 cursor-pointer text-sm text-muted-foreground">{{ detailsLabel }}</summary>
<div data-testid="danger-code-actions" class="mb-1 flex items-center justify-end gap-0.5">
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('dangerDialog.copyFullText')" @click="copyFullCode">
<Check v-if="copied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" class="h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
<TextWrap class="h-3.5 w-3.5" />
</Button>
</div>
<div data-native-clipboard data-testid="danger-code-preview" class="max-h-40 min-w-0 overflow-auto rounded bg-muted px-3 py-3 text-xs font-mono">
<pre class="font-inherit" :class="wrap ? 'w-full whitespace-pre-wrap break-all' : 'w-max min-w-full whitespace-pre'" v-html="highlightedHead" />
<div v-if="preview.truncated" data-testid="danger-preview-truncated" class="my-2 rounded border border-border/70 bg-background/70 px-2 py-1.5 text-center text-[11px] leading-4 text-muted-foreground whitespace-normal">
{{ t("dangerDialog.previewTruncated", { lines: preview.omittedLines.toLocaleString(), characters: preview.omittedCharacters.toLocaleString() }) }}
<slot name="preview">
<component :is="detailsLabel ? 'details' : 'div'" v-if="code" data-testid="danger-code-container" class="min-w-0">
<summary v-if="detailsLabel" class="mb-2 cursor-pointer text-sm text-muted-foreground">{{ detailsLabel }}</summary>
<div data-testid="danger-code-actions" class="mb-1 flex items-center justify-end gap-0.5">
<Button variant="ghost" size="icon-xs" class="h-6 w-6 text-muted-foreground" :title="t('dangerDialog.copyFullText')" @click="copyFullCode">
<Check v-if="copied" class="h-3.5 w-3.5 text-emerald-600" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" class="h-6 w-6" :class="wrap ? 'text-foreground bg-accent' : 'text-muted-foreground'" :title="t('dangerDialog.wrapLines')" @click="wrap = !wrap">
<TextWrap class="h-3.5 w-3.5" />
</Button>
</div>
<pre v-if="preview.tail" class="font-inherit" :class="wrap ? 'w-full whitespace-pre-wrap break-all' : 'w-max min-w-full whitespace-pre'" v-html="highlightedTail" />
</div>
</component>
<div data-native-clipboard data-testid="danger-code-preview" class="max-h-40 min-w-0 overflow-auto rounded bg-muted px-3 py-3 text-xs font-mono">
<pre class="font-inherit" :class="wrap ? 'w-full whitespace-pre-wrap break-all' : 'w-max min-w-full whitespace-pre'" v-html="highlightedHead" />
<div v-if="preview.truncated" data-testid="danger-preview-truncated" class="my-2 rounded border border-border/70 bg-background/70 px-2 py-1.5 text-center text-[11px] leading-4 text-muted-foreground whitespace-normal">
{{ t("dangerDialog.previewTruncated", { lines: preview.omittedLines.toLocaleString(), characters: preview.omittedCharacters.toLocaleString() }) }}
</div>
<pre v-if="preview.tail" class="font-inherit" :class="wrap ? 'w-full whitespace-pre-wrap break-all' : 'w-max min-w-full whitespace-pre'" v-html="highlightedTail" />
</div>
</component>
</slot>
<div v-if="showSuppressToggle" class="mt-3 flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<Label for="danger-confirm-suppress" class="text-sm leading-5">{{ suppressToggleLabel || t("dangerDialog.suppressFuturePrompts") }}</Label>
<Switch id="danger-confirm-suppress" v-model="suppressFuturePrompts" />
Loaded 100 of 873 files, more files were not shown because too many files have changed in this diff. Show more