fix(snowflake): resolve database and schema completion namespaces

Closes #10953
This commit is contained in:
t8y2
2026-10-02 18:52:05 +00:00
parent 6d1c3c8373
commit 59b6de2cfb
12 changed files with 378 additions and 23 deletions
@@ -9,7 +9,7 @@ import { useQueryEditorCompletion } from "../useQueryEditorCompletion";
import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMetadata";
import type { QueryEditorProps } from "../queryEditorTypes";
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
@@ -32,7 +32,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetadata?: (metadata: ReturnType<typeof useQueryEditorCompletionMetadata>) => void) {
function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetadata?: (metadata: ReturnType<typeof useQueryEditorCompletionMetadata>) => void, semanticCompletionEnabled = false) {
const props = reactive<QueryEditorProps>({ modelValue: "SEL", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides });
const parent = document.createElement("div");
document.body.append(parent);
@@ -44,14 +44,14 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
lookupLocalCompletionColumns: vi.fn(() => []),
lookupLocalCompletionColumnsByPrefix: vi.fn(() => []),
lookupLocalCompletionObjects: vi.fn(() => []),
lookupLocalCompletionDatabases: vi.fn(() => []),
lookupLocalCompletionSchemas: vi.fn(() => []),
lookupLocalCompletionDatabases: vi.fn((): string[] => []),
lookupLocalCompletionSchemas: vi.fn((): string[] => []),
lookupLocalCompletionForeignKeys: vi.fn(() => []),
listCompletionTables: vi.fn(async (): Promise<SqlCompletionTable[]> => []),
listCompletionObjects: vi.fn(async () => []),
listCompletionColumns: vi.fn(async () => []),
listCompletionSchemas: vi.fn(async () => []),
listCompletionDatabases: vi.fn(async () => []),
listCompletionColumns: vi.fn(async (): Promise<SqlCompletionColumn[]> => []),
listCompletionSchemas: vi.fn(async (): Promise<string[]> => []),
listCompletionDatabases: vi.fn(async (): Promise<string[]> => []),
refreshCompletionTables: vi.fn(async () => []),
refreshCompletionColumns: vi.fn(async () => []),
refreshCompletionSchemas: vi.fn(async () => []),
@@ -66,7 +66,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
};
const connectionStore = store as unknown as Options["connectionStore"];
const settings = reactive({ editorSettings: { completionTriggerMode: "positional", snippets: [], sqlFormatter: { keywordCase: "upper", functionCase: "upper" }, autoAliasTables: false, tableCompletionSchemaQualification: "collision", generateSqlQuoteIdentifiers: false } });
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false });
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled });
configureMetadata?.(metadata);
const startCompletion = vi.fn(() => true);
const runtime: Options["runtime"] = { codeMirrorStartCompletion: startCompletion, codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false };
@@ -92,7 +92,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
triggerDeferDelayMs: 25,
maxCompletionTables: 100,
onDemandTableLimit: 100,
semanticCompletionEnabled: false,
semanticCompletionEnabled,
});
cleanups.push(() => {
completion.clearDeferredCompletionTrigger();
@@ -104,6 +104,165 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetad
return { props, currentView, view, store, settings, runtime, completion, provide, startCompletion };
}
describe.each([false, true])("Snowflake namespace completion (semantic=%s)", (semanticCompletionEnabled) => {
beforeEach(() => vi.useRealTimers());
function snowflakeHarness(sql: string, schema: string | undefined = "PUBLIC") {
const harness = createHarness({ databaseType: "snowflake", dialect: "sql", database: "DEFAULT_DB", schema, modelValue: sql }, undefined, semanticCompletionEnabled);
const catalogs: Record<string, Record<string, string[]>> = {
DEFAULT_DB: { PUBLIC: ["DEFAULT_TABLE"] },
OTHER_DB: { PUBLIC: ["OTHER_TABLE"] },
ADVENTUREWORKS2025: { PUBLIC: ["DIMCUSTOMER"] },
Other_Db: { PUBLIC: ["QUOTED_TABLE"] },
"My.DB": { "Sales Area": ["Dim.Customer"], 'A"B': ["ESCAPED_TABLE"] },
};
const databases = Object.keys(catalogs);
harness.store.listCompletionDatabases.mockResolvedValue(databases);
harness.store.listCompletionSchemas.mockImplementation(async (...args: unknown[]) => Object.keys(catalogs[String(args[1])] ?? {}));
harness.store.listCompletionTables.mockImplementation(async (...args: unknown[]) => (catalogs[String(args[1])]?.[String(args[4])] ?? []).map((name) => ({ name, schema: String(args[4]) })));
return { ...harness, catalogs };
}
it.each(["DEFAULT_DB", "OTHER_DB", "ADVENTUREWORKS2025", "Adventureworks2025"])("lists schemas for explicit database %s with no selected schema", async (database) => {
const { provide, store, props } = snowflakeHarness(`SELECT * FROM ${database}.`);
props.schema = undefined;
const result = await provide();
expect(result?.options.map((option) => option.label)).toContain("PUBLIC");
expect(store.listCompletionSchemas).toHaveBeenCalledWith("connection", database.toUpperCase());
expect(result?.options.map((option) => option.label)).toEqual(["PUBLIC"]);
});
it.each([
['"Other_Db"', "Other_Db", ["PUBLIC"]],
['"My.DB"', "My.DB", ['A"B', "Sales Area"]],
] as const)("keeps the exact quoted database %s when listing schemas", async (qualifier, database, schemas) => {
const { provide, store } = snowflakeHarness(`SELECT * FROM ${qualifier}.`);
expect((await provide())?.options.map((option) => option.label).sort()).toEqual(schemas);
expect(store.listCompletionSchemas).toHaveBeenCalledWith("connection", database);
});
it.each([
["DEFAULT_DB.PUBLIC", "DEFAULT_DB", "PUBLIC", "DEFAULT_TABLE"],
["other_db.public", "OTHER_DB", "PUBLIC", "OTHER_TABLE"],
["Adventureworks2025.Public", "ADVENTUREWORKS2025", "PUBLIC", "DIMCUSTOMER"],
['"Other_Db".PUBLIC', "Other_Db", "PUBLIC", "QUOTED_TABLE"],
['"My.DB"."Sales Area"', "My.DB", "Sales Area", "Dim.Customer"],
['"My.DB"."A""B"', "My.DB", 'A"B', "ESCAPED_TABLE"],
])("loads only tables in %s and preserves the typed prefix", async (qualifier, database, schema, table) => {
const sql = `SELECT * FROM ${qualifier}.`;
const { provide, store, currentView, settings } = snowflakeHarness(sql);
settings.editorSettings.tableCompletionSchemaQualification = "always";
settings.editorSettings.generateSqlQuoteIdentifiers = true;
const result = await provide();
expect(store.listCompletionTables.mock.calls.map((args) => (args as unknown[]).slice(0, 5))).toContainEqual(["connection", database, "", 100, schema]);
expect(result?.options.map((option) => option.label)).toEqual([table]);
const option = result!.options[0]!;
if (typeof option.apply === "function") option.apply(currentView, option, result!.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, option.apply ?? option.label, result!.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe(`${sql}${table.includes(".") ? `"${table}"` : table}`);
});
it.each(["collision", "always", "never"])("preserves %s schema qualification for default tables", async (setting) => {
const { provide, settings, currentView } = snowflakeHarness("SELECT * FROM ");
settings.editorSettings.tableCompletionSchemaQualification = setting;
const result = (await provide())!;
const table = result.options.find((option) => option.label === "DEFAULT_TABLE")!;
if (typeof table.apply === "function") table.apply(currentView, table, result.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, table.apply ?? table.label, result.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe(`SELECT * FROM ${setting === "always" ? "PUBLIC.DEFAULT_TABLE" : "DEFAULT_TABLE"}`);
});
it("prefers a known schema over a same-name database after uppercase folding", async () => {
const { catalogs, provide, store } = snowflakeHarness("SELECT * FROM other_db.");
catalogs.DEFAULT_DB!.OTHER_DB = ["SCHEMA_TABLE"];
expect((await provide())?.options.map((option) => option.label)).toEqual(["SCHEMA_TABLE"]);
expect(store.listCompletionSchemas).not.toHaveBeenCalledWith("connection", "OTHER_DB");
});
it.each(['"other_db".', '"other_db".PUBLIC.', "MISSING_DB.", '"OTHER_DB"."public".'])("does not substitute case-distinct or unknown namespaces for %s", async (qualifier) => {
const { provide } = snowflakeHarness(`SELECT * FROM ${qualifier}`);
expect((await provide())?.options ?? []).toEqual([]);
});
it.each(["empty", "denied"])("does not invent tables when explicit schema metadata is %s", async (failure) => {
const { provide, store } = snowflakeHarness("SELECT * FROM OTHER_DB.PUBLIC.");
if (failure === "empty") store.listCompletionTables.mockResolvedValue([]);
else store.listCompletionTables.mockRejectedValue(new Error("permission denied"));
expect((await provide())?.options ?? []).toEqual([]);
});
it("offers accessible database names alongside the default schema and tables", async () => {
const { provide } = snowflakeHarness("SELECT * FROM ");
expect((await provide())?.options.map((option) => option.label)).toEqual(expect.arrayContaining(["DEFAULT_DB", "OTHER_DB", "PUBLIC", "DEFAULT_TABLE"]));
});
it("keeps cached table metadata separate when moving between databases", async () => {
const { provide, currentView, props } = snowflakeHarness("SELECT * FROM OTHER_DB.PUBLIC.");
expect((await provide())?.options.map((option) => option.label)).toEqual(["OTHER_TABLE"]);
const sql = "SELECT * FROM DEFAULT_DB.PUBLIC.";
props.modelValue = sql;
currentView.dispatch({ changes: { from: 0, to: currentView.state.doc.length, insert: sql }, selection: { anchor: sql.length } });
expect((await provide())?.options.map((option) => option.label)).toEqual(["DEFAULT_TABLE"]);
const quotedSql = 'SELECT * FROM "Other_Db".PUBLIC.';
props.modelValue = quotedSql;
currentView.dispatch({ changes: { from: 0, to: currentView.state.doc.length, insert: quotedSql }, selection: { anchor: quotedSql.length } });
expect((await provide())?.options.map((option) => option.label)).toEqual(["QUOTED_TABLE"]);
});
it.each([false, true])("preserves the quote-identifiers preference (%s) for mixed-case table insertion", async (quoteIdentifiers) => {
const { catalogs, provide, currentView, settings } = snowflakeHarness("SELECT * FROM ");
catalogs.DEFAULT_DB!.PUBLIC = ["DimCustomer"];
settings.editorSettings.generateSqlQuoteIdentifiers = quoteIdentifiers;
settings.editorSettings.tableCompletionSchemaQualification = "always";
const result = (await provide())!;
const table = result.options.find((option) => option.label === "DimCustomer")!;
if (typeof table.apply === "function") table.apply(currentView, table, result.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, table.apply ?? table.label, result.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe(`SELECT * FROM PUBLIC.${quoteIdentifiers ? '"DimCustomer"' : "DimCustomer"}`);
});
it.each(["collision", "always", "never"])("preserves %s behavior when table names collide across schemas", async (setting) => {
const { provide, store, currentView, settings } = snowflakeHarness("SELECT * FROM ");
store.listCompletionTables.mockResolvedValue([
{ name: "SHARED", schema: "PUBLIC" },
{ name: "SHARED", schema: "SALES" },
]);
settings.editorSettings.tableCompletionSchemaQualification = setting;
const result = (await provide())!;
const table = result.options.find((option) => option.label === "SHARED")!;
if (typeof table.apply === "function") table.apply(currentView, table, result.from, currentView.state.doc.length);
else currentView.dispatch(insertCompletionText(currentView.state, table.apply ?? table.label, result.from, currentView.state.doc.length));
expect(currentView.state.doc.toString()).toBe(`SELECT * FROM ${setting === "never" ? "SHARED" : "PUBLIC.SHARED"}`);
});
it.each([
["other_db.public.other_table", "OTHER_DB", "PUBLIC", "OTHER_TABLE"],
['"Other_Db".PUBLIC.QUOTED_TABLE', "Other_Db", "PUBLIC", "QUOTED_TABLE"],
['"My.DB"."Sales Area"."Dim.Customer"', "My.DB", "Sales Area", "Dim.Customer"],
])("loads columns from the explicit reference %s", async (reference, database, schema, table) => {
const { provide, store } = snowflakeHarness(`SELECT * FROM ${reference} t WHERE t.`);
store.listCompletionColumns.mockImplementation(async (...args: unknown[]) => (args[1] === database && args[2] === table && args[3] === schema ? [{ name: "ID", table, schema, dataType: "NUMBER" }] : []));
const result = await provide();
expect(store.listCompletionColumns.mock.calls.map((args) => (args as unknown[]).slice(0, 4))).toContainEqual(["connection", database, table, schema]);
expect(result?.options.map((option) => option.label)).toContain("ID");
});
it.each(["empty", "denied"])("does not invent schemas when metadata is %s", async (failure) => {
const { provide, store } = snowflakeHarness("SELECT * FROM OTHER_DB.");
if (failure === "empty") store.listCompletionSchemas.mockResolvedValue([]);
else store.listCompletionSchemas.mockRejectedValue(new Error("permission denied"));
expect((await provide())?.options ?? []).toEqual([]);
});
it("uses locally cached database and schema metadata", async () => {
const { provide, store } = snowflakeHarness("SELECT * FROM Other_Db.");
store.lookupLocalCompletionDatabases.mockReturnValue(["OTHER_DB"]);
store.lookupLocalCompletionSchemas.mockImplementation((...args: unknown[]) => (args[1] === "OTHER_DB" ? ["PUBLIC"] : []));
expect((await provide())?.options.map((option) => option.label)).toEqual(["PUBLIC"]);
expect(store.refreshCompletionSchemas).toHaveBeenCalledWith("connection", "OTHER_DB");
});
});
describe("QueryEditor completion provider ownership", () => {
it.each(["cursor", "document", "view", "composition", "mode"])("rejects asynchronous analysis after a %s change", async (change) => {
let resolve!: (result: SqlCompletionAnalysisResult) => void;
@@ -1071,6 +1071,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
});
const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)));
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
databaseType: props.databaseType,
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
@@ -1233,6 +1234,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
knownSchemas: currentDatabaseSchemaNames,
});
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
databaseType: props.databaseType,
currentDatabase: database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
@@ -1443,6 +1445,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
});
const shouldLoadTables = !schemaLookupDatabase && (completionContext.suggestTables || (!!completionContext.qualifier && !isReferencedTableQualifier(completionContext)));
const tableLookupTarget = resolveSqlCompletionTableLookupTarget({
databaseType: props.databaseType,
currentDatabase: scope.database,
currentSchema: scope.schema,
supportsDatabaseQualifier: supportsDatabaseQualifierCompletion(),
@@ -145,19 +145,19 @@ describe("zookeeper query capabilities", () => {
});
describe("database and schema qualifiers", () => {
it.each(["sqlserver", "trino", "prestosql"] as const)("supports three-part object names for %s", (databaseType) => {
it.each(["sqlserver", "trino", "prestosql", "snowflake"] as const)("supports three-part object names for %s", (databaseType) => {
expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(true);
});
it.each(["mysql", "postgres", "oracle", "snowflake"] as const)("does not widen unverified three-part completion for %s", (databaseType) => {
it.each(["mysql", "postgres", "oracle"] as const)("does not widen unverified three-part completion for %s", (databaseType) => {
expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(false);
});
it.each(["mysql", "sqlite", "sqlserver"] as const)("suggests database names for %s", (databaseType) => {
it.each(["mysql", "sqlite", "sqlserver", "snowflake"] as const)("suggests database names for %s", (databaseType) => {
expect(supportsDatabaseNameCompletion(databaseType)).toBe(true);
});
it.each(["postgres", "oracle", "snowflake", "trino", "prestosql"] as const)("does not add database name completion for %s", (databaseType) => {
it.each(["postgres", "oracle", "trino", "prestosql"] as const)("does not add database name completion for %s", (databaseType) => {
expect(supportsDatabaseNameCompletion(databaseType)).toBe(false);
});
});
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import { sqlReferenceAnalysisDialectFor, sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
describe("Snowflake semantic dialect", () => {
it.each([undefined, "mysql", "postgres"] as const)("preserves Snowflake identifiers with fallback %s", (fallback) => {
const dialect = sqlSemanticDialectFor({ databaseType: "snowflake", dialect: fallback });
expect(dialect.id).toBe("snowflake");
expect(dialect.normalizeIdentifier("Adventureworks2025")).toBe("ADVENTUREWORKS2025");
expect(dialect.normalizeIdentifier("My.DB", true)).toBe("My.DB");
expect(dialect.quoteIdentifier('Sales "Area"')).toBe('"Sales ""Area"""');
expect(dialect.supportsAsForTableAlias).toBe(true);
});
});
describe("ClickHouse semantic dialect", () => {
it("takes precedence over the legacy MySQL behavior dialect", () => {
const dialect = sqlSemanticDialectFor({
@@ -17,6 +17,34 @@ import {
} from "@/lib/sql/sqlCompletionLookupTarget";
describe("sqlCompletionLookupTarget", () => {
it.each([
["Adventureworks2025.Public", "ADVENTUREWORKS2025", "PUBLIC"],
['"Adventureworks2025".public', "Adventureworks2025", "PUBLIC"],
['"My.DB"."Sales Area"', "My.DB", "Sales Area"],
['"My.DB"."A""B"', "My.DB", 'A"B'],
['"adventureworks2025".PUBLIC', "adventureworks2025", "PUBLIC"],
])("resolves Snowflake %s using unquoted uppercase and quoted exact names", (qualifier, database, schema) => {
const sql = `SELECT * FROM ${qualifier}.Di`;
const completionContext = getSqlCompletionContext(sql, sql.length, { databaseType: "snowflake" });
expect(completionContext.qualifierParts).toEqual([database, schema]);
expect(
resolveSqlCompletionTableLookupTarget({
databaseType: "snowflake",
currentDatabase: "DEFAULT_DB",
currentSchema: "PUBLIC",
supportsDatabaseQualifier: false,
supportsDatabaseSchemaQualifier: true,
knownDatabases: ["Adventureworks2025", "ADVENTUREWORKS2025", "My.DB"],
completionContext,
}),
).toEqual({ database, schema, filter: "Di", qualifierDatabase: database });
});
it.each(["postgres", "sqlserver", "trino", "mysql"] as const)("preserves %s qualifier casing", (databaseType) => {
const sql = "SELECT * FROM Adventureworks2025.Public.";
expect(getSqlCompletionContext(sql, sql.length, { databaseType }).qualifierParts).toEqual(["Adventureworks2025", "Public"]);
});
it("treats qualified table completion as a database lookup for MySQL-compatible engines", () => {
const target = resolveSqlCompletionTableLookupTarget({
currentDatabase: "default_db",
@@ -13,7 +13,7 @@ export function supportsDatabaseSchemaQualifier(dbType?: DatabaseType): boolean
}
export function supportsDatabaseNameCompletion(dbType?: DatabaseType): boolean {
return !!dbType && ((!isSchemaAware(dbType) && !isSingleDatabase(dbType)) || dbType === "sqlserver");
return !!dbType && ((!isSchemaAware(dbType) && !isSingleDatabase(dbType)) || dbType === "sqlserver" || dbType === "snowflake");
}
/**
@@ -64,6 +64,15 @@ export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> =
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
qualifierRole: roleForGenericQualifier,
},
snowflake: {
id: "snowflake",
identifierQuotes: [{ open: '"', close: '"' }],
supportsAsForTableAlias: true,
projectionAliasVisibility: defaultProjectionAliasVisibility,
normalizeIdentifier: upperUnquoted,
quoteIdentifier: (identifier) => quoteWith(identifier, '"'),
qualifierRole: roleForGenericQualifier,
},
postgres: {
id: "postgres",
identifierQuotes: [{ open: '"', close: '"' }],
@@ -190,6 +199,7 @@ export function sqlReferenceAnalysisDialectFor(options: { databaseType?: Databas
}
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" | "soql" }): SqlSemanticDialectAdapter {
if (options.databaseType === "snowflake") return SQL_SEMANTIC_DIALECTS.snowflake;
if (options.databaseType === "salesforce") return SQL_SEMANTIC_DIALECTS.soql;
if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse;
// Doris/StarRocks connections ride the editor's MySQL fallback dialect (codeMirrorSqlDialect maps
+13 -7
View File
@@ -1471,7 +1471,7 @@ type SqlCompletionApplyDialect = "mysql" | "postgres" | "sqlserver" | "oracle" |
const MYSQL_LIKE_IDENTIFIER_DATABASES = new Set<DatabaseType>(["mysql", "clickhouse", "hive", "argo", "transwarp", "kyuubi", "impala", "spark", "databend", "tdengine", "access", "doris", "starrocks"]);
const POSTGRES_LIKE_IDENTIFIER_DATABASES = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "kingbase", "highgo", "uxdb", "vastbase", "kwdb", "opengauss"]);
export const ORACLE_COMPAT_IDENTIFIER_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "yashandb", "oscar", "xugu"]);
const UPPER_FOLDING_IDENTIFIER_DATABASES = new Set<DatabaseType>(["dameng", "db2"]);
const UPPER_FOLDING_IDENTIFIER_DATABASES = new Set<DatabaseType>(["dameng", "db2", "snowflake"]);
function sqlCompletionApplyDialect(databaseType: DatabaseType | undefined, fallback: "mysql" | "postgres" | "sqlserver" | undefined): SqlCompletionApplyDialect | undefined {
if (!databaseType) return fallback;
@@ -2850,7 +2850,7 @@ function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseTy
index -= 1;
continue;
}
parts.unshift(unquoteIdentifier(parsed.raw));
parts.unshift(parsed.raw);
index = parsed.start;
if (index <= 0 || tail[index - 1] !== ".") break;
index -= 1;
@@ -2860,8 +2860,8 @@ function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseTy
const start = index;
if (parts.length >= 2 || endsWithDot) {
const qualifierParts = endsWithDot ? parts : parts.slice(0, -1);
const prefixPart = endsWithDot ? "" : (parts[parts.length - 1] ?? "");
const qualifierParts = (endsWithDot ? parts : parts.slice(0, -1)).map((part) => completionLookupIdentifier(part, databaseType));
const prefixPart = endsWithDot ? "" : unquoteIdentifier(parts[parts.length - 1] ?? "");
const qualifierValue = qualifierParts.join(".");
return {
start,
@@ -2873,7 +2873,7 @@ function parseTrailingIdentifierContext(input: string, databaseType?: DatabaseTy
return {
start,
prefix: parts[0] ?? "",
prefix: unquoteIdentifier(parts[0] ?? ""),
};
}
@@ -3625,8 +3625,8 @@ function extractReferencedTables(sql: string, databaseType?: DatabaseType): SqlC
}
const rawParts = splitQualifiedNameRawParts(rawName);
const omittedSqlServerSchema = databaseType === "sqlserver" && rawParts.length >= 3 && rawParts[rawParts.length - 2] === "";
const unquotedRawParts = rawParts.map((part) => unquoteIdentifier(part));
const parts = rawParts.map((part) => unquoteIdentifier(part)).filter(Boolean);
const unquotedRawParts = rawParts.map((part) => completionLookupIdentifier(part, databaseType));
const parts = unquotedRawParts.filter(Boolean);
const name = unquotedRawParts[unquotedRawParts.length - 1];
if (!name) continue;
const table: SqlCompletionReferencedTable = {
@@ -3994,6 +3994,12 @@ function unquoteIdentifier(value: string): string {
return value;
}
function completionLookupIdentifier(value: string, databaseType?: DatabaseType): string {
const name = unquoteIdentifier(value);
if (databaseType !== "snowflake") return name;
return value.startsWith('"') ? name.replaceAll('""', '"') : name.toUpperCase();
}
export function quoteSqlIdentifier(identifier: string, dialect?: SqlCompletionApplyDialect): string {
if (dialect === "oracle") {
// Oracle folds bare identifiers to uppercase, so only all-uppercase names
@@ -274,6 +274,7 @@ export function resolveSqlCompletionSchemaLookupDatabase(options: {
}
export function resolveSqlCompletionTableLookupTarget(options: {
databaseType?: DatabaseType;
currentDatabase: string;
currentSchema?: string;
supportsDatabaseQualifier: boolean;
@@ -287,7 +288,7 @@ export function resolveSqlCompletionTableLookupTarget(options: {
if (options.supportsDatabaseSchemaQualifier && completionContext.suggestTables && !completionContext.insertTable && qualifierParts.length >= 2) {
const databaseQualifier = qualifierParts[qualifierParts.length - 2]!;
const schema = qualifierParts[qualifierParts.length - 1]!;
const database = findExactName(options.knownDatabases, databaseQualifier) ?? databaseQualifier;
const database = (options.databaseType === "snowflake" ? findCaseSensitiveName(options.knownDatabases, databaseQualifier) : findExactName(options.knownDatabases, databaseQualifier)) ?? databaseQualifier;
return {
database,
schema,
@@ -0,0 +1,133 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
beforeEach(() => {
vi.resetModules();
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
});
setActivePinia(createPinia());
});
afterEach(() => vi.unstubAllGlobals());
async function createHarness(mode: "simple" | "grouped" = "simple", disk = new Map<string, string>()) {
const listSchemaInfos = vi.fn(async (_id: string, _database: string) => [
{ name: "PUBLIC", comment: null },
{ name: "Sales Area", comment: null },
]);
const listTables = vi.fn(async (_id: string, database: string, schema: string) => [{ name: "SHARED_TABLE", table_type: "TABLE", schema, comment: database }]);
const getColumns = vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_nullable: false }]);
const loadSchemaCache = vi.fn(async (key: string) => disk.get(key) ?? null);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
deleteSchemaCachePrefix: vi.fn(async (prefix: string) => {
for (const key of disk.keys()) if (key.startsWith(prefix)) disk.delete(key);
}),
listSchemaInfos,
listTables,
getColumns,
listObjects: vi.fn().mockResolvedValue([]),
loadSchemaCache,
saveSchemaCache: vi.fn(async (key: string, value: string) => {
disk.set(key, value);
}),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
useSettingsStore().editorSettings.sidebarObjectDisplay = mode;
const store = useConnectionStore();
const connection = { id: "snowflake-test", name: "Snowflake", db_type: "snowflake", database: "DEFAULT_DB", host: "fixture.invalid", port: 443, username: "", password: "" } as ConnectionConfig;
const databases: TreeNode[] = ["DEFAULT_DB", "OTHER_DB"].map((database) => ({ id: `${connection.id}:${database}`, label: database, type: "database", connectionId: connection.id, database, children: [] }));
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: databases }];
return { store, connection, databases, listSchemaInfos, listTables, getColumns, loadSchemaCache, disk };
}
describe("Snowflake sidebar namespace metadata", () => {
it.each(["simple", "grouped"] as const)("loads database/schema/table/column scopes in %s mode", async (mode) => {
const { store, connection, databases, listSchemaInfos, listTables, getColumns } = await createHarness(mode);
for (const database of databases) {
await store.loadTreeNodeChildren(database);
expect(listSchemaInfos).toHaveBeenCalledWith(connection.id, database.database);
const schemas = database.children!.filter((node) => node.type === "schema");
expect(schemas.map((node) => node.schema)).toEqual(["PUBLIC", "Sales Area"]);
for (const schema of schemas) {
await store.loadTreeNodeChildren(schema);
const parent = mode === "simple" ? schema : schema.children!.find((node) => node.type === "group-tables")!;
if (mode === "grouped") await store.loadTreeNodeChildren(parent);
const table = parent.children!.find((node) => node.type === "table")!;
expect(table).toMatchObject({ label: "SHARED_TABLE", database: database.database, schema: schema.schema, comment: database.database });
await store.loadTreeNodeChildren(table);
const columns = table.children!.find((node) => node.type === "group-columns")!;
await store.loadTreeNodeChildren(columns);
expect(getColumns).toHaveBeenCalledWith(connection.id, database.database, schema.schema, "SHARED_TABLE", undefined);
expect(columns.children?.map((node) => node.label)).toContain("ID (NUMBER)");
}
}
expect(listTables.mock.calls.every((args) => ["PUBLIC", "Sales Area"].includes(args[2]))).toBe(true);
});
it("reuses cached schema metadata and refreshes changed schemas explicitly", async () => {
const { store, databases, listSchemaInfos } = await createHarness();
const database = databases[1]!;
await store.loadTreeNodeChildren(database);
await store.loadTreeNodeChildren(database);
expect(listSchemaInfos).toHaveBeenCalledOnce();
listSchemaInfos.mockResolvedValue([{ name: "NEW_SCHEMA", comment: null }]);
await store.loadTreeNodeChildren(database, { force: true });
expect(listSchemaInfos).toHaveBeenCalledTimes(2);
expect(database.children?.filter((node) => node.type === "schema").map((node) => node.schema)).toEqual(["NEW_SCHEMA"]);
});
it("restores persisted schema and table metadata after store reload", async () => {
const first = await createHarness();
await first.store.loadTreeNodeChildren(first.databases[1]!);
const schema = first.databases[1]!.children!.find((node) => node.schema === "PUBLIC")!;
await first.store.loadTreeNodeChildren(schema);
expect(first.disk.size).toBeGreaterThan(0);
vi.resetModules();
setActivePinia(createPinia());
const restored = await createHarness("simple", first.disk);
let finishRefresh!: (tables: Awaited<ReturnType<typeof restored.listTables>>) => void;
restored.listTables.mockReturnValue(
new Promise((resolve) => {
finishRefresh = resolve;
}),
);
await restored.store.loadTreeNodeChildren(restored.databases[1]!);
const restoredSchema = restored.databases[1]!.children!.find((node) => node.schema === "PUBLIC")!;
await restored.store.loadTreeNodeChildren(restoredSchema);
expect(restored.listSchemaInfos).not.toHaveBeenCalled();
expect(restoredSchema.children).toEqual(expect.arrayContaining([expect.objectContaining({ label: "SHARED_TABLE", database: "OTHER_DB", schema: "PUBLIC" })]));
finishRefresh([{ name: "REFRESHED_TABLE", table_type: "TABLE", schema: "PUBLIC", comment: "OTHER_DB" }]);
await vi.waitFor(() => expect(restoredSchema.children).toEqual(expect.arrayContaining([expect.objectContaining({ label: "REFRESHED_TABLE", database: "OTHER_DB", schema: "PUBLIC" })])));
});
it.each(["empty", "denied"])("preserves %s schema results without a flat table fallback", async (state) => {
const { store, databases, listSchemaInfos, listTables } = await createHarness();
const database = databases[1]!;
if (state === "empty") {
listSchemaInfos.mockResolvedValue([]);
await store.loadTreeNodeChildren(database);
} else {
listSchemaInfos.mockRejectedValueOnce(new Error("permission denied"));
await expect(store.loadTreeNodeChildren(database)).rejects.toThrow("permission denied");
}
expect(database.children?.filter((node) => node.type === "schema" || node.type === "table")).toEqual([]);
expect(listTables).not.toHaveBeenCalled();
if (state === "denied") {
await store.loadTreeNodeChildren(database, { force: true });
expect(database.children?.filter((node) => node.type === "schema")).toHaveLength(2);
}
});
});
@@ -1750,7 +1750,9 @@
"skipTcpProbe": true,
"defaultPort": 443,
"traits": {
"schemaAware": true
"schemaAware": true,
"databaseSchemaQualified": true,
"treeSchema": true
},
"supportLevel": "operate",
"capabilities": {
+2
View File
@@ -14,6 +14,8 @@ skipTcpProbe: true
defaultPort: 443
traits:
schemaAware: true
databaseSchemaQualified: true
treeSchema: true
supportLevel: operate
capabilities:
queryExecution: true