From 04376ef3fb10cddf1fe1eb50608ae97e9f95cca6 Mon Sep 17 00:00:00 2001 From: efeire Date: Sun, 20 Sep 2026 00:49:44 +0800 Subject: [PATCH] feat(oceanbase-oracle): support sequences, synonyms and db links --- .../oceanbase-oracle/scripts/README.md | 15 ++ .../scripts/verify-schema-objects.ts | 208 ++++++++++++++++++ .../oceanbaseoracle/OceanBaseOracleAgent.java | 11 +- .../OceanBaseSchemaObjects.java | 77 +++++++ .../OceanBaseOracleAgentTest.java | 70 ++++++ apps/desktop/src/App.vue | 1 + .../src/components/objects/ObjectBrowser.vue | 6 +- .../components/objects/ObjectSourceDialog.vue | 59 +++-- .../objects/OracleDatabaseLinksDialog.vue | 99 +++++++-- ...jectSourceDialog.oceanbaseSequence.spec.ts | 121 ++++++++++ ...atabaseLinksDialog.targetIsolation.spec.ts | 204 +++++++++++++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 6 +- apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../databaseObjectCapabilities.spec.ts | 11 +- .../database/oracleDatabaseLinks.spec.ts | 65 +++++- .../database/databaseObjectCapabilities.ts | 2 +- .../src/lib/database/oracleDatabaseLinks.ts | 72 +++++- .../lib/sql/oracleDatabaseLinkCompletion.ts | 4 +- .../queryStore.objectSourcePending.spec.ts | 34 ++- apps/desktop/src/stores/connectionStore.ts | 9 +- apps/desktop/src/stores/queryStore.ts | 37 ++-- apps/desktop/src/types/database.ts | 2 + crates/dbx-sql/src/object_source_sql.rs | 153 +++++++++++++ .../app-tests/sidebarContextMenuHost.test.ts | 2 +- 31 files changed, 1225 insertions(+), 91 deletions(-) create mode 100644 agents/drivers/oceanbase-oracle/scripts/verify-schema-objects.ts create mode 100644 agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseSchemaObjects.java create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectSourceDialog.oceanbaseSequence.spec.ts create mode 100644 apps/desktop/src/components/objects/__tests__/OracleDatabaseLinksDialog.targetIsolation.spec.ts diff --git a/agents/drivers/oceanbase-oracle/scripts/README.md b/agents/drivers/oceanbase-oracle/scripts/README.md index 7463138f3b..7dab1f85be 100644 --- a/agents/drivers/oceanbase-oracle/scripts/README.md +++ b/agents/drivers/oceanbase-oracle/scripts/README.md @@ -25,3 +25,18 @@ The preference applies only to OceanBase Oracle and removes these known table op ## Verified environment The script passed against OceanBase 4.2.5.7 in Oracle mode, with RANGE, LIST, HASH and RANGE/LIST tables, compound partition keys, constraints, defaults/comments and temporary tables. It uses `DBMS_METADATA.GET_DDL` supported by the [OceanBase 4.2.5 documentation](https://www.oceanbase.com/docs/common-oceanbase-database-cn-1000000001503693). Separate native index DDL and comments are appended because this version's TABLE result omits them. + +## Sequence, synonym and DBLink verification (#2549) + +`verify-schema-objects.ts` is a separate integration check. The partition result above is not evidence that this new check has passed. It uses the built agent over JSON-RPC and the frontend's production DBLink SQL builder. It creates two uniquely named `DBX2549_` users, checks sequence/synonym discovery and source, replays and changes a synonym target, and creates, lists, tests and explicitly removes an OB DBLink. OceanBase 4.2.5 exposes links created with `CREATE DATABASE LINK` tenant-wide and reports them as `OWNER=PUBLIC`; the script verifies that a second user can list, test and use the link but cannot manage it through `USER_DB_LINKS`. It verifies that reading sequence DDL does not consume a value, and that changing sequence options with `ALTER SEQUENCE` does not restart the counter. The Rust source-to-edit-SQL transformation has separate unit tests; this script does not invoke the Rust application backend. + +Use only an explicitly disposable Oracle tenant. In addition to the environment variables above, set `OB_TEST_DISPOSABLE=1`. `OB_LINK_HOST` optionally supplies the database-server-reachable remote endpoint (for an isolated single-node self-link test, `127.0.0.1:2881`); otherwise the script uses `OB_HOST:OB_PORT`. Supply the SYS password locally through the environment, never in source or a command committed to Git. + +```sh +pnpm exec tsx --tsconfig apps/desktop/tsconfig.json agents/drivers/oceanbase-oracle/scripts/verify-schema-objects.ts +cargo test -p dbx-sql oceanbase_sequence_edit +``` + +Unlike the partition verifier, this script attempts to remove both fixture users in `finally`. It never drops an existing user with the same name after a failed CREATE. Inspect `agents/drivers/oceanbase-oracle/build/schema-object-evidence/verification.json` (or `OB_TEST_OUTPUT`) for the executed checks, errors and any cleanup needed. There must be no errors before calling the integration check passed. A cleanup error is also a test failure. No credentials are written to evidence files. Literal `CREATE PUBLIC DATABASE LINK`, reverse links, OCI connectivity and complete desktop-client acceptance are outside the successful path. OceanBase 4.2.5.7 rejects the Oracle-style `PUBLIC` keyword with ORA-00900; its accepted `CREATE DATABASE LINK` syntax already creates a tenant-visible public link. + +The sequence viewer retains CREATE DDL for copying; editing changes only supported options through ALTER, without DROP/RESTART or replaying START WITH. Dictionary `LAST_NUMBER` is a cache-allocation boundary, not session CURRVAL. Synonym source uses CREATE OR REPLACE with quoted dictionary names. The DBLink manager uses the OceanBase `HOST` syntax and OB/OCI protocol selection. Existing OB links are never automatically dropped to update credentials or endpoints: create/test a replacement and explicitly remove the old link. Native Oracle's credential-update path is unchanged. diff --git a/agents/drivers/oceanbase-oracle/scripts/verify-schema-objects.ts b/agents/drivers/oceanbase-oracle/scripts/verify-schema-objects.ts new file mode 100644 index 0000000000..172cb99c5b --- /dev/null +++ b/agents/drivers/oceanbase-oracle/scripts/verify-schema-objects.ts @@ -0,0 +1,208 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import assert from "node:assert/strict"; +import { OCEANBASE_ORACLE_DATABASE_LINKS_SQL, createOceanBaseDatabaseLinkSql, oracleDatabaseLinksFromResult, dropOracleDatabaseLinkSql, testOracleDatabaseLinkSql } from "../../../../apps/desktop/src/lib/database/oracleDatabaseLinks"; + +// Explicit opt-in: creates one isolated user and drops only that user and its objects. +async function main() { + assert.equal(process.env.OB_TEST_DISPOSABLE, "1", "Set OB_TEST_DISPOSABLE=1 only for a disposable Oracle tenant"); + const host = process.env.OB_HOST, + tenant = process.env.OB_TENANT, + password = process.env.OB_PASSWORD; + assert.ok(host && tenant && password, "Set OB_HOST, OB_TENANT and OB_PASSWORD"); + const port = Number(process.env.OB_PORT ?? 2881); + const fixturePassword = "Dbx2549_" + randomBytes(12).toString("hex"); + const redact = (error: unknown) => + String(error) + .replaceAll(password, "[redacted]") + .replaceAll(fixturePassword, "[redacted]") + .replace(/IDENTIFIED\s+BY\s+(?:"[^"]*"|'[^']*'|\S+)/gi, "IDENTIFIED BY [redacted]"); + const owner = "DBX2549_" + randomBytes(4).toString("hex").toUpperCase(); + const observer = "DBX2549B_" + owner.slice(-8); + const linkName = "DBX2549_LINK_" + owner.slice(-8); + const output = process.env.OB_TEST_OUTPUT ?? "agents/drivers/oceanbase-oracle/build/schema-object-evidence"; + mkdirSync(output, { recursive: true }); + const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java") : "java"; + const child = spawn(java, ["-jar", "agents/drivers/oceanbase-oracle/build/libs/dbx-agent-oceanbase-oracle.jar"], { stdio: ["pipe", "pipe", "pipe"] }); + child.stderr.resume(); + const pending = new Map void; reject: (error: Error) => void }>(); + let requestId = 0; + createInterface({ input: child.stdout }).on("line", (line) => { + if (!line.startsWith("{")) return; + const response = JSON.parse(line), + task = pending.get(response.id); + if (!task) return; + pending.delete(response.id); + if (response.error) task.reject(new Error(redact(response.error.message))); + else task.resolve(response.result); + }); + const rejectPending = () => { + for (const task of pending.values()) task.reject(new Error("Agent exited")); + pending.clear(); + }; + child.on("error", rejectPending); + child.on("exit", rejectPending); + const call = (method: string, params: Record = {}) => + new Promise((resolve, reject) => { + const id = ++requestId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error("Agent timeout: " + method)); + }, 45_000); + pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); + const sql = (text: string, session = "fixture") => call("execute_query", { agentSessionId: session, sql: text, maxRows: 100, timeoutSecs: 20 }); + const connect = (user: string, secret: string, session: string) => call("open_session", { agentSessionId: session, host, port, database: user, username: user + "@" + tenant, password: secret, url_params: "connectTimeout=10000&socketTimeout=30000" }); + const qi = (name: string) => '"' + name.replaceAll('"', '""') + '"'; + const source = (name: string, object_type: string, schema = owner) => call("get_object_source", { agentSessionId: "fixture", schema, name, object_type }); + const checks: string[] = []; + let created = false, + observerCreated = false, + connected = false, + observerConnected = false, + linkCreated = false, + failure: string | undefined; + const notes: string[] = []; + try { + await call("handshake"); + await connect("SYS", password, "admin"); + await sql(`CREATE USER ${qi(owner)} IDENTIFIED BY ${qi(fixturePassword)}`, "admin"); + created = true; + await sql(`CREATE USER ${qi(observer)} IDENTIFIED BY ${qi(fixturePassword)}`, "admin"); + observerCreated = true; + await sql(`GRANT CREATE SESSION, CREATE TABLE, CREATE SEQUENCE, CREATE SYNONYM, CREATE DATABASE LINK, DROP DATABASE LINK, ALTER SESSION TO ${qi(owner)}`, "admin"); + await sql(`GRANT CREATE SESSION TO ${qi(observer)}`, "admin"); + await connect(owner, fixturePassword, "fixture"); + connected = true; + await connect(observer, fixturePassword, "observer"); + observerConnected = true; + await sql('CREATE TABLE "T1" ("ID" NUMBER)'); + await sql('CREATE TABLE "T2" ("ID" NUMBER)'); + await sql('INSERT INTO "T1" VALUES (1)'); + await sql('INSERT INTO "T2" VALUES (2)'); + await sql('CREATE SEQUENCE "MiX.Seq" START WITH 10 INCREMENT BY 2 NOCACHE NOCYCLE NOORDER'); + await sql('CREATE SYNONYM "MiX.Syn" FOR "T1"'); + const objects = await call("list_objects", { agentSessionId: "fixture", schema: owner }); + assert.ok(objects.some((item: any) => item.name === "MiX.Seq" && item.object_type === "SEQUENCE")); + assert.ok(objects.some((item: any) => item.name === "MiX.Syn" && item.object_type === "SYNONYM")); + checks.push("sequence/synonym discovery"); + assert.equal(Number((await sql('SELECT "MiX.Seq".NEXTVAL FROM DUAL')).rows[0][0]), 10); + const sequence = await source("MiX.Seq", "SEQUENCE"); + assert.ok(sequence.source.includes("CREATE SEQUENCE")); + assert.ok(sequence.source.includes("MiX.Seq")); + const currentSchemaSequence = await source("MiX.Seq", "SEQUENCE", ""); + assert.equal(currentSchemaSequence.schema, owner); + assert.equal(Number((await sql('SELECT "MiX.Seq".NEXTVAL FROM DUAL')).rows[0][0]), 12); + await sql(`ALTER SEQUENCE ${qi(owner)}."MiX.Seq" INCREMENT BY 3 NOCACHE NOCYCLE NOORDER`); + assert.equal(Number((await sql('SELECT "MiX.Seq".NEXTVAL FROM DUAL')).rows[0][0]), 15); + writeFileSync(join(output, "sequence.sql"), sequence.source); + checks.push("sequence source/current-schema/ALTER without counter reset"); + const synonym = await source("MiX.Syn", "SYNONYM"); + assert.ok(synonym.source.startsWith("CREATE OR REPLACE SYNONYM")); + await sql(synonym.source.replace(/;\s*$/, "")); + assert.equal(Number((await sql('SELECT ID FROM "MiX.Syn"')).rows[0][0]), 1); + await sql(synonym.source.replace('"T1"', '"T2"').replace(/;\s*$/, "")); + assert.equal(Number((await sql('SELECT ID FROM "MiX.Syn"')).rows[0][0]), 2); + writeFileSync(join(output, "synonym.sql"), synonym.source); + checks.push("synonym DDL replay and target replacement"); + const createLinkSql = createOceanBaseDatabaseLinkSql({ name: linkName, username: owner, tenant, password: fixturePassword, host: process.env.OB_LINK_HOST ?? `${host}:${port}`, protocol: "OB", public: false }); + assert.ok(createLinkSql.startsWith("CREATE DATABASE LINK ")); + assert.ok(!createLinkSql.startsWith("CREATE PUBLIC DATABASE LINK ")); + await sql(createLinkSql); + linkCreated = true; + const links = oracleDatabaseLinksFromResult(await sql(OCEANBASE_ORACLE_DATABASE_LINKS_SQL)); + const link = links.find((item) => item.owner === "PUBLIC" && item.name === linkName && item.canDrop); + if (!link) { + throw new Error("created link is not discoverable by the production listing query"); + } + assert.equal(Number((await sql(testOracleDatabaseLinkSql(link, "oceanbase-oracle"))).rows[0][0]), 1); + const observerLinks = oracleDatabaseLinksFromResult(await sql(OCEANBASE_ORACLE_DATABASE_LINKS_SQL, "observer")); + const observerLink = observerLinks.find((item) => item.owner === "PUBLIC" && item.name === linkName && item.canDrop === false); + assert.ok(observerLink, "link must be tenant-visible and read-only for users other than its creator"); + assert.equal(Number((await sql(testOracleDatabaseLinkSql(observerLink, "oceanbase-oracle"), "observer")).rows[0][0]), 1); + await sql("ALTER SESSION SET CURRENT_SCHEMA = SYS"); + const scopedLinks = oracleDatabaseLinksFromResult(await sql(OCEANBASE_ORACLE_DATABASE_LINKS_SQL)); + assert.ok(scopedLinks.some((item) => item.owner === "PUBLIC" && item.name === linkName && item.canDrop)); + await sql(`ALTER SESSION SET CURRENT_SCHEMA = ${qi(owner)}`); + checks.push("OB public link create/list/cross-user test and creator-only management"); + try { + await sql(`ALTER DATABASE LINK ${linkName} CONNECT TO ${qi(owner)} IDENTIFIED BY ${qi(fixturePassword)}`); + notes.push("ALTER DATABASE LINK accepted by this server; client still requires explicit replacement for host/tenant changes"); + } catch (error) { + notes.push("ALTER DATABASE LINK probe: " + redact(error)); + } + await sql(dropOracleDatabaseLinkSql(link, "oceanbase-oracle")); + linkCreated = false; + assert.ok(!oracleDatabaseLinksFromResult(await sql(OCEANBASE_ORACLE_DATABASE_LINKS_SQL)).some((item) => item.name === linkName)); + checks.push("explicit DBLink deletion"); + } catch (error) { + failure = redact(error); + } finally { + if (connected) { + if (linkCreated) { + try { + await sql(`DROP DATABASE LINK ${linkName}`); + linkCreated = false; + checks.push("failed-path DBLink cleanup"); + } catch (error) { + notes.push("Cleanup required for database link " + linkName + ": " + redact(error)); + failure ??= "Database link cleanup failed"; + } + } + try { + await call("close_session", { agentSessionId: "fixture" }); + } catch (error) { + notes.push("close fixture: " + redact(error)); + } + } + if (observerConnected) { + try { + await call("close_session", { agentSessionId: "observer" }); + } catch (error) { + notes.push("close observer: " + redact(error)); + } + } + if (observerCreated) { + try { + await sql(`DROP USER ${qi(observer)} CASCADE`, "admin"); + checks.push("observer fixture cleanup"); + } catch (error) { + notes.push("Cleanup required for " + observer + ": " + redact(error)); + failure ??= "Observer fixture cleanup failed"; + } + } + if (created) { + try { + await sql(`DROP USER ${qi(owner)} CASCADE`, "admin"); + checks.push("isolated fixture cleanup"); + } catch (error) { + notes.push("Cleanup required for " + owner + ": " + redact(error)); + failure ??= "Fixture cleanup failed"; + } + } + child.stdin.end(); + child.kill(); + rejectPending(); + } + const result = { timestamp: new Date().toISOString(), owner, host, port, checks, notes, passed: !failure, ...(failure ? { error: failure } : {}) }; + writeFileSync(join(output, "verification.json"), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + if (failure) process.exitCode = 1; +} +main().catch((error) => { + console.error(String(error).replaceAll(process.env.OB_PASSWORD ?? "__none__", "[redacted]")); + process.exitCode = 1; +}); diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java index 757125af71..d5dbcedf8a 100644 --- a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -383,6 +383,10 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { } private String queryDbmsMetadataSource(String owner, String name, String objectType) throws SQLException { + if ("SYNONYM".equals(objectType)) { + // GET_DDL does not cover synonyms on every supported OB version. + return OceanBaseSchemaObjects.synonymSource(requireConnection(), owner, name); + } String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; try (var stmt = requireConnection().prepareStatement(sql)) { stmt.setString(1, objectType); @@ -395,6 +399,9 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { } private String queryDictionarySource(String owner, String name, String objectType) throws SQLException { + if ("SEQUENCE".equals(objectType)) { + return OceanBaseSchemaObjects.sequenceSource(requireConnection(), owner, name); + } if ("VIEW".equals(objectType)) { String sql = "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?"; try (var stmt = requireConnection().prepareStatement(sql)) { @@ -435,7 +442,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { ? "" : objectType.trim().toUpperCase(Locale.ROOT).replace(' ', '_'); return switch (normalized) { - case "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", + case "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> normalized; default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); }; @@ -443,7 +450,7 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { private static boolean supportsDictionarySource(String objectType) { return switch (objectType) { - case "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> true; + case "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY", "SEQUENCE" -> true; default -> false; }; } diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseSchemaObjects.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseSchemaObjects.java new file mode 100644 index 0000000000..b1865b94ba --- /dev/null +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseSchemaObjects.java @@ -0,0 +1,77 @@ +package com.dbx.agent.oceanbaseoracle; + +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** Dictionary-backed DDL for objects not consistently covered by OB GET_DDL. */ +final class OceanBaseSchemaObjects { + private OceanBaseSchemaObjects() {} + + static String synonymSource(Connection connection, String owner, String name) throws SQLException { + String sql = "SELECT TABLE_OWNER, TABLE_NAME, DB_LINK FROM ALL_SYNONYMS WHERE OWNER = ? AND SYNONYM_NAME = ?"; + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, owner); + statement.setString(2, name); + try (var rs = statement.executeQuery()) { + if (!rs.next()) return null; + String targetOwner = rs.getString("TABLE_OWNER"); + String target = quote(rs.getString("TABLE_NAME")); + if (targetOwner != null && !targetOwner.isEmpty()) target = quote(targetOwner) + "." + target; + String link = rs.getString("DB_LINK"); + if (link != null && !link.isEmpty()) { + // A link's domain is part of its name, not a schema qualifier. + if (!link.matches("[A-Za-z][A-Za-z0-9_$#]*(?:\\.[A-Za-z0-9_$#]+)*") || link.length() > 128) { + throw new SQLException("Unsupported database link identifier in synonym metadata"); + } + target += "@" + link; + } + String declaration = "PUBLIC".equals(owner) ? "PUBLIC SYNONYM " + quote(name) + : "SYNONYM " + quote(owner) + "." + quote(name); + return "CREATE OR REPLACE " + declaration + " FOR " + target + ";"; + } + } + } + + static String sequenceSource(Connection connection, String owner, String name) throws SQLException { + String sql = "SELECT MIN_VALUE, MAX_VALUE, INCREMENT_BY, CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER " + + "FROM ALL_SEQUENCES WHERE SEQUENCE_OWNER = ? AND SEQUENCE_NAME = ?"; + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, owner); + statement.setString(2, name); + try (var rs = statement.executeQuery()) { + if (!rs.next()) return null; + // LAST_NUMBER is the dictionary/cache boundary, not session CURRVAL. + // Never consume NEXTVAL while displaying or editing a sequence. + String cache = integer(rs, "CACHE_SIZE"); + return "CREATE SEQUENCE " + quote(owner) + "." + quote(name) + + "\n MINVALUE " + integer(rs, "MIN_VALUE") + + "\n MAXVALUE " + integer(rs, "MAX_VALUE") + + "\n INCREMENT BY " + integer(rs, "INCREMENT_BY") + + "\n START WITH " + integer(rs, "LAST_NUMBER") + + "\n " + ("0".equals(cache) ? "NOCACHE" : "CACHE " + cache) + + "\n " + flag(rs, "CYCLE_FLAG", "CYCLE", "NOCYCLE") + + "\n " + flag(rs, "ORDER_FLAG", "ORDER", "NOORDER") + ";"; + } + } + } + + private static String integer(ResultSet rs, String column) throws SQLException { + String value = rs.getString(column); + if (value == null || !value.matches("[+-]?[0-9]+")) throw new SQLException("Invalid sequence metadata: " + column); + return new BigInteger(value).toString(); + } + + private static String flag(ResultSet rs, String column, String yes, String no) throws SQLException { + String value = rs.getString(column); + if ("Y".equals(value)) return yes; + if ("N".equals(value)) return no; + throw new SQLException("Invalid sequence metadata: " + column); + } + + private static String quote(String name) throws SQLException { + if (name == null || name.isEmpty() || name.indexOf('\0') >= 0) throw new SQLException("Missing object identifier"); + return "\"" + name.replace("\"", "\"\"") + "\""; + } +} diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index ba71767cd3..701706a80d 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -481,6 +481,76 @@ class OceanBaseOracleAgentTest { } } + @Test + void synonymSourcePreservesOwnersQuotedNamesAndRemoteLinkDomains() { + for (String owner : List.of("Mixed.Owner", "PUBLIC")) { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, params, resultSet( + new String[]{"TABLE_OWNER", "TABLE_NAME", "DB_LINK"}, + new Object[][]{{"Target.Owner", "A\"B", "REMOTE.EXAMPLE"}} + ))); + ObjectSource source = agent.getObjectSource(owner, "Syn.Name", "SYNONYM"); + String declaration = owner.equals("PUBLIC") ? "PUBLIC SYNONYM \"Syn.Name\"" : "SYNONYM \"Mixed.Owner\".\"Syn.Name\""; + Assertions.assertEquals("CREATE OR REPLACE " + declaration + " FOR \"Target.Owner\".\"A\"\"B\"@REMOTE.EXAMPLE;", source.getSource()); + Assertions.assertEquals(List.of(owner, "Syn.Name"), params); + Assertions.assertEquals(1, sql.size()); + Assertions.assertTrue(sql.get(0).contains("ALL_SYNONYMS")); + } + } + + @Test + void synonymSourceHandlesMissingAndLocalTargetsWithoutGuessing() { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), resultSet( + new String[]{"TABLE_OWNER", "TABLE_NAME", "DB_LINK"}, new Object[][]{{null, "T", null}} + ))); + Assertions.assertEquals("CREATE OR REPLACE SYNONYM \"APP\".\"S\" FOR \"T\";", agent.getObjectSource("APP", "S", "SYNONYM").getSource()); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), resultSet( + new String[]{"TABLE_OWNER", "TABLE_NAME", "DB_LINK"}, new Object[][]{} + ))); + Assertions.assertEquals("", agent.getObjectSource("APP", "missing", "SYNONYM").getSource()); + } + + @Test + void synonymSourceRejectsUnsafeRemoteMetadata() { + for (String link : List.of("x; DROP TABLE T", "x--", "a..b")) { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), resultSet( + new String[]{"TABLE_OWNER", "TABLE_NAME", "DB_LINK"}, new Object[][]{{"APP", "T", link}} + ))); + Assertions.assertThrows(RuntimeException.class, () -> agent.getObjectSource("APP", "S", "SYNONYM")); + } + } + + @Test + void sequenceFallbackUsesExactIntegerMetadataWithoutConsumingNextValue() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(sql, params, resultSet( + new String[]{"MIN_VALUE", "MAX_VALUE", "INCREMENT_BY", "CYCLE_FLAG", "ORDER_FLAG", "CACHE_SIZE", "LAST_NUMBER"}, + new Object[][]{{"-999", "9999999999999999999999999999", "-2", "N", "Y", "0", "40"}} + ))); + String source = agent.getObjectSource("Mixed.Owner", "S\"Q", "SEQUENCE").getSource(); + Assertions.assertEquals("CREATE SEQUENCE \"Mixed.Owner\".\"S\"\"Q\"\n MINVALUE -999\n MAXVALUE 9999999999999999999999999999\n INCREMENT BY -2\n START WITH 40\n NOCACHE\n NOCYCLE\n ORDER;", source); + Assertions.assertEquals(List.of("SEQUENCE", "S\"Q", "Mixed.Owner", "Mixed.Owner", "S\"Q"), params); + Assertions.assertFalse(sql.stream().anyMatch(query -> query.contains("NEXTVAL"))); + } + + @Test + void sequenceFallbackRejectsIncompleteOrNonIntegerMetadata() { + for (String maximum : Arrays.asList(null, "1.5", "1E28", "1; DROP TABLE T")) { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection(new ArrayList<>(), new ArrayList<>(), resultSet( + new String[]{"MIN_VALUE", "MAX_VALUE", "INCREMENT_BY", "CYCLE_FLAG", "ORDER_FLAG", "CACHE_SIZE", "LAST_NUMBER"}, + new Object[][]{{"1", maximum, "1", "N", "N", "20", "40"}} + ))); + Assertions.assertThrows(RuntimeException.class, () -> agent.getObjectSource("APP", "SEQ", "SEQUENCE")); + } + } + @Test void rejectsUnsupportedObjectSourceTypesBeforeQuerying() { OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 66433dbe37..65c35c61ca 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -2821,6 +2821,7 @@ function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: bo title: `Source - ${sourceName}`, schema: sourceSchema || target.database, catalog: target.catalog, + initialEditing, request: { name: sourceName, objectType, signature: navigation.signature }, }); return; diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 46e92c5c4c..a308393ef4 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1457,7 +1457,7 @@ async function openSource(row: ObjectBrowserRow) { try { const result = await api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined); if (sidePanelGuard.isStale(epoch)) return; - sourceCanEdit.value = result.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type); + sourceCanEdit.value = result.editable !== false && !["TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type) && (row.type !== "SEQUENCE" || effectiveDatabaseType.value === "oceanbase-oracle"); const editable = sourceCanEdit.value ? await api.buildEditableObjectSource({ databaseType: effectiveDatabaseType.value, @@ -1471,9 +1471,9 @@ async function openSource(row: ObjectBrowserRow) { // Viewing database source must preserve its original whitespace and comments; // formatting remains an explicit editor action instead of altering it on open. sourceEditableText.value = editable; - sourceContent.value = editable; + sourceContent.value = row.type === "SEQUENCE" ? result.source : editable; sourceDraft.value = editable; - sourceEditing.value = sourceCanEdit.value; + sourceEditing.value = sourceCanEdit.value && row.type !== "SEQUENCE"; if (!sourceCanEdit.value && row.type !== "SEQUENCE") { toast(t("objects.sourceReadOnly"), 3000); } diff --git a/apps/desktop/src/components/objects/ObjectSourceDialog.vue b/apps/desktop/src/components/objects/ObjectSourceDialog.vue index c6efe64c4c..014287bd9d 100644 --- a/apps/desktop/src/components/objects/ObjectSourceDialog.vue +++ b/apps/desktop/src/components/objects/ObjectSourceDialog.vue @@ -1,5 +1,5 @@ @@ -164,18 +202,31 @@ async function test() {

