Merge remote-tracking branch 'origin/main' into feat/12892-redis-danger-copy-command
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
+192
-11
@@ -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,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,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 >= {@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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", ¤tUser); 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
+17
@@ -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<>();
|
||||
|
||||
+47
@@ -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")) {
|
||||
|
||||
+57
-2
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -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:
|
||||
|
||||
Vendored
+43
-13
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+64
-5
@@ -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">
|
||||
|
||||
+321
-4
@@ -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();
|
||||
});
|
||||
});
|
||||
+230
@@ -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 " }));
|
||||
});
|
||||
});
|
||||
+46
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
+404
@@ -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>
|
||||
|
||||
+44
-3
@@ -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);
|
||||
});
|
||||
+146
@@ -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", () => {
|
||||
|
||||
+554
@@ -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
Reference in new issue
Block a user