fix(oceanbase): preserve quoted completion identities

This commit is contained in:
efeire
2026-10-09 16:09:24 +08:00
parent 7a70fb54d3
commit bd63bc8ece
9 changed files with 203 additions and 48 deletions
@@ -41,7 +41,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, 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<QueryEditorProps> = {}, configureMetad
listCompletionSchemas: vi.fn(async (): Promise<string[]> => []),
listCompletionDatabases: vi.fn(async (): Promise<string[]> => []),
refreshCompletionTables: vi.fn(async () => []),
refreshCompletionColumns: vi.fn(async () => []),
refreshCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
refreshCompletionSchemas: vi.fn(async () => []),
refreshCompletionDatabases: vi.fn(async () => []),
listRedisCompletionCommandDocs: vi.fn(async () => commands),
@@ -104,6 +104,32 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, 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());
@@ -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<QueryEditorProps> = {}) {
}
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) => {
@@ -42,6 +42,16 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const view = shallowRef<EditorView | null>(currentView);
const runtime: Options["runtime"] = { editorIsActive: true, executableStatementRangeCache: null, setSqlDiagnosticsEffect: StateEffect.define<SqlSemanticDiagnostic[]>(), diagnosticComp: null, buildSqlDiagnosticExtension: null, codeMirrorCompletionStatus: () => null };
const settingsStore = { editorSettings: { sqlSemanticDiagnosticsEnabled: true } } as Options["settingsStore"];
const metadata = {
cachedTables: [],
cachedColumnsByTable: new Map(),
loadedColumnsByTable: new Set<string>(),
usesOracleSessionCompletionColumns: () => false,
findExactSemanticDiagnosticTable: vi.fn().mockResolvedValue(null),
completionCacheKey: (table: { name: string }) => table.name,
ensureColumnsForTable: vi.fn().mockResolvedValue(false),
isMissingTableMetadataError: () => false,
};
let diagnostics!: ReturnType<typeof useQueryEditorDiagnostics>;
const host = document.createElement("div");
const app = createApp({
@@ -57,16 +67,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
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<QueryEditorProps> = {}) {
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);
@@ -1105,11 +1105,13 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
const columnsByTable = new Map<string, SqlCompletionColumn[]>();
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<typeof getSqlCompletionContext>) {
return { name: context.insertTable!, database: context.insertDatabase, schema: context.insertSchema, nameQuoted: context.insertTableQuoted, schemaQuoted: context.insertSchemaQuoted };
}
function insertCompletionMetadataTarget(table: ReturnType<typeof insertCompletionTable>, 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<typeof getSqlCompletionContext>, 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<string, SqlCompletionColumn[]>();
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 {
@@ -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<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted">, scope?: CompletionMetadataScope): Promise<boolean> {
async function ensureColumnsForTable(table: { name: string; database?: string | null; schema?: string | null; nameQuoted?: boolean; schemaQuoted?: boolean }, reference: Pick<SqlCompletionReferencedTable, "nameQuoted" | "schemaQuoted"> = table, scope?: CompletionMetadataScope): Promise<boolean> {
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);
@@ -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,
@@ -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<ColumnInfo[]>((resolve) => { releaseColumns = resolve; }));
mocks.getColumns.mockReturnValueOnce(
new Promise<ColumnInfo[]>((resolve) => {
releaseColumns = resolve;
}),
);
const load = loadTableMetadata({ connectionId: "ob-1", database: "service", schema: "", currentSchema: "SelectedOwner", tableName: "Items", tableType: "TABLE", databaseType: "oceanbase-oracle" });
await flush();
@@ -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<string>;
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<string, SqlTableReference>();
const scopesById = scopesByIdMap(analysis.scopes);
let tdengineStableTables: Set<string> | 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<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>): 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<string, SqlCompletionColumn[]>, loadedColumnTables?: Set<string>, 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<string, S
schema: table.schema ?? undefined,
}));
}
const scopedColumns = [...columnsByTable].filter(([key]) => 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);
+34 -1
View File
@@ -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<string, SqlCompletionColumn[]>): 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<string, SqlCompletionForeignKey[]>): 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);