From fe0dd02f2b1be7d7ad0fa49ee5b46a9e15010ce7 Mon Sep 17 00:00:00 2001 From: efeire Date: Fri, 18 Sep 2026 01:59:42 +0800 Subject: [PATCH] fix(oceanbase): preserve partition DDL and toggle storage options --- .../java/com/dbx/agent/DatabaseAgent.java | 8 + .../java/com/dbx/agent/JsonRpcServer.java | 8 + .../java/com/dbx/agent/PartitionInfo.java | 5 + .../oceanbase-oracle/scripts/README.md | 27 ++++ .../scripts/verify-partition-ddl.ts | 146 +++++++++++++++++ .../oceanbaseoracle/OceanBaseOracleAgent.java | 153 +++++++++++++----- .../OceanBaseOracleAgentTest.java | 79 +++++++-- .../src/components/editor/QueryEditor.vue | 3 +- apps/desktop/src/components/grid/DataGrid.vue | 32 ++-- .../components/objects/DdlStorageToggle.vue | 19 +++ .../src/components/objects/DdlViewDialog.vue | 17 +- .../src/components/objects/ObjectBrowser.vue | 19 ++- .../ObjectBrowserMetadataRefresh.spec.ts | 2 +- .../sidebar/SidebarTreeItemDialogs.vue | 3 + .../sidebar/sidebarTreeDialogState.ts | 1 + .../structure/TableStructureEditor.vue | 31 +++- ...bleStructureEditor.ddlTabLifecycle.spec.ts | 44 ++++- .../useSidebarTreeExportRuntime.spec.ts | 139 +++++++++++++++- .../useSidebarTreeExportRuntime.ts | 58 ++++++- apps/desktop/src/i18n/locales/az.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/tr.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/sql/__tests__/ddlStorage.spec.ts | 40 +++++ apps/desktop/src/lib/sql/ddlStorage.ts | 51 ++++++ apps/desktop/src/lib/sql/semantic/tokens.ts | 9 ++ .../connectionStore.xuguTableChildren.spec.ts | 18 +++ apps/desktop/src/stores/connectionStore.ts | 56 +++---- apps/desktop/src/stores/settingsStore.ts | 4 + apps/desktop/src/types/database.ts | 3 + 36 files changed, 870 insertions(+), 125 deletions(-) create mode 100644 agents/common/src/main/java/com/dbx/agent/PartitionInfo.java create mode 100644 agents/drivers/oceanbase-oracle/scripts/README.md create mode 100644 agents/drivers/oceanbase-oracle/scripts/verify-partition-ddl.ts create mode 100644 apps/desktop/src/components/objects/DdlStorageToggle.vue create mode 100644 apps/desktop/src/lib/sql/__tests__/ddlStorage.spec.ts create mode 100644 apps/desktop/src/lib/sql/ddlStorage.ts diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index 4d751e2a24..1924d958ea 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -147,6 +147,14 @@ public interface DatabaseAgent { List listTriggers(String schema, String table); + default List listPartitions(String schema, String table) { + return Collections.emptyList(); + } + + default List listSubpartitions(String schema, String table) { + return Collections.emptyList(); + } + default QueryResult executeQuery(String sql, String schema) { return executeQuery(sql, schema, new ExecuteQueryOptions()); } diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index 7a267fbe97..88b2a2b34c 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -246,6 +246,14 @@ public final class JsonRpcServer { switchCatalog(params); return agent.listTriggers(params.get("schema").getAsString(), params.get("table").getAsString()); } + if (AgentProtocol.METHOD_LIST_PARTITIONS.equals(method)) { + switchCatalog(params); + return agent.listPartitions(params.get("schema").getAsString(), params.get("table").getAsString()); + } + if (AgentProtocol.METHOD_LIST_SUBPARTITIONS.equals(method)) { + switchCatalog(params); + return agent.listSubpartitions(params.get("schema").getAsString(), params.get("table").getAsString()); + } if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method)) { return agent.executeQuery( params.get("sql").getAsString(), diff --git a/agents/common/src/main/java/com/dbx/agent/PartitionInfo.java b/agents/common/src/main/java/com/dbx/agent/PartitionInfo.java new file mode 100644 index 0000000000..7787384a01 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/PartitionInfo.java @@ -0,0 +1,5 @@ +package com.dbx.agent; + +/** Shared wire shape for partition and subpartition metadata. */ +public record PartitionInfo(String name, int position, String value, String partition_type, String partition_key) { +} diff --git a/agents/drivers/oceanbase-oracle/scripts/README.md b/agents/drivers/oceanbase-oracle/scripts/README.md new file mode 100644 index 0000000000..7463138f3b --- /dev/null +++ b/agents/drivers/oceanbase-oracle/scripts/README.md @@ -0,0 +1,27 @@ +# OceanBase Oracle partition DDL verification + +Build with JDK 21 from the repository root: + +```sh +./agents/gradlew -p agents :common:test :oceanbase-oracle:test :oceanbase-oracle:shadowJar +``` + +Set `OB_HOST`, `OB_PORT` (default 2881), `OB_TENANT`, and `OB_PASSWORD` in your environment. `OB_PASSWORD` is the SYS password for a **disposable Oracle tenant**. Then run: + +```sh +pnpm exec tsx --tsconfig apps/desktop/tsconfig.json agents/drivers/oceanbase-oracle/scripts/verify-partition-ddl.ts +``` + +The script starts the built agent over JSON-RPC, creates three isolated `DBX7055_` schemas, and replays eight tables into two of them: once with storage options omitted and once with full DDL. It checks partition and subpartition counts, keys and bounds, column metadata, defaults, comments, LOCAL indexes, and primary/check/foreign-key enforcement. It also covers quoted identifiers and a global temporary table whose internal session index must not be exported. + +DDL files and `verification.json` are written under `agents/drivers/oceanbase-oracle/build/partition-ddl-evidence` (override with `OB_TEST_OUTPUT`). Schemas are intentionally retained for manual DBX inspection; their exact names are printed in the report. Remove only those test schemas with `DROP USER "" CASCADE` after disconnecting their sessions. No credentials are written to evidence files. + +## UI verification + +Install the built agent as described in the root CONTRIBUTING.md and use the current frontend. Expand a partitioned table's Partitions and Subpartitions groups. Open its DDL and check that **Omit storage attributes** is enabled by default. Copy and export the script, turn the switch off, and repeat. Partition clauses, constraints, LOCAL indexes, defaults and comments must remain in both outputs. Changing the option does not re-fetch or overwrite the cached original. In the structure editor the switch is disabled while DDL has unsaved edits. + +The preference applies only to OceanBase Oracle and removes these known table options: `COMPRESS FOR ARCHIVE [HIGH|LOW]`, `NOCOMPRESS`, `REPLICA_NUM`, `BLOCK_SIZE`, `TABLET_SIZE`, `PCTFREE`, and `USE_BLOOM_FILTER`. Unknown options and partition clauses are preserved. + +## 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. diff --git a/agents/drivers/oceanbase-oracle/scripts/verify-partition-ddl.ts b/agents/drivers/oceanbase-oracle/scripts/verify-partition-ddl.ts new file mode 100644 index 0000000000..6cf38e66ac --- /dev/null +++ b/agents/drivers/oceanbase-oracle/scripts/verify-partition-ddl.ts @@ -0,0 +1,146 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline"; +import assert from "node:assert/strict"; +import { applyDdlStoragePreference } from "../../../../apps/desktop/src/lib/sql/ddlStorage"; +import { splitSqlStatementRanges } from "../../../../apps/desktop/src/lib/sql/sqlStatementRanges"; + +async function main() { + const password = process.env.OB_PASSWORD; + const host = process.env.OB_HOST; + const tenant = process.env.OB_TENANT; + assert.ok(password && host && tenant, "Set OB_HOST, OB_TENANT and OB_PASSWORD for a disposable Oracle tenant"); + const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, "bin", process.platform === "win32" ? "java.exe" : "java") : "java"; + const jar = "agents/drivers/oceanbase-oracle/build/libs/dbx-agent-oceanbase-oracle.jar"; + const output = process.env.OB_TEST_OUTPUT ?? join("agents/drivers/oceanbase-oracle/build", "partition-ddl-evidence"); + mkdirSync(output, { recursive: true }); + const child = spawn(java, ["-jar", jar], { stdio: ["pipe", "pipe", "pipe"] }); + child.stderr.resume(); + let id = 0; + const pending = new Map void; reject: (x: any) => void }>(); + createInterface({ input: child.stdout }).on("line", (line) => { + if (!line.startsWith("{")) return; + const response = JSON.parse(line); + const task = pending.get(response.id); + if (!task) return; + pending.delete(response.id); + response.error ? task.reject(new Error(response.error.message)) : task.resolve(response.result); + }); + child.on("exit", () => { + for (const request of pending.values()) request.reject(new Error("Agent exited")); + pending.clear(); + }); + child.on("error", (error) => { + for (const request of pending.values()) request.reject(error); + pending.clear(); + }); + function call(method: string, params: any = {}) { + return new Promise((resolve, reject) => { + const requestId = ++id; + const timeout = setTimeout(() => { + pending.delete(requestId); + reject(new Error("Agent request timed out: " + method)); + }, 60_000); + pending.set(requestId, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }) + "\n"); + }); + } + const connect = (user: string, session: string) => call("open_session", { agentSessionId: session, host, port: Number(process.env.OB_PORT ?? 2881), database: user, username: user + "@" + tenant, password }); + const sql = (text: string, session = "admin") => + call("execute_query", { agentSessionId: session, sql: text, maxRows: 1000, timeoutSecs: 30 }).catch((e) => { + throw new Error((text.startsWith("CREATE USER") ? "CREATE USER [redacted]" : text) + "\n" + e.message); + }); + const qi = (s: string) => '"' + s.replaceAll('"', '""') + '"'; + const suffix = randomUUID().slice(0, 8).toUpperCase(); + const source = "DBX7055_S" + suffix, + logical = "DBX7055_L" + suffix, + physical = "DBX7055_P" + suffix; + const fixtures = [ + ["PARENT", "(ID NUMBER PRIMARY KEY)"], + [ + "RANGE_LIST", + `(ID NUMBER NOT NULL, REGION VARCHAR2(30) DEFAULT 'PCTFREE = 20', NOTE VARCHAR2(100), CONSTRAINT C_PK PRIMARY KEY(ID,REGION), CONSTRAINT C_CK CHECK(ID>0)) PARTITION BY RANGE(ID) SUBPARTITION BY LIST(REGION) (PARTITION P1 VALUES LESS THAN(100) (SUBPARTITION P1_E VALUES('east'), SUBPARTITION P1_D VALUES(DEFAULT)), PARTITION PM VALUES LESS THAN(MAXVALUE) (SUBPARTITION PM_E VALUES('east'), SUBPARTITION PM_D VALUES(DEFAULT)))`, + ], + ["RANGE_T", `(ID NUMBER, D DATE) PARTITION BY RANGE(ID,D) (PARTITION P1 VALUES LESS THAN(100,DATE '2027-01-01'), PARTITION PM VALUES LESS THAN(MAXVALUE,MAXVALUE))`], + ["LIST_T", `(REGION VARCHAR2(30), ID NUMBER) PARTITION BY LIST(REGION) (PARTITION PE VALUES ('east','PCTFREE = 20'), PARTITION PD VALUES(DEFAULT))`], + ["HASH_T", "(ID NUMBER, NOTE VARCHAR2(100)) PARTITION BY HASH(ID) PARTITIONS 4"], + ["CHILD", "(ID NUMBER PRIMARY KEY, PARENT_ID NUMBER, CONSTRAINT C_FK FOREIGN KEY(PARENT_ID) REFERENCES " + qi(source) + ".PARENT(ID))"], + ["Mixed Table", `("PCTFREE" VARCHAR2(100) DEFAULT 'can''t ; ) PCTFREE=12', "ID" NUMBER)`], + ]; + const tables = fixtures.map((x) => x[0]); + tables.push("TEMP_T"); + try { + await call("handshake"); + await connect("SYS", "admin"); + for (const owner of [source, logical, physical]) { + await sql("CREATE USER " + qi(owner) + " IDENTIFIED BY " + qi(password)); + await sql("GRANT CREATE SESSION, CREATE TABLE TO " + qi(owner)); + } + for (const [name, definition] of fixtures) await sql("CREATE TABLE " + qi(source) + "." + qi(name) + " " + definition); + await sql("CREATE GLOBAL TEMPORARY TABLE " + qi(source) + ".TEMP_T(ID NUMBER) ON COMMIT PRESERVE ROWS"); + await sql("CREATE INDEX " + qi(source) + ".C_LOCAL ON " + qi(source) + ".RANGE_LIST(NOTE) LOCAL"); + await sql("COMMENT ON TABLE " + qi(source) + ".RANGE_LIST IS 'table''s PCTFREE = 20'"); + await sql("COMMENT ON COLUMN " + qi(source) + ".RANGE_LIST.NOTE IS 'note ; ) COMPRESS FOR ARCHIVE'"); + await connect(source, "source"); + await connect(logical, "logical"); + await connect(physical, "physical"); + const checks: any[] = []; + for (const name of tables) { + const params = { schema: source, table: name, agentSessionId: "source" }; + const ddl: string = await call("get_table_ddl", params); + const parts = await call("list_partitions", params), + subs = await call("list_subpartitions", params); + const expected: Record = { RANGE_LIST: [2, 4], RANGE_T: [2, 0], LIST_T: [2, 0], HASH_T: [4, 0] }; + if (expected[name]) assert.deepEqual([parts.length, subs.length], expected[name]); + const simple = applyDdlStoragePreference(ddl, "oceanbase-oracle"); + assert.equal(applyDdlStoragePreference(ddl, "oceanbase-oracle", false), ddl); + assert.ok(!simple.includes("REPLICA_NUM = ")); + for (const [owner, session, script] of [ + [logical, "logical", simple], + [physical, "physical", ddl], + ]) { + for (const statement of splitSqlStatementRanges(script.replaceAll(qi(source) + ".", qi(owner) + "."), "oceanbase-oracle").map((s) => s.sql)) await sql(statement, session); + assert.deepEqual(await call("list_partitions", { ...params, schema: owner, agentSessionId: session }), parts); + assert.deepEqual(await call("list_subpartitions", { ...params, schema: owner, agentSessionId: session }), subs); + assert.deepEqual(await call("get_columns", { ...params, schema: owner, agentSessionId: session }), await call("get_columns", params)); + } + writeFileSync(join(output, name.replaceAll('"', "") + ".full.sql"), ddl); + writeFileSync(join(output, name.replaceAll('"', "") + ".logical.sql"), simple); + checks.push({ table: name, partitions: parts.length, subpartitions: subs.length, replayed: ["logical", "physical"] }); + } + for (const session of ["source", "logical", "physical"]) { + await sql("INSERT INTO RANGE_LIST VALUES(1,'east','first')", session); + await sql("INSERT INTO RANGE_LIST VALUES(101,'west','second')", session); + await assert.rejects(sql("INSERT INTO RANGE_LIST VALUES(-1,'east','bad')", session)); + await assert.rejects(sql("INSERT INTO RANGE_LIST VALUES(1,'east','duplicate')", session)); + await sql("INSERT INTO PARENT VALUES(1)", session); + await sql("INSERT INTO CHILD VALUES(1,1)", session); + await assert.rejects(sql("INSERT INTO CHILD VALUES(2,999)", session)); + const comment = await sql("SELECT COMMENTS FROM USER_TAB_COMMENTS WHERE TABLE_NAME='RANGE_LIST'", session); + assert.equal(comment.rows[0][0], "table's PCTFREE = 20"); + const local = await sql("SELECT INDEX_NAME, LOCALITY FROM USER_PART_INDEXES WHERE TABLE_NAME='RANGE_LIST'", session); + assert.ok(local.rows.some((r: any[]) => r.includes("C_LOCAL") && r.includes("LOCAL"))); + } + const report = { source, logical, physical, checks, semanticChecks: "partition bounds, keys, columns/defaults/comments, local index, primary/check/foreign-key enforcement" }; + writeFileSync(join(output, "verification.json"), JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + } finally { + child.stdin.end(); + child.kill(); + } +} +main().catch((e) => { + console.error(e.message); + 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 40f4501ad9..7b53ef05a6 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 @@ -15,6 +15,7 @@ import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; import com.dbx.agent.ObjectSource; import com.dbx.agent.OracleObjectPrivilege; +import com.dbx.agent.PartitionInfo; import com.dbx.agent.TableInfo; import com.dbx.agent.TriggerInfo; @@ -32,6 +33,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.Matcher; public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { private static final long MICROS_PER_SECOND = 1_000_000L; @@ -536,45 +539,121 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { @Override public String getTableDdl(String schema, String table) { - List indexes; - try { - indexes = listIndexes(schema, table); - } catch (RuntimeException e) { - indexes = Collections.emptyList(); - } - - List foreignKeys; - try { - foreignKeys = listForeignKeys(schema, table); - } catch (RuntimeException e) { - foreignKeys = Collections.emptyList(); - } - - String tableComment = null; - try { - tableComment = getTableComment(schema, table); - } catch (RuntimeException e) { - // Table comment is optional; DDL generation should still succeed without it. - } - - String ddl = DdlBuilder.buildTableDdl( - schema, - table, - getColumns(schema, table), - indexes, - foreignKeys, - java.util.Collections.emptyList(), - false, - true, - tableComment - ); - try { + return unchecked(() -> { String owner = normalizeSchema(schema); - return DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table)); - } catch (RuntimeException | SQLException ignored) { - // Privilege metadata is optional; CREATE TABLE DDL should still succeed without it. + String ddl = queryDbmsMetadataSource(owner, table, "TABLE"); + if (ddl == null || ddl.isBlank()) { + throw new SQLException("DBMS_METADATA.GET_DDL returned empty DDL for " + owner + "." + table); + } + // OceanBase 4.2.5 omits the owner from CREATE TABLE, even for another schema. + var header = Pattern.compile("(?i)^(\\s*CREATE\\s+(?:GLOBAL\\s+TEMPORARY\\s+)?TABLE\\s+)" + + Pattern.quote(quoteIdentifier(table)) + "(?=\\s*\\()").matcher(ddl); + if (header.find()) { + ddl = header.replaceFirst(Matcher.quoteReplacement(header.group(1) + quoteIdentifier(owner) + "." + quoteIdentifier(table))); + } + ddl = ddl.strip(); + if (!ddl.endsWith(";")) ddl += ";"; + + // GET_DDL(TABLE) includes constraints/partitioning, but not secondary indexes or comments. + String indexSql = """ + SELECT i.INDEX_NAME FROM ALL_INDEXES i + WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ? + AND NOT EXISTS (SELECT 1 FROM ALL_CONSTRAINTS c + WHERE c.OWNER = i.OWNER AND c.INDEX_NAME = i.INDEX_NAME + AND c.CONSTRAINT_TYPE IN ('P', 'U')) + AND NOT (i.INDEX_NAME = 'IDX_FOR_HEAP_GTT_' || i.TABLE_NAME + AND EXISTS (SELECT 1 FROM ALL_TABLES t WHERE t.OWNER = i.TABLE_OWNER + AND t.TABLE_NAME = i.TABLE_NAME AND t.TEMPORARY = 'Y') + AND EXISTS (SELECT 1 FROM ALL_IND_COLUMNS c WHERE c.INDEX_OWNER = i.OWNER + AND c.INDEX_NAME = i.INDEX_NAME AND c.COLUMN_NAME = 'SYS_SESSION_ID')) + ORDER BY i.INDEX_NAME + """; + List indexNames = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(indexSql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) indexNames.add(rs.getString(1)); + } + } + for (String index : indexNames) { + String indexDdl = queryDbmsMetadataSource(owner, index, "INDEX"); + if (indexDdl == null || indexDdl.isBlank()) throw new SQLException("Empty index DDL: " + index); + ddl = DdlBuilder.appendTrailingSql(ddl, indexDdl); + } + String tableRef = quoteIdentifier(owner) + "." + quoteIdentifier(table); + String comment = getTableComment(owner, table); + if (comment != null && !comment.isBlank()) { + ddl = DdlBuilder.appendTrailingSql(ddl, "COMMENT ON TABLE " + tableRef + " IS '" + comment.replace("'", "''") + "';"); + } + String commentSql = "SELECT COLUMN_NAME, COMMENTS FROM ALL_COL_COMMENTS WHERE OWNER = ? AND TABLE_NAME = ? AND COMMENTS IS NOT NULL ORDER BY COLUMN_NAME"; + try (var stmt = requireConnection().prepareStatement(commentSql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String text = rs.getString("COMMENTS"); + if (text == null || text.isBlank()) continue; + ddl = DdlBuilder.appendTrailingSql(ddl, "COMMENT ON COLUMN " + tableRef + "." + quoteIdentifier(rs.getString("COLUMN_NAME")) + " IS '" + text.replace("'", "''") + "';"); + } + } + } + try { + ddl = DdlBuilder.appendTrailingSql(ddl, queryObjectGrantSql(owner, table)); + } catch (RuntimeException | SQLException ignored) { + // Privilege metadata remains optional for users without access to grant views. + } return ddl; - } + }); + } + + private static String quoteIdentifier(String name) { + return "\"" + name.replace("\"", "\"\"") + "\""; + } + + @Override + public List listPartitions(String schema, String table) { + return queryPartitions(schema, table, false); + } + + @Override + public List listSubpartitions(String schema, String table) { + return queryPartitions(schema, table, true); + } + + private List queryPartitions(String schema, String table, boolean subpartition) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String prefix = subpartition ? "SUBPARTITION" : "PARTITION"; + String keysView = subpartition ? "ALL_SUBPART_KEY_COLUMNS" : "ALL_PART_KEY_COLUMNS"; + List keys = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement("SELECT COLUMN_NAME FROM " + keysView + + " WHERE OWNER = ? AND NAME = ? AND OBJECT_TYPE = 'TABLE' ORDER BY COLUMN_POSITION")) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) keys.add(quoteIdentifier(rs.getString(1))); + } + } + String sql = "SELECT p." + prefix + "_NAME AS NAME, p." + prefix + "_POSITION AS POSITION, p.HIGH_VALUE, t." + + prefix + "ING_TYPE AS PARTITION_TYPE FROM " + (subpartition ? "ALL_TAB_SUBPARTITIONS" : "ALL_TAB_PARTITIONS") + + " p JOIN ALL_PART_TABLES t ON t.OWNER = p.TABLE_OWNER AND t.TABLE_NAME = p.TABLE_NAME" + + " WHERE p.TABLE_OWNER = ? AND p.TABLE_NAME = ? ORDER BY " + + (subpartition ? "p.PARTITION_NAME, " : "") + "p." + prefix + "_POSITION"; + List result = new ArrayList<>(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, table); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String value = rs.getString("HIGH_VALUE"); + result.add(new PartitionInfo(rs.getString("NAME"), rs.getInt("POSITION"), value == null ? "" : value, + rs.getString("PARTITION_TYPE"), String.join(", ", keys))); + } + } + } + return result; + }); } private String queryObjectGrantSql(String owner, String table) throws SQLException { 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 d13f0d5b68..2515043958 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 @@ -465,11 +465,11 @@ class OceanBaseOracleAgentTest { OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), resultSet( - new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, - new Object[][]{} + new String[]{"DDL"}, + new Object[][]{{"CREATE TABLE \"AUDIT_LOG\" (\"CREATED_AT\" TIMESTAMP DEFAULT SYSDATE NOT NULL, \"INTERNAL_NOTE\" VARCHAR2(100))"}} ), resultSet( - new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new String[]{"INDEX_NAME"}, new Object[][]{} ), resultSet( @@ -492,7 +492,7 @@ class OceanBaseOracleAgentTest { String ddl = agent.getTableDdl("APP", "AUDIT_LOG"); - Assertions.assertTrue(ddl.contains("\"CREATED_AT\" TIMESTAMP NOT NULL DEFAULT SYSDATE"), ddl); + Assertions.assertTrue(ddl.contains("\"CREATED_AT\" TIMESTAMP DEFAULT SYSDATE NOT NULL"), ddl); Assertions.assertTrue( ddl.contains("COMMENT ON COLUMN \"APP\".\"AUDIT_LOG\".\"CREATED_AT\" IS 'Created timestamp';"), ddl @@ -508,11 +508,11 @@ class OceanBaseOracleAgentTest { OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); TestSupport.setPrivateConnection(agent, preparedConnection(sql, resultSet( - new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, - new Object[][]{} + new String[]{"DDL"}, + new Object[][]{{"CREATE TABLE \"USERS\" (\"ID\" NUMBER PRIMARY KEY, \"NAME\" VARCHAR2(100)) PARTITION BY RANGE(\"ID\") (PARTITION P1 VALUES LESS THAN(MAXVALUE))"}} ), resultSet( - new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new String[]{"INDEX_NAME"}, new Object[][]{} ), resultSet( @@ -567,11 +567,11 @@ class OceanBaseOracleAgentTest { OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); TestSupport.setPrivateConnection(agent, dbaFallbackPrivilegeConnection(sql, resultSet( - new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, - new Object[][]{} + new String[]{"DDL"}, + new Object[][]{{"CREATE TABLE \"USERS\" (\"ID\" NUMBER PRIMARY KEY, \"NAME\" VARCHAR2(100)) PARTITION BY RANGE(\"ID\") (PARTITION P1 VALUES LESS THAN(MAXVALUE))"}} ), resultSet( - new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new String[]{"INDEX_NAME"}, new Object[][]{} ), resultSet( @@ -616,11 +616,11 @@ class OceanBaseOracleAgentTest { OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); TestSupport.setPrivateConnection(agent, privilegeFailingDdlConnection(sql, resultSet( - new String[]{"INDEX_NAME", "COLUMN_NAME", "COLUMN_POSITION", "UNIQUENESS", "CONSTRAINT_TYPE", "INDEX_TYPE"}, - new Object[][]{} + new String[]{"DDL"}, + new Object[][]{{"CREATE TABLE \"USERS\" (\"ID\" NUMBER PRIMARY KEY, \"NAME\" VARCHAR2(100)) PARTITION BY RANGE(\"ID\") (PARTITION P1 VALUES LESS THAN(MAXVALUE))"}} ), resultSet( - new String[]{"CONSTRAINT_NAME", "COLUMN_NAME", "TABLE_NAME", "REF_COLUMN_NAME"}, + new String[]{"INDEX_NAME"}, new Object[][]{} ), resultSet( @@ -642,6 +642,59 @@ class OceanBaseOracleAgentTest { ); } + @Test + void tableDdlPreservesPartitionsAndNativeLocalIndexes() { + var agent = new OceanBaseOracleAgent(); + String nativeTable = "CREATE TABLE \"T\" (\"ID\" NUMBER, CONSTRAINT \"CK\" CHECK (\"ID\" > 0)) " + + "REPLICA_NUM = 1 PARTITION BY RANGE(\"ID\") (PARTITION P1 VALUES LESS THAN(MAXVALUE))"; + String nativeIndex = "CREATE INDEX \"APP\".\"IX\" ON \"APP\".\"T\"(\"ID\") LOCAL;"; + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), + resultSet(new String[]{"DDL"}, new Object[][]{{nativeTable}}), + resultSet(new String[]{"INDEX_NAME"}, new Object[][]{{"IX"}}), + resultSet(new String[]{"DDL"}, new Object[][]{{nativeIndex}}), + resultSet(new String[]{"COMMENTS"}, new Object[][]{{"Owner's table"}}), + resultSet(new String[]{"COLUMN_NAME", "COMMENTS"}, new Object[][]{}), + resultSet(new String[]{"GRANTEE", "PRIVILEGE", "GRANTABLE"}, new Object[][]{}), + resultSet(new String[]{"GRANTEE", "COLUMN_NAME", "PRIVILEGE", "GRANTABLE"}, new Object[][]{}) + )); + String ddl = agent.getTableDdl("APP", "T"); + Assertions.assertTrue(ddl.startsWith(nativeTable.replace("CREATE TABLE \"T\"", "CREATE TABLE \"APP\".\"T\"") + ";"), ddl); + Assertions.assertTrue(ddl.contains(nativeIndex), ddl); + Assertions.assertTrue(ddl.contains("COMMENT ON TABLE \"APP\".\"T\" IS 'Owner''s table';"), ddl); + } + + @Test + void emptyMetadataDdlFailsInsteadOfReturningAnIncompleteTable() { + var agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(new ArrayList<>(), + resultSet(new String[]{"DDL"}, new Object[][]{{" "}}))); + Assertions.assertThrows(RuntimeException.class, () -> agent.getTableDdl("APP", "T")); + } + + @Test + void partitionsPreserveDictionaryOrderCompositeKeysAndNullHashBounds() { + List sql = new ArrayList<>(); + var agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, preparedConnection(sql, + resultSet(new String[]{"COLUMN_NAME"}, new Object[][]{{"ID"}, {"A\"B"}}), + resultSet(new String[]{"NAME", "POSITION", "HIGH_VALUE", "PARTITION_TYPE"}, new Object[][]{ + {"P1", 1, "100, 'East'", "RANGE"}, {"PM", 2, "MAXVALUE, MAXVALUE", "RANGE"}}), + resultSet(new String[]{"COLUMN_NAME"}, new Object[][]{{"REGION"}}), + resultSet(new String[]{"NAME", "POSITION", "HIGH_VALUE", "PARTITION_TYPE"}, new Object[][]{ + {"S1", 1, null, "HASH"}}) + )); + var partitions = agent.listPartitions("APP", "T"); + Assertions.assertEquals(List.of("P1", "PM"), partitions.stream().map(com.dbx.agent.PartitionInfo::name).toList()); + Assertions.assertEquals("\"ID\", \"A\"\"B\"", partitions.get(0).partition_key()); + Assertions.assertEquals("100, 'East'", partitions.get(0).value()); + var subpartitions = agent.listSubpartitions("APP", "T"); + Assertions.assertEquals("", subpartitions.get(0).value()); + Assertions.assertEquals("HASH", subpartitions.get(0).partition_type()); + Assertions.assertTrue(sql.get(0).contains("ORDER BY COLUMN_POSITION")); + Assertions.assertTrue(sql.get(1).contains("WHERE p.TABLE_OWNER = ? AND p.TABLE_NAME = ?")); + Assertions.assertTrue(sql.get(3).contains("ALL_TAB_SUBPARTITIONS")); + } + private static ResultSet columnResultSet(Object[][] rows) { return resultSet( new String[]{ diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 8ef87675d5..e3588e523d 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -197,6 +197,7 @@ import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMeta import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; import { analyzeIntentionActions, prepareExpandWildcardContext, buildExpandWildcardReplacement, type IntentionAction } from "@/lib/editor/sqlIntentionActions"; import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; +import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage"; import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; @@ -3397,7 +3398,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) // views. Hover only removes PostgreSQL's appended access-control tail. try { const { ddl } = await loadObjectDdl(objectMetadataRequest); - const rawDdl = ddlForHoverPreview(ddl); + const rawDdl = ddlForHoverPreview(applyDdlStoragePreference(ddl, props.databaseType, settingsStore.editorSettings.excludeDdlStorage)); if (rawDdl && rawDdl.trim()) { // A view's display DDL wraps the raw (often single-line) view source // in `CREATE ... VIEW ... AS`; the table-oriented reformatter cannot diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index aefa6e389c..b4effb1c29 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1,4 +1,7 @@ + + diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 076a0a015d..dc785e94f2 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -1,4 +1,6 @@