From c1270ba7930216338e408553449fb771caee1778 Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 25 Jul 2026 19:41:07 +0800 Subject: [PATCH] fix(oracle): improve alias column completion --- .../src/components/editor/QueryEditor.vue | 20 +- .../__tests__/sql/semantic/completion.spec.ts | 10 + .../src/lib/sql/semantic/completion.ts | 21 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 20 +- .../connectionStore.completion.spec.ts | 61 +++++ apps/desktop/src/stores/connectionStore.ts | 30 ++- crates/dbx-core/src/schema.rs | 213 ++++++++++++++++-- 7 files changed, 325 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 1a0b7d6159..8efb7f7746 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -414,25 +414,27 @@ function usesOracleSessionCompletionColumns(schema?: string | null): boolean { }); } -function completionColumnRequestContext() { +function completionColumnRequestContext(reference?: Pick) { return { clientSessionId: props.clientSessionId, version: props.completionContextVersion, + tableQuoted: reference?.nameQuoted, + schemaQuoted: reference?.schemaQuoted, }; } -async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog) { +async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick) { const requestedVersion = props.completionContextVersion; const sessionScoped = usesOracleSessionCompletionColumns(schema); - const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(), catalog); + const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog); if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); return columns; } -async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog) { +async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string, catalog = props.catalog, reference?: Pick) { const requestedVersion = props.completionContextVersion; const sessionScoped = usesOracleSessionCompletionColumns(schema); - const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(), catalog); + const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext(reference), catalog); if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); return columns; } @@ -2688,7 +2690,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } @@ -2795,7 +2797,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns); }) @@ -3048,7 +3050,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext try { const target = completionMetadataTarget(refTable); if (!target) return; - const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog); + const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable); if (epoch !== completionEpoch) return; if (columns.length === 0) return; cachedColumnsByTable.set(cacheKey, columns); @@ -3911,7 +3913,7 @@ onMounted(async () => { try { const target = completionMetadataTarget(refTable); if (!target) continue; - cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog); + cols = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema, target.catalog, refTable); cachedColumnsByTable.set(cacheKey, cols); } catch { continue; diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index 43af356306..0393cb2d18 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -35,6 +35,16 @@ function semanticCompletion(markedSql: string, input: Partial { + it.each([ + ["ordinary lowercase", "SELECT * FROM orders_alias a WHERE a.|", "ORDERS_ALIAS", false], + ["quoted lowercase", 'SELECT * FROM "orders_alias" a WHERE a.|', "orders_alias", true], + ["quoted mixed case", 'SELECT * FROM "Orders_Alias" a WHERE a.|', "Orders_Alias", true], + ] as const)("preserves Oracle identifier semantics for %s aliases", (_label, markedSql, expectedName, expectedQuoted) => { + const { context } = semanticCompletion(markedSql, {}, { databaseType: "oracle" }); + + expect(context.referencedTables).toEqual([expect.objectContaining({ name: expectedName, nameQuoted: expectedQuoted, alias: "A" })]); + }); + it("isolates SQL Server columns for database-qualified tables with the same schema and name", () => { const columnsByTable = new Map([ ["DatabaseA.OUT.orders", [{ name: "source_marker", table: "orders", schema: "OUT" }]], diff --git a/apps/desktop/src/lib/sql/semantic/completion.ts b/apps/desktop/src/lib/sql/semantic/completion.ts index 12c985c266..323a212194 100644 --- a/apps/desktop/src/lib/sql/semantic/completion.ts +++ b/apps/desktop/src/lib/sql/semantic/completion.ts @@ -16,14 +16,19 @@ export interface SqlSemanticCompletionScope { export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] { return model.rowSources .filter((source) => source.kind !== "unknown") - .map((source) => ({ - name: source.name, - database: source.metadataTarget?.database, - schema: source.qualifierParts[source.qualifierParts.length - 1], - alias: source.alias, - columns: source.columns, - columnAliases: source.columnAliases, - })); + .map((source) => { + const identifierParts = source.qualifiedName?.parts ?? []; + return { + name: source.name, + nameQuoted: !!identifierParts[identifierParts.length - 1]?.quote, + database: source.metadataTarget?.database, + schema: source.qualifierParts[source.qualifierParts.length - 1], + schemaQuoted: source.qualifierParts.length > 0 ? !!identifierParts[identifierParts.length - 2]?.quote : undefined, + alias: source.alias, + columns: source.columns, + columnAliases: source.columnAliases, + }; + }); } export function sqlSemanticLocalColumnsByTable(model: SqlSemanticModel): Map { diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index b444976569..b5967dbecd 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1204,8 +1204,10 @@ export type SqlKeywordCase = "preserve" | "upper" | "lower"; export interface SqlCompletionReferencedTable { name: string; + nameQuoted?: boolean; database?: string; schema?: string; + schemaQuoted?: boolean; alias?: string; columns?: string[]; columnAliases?: string[]; @@ -2504,13 +2506,16 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { referenced.push({ name: unquoteIdentifier(rawName), alias: cleanAlias }); continue; } - const parts = splitQualifiedNameParts(rawName); + const rawParts = splitQualifiedNameRawParts(rawName); + const parts = rawParts.map((part) => unquoteIdentifier(part)).filter(Boolean); const name = parts[parts.length - 1]; if (!name) continue; const table: SqlCompletionReferencedTable = { name, + nameQuoted: isQuotedIdentifier(rawParts[rawParts.length - 1]), database: parts.length >= 3 ? parts[parts.length - 3] : undefined, schema: parts.length >= 2 ? parts[parts.length - 2] : undefined, + schemaQuoted: parts.length >= 2 ? isQuotedIdentifier(rawParts[rawParts.length - 2]) : undefined, alias: cleanAlias, }; referenced.push(table); @@ -2771,6 +2776,12 @@ function splitQualifiedName(input: string): [string | undefined, string | undefi } function splitQualifiedNameParts(input: string): string[] { + return splitQualifiedNameRawParts(input) + .map((part) => unquoteIdentifier(part)) + .filter(Boolean); +} + +function splitQualifiedNameRawParts(input: string): string[] { const parts: string[] = []; let current = ""; let inDoubleQuote = false; @@ -2800,7 +2811,12 @@ function splitQualifiedNameParts(input: string): string[] { } if (current.trim()) parts.push(current.trim()); - return parts.map((p) => unquoteIdentifier(p)).filter(Boolean); + return parts; +} + +function isQuotedIdentifier(value: string | undefined): boolean { + if (!value) return false; + return (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`")) || (value.startsWith("[") && value.endsWith("]")); } function unquoteIdentifier(value: string): string { diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index c89b35e36b..7740c09643 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -272,6 +272,67 @@ describe("connectionStore completion assistant", () => { expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS")).toEqual([]); }); + it("normalizes unquoted Oracle aliases while preserving quoted case before catalog lookup", async () => { + const completionAssistantSearch = vi.fn(); + const getColumns = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + await store.listCompletionColumns("oracle-1", "ORCL", "orders_alias", undefined, { clientSessionId: "tab-a", version: 0, tableQuoted: false }); + await store.listCompletionColumns("oracle-1", "ORCL", "orders_alias", undefined, { clientSessionId: "tab-a", version: 1, tableQuoted: true }); + await store.listCompletionColumns("oracle-1", "ORCL", "Orders_Alias", undefined, { clientSessionId: "tab-a", version: 2, tableQuoted: true }); + + expect(getColumns.mock.calls.map((call) => call[3])).toEqual(["ORDERS_ALIAS", "orders_alias", "Orders_Alias"]); + expect(completionAssistantSearch).not.toHaveBeenCalled(); + }); + + it("keeps quoted and unquoted Oracle objects separate in the local column index", async () => { + const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null }) => ({ + candidates: [ + { + name: request.parent_name === "ORDERS_ALIAS" ? "UPPER_ID" : "LOWER_ID", + kind: "column", + schema: "APP", + parent_schema: "APP", + parent_name: request.parent_name, + data_type: "NUMBER", + }, + ], + incomplete: false, + fallback_used: false, + })); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns: vi.fn(), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + await store.listCompletionColumns("oracle-1", "ORCL", "orders_alias", "APP", { tableQuoted: false, schemaQuoted: false }); + await store.listCompletionColumns("oracle-1", "ORCL", "orders_alias", "APP", { tableQuoted: true, schemaQuoted: false }); + + expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS_ALIAS", "APP").map((column) => column.name)).toEqual(["UPPER_ID"]); + expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "orders_alias", "APP").map((column) => column.name)).toEqual(["LOWER_ID"]); + expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "orders_alias", "app", undefined, { tableQuoted: false, schemaQuoted: false }).map((column) => column.name)).toEqual(["UPPER_ID"]); + expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "orders_alias", "APP", undefined, { tableQuoted: true, schemaQuoted: false }).map((column) => column.name)).toEqual(["LOWER_ID"]); + }); + it("isolates Oracle CURRENT_SCHEMA column caches by tab and context version", async () => { const completionAssistantSearch = vi.fn(); const getColumns = vi diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 30785fc5a3..b1d1602bc7 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -4535,7 +4535,12 @@ export const useConnectionStore = defineStore("connection", () => { return `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:${schema?.toLowerCase() ?? ""}`; } - function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string): string { + function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string { + if (getConfig(connectionId)?.db_type === "oracle") { + const normalizedTable = context?.tableQuoted === false ? table.toUpperCase() : table; + const normalizedSchema = schema && context?.schemaQuoted === false ? schema.toUpperCase() : (schema ?? ""); + return `${connectionId}:${database}:${catalog ?? ""}:${normalizedSchema}:${normalizedTable}`; + } return `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`; } @@ -4905,8 +4910,8 @@ export const useConnectionStore = defineStore("connection", () => { return result; } - function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string, catalog?: string): SqlCompletionColumn[] { - return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog))?.columns ?? []; + function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] { + return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog, context))?.columns ?? []; } function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] { @@ -5255,15 +5260,18 @@ export const useConnectionStore = defineStore("connection", () => { return deduped; } - async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }, catalog?: string): Promise { + async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise { const config = getConfig(connectionId); - const completionSchema = schema?.trim() || undefined; + const oracleIdentifier = config?.db_type === "oracle"; + const completionTable = oracleIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table; + const rawCompletionSchema = schema?.trim() || undefined; + const completionSchema = oracleIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema; const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema; if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) { return []; } const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : ""; - const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${schema || ""}:${table}${sessionCacheScope}`; + const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${completionSchema || ""}:${completionTable}${sessionCacheScope}`; if (!completionColumnsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:columns`, @@ -5271,7 +5279,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (!usesOracleCurrentSchema && !catalog) { try { - const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, completionSchema); + const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema); if (assistantColumns.length > 0) { completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({ name: column.name, @@ -5293,7 +5301,7 @@ export const useConnectionStore = defineStore("connection", () => { } } const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema); - completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table, catalog, usesOracleCurrentSchema ? context?.clientSessionId : undefined); + completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesOracleCurrentSchema ? context?.clientSessionId : undefined); evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "columns" }, @@ -5302,13 +5310,13 @@ export const useConnectionStore = defineStore("connection", () => { const columns = completionColumnsCache.value[cacheKey].map((column) => ({ name: column.name, - table, + table: completionTable, schema: completionSchema, dataType: column.data_type, isNullable: column.is_nullable, comment: column.comment, })); - if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, table, schema, columns, catalog); + if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog); return columns; } @@ -5354,7 +5362,7 @@ export const useConnectionStore = defineStore("connection", () => { return listCompletionDatabases(connectionId); } - function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }, catalog?: string): Promise { + function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise { return listCompletionColumns(connectionId, database, table, schema, context, catalog); } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 1d273f8c3f..0c78cb9536 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1533,29 +1533,84 @@ fn oracle_table_comments_from_query_result(result: db::QueryResult) -> HashMap String { + let owner = oracle_columns_owner_filter(schema); format!( - "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_DEFAULT, \ + "WITH synonym_chain AS ( \ + SELECT CONNECT_BY_ROOT s.OWNER AS root_owner, \ + s.TABLE_OWNER AS resolved_owner, \ + s.TABLE_NAME AS resolved_table, \ + LEVEL AS synonym_depth \ + FROM ALL_SYNONYMS s \ + START WITH s.SYNONYM_NAME = {table} \ + AND s.OWNER IN ({owner}, 'PUBLIC') \ + AND s.DB_LINK IS NULL \ + CONNECT BY NOCYCLE \ + PRIOR s.TABLE_OWNER = s.OWNER \ + AND PRIOR s.TABLE_NAME = s.SYNONYM_NAME \ + AND s.DB_LINK IS NULL \ + ), \ + resolved_object AS ( \ + SELECT resolved_owner, resolved_table \ + FROM ( \ + SELECT resolved_owner, resolved_table, resolution_priority, synonym_depth \ + FROM ( \ + SELECT o.OWNER AS resolved_owner, o.OBJECT_NAME AS resolved_table, \ + 0 AS resolution_priority, 0 AS synonym_depth \ + FROM ALL_OBJECTS o \ + WHERE o.OWNER = {owner} \ + AND o.OBJECT_NAME = {table} \ + AND o.OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW') \ + UNION ALL \ + SELECT sc.resolved_owner, sc.resolved_table, \ + CASE WHEN sc.root_owner = {owner} THEN 1 ELSE 2 END AS resolution_priority, \ + sc.synonym_depth \ + FROM synonym_chain sc \ + JOIN ALL_OBJECTS o \ + ON o.OWNER = sc.resolved_owner \ + AND o.OBJECT_NAME = sc.resolved_table \ + AND o.OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW') \ + ) \ + ORDER BY resolution_priority, synonym_depth \ + ) \ + WHERE ROWNUM = 1 \ + ) \ + SELECT c.COLUMN_NAME, c.DATA_TYPE, c.NULLABLE, c.DATA_DEFAULT, \ c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE, c.COLUMN_ID, \ - CASE WHEN cc.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK, \ - cm.COMMENTS \ - FROM ALL_TAB_COLUMNS c \ - LEFT JOIN ( \ - SELECT cols.OWNER, cols.TABLE_NAME, cols.COLUMN_NAME \ + CASE WHEN EXISTS ( \ + SELECT 1 \ FROM ALL_CONS_COLUMNS cols \ JOIN ALL_CONSTRAINTS con \ - ON con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME \ - AND con.OWNER = cols.OWNER \ + ON con.OWNER = cols.OWNER \ + AND con.CONSTRAINT_NAME = cols.CONSTRAINT_NAME \ AND con.CONSTRAINT_TYPE = 'P' \ - ) cc ON cc.OWNER = c.OWNER AND cc.TABLE_NAME = c.TABLE_NAME AND cc.COLUMN_NAME = c.COLUMN_NAME \ - LEFT JOIN ALL_COL_COMMENTS cm \ - ON cm.OWNER = c.OWNER AND cm.TABLE_NAME = c.TABLE_NAME AND cm.COLUMN_NAME = c.COLUMN_NAME \ - WHERE c.OWNER = {} AND c.TABLE_NAME = {} \ + WHERE cols.OWNER = c.OWNER \ + AND cols.TABLE_NAME = c.TABLE_NAME \ + AND cols.COLUMN_NAME = c.COLUMN_NAME \ + ) THEN 1 ELSE 0 END AS IS_PK, \ + ( \ + SELECT cm.COMMENTS \ + FROM ALL_COL_COMMENTS cm \ + WHERE cm.OWNER = c.OWNER \ + AND cm.TABLE_NAME = c.TABLE_NAME \ + AND cm.COLUMN_NAME = c.COLUMN_NAME \ + ) AS COMMENTS \ + FROM ALL_TAB_COLUMNS c \ + JOIN resolved_object ro \ + ON ro.resolved_owner = c.OWNER AND ro.resolved_table = c.TABLE_NAME \ ORDER BY c.COLUMN_ID", - oracle_owner_filter(schema), - sql_string(table), + table = sql_string(table), ) } +fn oracle_columns_owner_filter(schema: &str) -> String { + let schema = schema.trim(); + if schema.is_empty() { + "SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')".to_string() + } else { + sql_string(&schema.to_uppercase()) + } +} + fn oracle_column_type(data_type: &str, precision: Option, scale: Option, length: Option) -> String { match data_type.to_ascii_uppercase().as_str() { "NUMBER" => match (precision, scale) { @@ -1650,6 +1705,16 @@ async fn external_driver_oracle_columns_via_sql( Ok(deduplicate_column_infos(oracle_columns_from_query_result(result))) } +fn should_query_oracle_columns_via_sql_first( + db_type: &DatabaseType, + schema: &str, + client_session_id: Option<&str>, +) -> bool { + *db_type == DatabaseType::Oracle + && schema.trim().is_empty() + && client_session_id.is_some_and(|session_id| !session_id.trim().is_empty()) +} + fn oracle_object_statistics_sql(schema: &str) -> String { oracle_object_statistics_owner_segments_sql(schema, "ALL_SEGMENTS") } @@ -2789,8 +2854,8 @@ mod tests { oracle_object_statistics_sql, oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql, oracle_table_comments_from_query_result, oracle_table_comments_sql, presto_like_columns_from_query_result, presto_like_information_schema_columns_sql, - presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, table_name_filter_matches, - visible_schema_filter, TableNameFilter, + presto_like_information_schema_tables_sql, presto_like_tables_from_query_result, + should_query_oracle_columns_via_sql_first, table_name_filter_matches, visible_schema_filter, TableNameFilter, }; #[cfg(feature = "duckdb-bundled")] use super::{ @@ -3676,8 +3741,62 @@ mod tests { assert!(sql.contains("ALL_TAB_COLUMNS")); assert!(sql.contains("ALL_COL_COMMENTS")); - assert!(sql.contains("c.OWNER = 'DBX_TEST'")); - assert!(sql.contains("c.TABLE_NAME = 'test'")); + assert!(sql.contains("o.OWNER = 'DBX_TEST'")); + assert!(sql.contains("o.OBJECT_NAME = 'test'")); + assert!(sql.contains("cols.OWNER = c.OWNER")); + assert!(sql.contains("cols.TABLE_NAME = c.TABLE_NAME")); + assert!(sql.contains("cm.OWNER = c.OWNER")); + } + + #[test] + fn oracle_columns_sql_resolves_private_and_public_synonyms_in_oracle_precedence_order() { + let sql = oracle_columns_sql("", "ORDERS_ALIAS"); + + assert!(sql.contains("SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA')")); + assert!(sql.contains("FROM ALL_SYNONYMS s")); + assert!(sql.contains("s.OWNER IN (SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA'), 'PUBLIC')")); + assert!(sql.contains("CASE WHEN sc.root_owner = SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') THEN 1 ELSE 2 END")); + assert!(sql.contains("s.DB_LINK IS NULL")); + assert!(sql.contains("ORDER BY resolution_priority, synonym_depth")); + assert!(sql.contains("WHERE ROWNUM = 1")); + } + + #[test] + fn oracle_columns_sql_follows_two_level_synonyms_without_cycles() { + let sql = oracle_columns_sql("DBX_TEST", "ORDERS_ALIAS"); + + assert!(sql.contains("SELECT CONNECT_BY_ROOT s.OWNER AS root_owner")); + assert!(sql.contains("LEVEL AS synonym_depth")); + assert!(sql.contains("CONNECT BY NOCYCLE")); + assert!(sql.contains("PRIOR s.TABLE_OWNER = s.OWNER")); + assert!(sql.contains("PRIOR s.TABLE_NAME = s.SYNONYM_NAME")); + assert!(sql.contains("JOIN ALL_OBJECTS o")); + assert!(sql.contains("o.OWNER = sc.resolved_owner")); + assert!(sql.contains("o.OBJECT_NAME = sc.resolved_table")); + } + + #[test] + fn oracle_columns_sql_preserves_quoted_case_synonym_names_and_excludes_database_links() { + let sql = oracle_columns_sql("DBX_TEST", "Order Alias"); + + assert!(sql.contains("s.SYNONYM_NAME = 'Order Alias'")); + assert!(sql.contains("o.OBJECT_NAME = 'Order Alias'")); + assert!(!sql.contains("ORDER ALIAS")); + assert_eq!(sql.matches("s.DB_LINK IS NULL").count(), 2); + } + + #[test] + fn oracle_session_completion_queries_synonym_aware_columns_sql_first() { + assert!(should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, "", Some("tab-1"))); + assert!(should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, " ", Some("tab-1"))); + } + + #[test] + fn oracle_columns_sql_first_is_limited_to_current_schema_editor_sessions() { + assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, "DBX_TEST", Some("tab-1"))); + assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, "", None)); + assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Oracle, "", Some(" "))); + assert!(!should_query_oracle_columns_via_sql_first(&DatabaseType::Postgres, "", Some("tab-1"))); } #[test] @@ -4784,6 +4903,32 @@ pub async fn get_columns_core_for_session( if uses_presto_like_information_schema_tables(&config.db_type) { return external_driver_presto_like_columns(session, config.as_ref(), database, schema, table).await; } + let query_oracle_columns_first = + should_query_oracle_columns_via_sql_first(&config.db_type, schema, client_session_id); + if query_oracle_columns_first { + match external_driver_oracle_columns_via_sql( + session.clone(), + config.as_ref(), + database, + schema, + table, + ) + .await + { + Ok(columns) if !columns.is_empty() => return Ok(columns), + Ok(_) => {} + Err(error) => { + log::warn!( + "[schema][external-driver:get_columns:oracle-primary-sql-failed] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table, + error + ); + } + } + } let columns = session .invoke_with_timeout::>( "getColumns", @@ -4796,7 +4941,7 @@ pub async fn get_columns_core_for_session( agent_metadata_timeout(Some(config.as_ref())), ) .await?; - if columns.is_empty() && config.db_type == DatabaseType::Oracle { + if columns.is_empty() && config.db_type == DatabaseType::Oracle && !query_oracle_columns_first { match external_driver_oracle_columns_via_sql( session.clone(), config.as_ref(), @@ -4872,6 +5017,34 @@ pub async fn get_columns_core_for_session( let fallback_config = db_config.clone(); drop(connections); let mut client = client.lock().await; + let oracle_sql_config = fallback_config.as_ref().filter(|config| { + should_query_oracle_columns_via_sql_first(&config.db_type, schema, client_session_id) + }); + let query_oracle_columns_first = oracle_sql_config.is_some(); + if let Some(config) = oracle_sql_config { + match oracle_columns_via_sql( + database, + schema, + table, + &mut client, + agent_metadata_timeout(Some(config)), + ) + .await + { + Ok(columns) if !columns.is_empty() => return Ok(columns), + Ok(_) => {} + Err(error) => { + log::warn!( + "[schema][agent:get_columns:oracle-primary-sql-failed] connection_id={} database={} schema={} table={} error={}", + connection_id, + database, + schema, + table, + error + ); + } + } + } match client .get_columns::>( database, @@ -4884,7 +5057,7 @@ pub async fn get_columns_core_for_session( Ok(columns) if !columns.is_empty() => return Ok(deduplicate_column_infos(columns)), Ok(columns) => { if let Some(config) = fallback_config.as_ref() { - if config.db_type == DatabaseType::Oracle { + if config.db_type == DatabaseType::Oracle && !query_oracle_columns_first { match oracle_columns_via_sql( database, schema,