diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts index bc5b8d08f3..ffa1073501 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletion.spec.ts @@ -41,7 +41,7 @@ function createHarness(overrides: Partial = {}, configureMetad const store = { getConfig: vi.fn(() => undefined), lookupLocalCompletionTables: vi.fn((): SqlCompletionTable[] => []), - lookupLocalCompletionColumns: vi.fn(() => []), + lookupLocalCompletionColumns: vi.fn((): SqlCompletionColumn[] => []), lookupLocalCompletionColumnsByPrefix: vi.fn(() => []), lookupLocalCompletionObjects: vi.fn(() => []), lookupLocalCompletionDatabases: vi.fn((): string[] => []), @@ -53,7 +53,7 @@ function createHarness(overrides: Partial = {}, configureMetad listCompletionSchemas: vi.fn(async (): Promise => []), listCompletionDatabases: vi.fn(async (): Promise => []), refreshCompletionTables: vi.fn(async () => []), - refreshCompletionColumns: vi.fn(async () => []), + refreshCompletionColumns: vi.fn(async (): Promise => []), refreshCompletionSchemas: vi.fn(async () => []), refreshCompletionDatabases: vi.fn(async () => []), listRedisCompletionCommandDocs: vi.fn(async () => commands), @@ -104,6 +104,32 @@ function createHarness(overrides: Partial = {}, configureMetad return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion }; } +describe("OceanBase INSERT completion", () => { + beforeEach(() => vi.useRealTimers()); + + it.each([ + ["Alias", undefined], + ["APP.Alias", "APP"], + ['"Alias"', undefined], + ['"MixedOwner"."Alias"', "MixedOwner"], + ])("resolves %s with the selected schema kept as context", async (source, explicitSchema) => { + const { provide, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: `INSERT INTO ${source} (` }); + store.listCompletionColumns.mockImplementation(async (...args: unknown[]) => (args[3] === explicitSchema ? [{ name: "ID", table: "Alias", schema: explicitSchema }] : [])); + expect((await provide())?.options.map((option) => option.label)).toContain("ID"); + expect(store.listCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", explicitSchema, expect.objectContaining({ currentSchema: "APP" }), undefined); + if (source.startsWith('"')) expect(store.listCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", explicitSchema, expect.objectContaining({ tableQuoted: true }), undefined); + }); + + it("keeps synchronous INSERT lookup and background refresh unqualified", async () => { + const { provide, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: "INSERT INTO Alias (" }); + store.lookupLocalCompletionColumns.mockImplementation((...args: unknown[]) => (args[3] === undefined ? [{ name: "ID", table: "Alias" }] : [])); + store.refreshCompletionColumns.mockResolvedValue([{ name: "ID", table: "Alias" }]); + expect((await provide(false))?.options.map((option) => option.label)).toContain("ID"); + expect(store.lookupLocalCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", undefined, undefined, expect.objectContaining({ currentSchema: "APP" })); + expect(store.refreshCompletionColumns).toHaveBeenCalledWith("connection", "demo", "Alias", undefined, expect.objectContaining({ currentSchema: "APP" }), undefined); + }); +}); + describe.each([false, true])("Snowflake namespace completion (semantic=%s)", (semanticCompletionEnabled) => { beforeEach(() => vi.useRealTimers()); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletionMetadata.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletionMetadata.spec.ts index 048990c622..d044d1ae89 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletionMetadata.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorCompletionMetadata.spec.ts @@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata"; import type { QueryEditorProps } from "../queryEditorTypes"; import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; +import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; +import { buildSqlSemanticDiagnostics } from "@/lib/sql/semantic/diagnostics"; vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 })); vi.mock("@/lib/backend/api", () => ({})); @@ -42,15 +44,70 @@ function createHarness(overrides: Partial = {}) { } describe("QueryEditor completion metadata ownership", () => { + it.each([ + ["Alias", "PUBLIC_ID"], + ["APP.Alias", "PRIVATE_ID"], + ['"Alias"', "QUOTED_ID"], + ])("uses only the cached OceanBase reference %s for star and column completion", async (source, column) => { + const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" }); + store.listCompletionColumns + .mockResolvedValueOnce([{ name: "PUBLIC_ID", table: "Alias" }]) + .mockResolvedValueOnce([{ name: "PRIVATE_ID", table: "Alias", schema: "APP" }]) + .mockResolvedValueOnce([{ name: "QUOTED_ID", table: "Alias" }]); + await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: false }); + await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" }); + await metadata.ensureColumnsForTable({ name: "Alias" }, { nameQuoted: true }); + expect(store.listCompletionColumns).toHaveBeenCalledTimes(3); + const sql = `SELECT s. FROM ${source} s`; + const context = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" }); + expect(buildSelectStarExpansion(context, metadata.cachedColumnsByTable, "oracle", context.qualifier, "oceanbase-oracle")).toBe(`"${column}"`); + const items = buildSqlCompletionItems(sql, 9, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle", currentSchema: "APP" }); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual([column]); + expect(items.find((item) => item.label === "s.*")?.apply).toBe(`"${column}"`); + }); + + it.each(["Alias", "APP.Alias", '"Alias"'])("uses the same OceanBase cache identity for diagnostics on %s", async (source) => { + const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" }); + store.listCompletionColumns.mockResolvedValue([{ name: "ID", table: "Alias" }]); + const sql = `SELECT s.MISSING FROM ${source} s`; + const reference = getSqlCompletionContext(sql, 9, { databaseType: "oceanbase-oracle" }).referencedTables[0]!; + await metadata.ensureColumnsForTable(reference); + const tableStart = sql.indexOf(source); + const diagnostics = buildSqlSemanticDiagnostics( + { + tables: [{ name: reference.name, schema: reference.schema, alias: "s", span: { start_line: 1, start_column: tableStart + 1, end_line: 1, end_column: tableStart + source.length } }], + columns: [{ name: "MISSING", qualifier: "s", span: { start_line: 1, start_column: 8, end_line: 1, end_column: 16 } }], + }, + { tables: [], columnsByTable: metadata.cachedColumnsByTable, loadedColumnTables: metadata.loadedColumnsByTable, sql, databaseType: "oceanbase-oracle", currentSchema: "APP" }, + ); + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(["Unknown column s.MISSING"]); + }); + + it("uses scoped synonym columns for JOIN condition completion", async () => { + const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" }); + store.listCompletionColumns.mockResolvedValue([{ name: "TENANT_ID", table: "Alias" }]); + await metadata.ensureColumnsForTable({ name: "Alias" }); + await metadata.ensureColumnsForTable({ name: "Alias", schema: "APP" }); + const sql = "SELECT * FROM Alias a JOIN APP.Alias b ON "; + const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, databaseType: "oceanbase-oracle" }); + expect(items.map((item) => item.label)).toContain("a.TENANT_ID = b.TENANT_ID"); + }); + + it("keeps foreign-key JOIN suggestions available under the same scoped cache keys", () => { + const { metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP" }); + metadata.cachedForeignKeysByTable.set(metadata.completionCacheKey({ name: "Alias" }), [{ name: "FK_PARENT", column: "PARENT", ref_table: "OtherAlias", ref_column: "ID" }]); + const sql = "SELECT * FROM Alias a JOIN OtherAlias b ON "; + const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: metadata.cachedColumnsByTable, foreignKeysByTable: metadata.cachedForeignKeysByTable, databaseType: "oceanbase-oracle" }); + expect(items.map((item) => item.label)).toContain("a.PARENT = b.ID"); + }); + it("keeps an OceanBase selected schema separate from an explicitly qualified synonym", async () => { const { metadata, store } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "MixedOwner" }); store.listCompletionColumns.mockResolvedValue(columns); await metadata.ensureColumnsForTable({ name: "Alias" }); - expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", undefined, - expect.objectContaining({ currentSchema: "MixedOwner" }), undefined); + expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", undefined, expect.objectContaining({ currentSchema: "MixedOwner" }), undefined); await metadata.ensureColumnsForTable({ name: "Alias", schema: "MixedOwner" }); - expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", "MixedOwner", - expect.objectContaining({ currentSchema: "MixedOwner" }), undefined); + expect(store.listCompletionColumns).toHaveBeenLastCalledWith("connection", "demo", "Alias", "MixedOwner", expect.objectContaining({ currentSchema: "MixedOwner" }), undefined); }); it.each(["getEditorSqlCompletionContext", "getEditorSemanticModel"] as const)("keys %s by document, editor state, position and dialect", (method) => { diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts index 523e9e5f98..164f97bb3b 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts @@ -42,6 +42,16 @@ function createHarness(overrides: Partial = {}) { const view = shallowRef(currentView); const runtime: Options["runtime"] = { editorIsActive: true, executableStatementRangeCache: null, setSqlDiagnosticsEffect: StateEffect.define(), diagnosticComp: null, buildSqlDiagnosticExtension: null, codeMirrorCompletionStatus: () => null }; const settingsStore = { editorSettings: { sqlSemanticDiagnosticsEnabled: true } } as Options["settingsStore"]; + const metadata = { + cachedTables: [], + cachedColumnsByTable: new Map(), + loadedColumnsByTable: new Set(), + usesOracleSessionCompletionColumns: () => false, + findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null), + completionCacheKey: (table: { name: string }) => table.name, + ensureColumnsForTable: vi.fn().mockResolvedValue(false), + isMissingTableMetadataError: () => false, + }; let diagnostics!: ReturnType; const host = document.createElement("div"); const app = createApp({ @@ -57,16 +67,7 @@ function createHarness(overrides: Partial = {}) { sqlBehaviorDialect: () => props.dialect, semanticCompletionEnabled: false, maxCompletionTables: 100, - metadata: { - cachedTables: [], - cachedColumnsByTable: new Map(), - loadedColumnsByTable: new Set(), - usesOracleSessionCompletionColumns: () => false, - findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null), - completionCacheKey: (table) => table.name, - ensureColumnsForTable: vi.fn().mockResolvedValue(false), - isMissingTableMetadataError: () => false, - }, + metadata, }); return () => h("div"); }, @@ -83,10 +84,22 @@ function createHarness(overrides: Partial = {}) { currentView.destroy(); parent.remove(); }); - return { diagnostics, props, runtime, settingsStore, currentView, unmount }; + return { diagnostics, props, runtime, settingsStore, currentView, unmount, metadata }; } describe("QueryEditor diagnostic scheduling ownership", () => { + it("preserves the unqualified quoted OceanBase reference and selected schema through metadata loading", async () => { + const sql = 'SELECT MISSING FROM "Alias"'; + const { diagnostics, metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: sql }); + analyze.mockResolvedValue({ tables: [{ name: "Alias", span: { start_line: 1, start_column: 21, end_line: 1, end_column: 27 } }], columns: [] }); + metadata.findExactSemanticDiagnosticTable.mockResolvedValue({ name: "Alias", schema: "PUBLIC" }); + diagnostics.scheduleSemanticDiagnostics(0); + await vi.advanceTimersByTimeAsync(0); + expect(metadata.ensureColumnsForTable).toHaveBeenCalledWith(expect.objectContaining({ name: "Alias", nameQuoted: true }), undefined, expect.objectContaining({ schema: "APP" })); + expect(metadata.ensureColumnsForTable.mock.calls[0]![0].schema).toBeUndefined(); + expect(buildDiagnostics).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ currentSchema: "APP" })); + }); + it("coalesces timers and shares the latest executable statement cache", async () => { const { diagnostics, currentView, runtime } = createHarness(); diagnostics.scheduleSemanticDiagnostics(50); diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 24cd2c4c1e..e36e95c5d9 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -1105,11 +1105,13 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) const columnsByTable = new Map(); if (completionContext.insertTable) { - const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database; - const insertSchema = completionContext.insertSchema ?? scope.schema; - const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, insertDatabase, completionContext.insertTable, insertSchema, props.catalog); + const insertTable = insertCompletionTable(completionContext); + const target = insertCompletionMetadataTarget(insertTable, scope); + const insertKey = completionCacheKey(insertTable, scope); + const insertColumns = + cachedColumnsByTable.get(insertKey) ?? (usesOracleSessionCompletionColumns(target.schema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, target.database, insertTable.name, target.schema, target.catalog, completionColumnRequestContext(insertTable))); if (insertColumns.length > 0) { - columnsByTable.set(completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), insertColumns); + columnsByTable.set(insertKey, insertColumns); } } @@ -1287,12 +1289,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) } } if (!onDemandOnlyColumns && completionContext.insertTable) { - const insertTable = completionContext.insertTable; - const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? database; - void refreshCompletionColumnsForEditor(connectionId, insertDatabase, insertTable, completionContext.insertSchema ?? scope.schema) + const insertTable = insertCompletionTable(completionContext); + const target = insertCompletionMetadataTarget(insertTable, scope); + void refreshCompletionColumnsForEditor(connectionId, target.database, insertTable.name, target.schema, target.catalog, insertTable) .then((columns) => { - const insertSchema = completionContext.insertSchema ?? scope.schema; - cachedColumnsByTable.set(completionCacheKey({ name: insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope), columns); + cachedColumnsByTable.set(completionCacheKey(insertTable, scope), columns); }) .catch(() => {}); } @@ -1412,6 +1413,15 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return ["routine"]; } + function insertCompletionTable(context: ReturnType) { + return { name: context.insertTable!, database: context.insertDatabase, schema: context.insertSchema, nameQuoted: context.insertTableQuoted, schemaQuoted: context.insertSchemaQuoted }; + } + + function insertCompletionMetadataTarget(table: ReturnType, scope: CompletionMetadataScope) { + if (props.databaseType === "oceanbase-oracle") return completionMetadataTarget(table, scope)!; + return { database: (supportsDatabaseSchemaQualifierCompletion() ? table.database : undefined) ?? scope.database, schema: table.schema ?? scope.schema, catalog: props.catalog }; + } + async function performAsyncCompletionWithResult(epoch: number, completionContext: ReturnType, fullDoc: string, position: number, scope: CompletionMetadataScope) { const localOnlyMetadata = usesLocalOnlyCompletionMetadata(); const onDemandOnlyColumns = usesOnDemandOnlyCompletionColumns(); @@ -1419,12 +1429,12 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) let insertColumnsByTable = new Map(); if (completionContext.insertTable) { try { - const insertDatabase = (supportsDatabaseSchemaQualifierCompletion() ? completionContext.insertDatabase : undefined) ?? scope.database; - const insertCols = await listCompletionColumnsForEditor(props.connectionId!, insertDatabase, completionContext.insertTable, completionContext.insertSchema ?? scope.schema); + const insertTable = insertCompletionTable(completionContext); + const target = insertCompletionMetadataTarget(insertTable, scope); + const insertCols = await listCompletionColumnsForEditor(props.connectionId!, target.database, insertTable.name, target.schema, target.catalog, insertTable); if (epoch !== completionEpoch) return null; if (insertCols.length > 0) { - const insertSchema = completionContext.insertSchema ?? scope.schema; - const insertKey = completionCacheKey({ name: completionContext.insertTable, database: completionContext.insertDatabase, schema: insertSchema }, scope); + const insertKey = completionCacheKey(insertTable, scope); insertColumnsByTable.set(insertKey, insertCols); } } catch { diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts index 064b5a4829..33a04e5cec 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletionMetadata.ts @@ -2,7 +2,7 @@ import type { EditorState, Text } from "@codemirror/state"; import type { CompletionMetadataScope } from "./queryEditorTypes"; import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns"; import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; -import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion"; +import { getSqlCompletionContext, oceanBaseCompletionCacheKey } from "@/lib/sql/sqlCompletion"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import type { SqlSemanticModel } from "@/lib/sql/semantic/types"; import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis"; @@ -246,11 +246,11 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM } function completionCacheKey(table: { name: string; catalog?: string | null; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, scope?: CompletionMetadataScope) { + if (props.databaseType === "oceanbase-oracle") return oceanBaseCompletionCacheKey(table, scope?.schema ?? props.schema); const schema = table.schema ?? scope?.schema ?? props.schema; const scopedDatabase = scope && scope.database !== props.database ? scope.database : undefined; const database = supportsDatabaseSchemaQualifierCompletion() ? (table.database ?? scopedDatabase) : undefined; const baseKey = schema ? `${database ? `${database}.` : ""}${schema}.${table.name}` : table.name; - if (props.databaseType === "oceanbase-oracle") return `${baseKey}:${table.schema ? "qualified" : "unqualified"}`; if (props.databaseType !== "postgres" || (!table.nameQuoted && !table.schemaQuoted)) return baseKey; return `${baseKey}:quoted:s=${table.schemaQuoted ? "1" : "0"}:t=${table.nameQuoted ? "1" : "0"}`; } @@ -363,7 +363,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM // unqualified table name. The query editor commonly has no schema selected // when the user is working from a database-level tab, so use the same // default as the table/DDL metadata paths instead of returning no columns. - const selectedSchema = props.databaseType === "oceanbase-oracle" ? table.schema : table.schema ?? scope?.schema ?? props.schema; + const selectedSchema = props.databaseType === "oceanbase-oracle" ? table.schema : (table.schema ?? scope?.schema ?? props.schema); const effectiveSchema = selectedSchema ?? (props.databaseType === "sqlserver" ? metadataSchemaForConnection(connectionStore.getConfig(props.connectionId ?? ""), currentDatabase, undefined) : undefined); if (supportsDatabaseSchemaQualifierCompletion() && table.database) { return { database: table.database, schema: effectiveSchema, catalog: table.catalog ?? props.catalog }; @@ -415,9 +415,9 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM return remoteMatches.find((item) => completionTablesMatch(item, table)) ?? null; } - async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null }, reference?: Pick, scope?: CompletionMetadataScope): Promise { + async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, reference: Pick = table, scope?: CompletionMetadataScope): Promise { if (isVirtualCompletionTableReference(table)) return false; - const cacheKey = completionCacheKey(table, scope); + const cacheKey = completionCacheKey({ ...table, ...reference }, scope); if (cachedColumnsByTable.has(cacheKey)) return true; if (!props.connectionId || props.database == null) return false; const target = completionMetadataTarget(table, scope); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index 766d6425c1..afde545951 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -27,6 +27,7 @@ import { buildSqlSemanticDiagnostics, isSqlSemanticDiagnosticInputContext, isSqlVirtualTableReference, + oceanBaseDiagnosticTableReference, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, sqlServerRoutineDefinitionRangesForViewport, @@ -259,7 +260,8 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions }; } - function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope): SqlTableReference[] { + function semanticDiagnosticTablesForScope(tables: SqlTableReference[], scope: CompletionMetadataScope, sql: string): SqlTableReference[] { + if (props.databaseType === "oceanbase-oracle") return tables.map((table) => oceanBaseDiagnosticTableReference(table, sql)); if (props.databaseType !== "sqlserver" || scope.database === props.database) return tables; return tables.map((table) => (table.database ? table : { ...table, database: scope.database, schema: table.schema ?? scope.schema })); } @@ -274,7 +276,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions enriched.push(table); continue; } - if (metadata.usesOracleSessionCompletionColumns(table.schema)) { + if ((props.databaseType === "oceanbase-oracle" && !table.schema) || metadata.usesOracleSessionCompletionColumns(table.schema)) { enriched.push(table); continue; } @@ -448,7 +450,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions const metadataScope = semanticDiagnosticMetadataScope(sql, range); const scopedAnalysis = { ...semanticAnalysis, - tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope), + tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope, range.sql), }; const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope); const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope); @@ -468,6 +470,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions loadedColumnTables: metadata.loadedColumnsByTable, sql: range.sql, databaseType: props.databaseType, + currentSchema: metadataScope.schema, }), range, sql, @@ -582,7 +585,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions const metadataScope = semanticDiagnosticMetadataScope(sql, range); const scopedAnalysis = { ...semanticAnalysis, - tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope), + tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope, range.sql), }; const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope); const loadedColumnsBefore = metadata.loadedColumnsByTable.size; @@ -603,6 +606,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions loadedColumnTables: metadata.loadedColumnsByTable, sql: range.sql, databaseType: props.databaseType, + currentSchema: metadataScope.schema, }, ), range, diff --git a/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts b/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts index 1460c4cd94..32ec3092e0 100644 --- a/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts +++ b/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts @@ -70,9 +70,7 @@ describe("tableMetadataCache columns facet request counts", () => { }); it("uses a synonym's exact resolved target for indexes while retaining its source name", async () => { - mocks.getColumns.mockResolvedValue([ - { ...column("ItemId"), is_primary_key: false, resolved_schema: "MixedOwner", resolved_table: "ActualItems", resolved_object_type: "TABLE" }, - ]); + mocks.getColumns.mockResolvedValue([{ ...column("ItemId"), is_primary_key: false, resolved_schema: "MixedOwner", resolved_table: "ActualItems", resolved_object_type: "TABLE" }]); mocks.listIndexes.mockResolvedValue([{ name: "ITEMS_PK", columns: ["ItemId"], is_primary: true, is_unique: true }]); const request = { connectionId: "ob-1", database: "service", schema: "AppOwner", tableName: "ItemsAlias", tableType: "SYNONYM", databaseType: "oceanbase-oracle" }; @@ -106,7 +104,11 @@ describe("tableMetadataCache columns facet request counts", () => { it("uses the current schema for parallel index discovery on a known direct table", async () => { let releaseColumns: (value: ColumnInfo[]) => void = () => {}; - mocks.getColumns.mockReturnValueOnce(new Promise((resolve) => { releaseColumns = resolve; })); + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + releaseColumns = resolve; + }), + ); const load = loadTableMetadata({ connectionId: "ob-1", database: "service", schema: "", currentSchema: "SelectedOwner", tableName: "Items", tableType: "TABLE", databaseType: "oceanbase-oracle" }); await flush(); diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts index 0053aa72e9..68b408124c 100644 --- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts +++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts @@ -1,5 +1,6 @@ import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; -import { getSqlCompletionContext, isOracleSystemValueName } from "@/lib/sql/sqlCompletion"; +import { getSqlCompletionContext, isOracleSystemValueName, matchesOceanBaseCompletionCacheKey } from "@/lib/sql/sqlCompletion"; +import { tokenizeSqlSemantic, tokenIsIdentifier } from "@/lib/sql/semantic/tokens"; import { executableStatementRanges, keepsOracleStyleBlockTogether, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { DBX_TDENGINE_TBNAME_COLUMN, isTdengineStableTableType } from "@/lib/table/tableEditing"; import type { DatabaseType, SqlColumnReference, SqlGroupByViolation, SqlReferenceAnalysis, SqlReferenceScope, SqlTableReference, SqlTextSpan } from "@/types/database"; @@ -17,6 +18,7 @@ export interface SqlSemanticDiagnosticSchema { loadedColumnTables?: Set; sql?: string; databaseType?: DatabaseType; + currentSchema?: string; } export interface SqlSemanticDiagnosticVisibleRange { @@ -265,7 +267,7 @@ function sqlServerStatementNeedsBatchContext(sql: string): boolean { export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, schema: SqlSemanticDiagnosticSchema): SqlSemanticDiagnostic[] { const diagnostics: SqlSemanticDiagnostic[] = []; - const tables = analysis.tables.filter((table) => table.name.trim()); + const tables = analysis.tables.filter((table) => table.name.trim()).map((table) => (schema.databaseType === "oceanbase-oracle" && schema.sql ? oceanBaseDiagnosticTableReference(table, schema.sql) : table)); const knownTables = new Map(); const scopesById = scopesByIdMap(analysis.scopes); let tdengineStableTables: Set | undefined; @@ -292,7 +294,7 @@ export function buildSqlSemanticDiagnostics(analysis: SqlReferenceAnalysis, sche if (!table) continue; if (schema.missingTables?.has(tableReferenceKey(table))) continue; - const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables); + const columns = columnsForTable(table, schema.columnsByTable, schema.loadedColumnTables, schema.currentSchema); if (!columns) continue; const columnNames = new Set(columns.map((item) => normalizeName(item.name))); @@ -592,7 +594,13 @@ function statementIndexAt(sql: string, offset: number): number { return statementIndex; } -function columnsForTable(table: SqlTableReference, columnsByTable: Map, loadedColumnTables?: Set): SqlCompletionColumn[] | null { +export function oceanBaseDiagnosticTableReference(table: SqlTableReference, sql: string) { + const range = sqlTextSpanToOffsetRange(sql, table.span); + const identifiers = range ? tokenizeSqlSemantic(sql.slice(range.from, range.to), "oracle").filter(tokenIsIdentifier) : []; + return { ...table, nameQuoted: !!identifiers[table.schema ? 1 : 0]?.quote, schemaQuoted: !!table.schema && !!identifiers[0]?.quote }; +} + +function columnsForTable(table: SqlTableReference, columnsByTable: Map, loadedColumnTables?: Set, currentSchema?: string): SqlCompletionColumn[] | null { const inlineColumns = (table as SqlTableReference & { columns?: string[] }).columns; if (inlineColumns && inlineColumns.length > 0) { return inlineColumns.map((name) => ({ @@ -601,6 +609,8 @@ function columnsForTable(table: SqlTableReference, columnsByTable: Map matchesOceanBaseCompletionCacheKey(key, table, currentSchema)); + if (scopedColumns.length > 0) return scopedColumns.length === 1 ? scopedColumns[0]![1] : null; const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name, ...keysWithTableName(columnsByTable, table.name)]; for (const key of keys) { const normalizedKey = normalizeName(key); diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 9d502b7bf8..7ee89daf4b 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1544,6 +1544,8 @@ export interface SqlCompletionContext { insertTable?: string; insertDatabase?: string; insertSchema?: string; + insertTableQuoted?: boolean; + insertSchemaQuoted?: boolean; statementKind: SqlStatementKind; tableTriggerWord?: string; isGroupBy: boolean; @@ -2668,6 +2670,8 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq insertTable: insertInfo?.table, insertDatabase: insertInfo?.database, insertSchema: insertInfo?.schema, + insertTableQuoted: insertInfo?.nameQuoted, + insertSchemaQuoted: insertInfo?.schemaQuoted, statementKind, tableTriggerWord: lastWord || undefined, isGroupBy: isInGroupByContext(beforeCursor), @@ -3252,7 +3256,7 @@ function detectComparisonLeftColumn(beforeCursor: string): string | undefined { return match?.[1]; } -function detectInsertColumnListContext(beforeCursor: string): { table: string; database?: string; schema?: string } | null { +function detectInsertColumnListContext(beforeCursor: string): { table: string; database?: string; schema?: string; nameQuoted?: boolean; schemaQuoted?: boolean } | null { // Keep quoted identifiers intact so schema/table targets resolve to their // real names instead of placeholder string contents. const cleaned = beforeCursor.replace(/'[^']*'/g, "''"); @@ -3263,12 +3267,15 @@ function detectInsertColumnListContext(beforeCursor: string): { table: string; d const fullTable = match[1]; if (!fullTable) return null; const parts = splitQualifiedNameParts(fullTable); + const rawParts = splitQualifiedNameRawParts(fullTable); const table = parts[parts.length - 1]; if (!table) return null; return { table, database: parts.length >= 3 ? parts[parts.length - 3] : undefined, schema: parts.length >= 2 ? parts[parts.length - 2] : undefined, + nameQuoted: isQuotedIdentifier(rawParts[rawParts.length - 1]), + schemaQuoted: parts.length >= 2 ? isQuotedIdentifier(rawParts[rawParts.length - 2]) : undefined, }; } @@ -5100,7 +5107,10 @@ function columnsForInsertTarget(context: SqlCompletionContext, columnsByTable: M const schemaKey = context.insertSchema ? normalizeIdentifierPart(context.insertSchema) : undefined; const databaseKey = context.insertDatabase ? normalizeIdentifierPart(context.insertDatabase) : undefined; const qualifiedKey = schemaKey ? normalizeCompletionKey(`${context.insertDatabase ? `${context.insertDatabase}.` : ""}${context.insertSchema}.${context.insertTable}`) : undefined; + const reference = { name: context.insertTable, schema: context.insertSchema, nameQuoted: context.insertTableQuoted, schemaQuoted: context.insertSchemaQuoted }; return collectCompletionColumns(columnsByTable).filter((column) => { + const scopedMatch = matchesOceanBaseCompletionCacheKey(column.key, reference); + if (scopedMatch !== undefined) return scopedMatch; if (normalizeIdentifierPart(column.table) !== tableKey) return false; if (!schemaKey) return true; if (!databaseKey && column.schema && normalizeIdentifierPart(column.schema) === schemaKey) return true; @@ -5235,6 +5245,8 @@ function referencedTableMatchesColumnQualifier(table: SqlCompletionReferencedTab } function columnMatchesReferencedTable(column: SqlCompletionColumn & { key: string }, table: SqlCompletionReferencedTable): boolean { + const scopedMatch = matchesOceanBaseCompletionCacheKey(column.key, table); + if (scopedMatch !== undefined) return scopedMatch; if (normalizeIdentifierPart(column.table) !== normalizeIdentifierPart(table.name)) return false; if (!table.schema) return true; return columnMatchesQualifiedTable(column, { database: table.database, schema: table.schema, table: table.name }); @@ -5309,7 +5321,26 @@ function buildJoinConditionItems(context: SqlCompletionContext, columnsByTable: return items; } +type CompletionCacheTableReference = { name: string; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }; + +function oceanBaseCompletionReferenceKey(table: CompletionCacheTableReference): string { + const schema = table.schema ? (table.schemaQuoted ? table.schema : table.schema.toUpperCase()) : null; + const name = table.nameQuoted ? table.name : table.name.toUpperCase(); + return `oceanbase-oracle:${JSON.stringify([schema, name, !!table.schemaQuoted, !!table.nameQuoted])}:`; +} + +export function oceanBaseCompletionCacheKey(table: CompletionCacheTableReference, currentSchema?: string | null): string { + return `${oceanBaseCompletionReferenceKey(table)}${JSON.stringify(currentSchema ?? null)}`; +} + +export function matchesOceanBaseCompletionCacheKey(key: string, table: CompletionCacheTableReference, currentSchema?: string): boolean | undefined { + if (!key.startsWith("oceanbase-oracle:[")) return undefined; + return currentSchema === undefined ? key.startsWith(oceanBaseCompletionReferenceKey(table)) : key === oceanBaseCompletionCacheKey(table, currentSchema); +} + function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsByTable: Map): SqlCompletionColumn[] { + const scopedColumns = [...columnsByTable].filter(([key]) => matchesOceanBaseCompletionCacheKey(key, table)); + if (scopedColumns.length > 0) return scopedColumns.length === 1 ? applyReferencedColumnAliases(table, scopedColumns[0]![1]) : []; const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name]; for (const key of keys) { const columns = columnsByTable.get(key); @@ -5320,6 +5351,8 @@ function columnsForReferencedTable(table: SqlCompletionReferencedTable, columnsB function foreignKeysForReferencedTable(table: SqlCompletionReferencedTable, foreignKeysByTable?: Map): SqlCompletionForeignKey[] { if (!foreignKeysByTable) return []; + const scopedKeys = [...foreignKeysByTable].filter(([key]) => matchesOceanBaseCompletionCacheKey(key, table)); + if (scopedKeys.length > 0) return scopedKeys.length === 1 ? scopedKeys[0]![1] : []; const keys = table.schema ? [table.database ? `${table.database}.${table.schema}.${table.name}` : undefined, `${table.schema}.${table.name}`, table.name].filter((key): key is string => !!key) : [table.name]; for (const key of keys) { const foreignKeys = foreignKeysByTable.get(key);