{{ t("databaseLinks.alterHint", { name: selected?.name }) }}

diff --git a/apps/desktop/src/components/objects/__tests__/ObjectSourceDialog.oceanbaseSequence.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectSourceDialog.oceanbaseSequence.spec.ts new file mode 100644 index 0000000000..6c1d86d59a --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectSourceDialog.oceanbaseSequence.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, reactive } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useConnectionStore } from "@/stores/connectionStore"; +import type { ConnectionConfig, DatabaseType, ObjectSourceKind } from "@/types/database"; +import ObjectSourceDialog from "../ObjectSourceDialog.vue"; + +const ddl = 'CREATE SEQUENCE "APP"."SEQ" START WITH 40 INCREMENT BY 1 NOCACHE;'; +const alter = 'ALTER SEQUENCE "APP"."SEQ" INCREMENT BY 1 NOCACHE;'; +const mocks = vi.hoisted(() => ({ getObjectSource: vi.fn(), buildEditableObjectSource: vi.fn(), buildExecutableObjectSourceStatements: vi.fn(), executeQuery: vi.fn() })); +// The Rust/backend boundary is mocked; the dialog, CodeMirror editor and shared +// save/production-guard pipeline remain real. Rust builder tests cover the SQL. +vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal()), ...mocks })); +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + vi.restoreAllMocks(); + for (const mock of Object.values(mocks)) mock.mockReset(); +}); +function button(text: string) { + return [...document.querySelectorAll("button")].find((element) => element.textContent?.trim() === text); +} +function editor() { + const root = document.querySelector('[role="dialog"] .cm-editor'); + return root ? EditorView.findFromDOM(root) : null; +} +async function mount(options: { databaseType?: DatabaseType; initialEditing?: boolean; editable?: boolean; type?: ObjectSourceKind; source?: string } = {}) { + const databaseType = options.databaseType ?? "oceanbase-oracle", + objectType = options.type ?? "SEQUENCE", + source = options.source ?? ddl; + const pinia = createPinia(); + setActivePinia(pinia); + const store = useConnectionStore(); + vi.spyOn(store, "ensureConnected").mockResolvedValue(undefined); + vi.spyOn(store, "getConfig").mockReturnValue({ id: "ob", name: "test", db_type: databaseType } as ConnectionConfig); + mocks.getObjectSource.mockResolvedValue({ name: "SEQ", schema: "APP", object_type: objectType, source, editable: options.editable !== false }); + mocks.buildEditableObjectSource.mockResolvedValue(objectType === "SEQUENCE" && databaseType === "oceanbase-oracle" ? alter : source); + mocks.buildExecutableObjectSourceStatements.mockResolvedValue([objectType === "SEQUENCE" ? alter : source]); + mocks.executeQuery.mockResolvedValue({ columns: [], rows: [], affected_rows: 0 }); + const saved = vi.fn(); + const state = reactive({ open: true, connectionId: "ob", database: "service", schema: "APP", name: "SEQ" }); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ render: () => h(ObjectSourceDialog, { ...state, objectType, databaseType, dialect: "mysql", formatDialect: "plsql", initialEditing: options.initialEditing ?? false, onSaved: saved }) }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => { + app.unmount(); + host.remove(); + }); + await vi.waitFor(() => expect(editor()).not.toBeNull(), { timeout: 10_000 }); + return { saved, state }; +} + +describe("OceanBase sequence source through the shared Oracle object dialog", () => { + it("previews the CREATE DDL read-only and switches to the backend ALTER draft only on edit", async () => { + await mount(); + expect(editor()!.state.facet(EditorState.readOnly)).toBe(true); + expect(editor()!.state.doc.toString()).toMatch(/CREATE\s+SEQUENCE/i); + expect(editor()!.state.doc.toString()).toMatch(/START\s+WITH\s+40/i); + expect(mocks.buildEditableObjectSource).toHaveBeenCalledWith({ databaseType: "oceanbase-oracle", objectType: "SEQUENCE", schema: "APP", name: "SEQ", source: ddl }); + button("contextMenu.editView")!.click(); + await vi.waitFor(() => expect(editor()!.state.facet(EditorState.readOnly)).toBe(false)); + expect(editor()!.state.doc.toString()).toBe(alter); + expect(editor()!.state.doc.toString()).not.toContain("START WITH"); + }); + it("saves only the generated ALTER and reloads the original object", async () => { + const { saved } = await mount({ initialEditing: true }); + expect(editor()!.state.doc.toString()).toBe(alter); + button("objects.saveSource")!.click(); + await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); + expect(mocks.buildExecutableObjectSourceStatements).toHaveBeenCalledWith({ databaseType: "oceanbase-oracle", objectType: "SEQUENCE", schema: "APP", name: "SEQ", source: alter }); + expect(mocks.executeQuery).toHaveBeenCalledExactlyOnceWith("ob", "service", alter, "APP"); + await vi.waitFor(() => expect(mocks.getObjectSource).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(editor()!.state.facet(EditorState.readOnly)).toBe(true)); + }); + it.each([false, true])("leaves native Oracle sequences read-only (initialEditing=%s)", async (initialEditing) => { + await mount({ databaseType: "oracle", initialEditing }); + expect(editor()!.state.facet(EditorState.readOnly)).toBe(true); + expect(editor()!.state.doc.toString()).toMatch(/CREATE\s+SEQUENCE/i); + expect(button("contextMenu.editView")).toBeUndefined(); + expect(button("objects.saveSource")).toBeUndefined(); + expect(mocks.executeQuery).not.toHaveBeenCalled(); + }); + it("honors a driver-declared read-only OceanBase object", async () => { + await mount({ editable: false, initialEditing: true }); + expect(editor()!.state.facet(EditorState.readOnly)).toBe(true); + expect(button("contextMenu.editView")).toBeUndefined(); + expect(mocks.executeQuery).not.toHaveBeenCalled(); + }); + it("does not execute a sequence save after the selected connection changes during SQL generation", async () => { + const { saved, state } = await mount({ initialEditing: true }); + let complete!: (value: string[]) => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + mocks.buildExecutableObjectSourceStatements.mockReturnValueOnce(pending); + button("objects.saveSource")!.click(); + await vi.waitFor(() => expect(mocks.buildExecutableObjectSourceStatements).toHaveBeenCalledOnce()); + state.connectionId = "another-connection"; + await nextTick(); + complete([alter]); + await pending; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mocks.executeQuery).not.toHaveBeenCalled(); + expect(saved).not.toHaveBeenCalled(); + }); + it("keeps synonym replacement on the existing source editor path", async () => { + const source = 'CREATE OR REPLACE SYNONYM "APP"."SEQ" FOR "APP"."TARGET";'; + const { saved } = await mount({ type: "SYNONYM", source, initialEditing: true }); + expect(editor()!.state.doc.toString()).toBe(source); + button("objects.saveSource")!.click(); + await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce()); + expect(mocks.executeQuery).toHaveBeenCalledExactlyOnceWith("ob", "service", source, "APP"); + }); +}); diff --git a/apps/desktop/src/components/objects/__tests__/OracleDatabaseLinksDialog.targetIsolation.spec.ts b/apps/desktop/src/components/objects/__tests__/OracleDatabaseLinksDialog.targetIsolation.spec.ts new file mode 100644 index 0000000000..b92bb15d49 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/OracleDatabaseLinksDialog.targetIsolation.spec.ts @@ -0,0 +1,204 @@ +// @vitest-environment happy-dom +import { createApp, h, nextTick, reactive } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useReadOnlyUnlockStore } from "@/stores/readOnlyUnlockStore"; +import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import OracleDatabaseLinksDialog from "../OracleDatabaseLinksDialog.vue"; + +const mocks = vi.hoisted(() => ({ executeQuery: vi.fn() })); +vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal()), executeQuery: mocks.executeQuery })); +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + vi.restoreAllMocks(); + mocks.executeQuery.mockReset(); +}); +const link = { name: "REMOTE", owner: "A_USER", username: "REMOTE_USER", host: "127.0.0.1:2881", created: "" }; +function deferred() { + let resolve!: (value: T) => void, reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function button(text: string): HTMLButtonElement { + const match = [...document.querySelectorAll("button")].find((item) => item.textContent?.trim() === text); + expect(match, text).toBeDefined(); + return match!; +} +async function fill(label: string, value: string) { + const item = [...document.querySelectorAll("label")].find((element) => element.textContent?.includes(label)); + const input = item?.querySelector("input"); + expect(input, label).toBeTruthy(); + input!.value = value; + input!.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); +} +async function createForm() { + button("databaseLinks.create").click(); + await nextTick(); + for (const [label, value] of [ + ["name", "REMOTE_NEW"], + ["username", "remote_user"], + ["host", "127.0.0.1:2881"], + ["remoteTenant", "MixedTenant"], + ["password", "local-test-secret"], + ]) + await fill("databaseLinks." + label, value); +} +function submit() { + document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +} +async function mount(options: { dbType?: DatabaseType; readonly?: boolean; list?: ReturnType; owner?: string; name?: string } = {}) { + const pinia = createPinia(); + setActivePinia(pinia); + const store = useConnectionStore(); + vi.spyOn(store, "getConfig").mockImplementation((id) => ({ id, name: id, db_type: options.dbType ?? "oceanbase-oracle", read_only: options.readonly ?? false }) as ConnectionConfig); + const list = vi.spyOn(store, "listOracleDatabaseLinks").mockImplementation(options.list ?? (async () => [link])); + mocks.executeQuery.mockImplementation(async (connectionId: string, _database: string, sql: string) => ({ columns: ["VALUE"], rows: [[sql.includes("SESSION_USER") ? (connectionId === "a" ? "A_USER" : "B_USER") : 1]] })); + const state = reactive({ connectionId: "a", database: "service", open: true }); + const changed = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ render: () => h(OracleDatabaseLinksDialog, { ...state, name: options.name, owner: options.owner, onChanged: changed }) }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => { + app.unmount(); + host.remove(); + }); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).not.toBeNull()); + return { state, changed, list }; +} +async function ready() { + await vi.waitFor(() => expect(button("databaseLinks.create").disabled).toBe(false)); +} + +describe("DBX Oracle DBLink dialog reused for OceanBase", () => { + it.each(["oracle", "oceanbase-oracle"] as const)("preserves supported controls for %s", async (dbType) => { + await mount({ dbType, name: "REMOTE", owner: "A_USER" }); + await ready(); + expect([...document.querySelectorAll("button")].some((element) => element.textContent?.trim() === "databaseLinks.alter")).toBe(dbType === "oracle"); + button("databaseLinks.create").click(); + await nextTick(); + expect(document.querySelector("select") !== null).toBe(dbType === "oceanbase-oracle"); + const publicControl = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes(dbType === "oceanbase-oracle" ? "databaseLinks.oceanbasePublic" : "databaseLinks.public")); + expect(publicControl).toBeDefined(); + expect(publicControl?.querySelector("input")?.disabled).toBe(false); + expect(publicControl?.querySelector("input")?.checked).toBe(false); + }); + it("creates through the shared execution guard using SESSION_USER instead of selected schema", async () => { + const { changed, list } = await mount(); + await ready(); + await createForm(); + submit(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce()); + const request = mocks.executeQuery.mock.calls.find((call) => String(call[2]).startsWith("CREATE DATABASE LINK")); + expect(request?.slice(0, 2)).toEqual(["a", "service"]); + expect(request?.[2]).toContain('CONNECT TO "REMOTE_USER"@"MixedTenant"'); + expect(request?.[2]).toContain(" OB HOST '127.0.0.1:2881'"); + expect(request?.[2]).not.toContain("CREATE PUBLIC"); + expect(request?.[3]).toBe("A_USER"); + expect(list).toHaveBeenCalledTimes(2); + }); + it("creates the explicit PUBLIC form when the OceanBase option is selected", async () => { + const { changed } = await mount(); + await ready(); + await createForm(); + const publicControl = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes("databaseLinks.oceanbasePublic")); + const checkbox = publicControl?.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeDefined(); + checkbox!.click(); + await nextTick(); + submit(); + await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce()); + const request = mocks.executeQuery.mock.calls.find((call) => String(call[2]).startsWith("CREATE PUBLIC DATABASE LINK")); + expect(request?.[2]).toContain('CONNECT TO "REMOTE_USER"@"MixedTenant"'); + }); + it("does not leak a supplied password in backend errors", async () => { + await mount(); + await ready(); + await createForm(); + mocks.executeQuery.mockRejectedValueOnce(new Error('rejected IDENTIFIED BY "local-test-secret"')); + submit(); + await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("[redacted]")); + expect(document.querySelector('[role="alert"]')?.textContent).not.toContain("local-test-secret"); + }); + it("does not leak a password prefix from a truncated OceanBase SQL error", async () => { + await mount(); + await ready(); + await createForm(); + mocks.executeQuery.mockRejectedValueOnce(new Error('syntax error near IDENTIFIED BY "local-test-se')); + submit(); + await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("[redacted]")); + expect(document.querySelector('[role="alert"]')?.textContent).not.toContain("local-test-se"); + }); + it("does not send a late identity lookup to a newly selected connection", async () => { + const pending = deferred<(typeof link)[]>(); + const { state } = await mount({ list: vi.fn((id: string) => (id === "a" ? pending.promise : Promise.resolve([{ ...link, name: "B_LINK", owner: "B_USER" }]))) }); + state.connectionId = "b"; + await nextTick(); + await ready(); + pending.resolve([link]); + await nextTick(); + await nextTick(); + const identityRequests = mocks.executeQuery.mock.calls.filter((call) => String(call[2]).includes("SESSION_USER")); + expect(identityRequests).toHaveLength(1); + expect(identityRequests[0][0]).toBe("b"); + expect(document.body.textContent).toContain("B_LINK"); + expect(document.body.textContent).not.toContain("A_USER"); + }); + it("ignores a write failure belonging to the previous connection", async () => { + const { state, changed } = await mount(); + await ready(); + await createForm(); + const pending = deferred(); + mocks.executeQuery.mockImplementationOnce(() => pending.promise); + submit(); + await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2)); + state.connectionId = "b"; + await nextTick(); + await ready(); + pending.reject(new Error("old-target failure")); + await nextTick(); + await nextTick(); + expect(changed).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("old-target failure"); + expect(button("databaseLinks.create").disabled).toBe(false); + }); + it("revalidates the target after awaiting read-only unlock approval", async () => { + const { state, changed } = await mount({ readonly: true }); + await ready(); + await createForm(); + const pending = deferred(); + const unlock = vi.spyOn(useReadOnlyUnlockStore(), "requestUnlock").mockImplementation(() => pending.promise); + submit(); + await vi.waitFor(() => expect(unlock).toHaveBeenCalledOnce()); + state.connectionId = "b"; + await nextTick(); + await ready(); + pending.resolve(true); + await nextTick(); + await nextTick(); + expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).startsWith("CREATE"))).toBe(false); + expect(changed).not.toHaveBeenCalled(); + }); + it("does not test a public link shadowed by the login user's private link", async () => { + await mount({ dbType: "oracle", owner: "PUBLIC", name: "REMOTE", list: vi.fn(async () => [link, { ...link, owner: "PUBLIC" }]) }); + await ready(); + expect(button("databaseLinks.test").disabled).toBe(true); + expect(document.body.textContent).toContain("databaseLinks.shadowed"); + }); + it("allows testing but not deleting an OceanBase link created by another user", async () => { + await mount({ owner: "PUBLIC", name: "REMOTE", list: vi.fn(async () => [{ ...link, owner: "PUBLIC", canDrop: false }]) }); + await ready(); + expect(button("databaseLinks.test").disabled).toBe(false); + expect(button("common.delete").disabled).toBe(true); + expect(document.body.textContent).toContain("databaseLinks.oceanbaseSharedReadOnly"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index a515539047..4f1ec7322f 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -2595,6 +2595,7 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false title: `Source - ${node.label}`, schema, catalog: node.catalog, + initialEditing, request: { name: sourceTarget.name, objectType: sourceTarget.objectType, signature: sourceNode.signature }, }); return; @@ -6380,8 +6381,11 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { return true; } - if (node.type === "sequence") { + if (node.type === "sequence" || (node.type === "synonym" && currentDatabaseType() === "oceanbase-oracle")) { items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); + if (currentDatabaseType() === "oceanbase-oracle") { + items.push({ label: t("contextMenu.editObject"), action: () => openObjectSourceDialog(true), icon: Pencil }); + } items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); return true; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1b3a491a2e..0cfaafb0e5 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3885,6 +3885,12 @@ export default { loadFailed: "Failed to load schemas: {message}", }, databaseLinks: { + remoteTenant: "Remote tenant", + remoteProtocol: "Remote database protocol", + remoteCluster: "Remote cluster (optional)", + oceanbaseRecreateHint: "In-place updates are not available for OceanBase links. Create and test a replacement before explicitly deleting the old link. Existing links are never dropped automatically.", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session. Test the public link from a user without that private link.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 69bf2ccd7d..facf8e7c89 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3756,6 +3756,12 @@ export default withEnglishFallback({ loadFailed: "No se pudieron cargar los schemas: {message}", }, databaseLinks: { + remoteTenant: "Inquilino remoto", + remoteProtocol: "Protocolo de la base de datos remota", + remoteCluster: "Clúster remoto (opcional)", + oceanbaseRecreateHint: "Los enlaces de OceanBase no se modifican directamente. Cree y pruebe un reemplazo antes de eliminar expresamente el enlace anterior. Nunca se eliminan enlaces automáticamente.", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 06c34fd4c8..09741f00b5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3754,6 +3754,12 @@ export default withEnglishFallback({ loadFailed: "Impossibile caricare gli schema: {message}", }, databaseLinks: { + remoteTenant: "Tenant remoto", + remoteProtocol: "Protocollo del database remoto", + remoteCluster: "Cluster remoto (facoltativo)", + oceanbaseRecreateHint: "I collegamenti OceanBase non si modificano direttamente. Creare e verificare un sostituto prima di eliminare esplicitamente il collegamento precedente. Nessun collegamento viene eliminato automaticamente.", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6c28514173..aa4576cdd7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3780,6 +3780,12 @@ export default withEnglishFallback({ loadFailed: "スキーマの読み込みに失敗しました: {message}", }, databaseLinks: { + remoteTenant: "リモートテナント", + remoteProtocol: "リモートデータベースのプロトコル", + remoteCluster: "リモートクラスタ(任意)", + oceanbaseRecreateHint: "OceanBaseリンクの直接変更には対応していません。代替リンクを作成してテストした後、古いリンクを明示的に削除してください。既存リンクを自動削除することはありません。", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9a52318f30..10230bd1da 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3642,6 +3642,12 @@ export default withEnglishFallback({ loadFailed: "스키마를 불러오는 데 실패했습니다: {message}", }, databaseLinks: { + remoteTenant: "원격 테넌트", + remoteProtocol: "원격 데이터베이스 프로토콜", + remoteCluster: "원격 클러스터(선택 사항)", + oceanbaseRecreateHint: "OceanBase 링크는 직접 수정할 수 없습니다. 대체 링크를 만들고 테스트한 후 기존 링크를 명시적으로 삭제하세요. 기존 링크를 자동으로 삭제하지 않습니다.", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3d0732e31c..168704ac18 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3756,6 +3756,12 @@ export default withEnglishFallback({ loadFailed: "Falha ao carregar schemas: {message}", }, databaseLinks: { + remoteTenant: "Tenant remoto", + remoteProtocol: "Protocolo do banco de dados remoto", + remoteCluster: "Cluster remoto (opcional)", + oceanbaseRecreateHint: "Links do OceanBase não são alterados diretamente. Crie e teste um substituto antes de excluir explicitamente o link antigo. Links existentes nunca são excluídos automaticamente.", + oceanbasePublic: "Public", + oceanbaseSharedReadOnly: "Another user created this public link. You can test and use it, but only its creator can delete it.", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e4a5312f41..fd38ea43da 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3796,6 +3796,12 @@ export default withEnglishFallback({ loadFailed: "加载 Schema 失败:{message}", }, databaseLinks: { + remoteTenant: "远程租户", + remoteProtocol: "远程数据库协议", + remoteCluster: "远程集群(可选)", + oceanbaseRecreateHint: "OceanBase 链接暂不提供原地修改。请先新建并测试替代链接,再明确删除旧链接;不会自动删除已有链接。", + oceanbasePublic: "公有", + oceanbaseSharedReadOnly: "此公有链接由其他用户创建。你可以测试和使用,但只有创建者可以删除。", shadowed: "当前会话会优先使用同名私有链接。请使用没有该私有链接的用户测试此公有链接。", search: "搜索数据库链接", name: "链接名称", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1a396b2f60..3d55c56399 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3755,6 +3755,12 @@ export default withEnglishFallback({ loadFailed: "載入 Schema 失敗:{message}", }, databaseLinks: { + remoteTenant: "遠端租戶", + remoteProtocol: "遠端資料庫協定", + remoteCluster: "遠端叢集(選填)", + oceanbaseRecreateHint: "OceanBase 連結暫不提供原地修改。請先建立並測試替代連結,再明確刪除舊連結;不會自動刪除既有連結。", + oceanbasePublic: "公有", + oceanbaseSharedReadOnly: "此公有連結由其他使用者建立。你可以測試和使用,但只有建立者可以刪除。", shadowed: "A private link with the same name takes precedence in this session.", search: "Search database links", name: "Link name", diff --git a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts index 3c0e0bcbef..2746342df0 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseObjectCapabilities.spec.ts @@ -20,16 +20,15 @@ describe("databaseObjectCapabilities", () => { it("exposes synonyms only for database paths with synonym metadata", () => { expect(sidebarObjectKindsForDatabase("oracle")).toContain("SYNONYM"); expect(sidebarObjectKindsForDatabase("xugu")).toContain("SYNONYM"); - expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).not.toContain("SYNONYM"); + expect(sidebarObjectKindsForDatabase("oceanbase-oracle")).toContain("SYNONYM"); expect(sidebarObjectKindsForDatabase("postgres")).not.toContain("SYNONYM"); }); - it("exposes OceanBase Oracle sequences without widening synonym support", () => { + it("exposes OceanBase Oracle sequences and synonyms through the grouped object path", () => { const oceanBaseObjects = sidebarObjectKindsForDatabase("oceanbase-oracle"); - expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]); - expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]); - expect(oceanBaseObjects).not.toContain("SYNONYM"); + expect(oceanBaseObjects).toEqual(["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]); + expect(databaseObjectCapabilities("oceanbase-oracle").sourceReadable).toEqual(["VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]); expect( buildObjectGroupPlaceholderNodes({ nodeId: "connection:database:APP", @@ -38,7 +37,7 @@ describe("databaseObjectCapabilities", () => { schema: "APP", objectTypes: oceanBaseObjects, }).map((node) => node.type), - ).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]); + ).toEqual(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-synonyms", "group-packages"]); }); it("exposes Oracle sequences through the existing grouped object path", () => { diff --git a/apps/desktop/src/lib/__tests__/database/oracleDatabaseLinks.spec.ts b/apps/desktop/src/lib/__tests__/database/oracleDatabaseLinks.spec.ts index 175a034555..8bcdb0332d 100644 --- a/apps/desktop/src/lib/__tests__/database/oracleDatabaseLinks.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/oracleDatabaseLinks.spec.ts @@ -1,6 +1,57 @@ import { describe, expect, it } from "vitest"; -import { ORACLE_DATABASE_LINKS_SQL, createOracleDatabaseLinkSql, alterOracleDatabaseLinkSql, dropOracleDatabaseLinkSql, oracleDatabaseLinkName } from "@/lib/database/oracleDatabaseLinks"; +import { + OCEANBASE_ORACLE_DATABASE_LINKS_SQL, + ORACLE_DATABASE_LINKS_SQL, + createOracleDatabaseLinkSql, + createOceanBaseDatabaseLinkSql, + supportsOracleDatabaseLinks, + alterOracleDatabaseLinkSql, + dropOracleDatabaseLinkSql, + oracleDatabaseLinkName, + oracleDatabaseLinksSql, + redactOracleDatabaseLinkError, + testOracleDatabaseLinkSql, +} from "@/lib/database/oracleDatabaseLinks"; +import { databaseObjectCapabilities } from "@/lib/database/databaseObjectCapabilities"; import { oracleDatabaseLinkCompletionContext as context, oracleDatabaseLinkCompletionItems as items } from "@/lib/sql/oracleDatabaseLinkCompletion"; +describe("OceanBase Oracle schema objects", () => { + it("exposes sequence and synonym source through the same object model as Oracle", () => { + for (const db of ["oracle", "oceanbase-oracle"] as const) { + const capabilities = databaseObjectCapabilities(db); + expect(capabilities.sidebarObjects).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"])); + expect(capabilities.sourceReadable).toEqual(expect.arrayContaining(["SEQUENCE", "SYNONYM"])); + expect(supportsOracleDatabaseLinks(db)).toBe(true); + } + expect(supportsOracleDatabaseLinks("mysql")).toBe(false); + expect(supportsOracleDatabaseLinks(undefined)).toBe(false); + }); + const input = { name: "REMOTE.EXAMPLE", public: false, username: "remote_user", tenant: "MixedTenant", password: "test-only@pass", host: "127.0.0.1:2881", protocol: "OB" as const }; + it("creates OB links using HOST and preserves tenant and cluster case", () => { + expect(createOceanBaseDatabaseLinkSql({ ...input, cluster: "MixedCluster" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\' CLUSTER "MixedCluster"'); + }); + it("generates the explicit PUBLIC form when selected", () => { + expect(createOceanBaseDatabaseLinkSql({ ...input, public: true })).toBe('CREATE PUBLIC DATABASE LINK REMOTE.EXAMPLE CONNECT TO "REMOTE_USER"@"MixedTenant" IDENTIFIED BY "test-only@pass" OB HOST \'127.0.0.1:2881\''); + }); + it("creates OCI links using the oracle tenant and preserves quoted remote users", () => { + expect(createOceanBaseDatabaseLinkSql({ ...input, username: '"MixedUser"', protocol: "OCI", host: "127.0.0.1:1521/service" })).toBe('CREATE DATABASE LINK REMOTE.EXAMPLE CONNECT TO "MixedUser"@"oracle" IDENTIFIED BY "test-only@pass" OCI HOST \'127.0.0.1:1521/service\''); + }); + it("escapes host literals rather than accepting SQL syntax", () => { + expect(createOceanBaseDatabaseLinkSql({ ...input, host: "bad'host" })).toContain("HOST 'bad''host'"); + }); + it("redacts complete and truncated passwords echoed by database errors", () => { + expect(redactOracleDatabaseLinkError('failed: IDENTIFIED BY "test-only@pass" OB HOST', "test-only@pass")).not.toContain("test-only@pass"); + expect(redactOracleDatabaseLinkError('failed near IDENTIFIED BY "test-on', "test-only@pass")).toBe('failed near IDENTIFIED BY "[redacted]"'); + }); + it.each([{ tenant: "" }, { tenant: "\n" }, { password: "" }, { password: 'p"ass' }, { password: "p\nass" }, { host: "" }, { host: "bad\0host" }, { name: "x;DROP TABLE T" }, { protocol: "OCI", cluster: "c" }, { username: "" }])("rejects malformed link inputs %j", (overrides) => { + expect(() => createOceanBaseDatabaseLinkSql({ ...input, ...overrides } as Parameters[0])).toThrow(); + }); + it("enables the shared @link completion only in supported contexts", () => { + const sql = "SELECT * FROM DUAL@REM"; + expect(context(sql, sql.length, "oceanbase-oracle")).toEqual({ prefix: "REM", from: sql.indexOf("REM"), to: sql.length }); + for (const text of ["SELECT 'DUAL@", "-- DUAL@", "SELECT @@"]) expect(context(text, text.length, "oceanbase-oracle")).toBeNull(); + }); +}); + const privateLink = { owner: "APP", name: "REMOTE.EXAMPLE.COM", username: "REMOTE_USER", host: "//localhost:1521/XE", created: "" }; describe("Oracle database links", () => { it.each(["SELECT * FROM DUAL@", "SELECT * FROM APP.DUAL@", 'SELECT * FROM "Odd table"@', "UPDATE APP.T@", "BEGIN APP.PROC@", "INSERT INTO T@", "DELETE FROM T@", "SELECT * FROM A JOIN B@"])("offers suffix completion in %s", (sql) => { @@ -27,6 +78,17 @@ describe("Oracle database links", () => { expect(ORACLE_DATABASE_LINKS_SQL).toContain("'PUBLIC'"); expect(ORACLE_DATABASE_LINKS_SQL).not.toContain("CURRENT_SCHEMA"); }); + it("lists tenant-visible OceanBase links and marks links created by the session user", () => { + expect(oracleDatabaseLinksSql("oceanbase-oracle")).toBe(OCEANBASE_ORACLE_DATABASE_LINKS_SQL); + expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM ALL_DB_LINKS"); + expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("FROM USER_DB_LINKS"); + expect(OCEANBASE_ORACLE_DATABASE_LINKS_SQL).toContain("DBX_CAN_DROP"); + expect(oracleDatabaseLinksSql("oracle")).toBe(ORACLE_DATABASE_LINKS_SQL); + }); + it("tests OceanBase links through a remote catalog table instead of DUAL", () => { + expect(testOracleDatabaseLinkSql(privateLink, "oceanbase-oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@REMOTE.EXAMPLE.COM WHERE ROWNUM = 1"); + expect(testOracleDatabaseLinkSql(privateLink, "oracle")).toBe("SELECT 1 AS DBX_LINK_OK FROM DUAL@REMOTE.EXAMPLE.COM"); + }); it("creates fixed-user links with an escaped connect string", () => { expect(createOracleDatabaseLinkSql({ name: privateLink.name, username: "REMOTE_USER", password: "p@ss", host: "test'host", public: false })).toBe(`CREATE DATABASE LINK REMOTE.EXAMPLE.COM CONNECT TO "REMOTE_USER" IDENTIFIED BY "p@ss" USING 'test''host'`); }); @@ -34,5 +96,6 @@ describe("Oracle database links", () => { it("preserves a case-sensitive remote user read from metadata", () => expect(alterOracleDatabaseLinkSql({ ...privateLink, username: "MixedCase" }, "new-pass")).toContain('CONNECT TO "MixedCase"')); it("normalizes unquoted usernames when creating links", () => expect(createOracleDatabaseLinkSql({ name: "REMOTE", username: "remote_user", password: "pass", host: "XE", public: false })).toContain('CONNECT TO "REMOTE_USER"')); it("drops public links with the public modifier", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" })).toBe("DROP PUBLIC DATABASE LINK REMOTE.EXAMPLE.COM")); + it("drops OceanBase public links with the syntax accepted by OceanBase 4.2.5", () => expect(dropOracleDatabaseLinkSql({ ...privateLink, owner: "PUBLIC" }, "oceanbase-oracle")).toBe("DROP DATABASE LINK REMOTE.EXAMPLE.COM")); it.each(["x; DROP TABLE t", "x--", 'x"', "a b", "a..b"])("rejects unsafe link names %s", (name) => expect(() => oracleDatabaseLinkName(name)).toThrow()); }); diff --git a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts index 776bfb3f53..e634abd7f2 100644 --- a/apps/desktop/src/lib/database/databaseObjectCapabilities.ts +++ b/apps/desktop/src/lib/database/databaseObjectCapabilities.ts @@ -33,7 +33,7 @@ const VASTBASE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VI const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"]; const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]; -const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]; +const OCEANBASE_ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY"]; const DAMENG_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"]; const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "SYNONYM", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"]; const PACKAGE_MEMBER_EXPANSION_DATABASES = new Set(["oracle", "xugu"]); diff --git a/apps/desktop/src/lib/database/oracleDatabaseLinks.ts b/apps/desktop/src/lib/database/oracleDatabaseLinks.ts index 88bbcf8d1c..64555929ae 100644 --- a/apps/desktop/src/lib/database/oracleDatabaseLinks.ts +++ b/apps/desktop/src/lib/database/oracleDatabaseLinks.ts @@ -1,4 +1,8 @@ -import type { QueryResult } from "@/types/database"; +import type { DatabaseType, QueryResult } from "@/types/database"; + +export function supportsOracleDatabaseLinks(databaseType?: DatabaseType): boolean { + return databaseType === "oracle" || databaseType === "oceanbase-oracle"; +} export interface OracleDatabaseLink { name: string; @@ -6,6 +10,7 @@ export interface OracleDatabaseLink { username: string; host: string; created: string; + canDrop?: boolean; } // Links belong to the login user, independently of ALTER SESSION SET CURRENT_SCHEMA. @@ -16,12 +21,33 @@ FROM ALL_DB_LINKS WHERE OWNER IN (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'PUBLIC') ORDER BY DB_LINK, CASE WHEN OWNER = 'PUBLIC' THEN 1 ELSE 0 END`; +// OceanBase 4.2.5 links created without a PUBLIC keyword are tenant-visible +// and ALL_DB_LINKS reports them as PUBLIC. USER_DB_LINKS identifies links +// created by the login user, which are the only links that user can drop. +export const OCEANBASE_ORACLE_DATABASE_LINKS_SQL = `SELECT L.OWNER, L.DB_LINK, L.USERNAME, L.HOST, + TO_CHAR(L.CREATED, 'YYYY-MM-DD HH24:MI:SS') AS CREATED, + CASE WHEN EXISTS (SELECT 1 FROM USER_DB_LINKS U WHERE U.DB_LINK = L.DB_LINK) THEN 1 ELSE 0 END AS DBX_CAN_DROP +FROM ALL_DB_LINKS L +ORDER BY L.DB_LINK`; + +export function oracleDatabaseLinksSql(databaseType?: DatabaseType): string { + return databaseType === "oceanbase-oracle" ? OCEANBASE_ORACLE_DATABASE_LINKS_SQL : ORACLE_DATABASE_LINKS_SQL; +} + export function oracleDatabaseLinksFromResult(result: QueryResult): OracleDatabaseLink[] { const indexes = new Map(result.columns.map((name, index) => [name.toUpperCase(), index])); + const canDropIndex = indexes.get("DBX_CAN_DROP"); return result.rows .map((row) => { const value = (name: string) => String(row[indexes.get(name) ?? -1] ?? ""); - return { name: value("DB_LINK"), owner: value("OWNER"), username: value("USERNAME"), host: value("HOST"), created: value("CREATED") }; + return { + name: value("DB_LINK"), + owner: value("OWNER"), + username: value("USERNAME"), + host: value("HOST"), + created: value("CREATED"), + canDrop: canDropIndex === undefined || String(row[canDropIndex]) === "1", + }; }) .filter((link) => !!link.name && !!link.owner); } @@ -33,25 +59,53 @@ export function oracleDatabaseLinkName(name: string): string { return name; } +export function redactOracleDatabaseLinkError(error: unknown, secret: string): string { + let message = String(error); + if (secret) message = message.replaceAll(secret, "[redacted]"); + // OceanBase may truncate the echoed SQL in the middle of a password, so + // replacing only the complete input is insufficient. + return message.replace(/(IDENTIFIED\s+BY\s+")[^"]*(?:"|$)/gi, '$1[redacted]"'); +} + function oracleQuotedIdentifier(value: string, fromMetadata = false): string { - if (!value || /[\0\r\n]/.test(value)) throw new Error("Invalid identifier"); + if (!value || value.includes("\0") || value.includes("\r") || value.includes("\n")) throw new Error("Invalid identifier"); const name = fromMetadata ? value : value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replaceAll('""', '"') : value.toUpperCase(); return `"${name.replaceAll('"', '""')}"`; } export function createOracleDatabaseLinkSql(input: { name: string; public: boolean; username: string; password: string; host: string }): string { - if (!input.host.trim() || !input.password || /[\0\r\n"]/.test(input.password)) throw new Error("A connect string and a password without double quotes or line breaks are required"); + if (!input.host.trim() || !input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"')) { + throw new Error("A connect string and a password without double quotes or line breaks are required"); + } return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)} IDENTIFIED BY "${input.password}" USING '${input.host.replaceAll("'", "''")}'`; } +export function createOceanBaseDatabaseLinkSql(input: { name: string; public: boolean; username: string; tenant: string; password: string; host: string; protocol: "OB" | "OCI"; cluster?: string }): string { + if (!["OB", "OCI"].includes(input.protocol)) throw new Error("Unsupported database link protocol"); + if (!input.password || input.password.includes("\0") || input.password.includes("\r") || input.password.includes("\n") || input.password.includes('"') || !input.host.trim() || input.host.includes("\0") || input.host.includes("\r") || input.host.includes("\n")) { + throw new Error("A host and a password without double quotes or line breaks are required"); + } + const tenant = input.protocol === "OCI" ? "oracle" : input.tenant.trim(); + const cluster = input.cluster?.trim(); + if (input.protocol === "OCI" && cluster) throw new Error("Cluster is only supported for OceanBase links"); + // Tenant/cluster names are case-sensitive configuration, not unquoted SQL identifiers. + // 4.2.5 rejects the Oracle-compatible PUBLIC keyword, while links created + // with this syntax are visible tenant-wide and reported as OWNER=PUBLIC. + return `CREATE ${input.public ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(input.name)} CONNECT TO ${oracleQuotedIdentifier(input.username)}@${oracleQuotedIdentifier(tenant, true)} IDENTIFIED BY "${input.password}" ${input.protocol} HOST '${input.host.replaceAll("'", "''")}'${cluster ? ` CLUSTER ${oracleQuotedIdentifier(cluster, true)}` : ""}`; +} + export function alterOracleDatabaseLinkSql(link: OracleDatabaseLink, password: string): string { - if (!link.username || !password || /[\0\r\n"]/.test(password)) throw new Error("A fixed-user link and a password without double quotes or line breaks are required"); + if (!link.username || !password || password.includes("\0") || password.includes("\r") || password.includes("\n") || password.includes('"')) { + throw new Error("A fixed-user link and a password without double quotes or line breaks are required"); + } return `ALTER ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)} CONNECT TO ${oracleQuotedIdentifier(link.username, true)} IDENTIFIED BY "${password}"`; } -export function dropOracleDatabaseLinkSql(link: OracleDatabaseLink): string { - return `DROP ${link.owner === "PUBLIC" ? "PUBLIC " : ""}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`; +export function dropOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string { + const publicKeyword = databaseType !== "oceanbase-oracle" && link.owner === "PUBLIC" ? "PUBLIC " : ""; + return `DROP ${publicKeyword}DATABASE LINK ${oracleDatabaseLinkName(link.name)}`; } -export function testOracleDatabaseLinkSql(link: OracleDatabaseLink): string { - return `SELECT 1 AS DBX_LINK_OK FROM DUAL@${oracleDatabaseLinkName(link.name)}`; +export function testOracleDatabaseLinkSql(link: OracleDatabaseLink, databaseType?: DatabaseType): string { + const name = oracleDatabaseLinkName(link.name); + return databaseType === "oceanbase-oracle" ? `SELECT 1 AS DBX_LINK_OK FROM SYS.ALL_USERS@${name} WHERE ROWNUM = 1` : `SELECT 1 AS DBX_LINK_OK FROM DUAL@${name}`; } diff --git a/apps/desktop/src/lib/sql/oracleDatabaseLinkCompletion.ts b/apps/desktop/src/lib/sql/oracleDatabaseLinkCompletion.ts index ef6789a847..91a3f79a22 100644 --- a/apps/desktop/src/lib/sql/oracleDatabaseLinkCompletion.ts +++ b/apps/desktop/src/lib/sql/oracleDatabaseLinkCompletion.ts @@ -1,9 +1,9 @@ import type { DatabaseType } from "@/types/database"; -import type { OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks"; +import { supportsOracleDatabaseLinks, type OracleDatabaseLink } from "@/lib/database/oracleDatabaseLinks"; import { isSqlCompletionSuppressedContext } from "@/lib/sql/sqlCompletion"; export function oracleDatabaseLinkCompletionContext(sql: string, cursor: number, databaseType?: DatabaseType) { - if (databaseType !== "oracle" || isSqlCompletionSuppressedContext(sql, cursor, { databaseType })) return null; + if (!supportsOracleDatabaseLinks(databaseType) || isSqlCompletionSuppressedContext(sql, cursor, { databaseType })) return null; const before = sql.slice(0, cursor); const match = /(?:[A-Za-z0-9_$#]|"(?:[^"]|"")+"|\))@([A-Za-z0-9_$#.]*)$/.exec(before); if (!match) return null; diff --git a/apps/desktop/src/stores/__tests__/queryStore.objectSourcePending.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.objectSourcePending.spec.ts index bb23508c7d..f1dc9b26ca 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.objectSourcePending.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.objectSourcePending.spec.ts @@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => { const connectionStore = { activeConnectionId: null as string | null, ensureConnected: vi.fn(async () => {}), - getConfig: (connectionId: string) => (connectionId ? { id: connectionId, name: connectionId, db_type: "oracle" } : undefined), + dbType: "oracle", + getConfig: (connectionId: string) => (connectionId ? { id: connectionId, name: connectionId, db_type: connectionStore.dbType } : undefined), }; return { connectionStore, @@ -36,7 +37,7 @@ const CONNECTION_ID = "ora-1"; const DATABASE = "ORCL"; const SCHEMA = "APP"; -const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION"; signature?: string }) => ({ +const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE"; signature?: string }) => ({ connectionId: CONNECTION_ID, database: DATABASE, title: `Source - ${request.name}`, @@ -59,6 +60,7 @@ describe("queryStore pending object source tab", () => { vi.clearAllMocks(); vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() }); setActivePinia(createPinia()); + mocks.connectionStore.dbType = "oracle"; }); it("creates a visible loading tab before the source arrives", async () => { @@ -269,4 +271,32 @@ describe("queryStore pending object source tab", () => { expect(tab.objectSource).toBeUndefined(); expect(tab.sourceLoad).toBeUndefined(); }); + + it("keeps OceanBase sequence CREATE source for viewing and uses ALTER only for editing", async () => { + mocks.connectionStore.dbType = "oceanbase-oracle"; + mocks.getObjectSource.mockResolvedValue(objectSource('CREATE SEQUENCE "APP"."SEQ_USERS" START WITH 10 INCREMENT BY 2')); + mocks.buildEditableObjectSource.mockResolvedValue('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 2'); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const base = pendingOptions({ name: "SEQ_USERS", objectType: "SEQUENCE" }); + + store.openObjectSourceTabPending({ ...base, initialEditing: false }); + await settle(); + expect(store.tabs[0]?.sql).toContain("CREATE SEQUENCE"); + expect(store.tabs[0]?.sql).toContain("START WITH 10"); + expect(store.tabs[0]?.objectSource).toBeUndefined(); + + store.openObjectSourceTabPending({ ...base, initialEditing: true }); + await settle(); + expect(store.tabs).toHaveLength(2); + expect(store.tabs[1]?.sql).toBe('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 2'); + expect(store.tabs[1]?.objectSource).toMatchObject({ schema: SCHEMA, name: "SEQ_USERS", objectType: "SEQUENCE" }); + + mocks.buildEditableObjectSource.mockResolvedValue('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 4'); + const editId = store.tabs[1]!.id; + expect(store.openObjectSourceTabPending({ ...base, initialEditing: true })).toBe(editId); + await settle(); + expect(store.tabs).toHaveLength(2); + expect(store.tabs[1]?.sql).toBe('ALTER SEQUENCE "APP"."SEQ_USERS" INCREMENT BY 4'); + }); }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d958111f95..aa69e21920 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -105,7 +105,7 @@ import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, import { usesOracleCurrentSchemaCompletion } from "@/lib/sql/oracleCompletionSession"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import * as api from "@/lib/backend/api"; -import { ORACLE_DATABASE_LINKS_SQL, oracleDatabaseLinksFromResult } from "@/lib/database/oracleDatabaseLinks"; +import { oracleDatabaseLinksFromResult, oracleDatabaseLinksSql, supportsOracleDatabaseLinks } from "@/lib/database/oracleDatabaseLinks"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, schemaNodeHasLoadableName, shouldShowDorisCatalogTree, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; @@ -1984,15 +1984,16 @@ export const useConnectionStore = defineStore("connection", () => { function buildOracleDatabaseLinksNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { const config = getConfig(connectionId); - if (effectiveDatabaseTypeForConnection(config) !== "oracle") return undefined; + if (!supportsOracleDatabaseLinks(effectiveDatabaseTypeForConnection(config))) return undefined; const existing = existingConnectionNode?.children?.find((child) => child.type === "oracle-db-links"); return { ...existing, id: `${connectionId}:__oracle_db_links`, label: "tree.databaseLinks", type: "oracle-db-links", connectionId, database: config?.database || "", isExpanded: existing?.isExpanded ?? false, children: existing?.children ?? [] }; } async function listOracleDatabaseLinks(connectionId: string, database: string) { - if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) !== "oracle") return []; + const databaseType = effectiveDatabaseTypeForConnection(getConfig(connectionId)); + if (!supportsOracleDatabaseLinks(databaseType)) return []; await ensureConnected(connectionId); - return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, ORACLE_DATABASE_LINKS_SQL, undefined, undefined, { maxRows: 10000, timeoutSecs: 15 })); + return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, oracleDatabaseLinksSql(databaseType), undefined, undefined, { maxRows: 10000, timeoutSecs: 15 })); } async function refreshOracleDatabaseLinks(connectionId: string) { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5549efb290..0fb2cf663d 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -202,6 +202,7 @@ interface OpenPendingObjectSourceTabOptions { title: string; schema?: string; catalog?: string; + initialEditing?: boolean; request: ObjectSourceRequestIdentity; } @@ -2716,6 +2717,7 @@ export const useQueryStore = defineStore("query", () => { tab.database === options.database && (tab.schema || "") === (options.schema || "") && (tab.catalog || "") === (options.catalog || "") && + (tab.sourceLoad.initialEditing ?? true) === (options.initialEditing ?? true) && tab.sourceLoad.request.name === options.request.name && tab.sourceLoad.request.objectType === options.request.objectType && (tab.sourceLoad.request.signature || "") === (options.request.signature || ""), @@ -2732,15 +2734,18 @@ export const useQueryStore = defineStore("query", () => { // 两条弯路都要避开 —— 再建一个 pending tab 会让界面上多出一个转圈 tab, // 随后又被交接逻辑关掉;而只切过去不校验,会让重开看到的是旧 DDL // (改动前每次打开都会重新取源,源码 tab 没有其它刷新入口)。 - const loaded = findMatchingObjectSourceTab({ - connectionId: options.connectionId, - database: options.database, - title: options.title, - schema: options.schema, - catalog: options.catalog, - sql: "", - objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature }, - }); + const loaded = + options.initialEditing === false + ? undefined + : findMatchingObjectSourceTab({ + connectionId: options.connectionId, + database: options.database, + title: options.title, + schema: options.schema, + catalog: options.catalog, + sql: "", + objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature }, + }); if (loaded) { loaded.sourceView = true; switchTab(loaded.id); @@ -2760,7 +2765,7 @@ export const useQueryStore = defineStore("query", () => { const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, "", options.catalog, { forceNew: true, sourceView: true }); const tab = tabs.value.find((candidate) => candidate.id === id); - if (tab) tab.sourceLoad = { startedAt: Date.now(), request: { ...options.request } }; + if (tab) tab.sourceLoad = { startedAt: Date.now(), initialEditing: options.initialEditing, request: { ...options.request } }; void loadObjectSourceIntoTab(id); return id; } @@ -2799,7 +2804,7 @@ export const useQueryStore = defineStore("query", () => { // 期间 tab 可能被关闭、被复用或已被编辑:身份没变且用户没改过内容时才回填 const current = tabs.value.find((candidate) => candidate.id === id); if (current?.objectSource !== objectSource || resolvedType !== objectSource.objectType) return; - if (raw.editable === false || OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType)) return; + if (raw.editable === false || (OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType) && !(databaseType === "oceanbase-oracle" && resolvedType === "SEQUENCE"))) return; if (isTabDirty(current)) return; updateSql(id, editableSource); markTabClean(current); @@ -2828,7 +2833,7 @@ export const useQueryStore = defineStore("query", () => { const { connectionId } = tab; const { database } = tab; const schema = tab.schema || database; - const { request } = tab.sourceLoad; + const { request, initialEditing } = tab.sourceLoad; try { const connectionStore = useConnectionStore(); await connectionStore.ensureConnected(connectionId); @@ -2848,7 +2853,7 @@ export const useQueryStore = defineStore("query", () => { databaseType, signature: request.signature, }); - applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, editableSource, raw, resolvedType }); + applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, initialEditing, databaseType, editableSource, raw, resolvedType }); } catch (e: any) { // 就地显示错误 + Retry:用户此刻正看着这个 tab,比 toast 更可发现 const failed = tabs.value.find((candidate) => candidate.id === id); @@ -2865,6 +2870,8 @@ export const useQueryStore = defineStore("query", () => { catalog?: string; title: string; request: ObjectSourceRequestIdentity; + initialEditing?: boolean; + databaseType: DatabaseType; editableSource: string; raw: ObjectSource; resolvedType: ObjectSourceKind; @@ -2873,7 +2880,7 @@ export const useQueryStore = defineStore("query", () => { // 加载期间 tab 被关掉(用户放弃)或连接被断开:静默丢弃,不重建、不写库 const tab = tabs.value.find((candidate) => candidate.id === id); if (!tab?.sourceLoad) return; - const sourceIsEditable = loaded.raw.editable !== false && !OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType); + const sourceIsEditable = loaded.initialEditing !== false && loaded.raw.editable !== false && (!OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType) || (loaded.databaseType === "oceanbase-oracle" && loaded.resolvedType === "SEQUENCE")); if (sourceIsEditable) { const options: OpenObjectSourceTabOptions = { connectionId: loaded.connectionId, @@ -2901,7 +2908,7 @@ export const useQueryStore = defineStore("query", () => { updateSql(id, loaded.editableSource); setObjectSource(id, options.objectSource); } else { - updateSql(id, loaded.editableSource); + updateSql(id, loaded.raw.source); } tab.sourceView = true; markTabClean(tab); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index c60aab60e9..945c21cb66 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1788,6 +1788,8 @@ export interface QueryTab { */ sourceLoad?: { startedAt: number; + /** Whether this request should open an editable object definition instead of the original source. */ + initialEditing?: boolean; /** 加载失败时写入;保留 request 以便就地重试 */ error?: string; /** diff --git a/crates/dbx-sql/src/object_source_sql.rs b/crates/dbx-sql/src/object_source_sql.rs index 253262d874..2e0ca91fa8 100644 --- a/crates/dbx-sql/src/object_source_sql.rs +++ b/crates/dbx-sql/src/object_source_sql.rs @@ -127,6 +127,9 @@ pub fn build_routine_rename_object_source_statements( } pub fn build_executable_object_source_statements(input: EditableObjectSourceSqlInput) -> Result, String> { + if input.database_type == DatabaseType::OceanbaseOracle && input.object_type == ObjectSourceKind::Sequence { + return Ok(vec![oceanbase_sequence_edit_sql(&input)?]); + } let source = input.source.trim(); let source = if is_opengauss_like(input.database_type) && input.object_type == ObjectSourceKind::Procedure { strip_standalone_trailing_slash(source) @@ -322,6 +325,92 @@ fn build_routine_rename_cleanup(input: &EditableObjectSourceSqlInput, source: &s )) } +/// Alter sequence options without replaying START WITH or dropping the existing sequence. +fn oceanbase_sequence_edit_sql(input: &EditableObjectSourceSqlInput) -> Result { + let invalid = || { + "Expected CREATE/ALTER SEQUENCE for the selected object with supported sequence options; restart and additional statements are not allowed.".to_string() + }; + let mut tokens: Vec = Tokenizer::new(&OracleDialect {}, input.source.trim()) + .tokenize() + .map_err(|_| invalid())? + .into_iter() + .filter(|token| !matches!(token, Token::Whitespace(_))) + .collect(); + if matches!(tokens.last(), Some(Token::SemiColon)) { + tokens.pop(); + } + let word = |index: usize, expected: &str| matches!(tokens.get(index), Some(Token::Word(w)) if w.quote_style.is_none() && w.value.eq_ignore_ascii_case(expected)); + let creating = word(0, "CREATE"); + if (!creating && !word(0, "ALTER")) || !word(1, "SEQUENCE") { + return Err(invalid()); + } + let mut index = 2; + let mut names = Vec::new(); + loop { + let Some(Token::Word(name)) = tokens.get(index) else { + return Err(invalid()); + }; + names.push(if name.quote_style.is_some() { name.value.clone() } else { name.value.to_uppercase() }); + index += 1; + if !matches!(tokens.get(index), Some(Token::Period)) { + break; + } + index += 1; + } + let expected = match input.schema.as_deref().filter(|schema| !schema.is_empty()) { + Some(schema) => vec![schema.to_string(), input.name.clone()], + None => vec![input.name.clone()], + }; + if names != expected && names != vec![input.name.clone()] { + return Err(invalid()); + } + let mut options = Vec::new(); + while index < tokens.len() { + let start = index; + let skip_start = creating && word(index, "START"); + let numeric = if word(index, "INCREMENT") || skip_start { + let next = if skip_start { "WITH" } else { "BY" }; + if !word(index + 1, next) { + return Err(invalid()); + } + index += 2; + true + } else if ["MINVALUE", "MAXVALUE", "CACHE"].iter().any(|option| word(index, option)) { + index += 1; + true + } else if ["NOMINVALUE", "NOMAXVALUE", "NOCACHE", "CYCLE", "NOCYCLE", "ORDER", "NOORDER"] + .iter() + .any(|option| word(index, option)) + { + index += 1; + false + } else { + return Err(invalid()); + }; + if numeric { + if matches!(tokens.get(index), Some(Token::Minus | Token::Plus)) { + index += 1; + } + if !matches!(tokens.get(index), Some(Token::Number(value, false)) if !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit())) + { + return Err(invalid()); + } + index += 1; + } + if !skip_start { + options.push(tokens[start..index].iter().map(ToString::to_string).collect::>().join(" ")); + } + } + if options.is_empty() { + return Err(invalid()); + } + Ok(format!( + "ALTER SEQUENCE {}\n {};", + postgres_qualified_name(input.schema.as_deref(), &input.name), + options.join("\n ") + )) +} + fn is_postgres_like(database_type: DatabaseType) -> bool { matches!( database_type, @@ -994,6 +1083,70 @@ fn parse_object_source_kind(value: &str) -> Option { mod tests { use super::*; + fn ob_sequence(source: &str, schema: Option<&str>, name: &str) -> EditableObjectSourceSqlInput { + EditableObjectSourceSqlInput { + database_type: DatabaseType::OceanbaseOracle, + object_type: ObjectSourceKind::Sequence, + schema: schema.map(str::to_string), + name: name.to_string(), + source: source.to_string(), + } + } + + #[test] + fn oceanbase_sequence_edit_preserves_counter_and_object_identity() { + let source = "CREATE SEQUENCE \"App\".\"Seq\" MINVALUE -999 MAXVALUE 9999999999999999999999999999 INCREMENT BY -2 START WITH 40 CACHE 20 NOCYCLE NOORDER;"; + let input = ob_sequence(source, Some("App"), "Seq"); + let expected = "ALTER SEQUENCE \"App\".\"Seq\"\n MINVALUE - 999\n MAXVALUE 9999999999999999999999999999\n INCREMENT BY - 2\n CACHE 20\n NOCYCLE\n NOORDER;"; + assert_eq!(build_editable_object_source(input.clone()), expected); + assert_eq!(build_executable_object_source_statements(input).unwrap(), vec![expected]); + let alter = ob_sequence(expected, Some("App"), "Seq"); + assert_eq!(build_executable_object_source_statements(alter).unwrap(), vec![expected]); + } + + #[test] + fn oceanbase_sequence_edit_handles_unqualified_and_quoted_names() { + for (schema, name, source, expected_name) in [ + (Some("APP"), "SEQ", "alter sequence seq increment by 3 nocache", "\"APP\".\"SEQ\""), + (None, "SEQ", "create sequence seq start with 1 increment by 1", "\"SEQ\""), + ( + Some("A.B"), + "S\"Q", + "CREATE SEQUENCE \"A.B\".\"S\"\"Q\" START WITH 1 -- no reset\n INCREMENT BY 2", + "\"A.B\".\"S\"\"Q\"", + ), + ] { + let sql = build_executable_object_source_sql(ob_sequence(source, schema, name)).unwrap(); + assert!(sql.starts_with(&format!("ALTER SEQUENCE {expected_name}"))); + assert!(!sql.contains("START WITH")); + assert!(!sql.contains("DROP")); + } + } + + #[test] + fn oceanbase_sequence_edit_rejects_restart_wrong_targets_and_extra_statements() { + for source in [ + "ALTER SEQUENCE APP.SEQ RESTART START WITH 1", + "ALTER SEQUENCE APP.OTHER INCREMENT BY 1", + "ALTER SEQUENCE OTHER.SEQ INCREMENT BY 1", + "ALTER SEQUENCE \"app\".SEQ INCREMENT BY 1", + "ALTER SEQUENCE APP.SEQ INCREMENT BY 1; DROP TABLE APP.T", + "DROP SEQUENCE APP.SEQ", + "CREATE OR REPLACE SEQUENCE APP.SEQ INCREMENT BY 1", + "CREATE SEQUENCE APP.SEQ START WITH 1", + "ALTER SEQUENCE APP.SEQ CACHE 1.5", + "ALTER SEQUENCE APP.SEQ CACHE 1e3", + "ALTER SEQUENCE APP.SEQ INCREMENT BY", + "ALTER SEQUENCE APP.SEQ START WITH 1", + "CREATE SEQUENCE APP.SEQ START WITH 1 INCREMENT BY 1; SELECT 1 FROM DUAL", + ] { + assert!( + build_executable_object_source_statements(ob_sequence(source, Some("APP"), "SEQ")).is_err(), + "{source}" + ); + } + } + #[test] fn oracle_ddl_terminator_respects_quoted_tokens_and_slash_delimiters() { for (source, expected) in [ diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index 1af43c01fa..b64631896c 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -110,7 +110,7 @@ test("object source identity and editability are enforced in queryStore", () => // pending 占位:同步返回(不 await),tab 已可见并带着可重试的请求身份 assert.doesNotMatch(pendingBody, /await /); - assert.match(pendingBody, /tab\.sourceLoad = \{ startedAt: Date\.now\(\), request: \{ \.\.\.options\.request \} \}/); + assert.match(pendingBody, /tab\.sourceLoad = \{ startedAt: Date\.now\(\), initialEditing: options\.initialEditing, request: \{ \.\.\.options\.request \} \}/); assert.match(pendingBody, /void loadObjectSourceIntoTab\(id\)/); // 落地时清掉加载态,否则 tab 会永远停在转圈 assert.match(applyBody, /clearObjectSourceLoad\(tab\)/);