fix(oracle): improve alias column completion

This commit is contained in:
zipg
2026-07-25 19:41:07 +08:00
committed by GitHub
parent 30641f3c89
commit c1270ba793
7 changed files with 325 additions and 50 deletions
@@ -414,25 +414,27 @@ function usesOracleSessionCompletionColumns(schema?: string | null): boolean {
});
}
function completionColumnRequestContext() {
function completionColumnRequestContext(reference?: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
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<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
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<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">) {
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<typeof getS
continue;
}
const target = completionMetadataTarget(refTable);
const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema, target.catalog) : [];
const localColumns = target && !usesOracleSessionCompletionColumns(target.schema) ? connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, refTable.name, target.schema, target.catalog, refTable) : [];
if (localColumns.length > 0) {
columnsByTable.set(cacheKey, localColumns);
}
@@ -2795,7 +2797,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType<typeof
if (cachedColumnsByTable.has(cacheKey)) continue;
const target = completionMetadataTarget(refTable);
if (!target) continue;
void refreshCompletionColumnsForEditor(connectionId, target.database, refTable.name, target.schema, target.catalog)
void refreshCompletionColumnsForEditor(connectionId, target.database, refTable.name, target.schema, target.catalog, refTable)
.then((columns) => {
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;
@@ -35,6 +35,16 @@ function semanticCompletion(markedSql: string, input: Partial<SqlCompletionProvi
}
describe("semantic SQL completion candidates", () => {
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<string, SqlCompletionColumn[]>([
["DatabaseA.OUT.orders", [{ name: "source_marker", table: "orders", schema: "OUT" }]],
@@ -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<string, SqlCompletionColumn[]> {
+18 -2
View File
@@ -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 {
@@ -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
+19 -11
View File
@@ -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<SqlCompletionColumn[]> {
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
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<SqlCompletionColumn[]> {
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema, context, catalog);
}
+193 -20
View File
@@ -1533,29 +1533,84 @@ fn oracle_table_comments_from_query_result(result: db::QueryResult) -> HashMap<S
}
fn oracle_columns_sql(schema: &str, table: &str) -> 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<i32>, scale: Option<i32>, length: Option<i32>) -> 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::<Vec<db::ColumnInfo>>(
"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::<Vec<db::ColumnInfo>>(
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,