Files
dbx/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts
T

1347 lines
60 KiB
TypeScript
Vendored

import { describe, expect, it } from "vitest";
import {
buildPostgresSequenceLiteralCompletionItems,
buildSelectStarExpansion,
buildSqlCompletionItems,
buildSqlCompletionItemsFromContext,
getPostgresSequenceLiteralCompletionContext,
getSqlCompletionContext,
selectStarResultColumnsMatch,
shouldAutoOpenSqlCompletion,
} from "@/lib/sql/sqlCompletion";
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy";
describe("sqlCompletion keyword snippets", () => {
it("auto-opens and suggests SELECT when typing sel", () => {
const sql = "sel";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
columnsByTable: new Map(),
});
expect(shouldAutoOpenSqlCompletion(sql, sql.length)).toBe(true);
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "select *", type: "snippet" }), expect.objectContaining({ label: "SELECT", type: "keyword" })]));
});
});
describe("SQL Server datepart completion", () => {
it.each(["DATEADD", "DATEDIFF", "DATEPART", "DATENAME"])("suggests datepart values for the first %s argument", (functionName) => {
const sql = `SELECT ${functionName}(d`;
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
columnsByTable: new Map(),
databaseType: "sqlserver",
dialect: "sqlserver",
keywordCase: "lower",
});
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "day", type: "keyword" }), expect.objectContaining({ label: "dayofyear", type: "keyword" })]));
expect(items.some((item) => item.label === "deadlock_priority")).toBe(false);
});
it("auto-opens the datepart list immediately after the opening parenthesis", () => {
const sql = "SELECT DATEADD(";
expect(shouldAutoOpenSqlCompletion(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" })).toBe(true);
});
it("stops offering datepart values after the first argument", () => {
const sql = "SELECT DATEADD(day, d";
expect(getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver", dialect: "sqlserver" }).preferredValueKeywords).toBeUndefined();
});
it("does not apply SQL Server datepart values to other dialects", () => {
const sql = "SELECT DATEADD(d";
expect(getSqlCompletionContext(sql, sql.length, { databaseType: "mysql", dialect: "mysql" }).preferredValueKeywords).toBeUndefined();
});
});
describe("MySQL DESCRIBE table completion", () => {
it.each(["DESC", "DESCRIBE"])("treats %s as a table-name context", (keyword) => {
const sql = `${keyword} ord`;
const options = { databaseType: "mysql", dialect: "mysql" } as const;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length, options), getSqlCompletionContext(sql, sql.length, options));
expect(context.suggestTables).toBe(true);
expect(shouldAutoOpenSqlCompletion(sql, sql.length, options)).toBe(true);
expect(
buildSqlCompletionItemsFromContext(context, {
tables: [{ name: "orders" }],
columnsByTable: new Map(),
...options,
}),
).toEqual(expect.arrayContaining([expect.objectContaining({ label: "orders", type: "table" })]));
});
it("does not treat PostgreSQL ORDER BY DESC as a table-name context", () => {
const sql = "SELECT * FROM users ORDER BY name DESC ord";
const options = { databaseType: "postgres", dialect: "postgres" } as const;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, sql.length, options), getSqlCompletionContext(sql, sql.length, options));
expect(context.suggestTables).toBe(false);
expect(
buildSqlCompletionItemsFromContext(context, {
tables: [{ name: "orders" }],
columnsByTable: new Map(),
...options,
}).some((item) => item.type === "table"),
).toBe(false);
});
});
describe("PostgreSQL sequence literal completion", () => {
it.each(["nextval", "currval", "setval"])("recognizes the first %s regclass literal", (functionName) => {
const sql = `SELECT ${functionName}('public.order_`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(
expect.objectContaining({
prefix: "order_",
schema: "public",
schemaQuoted: false,
nameQuoted: false,
}),
);
expect(context?.from).toBe(sql.lastIndexOf("order_"));
expect(shouldAutoOpenSqlCompletion(sql, sql.length, { databaseType: "postgres" })).toBe(true);
});
it("preserves quoted mixed-case schema and sequence identifiers", () => {
const sql = `SELECT pg_catalog.nextval('"App"."Order`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(
expect.objectContaining({
prefix: "Order",
schema: "App",
schemaQuoted: true,
nameQuoted: true,
nameQuoteClosed: false,
}),
);
expect(
buildPostgresSequenceLiteralCompletionItems(context!, [
{ name: "OrderSequence", schema: "App", type: "sequence" },
{ name: "order_sequence", schema: "App", type: "sequence" },
]),
).toEqual([
expect.objectContaining({
label: "OrderSequence",
apply: 'OrderSequence"',
replaceClosingQuote: '"',
detail: "sequence in App",
}),
]);
});
it("keeps doubled apostrophes inside the sequence identifier and escapes insertion", () => {
const sql = `SELECT nextval('"customer''s_`;
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(context).toEqual(expect.objectContaining({ prefix: "customer's_", nameQuoted: true }));
expect(buildPostgresSequenceLiteralCompletionItems(context!, [{ name: "customer's_seq", schema: "public", type: "sequence" }])).toEqual([expect.objectContaining({ label: "customer's_seq", apply: `customer''s_seq"` })]);
});
it("quotes an accepted mixed-case identifier in an unquoted literal", () => {
const sql = "SELECT nextval('mix";
const context = getPostgresSequenceLiteralCompletionContext(sql, sql.length, "postgres");
expect(buildPostgresSequenceLiteralCompletionItems(context!, [{ name: "MixedSequence", schema: "public", type: "sequence" }])).toEqual([expect.objectContaining({ label: "MixedSequence", filterText: "MixedSequence", apply: '"MixedSequence"' })]);
});
it("does not expose sequence metadata through ordinary SQL object completion", () => {
const sql = "SELECT order_";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
objects: [{ name: "order_seq", schema: "public", type: "sequence" }],
columnsByTable: new Map(),
databaseType: "postgres",
dialect: "postgres",
});
expect(items.some((item) => item.label === "order_seq")).toBe(false);
});
it.each([
["SELECT 'order_", "postgres"],
["SELECT nextval('order_", "mysql"],
["SELECT nextval('order_') || 'suffix", "postgres"],
["SELECT setval(42, 'order_", "postgres"],
["SELECT app.nextval('order_", "postgres"],
["SELECT app. nextval('order_", "postgres"],
["SELECT app.pg_catalog.nextval('order_", "postgres"],
[`SELECT "PG_CATALOG".nextval('order_`, "postgres"],
["SELECT $$ nextval('order_", "postgres"],
["SELECT $body$ nextval('order_", "postgres"],
] as const)("does not enable sequence completion for unrelated literals: %s (%s)", (sql, databaseType) => {
expect(getPostgresSequenceLiteralCompletionContext(sql, sql.length, databaseType)).toBeNull();
expect(shouldAutoOpenSqlCompletion(sql, sql.length, { databaseType })).toBe(false);
});
});
describe("SELECT star expansion", () => {
it("reuses completion column ordering for an unqualified star", () => {
const sql = "SELECT * FROM apis";
const context = getSqlCompletionContext(sql, "SELECT *".length);
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
{ name: "method", table: "apis" },
],
],
]),
),
).toBe("id, created_at, method");
});
it("expands a multi-table star with aliases and preserves duplicate column names", () => {
const sql = "SELECT * FROM tVillage tV INNER JOIN tland tl ON tV.villageId = tl.villageId";
const cursor = "SELECT *".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"tVillage",
[
{ name: "villageId", table: "tVillage" },
{ name: "villageName", table: "tVillage" },
],
],
[
"tland",
[
{ name: "villageId", table: "tland" },
{ name: "landName", table: "tland" },
],
],
]),
),
).toBe("tV.villageId, tV.villageName, tl.villageId, tl.landName");
});
it("uses FROM/JOIN order even when the metadata map arrives in another order", () => {
const sql = "SELECT * FROM tVillage tv INNER JOIN tland tl ON tv.villageId = tl.villageId";
const cursor = "SELECT *".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"tland",
[
{ name: "landName", table: "tland" },
{ name: "villageId", table: "tland" },
],
],
[
"tVillage",
[
{ name: "villageName", table: "tVillage" },
{ name: "villageId", table: "tVillage" },
],
],
]),
),
).toBe("tv.villageName, tv.villageId, tl.landName, tl.villageId");
});
it("preserves an alias while replacing only the star", () => {
const sql = "SELECT ap.* FROM apis AS ap";
const cursor = "SELECT ap.*".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
],
],
]),
),
).toBe("id, ap.created_at");
});
it.each([
["postgres", "postgres", '"Order Alias"', '"created at"'],
["mysql", "mysql", "`Order Alias`", "`created at`"],
["sqlserver", "sqlserver", "[Order Alias]", "[created at]"],
["oracle", "mysql", '"Order Alias"', '"created at"'],
] as const)("preserves a quoted %s alias for every expanded column", (databaseType, dialect, qualifierSql, quotedColumn) => {
const sql = `SELECT ${qualifierSql}.* FROM orders AS ${qualifierSql}`;
const cursor = sql.indexOf("*") + 1;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor, { databaseType, dialect }), getSqlCompletionContext(sql, cursor, { databaseType, dialect }));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"orders",
[
{ name: "id", table: "orders" },
{ name: "created at", table: "orders" },
],
],
]),
dialect,
qualifierSql,
databaseType,
),
).toBe(`id, ${qualifierSql}.${quotedColumn}`);
});
it("expands an unqualified star from result columns when the table has an alias", () => {
const sql = "select *\nfrom apis as ap\nlimit 100;";
const cursor = "select *".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
{ name: "updated_at", table: "apis" },
{ name: "deleted_at", table: "apis" },
{ name: "method", table: "apis" },
],
],
]),
),
).toBe("id, created_at, updated_at, deleted_at, method");
});
it("accepts result columns only when their source still contains the target star", () => {
const currentSql = "select * from apis;\nselect * from users;";
const sourceStatement = "select * from users";
const sourceFrom = currentSql.lastIndexOf("select");
const targetFrom = currentSql.lastIndexOf("*");
expect(selectStarResultColumnsMatch({ currentSql, targetFrom, targetTo: targetFrom + 1, statementSql: sourceStatement, sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(true);
expect(selectStarResultColumnsMatch({ currentSql, targetFrom: currentSql.indexOf("*"), targetTo: currentSql.indexOf("*") + 1, statementSql: "select * from apis", sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(false);
});
it("rejects stale and incomplete result source metadata", () => {
expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from apis" })).toBe(false);
expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from users", sourceFrom: 0 })).toBe(false);
});
});
describe("sqlCompletion database functions", () => {
it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => {
const sql = "SELECT tostart";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "clickhouse",
tables: [],
columnsByTable: new Map(),
functionCase: "lower",
});
expect(items.find((item) => item.label === "toStartOfDay")).toMatchObject({
type: "function",
apply: "toStartOfDay(${value})",
});
});
it("uses exact ClickHouse window function placeholders", () => {
const denseRankSql = "SELECT dense_";
const denseRankItems = buildSqlCompletionItems(denseRankSql, denseRankSql.length, {
databaseType: "clickhouse",
tables: [],
columnsByTable: new Map(),
});
expect(denseRankItems.find((item) => item.label === "dense_rank")?.apply).toBe("dense_rank()");
const ntileSql = "SELECT nti";
const ntileItems = buildSqlCompletionItems(ntileSql, ntileSql.length, {
databaseType: "clickhouse",
tables: [],
columnsByTable: new Map(),
});
expect(ntileItems.find((item) => item.label === "ntile")?.apply).toBe("ntile(${buckets})");
});
it("does not leak ClickHouse-only functions to MySQL", () => {
const sql = "SELECT tostart";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "mysql",
tables: [],
columnsByTable: new Map(),
});
expect(items.some((item) => item.label === "toStartOfDay")).toBe(false);
});
it("suggests only ClickHouse table functions alongside tables after FROM", () => {
const sql = "SELECT * FROM num";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "clickhouse",
tables: [{ name: "number_events", type: "table" }],
columnsByTable: new Map(),
});
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "numbers", type: "function" }), expect.objectContaining({ label: "number_events", type: "table" })]));
expect(items.some((item) => item.label === "toStartOfDay")).toBe(false);
});
it("does not insert a duplicate opening parenthesis before an existing call", () => {
const sql = "SELECT toStart()";
const cursor = "SELECT toStart".length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "clickhouse",
tables: [],
columnsByTable: new Map(),
});
expect(items.find((item) => item.label === "toStartOfDay")?.apply).toBe("toStartOfDay");
});
it("suggests MySQL Unix timestamp functions with function snippets", () => {
const fromUnixSql = "SELECT from_unix";
const fromUnixItems = buildSqlCompletionItems(fromUnixSql, fromUnixSql.length, {
databaseType: "mysql",
tables: [],
columnsByTable: new Map(),
});
const fromUnixTime = fromUnixItems.find((item) => item.label === "FROM_UNIXTIME");
expect(fromUnixItems[0]).toBe(fromUnixTime);
expect(fromUnixTime).toEqual(
expect.objectContaining({
type: "function",
apply: "FROM_UNIXTIME(${unix_timestamp})",
}),
);
const unixTimestampSql = "SELECT unix_time";
const unixTimestampItems = buildSqlCompletionItems(unixTimestampSql, unixTimestampSql.length, {
databaseType: "mysql",
tables: [],
columnsByTable: new Map(),
});
expect(unixTimestampItems[0]).toEqual(
expect.objectContaining({
label: "UNIX_TIMESTAMP",
type: "function",
apply: "UNIX_TIMESTAMP()",
}),
);
});
it("ranks MySQL function prefixes ahead of ordinary keyword prefixes", () => {
const sql = "SELECT uni";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "mysql",
tables: [],
columnsByTable: new Map(),
});
expect(items.some((item) => item.type === "keyword")).toBe(true);
expect(items[0]).toEqual(expect.objectContaining({ label: "UNIX_TIMESTAMP", type: "function" }));
});
it("does not expose MySQL-only functions to other databases", () => {
const sql = "SELECT from_unix";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "postgres",
tables: [],
columnsByTable: new Map(),
});
expect(items.some((item) => item.label === "FROM_UNIXTIME")).toBe(false);
});
});
describe("sqlCompletion quoted schema qualifiers", () => {
it("parses quoted PostgreSQL schema names before a dot", () => {
const sql = 'SELECT *\nFROM "order-management".';
const context = getSqlCompletionContext(sql, sql.length);
expect(context.qualifier).toBe("order-management");
expect(context.prefix).toBe("");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveColumnSuggestions).toBe(false);
});
it("suggests tables after a quoted schema qualifier", () => {
const sql = 'SELECT *\nFROM "order-management".';
const items = buildSqlCompletionItems(sql, sql.length, {
dialect: "postgres",
tables: [
{ name: "orders", schema: "order-management", type: "table" },
{ name: "shipments", schema: "order-management", type: "table" },
],
columnsByTable: new Map(),
});
expect(items.some((item) => item.label === "orders" && item.type === "table")).toBe(true);
expect(items.some((item) => item.label === "shipments" && item.type === "table")).toBe(true);
});
});
describe("sqlCompletion table targets", () => {
it("suggests tables after a database qualifier in an EXISTS table list", () => {
const sql = "SELECT * FROM aa.tb t WHERE EXISTS (SELECT 1 FROM aa.tb1 t1, aa.";
const context = getSqlCompletionContext(sql, sql.length);
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "mysql",
tables: [{ name: "tb2", schema: "aa", type: "table" }],
columnsByTable: new Map(),
});
expect(context.qualifier).toBe("aa");
expect(context.suggestTables).toBe(true);
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "tb2", type: "table" })]));
});
it("does not suggest aliases while completing an empty FROM target before LIMIT", () => {
const sql = "SELECT *\nFROM \nLIMIT 100;";
const cursor = "SELECT *\nFROM ".length;
const items = buildSqlCompletionItems(sql, cursor, {
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map(),
});
expect(items.some((item) => item.type === "snippet" && item.detail === "alias for LIMIT")).toBe(false);
expect(items.some((item) => item.type === "table" && item.label === "users")).toBe(true);
});
});
describe("sqlCompletion table aliases", () => {
it("uses initials from all words for generated aliases", () => {
const sql = "SELECT * FROM mat";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "materials_order_item", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "materials_order_item" && item.type === "table");
expect(table?.apply).toBe("materials_order_item AS moi");
});
it("uses every word initial for longer multi-word names", () => {
const sql = "SELECT * FROM sup";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "super_long_customer_order_history_archive_snapshot_daily_replica", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "super_long_customer_order_history_archive_snapshot_daily_replica" && item.type === "table");
expect(table?.apply).toBe("super_long_customer_order_history_archive_snapshot_daily_replica AS slcohasdr");
});
it("applies generated aliases to table completions when enabled", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
});
it("omits AS from Oracle table alias completions", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
databaseType: "oracle",
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items oi");
});
it("keeps plain table completions when generated aliases are disabled", () => {
const sql = "SELECT * FROM ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: false,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items");
});
it("omits AS from Oracle alias suggestions", () => {
const sql = "SELECT * FROM order_items ";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
databaseType: "oracle",
});
const alias = items.find((item) => item.type === "snippet" && item.detail === "alias for order_items");
expect(alias?.apply).toBe("oi ");
});
it("uses a numbered alias when the generated table alias already exists", () => {
const sql = "SELECT * FROM order_items oi JOIN ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi2");
});
it("applies generated aliases in comma-separated FROM table lists", () => {
const sql = "SELECT * FROM users u, ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items AS oi");
});
it("does not apply generated aliases to non-query table completions", () => {
const sql = "INSERT INTO ord";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "order_items", type: "table" }],
columnsByTable: new Map(),
autoAliasTables: true,
});
const table = items.find((item) => item.label === "order_items" && item.type === "table");
expect(table?.apply).toBe("order_items");
});
});
describe("sqlCompletion scoped context classification", () => {
it("classifies JOIN table contexts", () => {
const sql = "SELECT * FROM users u JOIN ";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("join");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
});
it("classifies alias-qualified column contexts", () => {
const sql = "SELECT * FROM users u WHERE u.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("alias_column");
expect(context.qualifier).toBe("u");
expect(context.suggestColumns).toBe(true);
});
it("keeps alias-qualified column context after select-list subqueries", () => {
const sql = `
SELECT
p.id,
p.create_user_name 'creator',
(SELECT t.\`code\` FROM sys_user t WHERE t.user_id = p.apply_user_id) 'creator_code',
p.
FROM sys_process p
LIMIT 10
`;
const cursor = sql.indexOf("p.\n FROM");
const context = getSqlCompletionContext(sql, cursor + 2);
expect(context.contextKind).toBe("alias_column");
expect(context.qualifier).toBe("p");
expect(context.suggestTables).toBe(false);
expect(context.exclusiveTableSuggestions).toBe(false);
expect(context.suggestColumns).toBe(true);
});
it("suggests alias columns after select-list subqueries instead of tables", () => {
const sql = `
SELECT
p.id,
p.create_user_name 'creator',
(SELECT t.\`code\` FROM sys_user t WHERE t.user_id = p.apply_user_id) 'creator_code',
p.
FROM sys_process p
LIMIT 10
`;
const cursor = sql.indexOf("p.\n FROM") + 2;
const items = buildSqlCompletionItems(sql, cursor, {
dialect: "mysql",
tables: [
{ name: "act_evt_log", type: "table" },
{ name: "sys_process", type: "table" },
{ name: "sys_user", type: "table" },
],
columnsByTable: new Map([
[
"sys_process",
[
{ name: "id", table: "sys_process" },
{ name: "create_user_name", table: "sys_process" },
{ name: "apply_user_id", table: "sys_process" },
],
],
["sys_user", [{ name: "code", table: "sys_user" }]],
]),
});
const columnLabels = items.filter((item) => item.type === "column").map((item) => item.label);
expect(columnLabels).toEqual(expect.arrayContaining(["id", "create_user_name", "apply_user_id"]));
expect(items[0]?.type).toBe("column");
expect(items.some((item) => item.type === "table")).toBe(false);
expect(items.some((item) => item.type === "keyword")).toBe(false);
});
it("classifies unqualified WHERE field input as column context", () => {
const sql = "SELECT * FROM A1User WHERE userc";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.prefix).toBe("userc");
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "A1User" })]));
expect(context.suggestColumns).toBe(true);
expect(context.suggestRoutines).toBe(true);
});
it("classifies unqualified WHERE field input as column context for unquoted non-ASCII table/schema names", () => {
const options = { databaseType: "mysql" } as const;
const tableOnly = getSqlCompletionContext("SELECT * FROM 用户表 WHERE ", "SELECT * FROM 用户表 WHERE ".length, options);
expect(tableOnly.contextKind).toBe("column");
expect(tableOnly.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "用户表" })]));
expect(tableOnly.suggestColumns).toBe(true);
const withSchema = getSqlCompletionContext("SELECT * FROM 中文库.用户表 WHERE ", "SELECT * FROM 中文库.用户表 WHERE ".length, options);
expect(withSchema.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "中文库", name: "用户表" })]));
expect(withSchema.suggestColumns).toBe(true);
});
it("does not treat a non-ASCII 'from' phrase inside a string literal as a referenced table", () => {
const sql = "SELECT * FROM real_table WHERE note = 'from 测试表' AND ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "测试表")).toBe(false);
});
it("does not treat a non-ASCII 'from' phrase inside a line comment as a referenced table", () => {
const sql = "SELECT *\nFROM real_table\n-- from 测试表\nWHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "测试表")).toBe(false);
});
it("does not treat a 'from' phrase inside a block comment as a referenced table", () => {
const sql = "SELECT *\nFROM real_table\n/* from ghost_table */\nWHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "ghost_table")).toBe(false);
});
it("does not let a quote inside a line comment desync literal/comment masking", () => {
const sql = "SELECT * FROM real_table -- it's a comment\nWHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
});
it("does not let a comment-like sequence inside a string literal swallow real SQL", () => {
const sql = "SELECT * FROM t1 WHERE note = '-- not a comment' AND ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t1" })]));
});
it("does not match unquoted non-ASCII table names for ANSI-strict dialects", () => {
const sql = "SELECT * FROM 用户表 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "clickhouse" });
expect(context.referencedTables.some((t) => t.name === "用户表")).toBe(false);
});
it("masks a double-quoted value right after an operator from being read as a table reference (mysql)", () => {
const sql = 'SELECT * FROM real_table WHERE note = "from 测试表" AND ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "测试表")).toBe(false);
});
it("treats a double-quoted FROM target as a table reference regardless of MySQL's sql_mode (position-aware masking)", () => {
// sql_mode (and therefore whether "..." is ANSI_QUOTES identifier quoting or a string literal)
// isn't observable at parse time, so "..." right after FROM/JOIN/UPDATE/etc. is always treated
// as a potential table identifier -- matching ANSI_QUOTES-enabled MySQL -- while "..." elsewhere
// (function args, CASE branches, operator-adjacent values) is still masked as a value, matching
// MySQL's actual default sql_mode. See maskSqlLiteralsAndComments's doc comment.
const sql = 'SELECT * FROM "orders" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "orders" })]));
});
it("keeps resolving a double-quoted FROM target as a table when a comment sits between FROM and it (mysql)", () => {
const sql = 'SELECT * FROM /* c */ "orders" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "orders" })]));
});
it("resolves a double-quoted, dotted schema-qualified table reference (mysql)", () => {
const sql = 'SELECT * FROM "db"."orders" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "db", name: "orders" })]));
});
it("resolves both an unquoted and a double-quoted table across a JOIN, alongside a plain alias (mysql)", () => {
const sql = 'SELECT * FROM a JOIN "orders" o ON a.id = o.id WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "a" }), expect.objectContaining({ name: "orders", alias: "o" })]));
});
it("resolves a double-quoted UPDATE target as a referenced table while still masking a double-quoted value in SET (mysql)", () => {
const sql = 'UPDATE "orders" SET status = "x" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "orders" })]));
expect(context.referencedTables.some((t) => t.name === "x")).toBe(false);
});
it("does not desync the token stream on a backslash-escaped quote inside a double-quoted value (mysql)", () => {
const sql = 'SELECT * FROM real_table WHERE note = "she said \\"hi\\"" ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.preferredKeywords).toEqual(expect.arrayContaining(["AND", "OR"]));
});
it("does not desync the token stream on a backslash-escaped quote inside a double-quoted value (hive)", () => {
// Hive isn't in MYSQL_DASH_COMMENT_DIALECTS, so "..." here is (and was, before this fix) always
// a quoted_identifier, masked only via the operator-adjacency fallback -- this test isn't about
// that masking decision, it's about whether reading the "..." span itself (now always routed
// through readQuotedString, see tokens.ts) still finds the true closing quote for a
// mysqlBackslashEscape dialect outside MYSQL_DASH_COMMENT_DIALECTS. Before this fix, hive read
// "..." with the doubled-quote-only reader regardless of mysqlBackslashEscape, so this span
// would desync at the first backslash-escaped quote and corrupt everything parsed after it.
const sql = 'SELECT * FROM real_table WHERE note = "she said \\"hi\\"" ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "hive" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.preferredKeywords).toEqual(expect.arrayContaining(["AND", "OR"]));
});
it("masks a double-quoted string used as a function argument from being read as a table reference (mysql)", () => {
const sql = 'SELECT CONCAT("from ghost_table", name) FROM real_table WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "ghost_table")).toBe(false);
});
it("masks a double-quoted CASE branch value from being read as a table reference (mysql)", () => {
const sql = 'SELECT CASE WHEN enabled THEN "from ghost_case" ELSE "ok" END FROM real_table WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "ghost_case")).toBe(false);
});
it("masks a double-quoted string nested inside a parenthesized expression from being read as a table reference (mysql)", () => {
const sql = 'SELECT * FROM real_table WHERE (status = "open" AND note = CONCAT("from ghost_nested", 1)) AND ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "ghost_nested")).toBe(false);
});
it("masks a double-quoted value right after an operator from being read as a table reference (postgres)", () => {
const sql = 'SELECT * FROM real_table WHERE note = "from 测试表" AND ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "postgres" });
// Postgres treats "..." as a quoted identifier, not a string literal -- but a "..." span
// right after "=" is unambiguously a value position regardless of dialect, so this is masked
// the same way as mysql above, not left as a (harmless but avoidable) false-positive parse.
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "real_table" })]));
expect(context.referencedTables.some((t) => t.name === "测试表")).toBe(false);
});
it("keeps a double-quoted table name intact for dialects where they aren't string literals (postgres)", () => {
const sql = 'SELECT * FROM "orders" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "postgres" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "orders", nameQuoted: true })]));
});
it("does not let a MySQL backslash-escaped quote desync the literal scan", () => {
const sql = "SELECT * FROM t1 WHERE note = 'it\\'s a test' UNION SELECT * FROM real_table2 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t1" }), expect.objectContaining({ name: "real_table2" })]));
});
it("does not treat MySQL's unspaced '--' double-negation as a comment", () => {
const sql = "SELECT * FROM t1 WHERE x = 1--1 FROM real_table3 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t1" }), expect.objectContaining({ name: "real_table3" })]));
});
it("still treats a bare '--' as a comment for non-MySQL dialects", () => {
const sql = "SELECT * FROM t1 WHERE x = 1--1 FROM real_table3 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "postgres" });
expect(context.referencedTables.some((t) => t.name === "real_table3")).toBe(false);
});
it("does not let a doubled single-quote escape desync the literal scan", () => {
const sql = "SELECT * FROM t1 WHERE note = 'it''s a test' AND ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t1" })]));
});
it("does not let a backslash-escaped quote desync the literal scan for MySQL-family dialects outside the dash-comment allowlist (hive)", () => {
const sql = "SELECT * FROM t1 WHERE note = 'it\\'s a test' UNION SELECT * FROM real_table2 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "hive" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t1" }), expect.objectContaining({ name: "real_table2" })]));
});
it("defaults to ASCII-only unquoted identifiers when databaseType is not yet known", () => {
const sql = "SELECT * FROM 用户表 WHERE ";
const context = getSqlCompletionContext(sql, sql.length, {});
expect(context.referencedTables.some((t) => t.name === "用户表")).toBe(false);
});
it("does not let a special character inside a double-quoted qualified table name desync the scan (postgres)", () => {
const sql = 'SELECT * FROM "mydb"."orders" WHERE ';
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "postgres" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "mydb", name: "orders" })]));
});
it("does not let a special character inside a backtick-quoted qualified table name desync the scan (mysql)", () => {
const sql = "SELECT * FROM `mydb`.`orders` WHERE ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "mydb", name: "orders" })]));
});
it("auto-opens column completion after WHERE whitespace before LIMIT", () => {
const sql = "SELECT *\nFROM t_0001 AS t0 WHERE \nLIMIT 100;";
const cursor = "SELECT *\nFROM t_0001 AS t0 WHERE ".length;
const context = getSqlCompletionContext(sql, cursor);
expect(context.contextKind).toBe("column");
expect(context.prefix).toBe("");
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "t_0001", alias: "t0" })]));
expect(context.suggestColumns).toBe(true);
expect(shouldAutoOpenSqlCompletion(sql, cursor)).toBe(true);
});
it("does not let a trailing comment's stray 'and'/'or' text suppress the AND/OR keyword suggestion", () => {
const sql = "SELECT * FROM t WHERE id = 1 # mentions where and or\n";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.preferredKeywords).toEqual(expect.arrayContaining(["AND", "OR"]));
});
it("does not let a special character inside a backtick-quoted table name break WHERE-clause keyword detection", () => {
const sql = "SELECT * FROM `my's table` t WHERE t.id = 1 ";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" });
expect(context.preferredKeywords).toEqual(expect.arrayContaining(["AND", "OR"]));
});
it("does not let a trailing comment's stray 'where' text auto-open column completion with no active clause", () => {
const sql = "SELECT * FROM t_0001 t0 -- mentions where\n";
const cursor = sql.length;
expect(shouldAutoOpenSqlCompletion(sql, cursor, { databaseType: "mysql" })).toBe(false);
});
it("classifies CALL routine contexts", () => {
const sql = "CALL usp_";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("exec");
expect(context.suggestRoutines).toBe(true);
expect(context.exclusiveRoutineSuggestions).toBe(true);
});
it("classifies INSERT column-list contexts", () => {
const sql = "INSERT INTO dbo.Users (";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.insertSchema).toBe("dbo");
expect(context.insertTable).toBe("Users");
expect(context.exclusiveColumnSuggestions).toBe(true);
});
it("classifies UPDATE SET column contexts", () => {
const sql = "UPDATE dbo.Users SET ";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.contextKind).toBe("column");
expect(context.updateTarget).toEqual({ schema: "dbo", table: "Users" });
expect(context.suggestColumns).toBe(true);
});
it("extracts statement-local table aliases", () => {
const sql = "SELECT * FROM dbo.Users u JOIN Orders AS o ON o.user_id = u.id WHERE u.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ schema: "dbo", name: "Users", alias: "u" }), expect.objectContaining({ name: "Orders", alias: "o" })]));
});
it("preserves SQL Server database and omitted schema in legacy table references", () => {
const sql = "SELECT * FROM BarDB..orders AS o WHERE o.";
const context = getSqlCompletionContext(sql, sql.length, { databaseType: "sqlserver" });
expect(context.referencedTables).toEqual([expect.objectContaining({ database: "BarDB", schema: "dbo", name: "orders", alias: "o" })]);
});
it("treats schema-qualified table prefixes in FROM as table completion input", () => {
const sql = "SELECT * FROM dws_game_sdk_base.di";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.qualifier).toBe("dws_game_sdk_base");
expect(context.prefix).toBe("di");
expect(context.suggestTables).toBe(true);
expect(context.exclusiveTableSuggestions).toBe(true);
expect(context.suggestColumns).toBe(true);
});
it("exposes CTEs as table-like referenced tables", () => {
const sql = "WITH recent_orders(id, total) AS (SELECT id, total FROM orders) SELECT * FROM recent_orders ro WHERE ro.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recent_orders", columns: ["id", "total"] }), expect.objectContaining({ name: "recent_orders", alias: "ro" })]));
});
it("scopes CTE bodies out of the outer query's referenced tables", () => {
const sql = "WITH cte AS (SELECT id, name FROM orders) SELECT id, na FROM cte";
const cursor = sql.indexOf(" FROM cte");
const context = getSqlCompletionContext(sql, cursor);
expect(context.referencedTables.map((table) => table.name)).toEqual(["cte"]);
});
it("suggests bare CTE column names instead of forcing a cte. qualifier (issue #7396)", () => {
const sql = "WITH cte AS (SELECT id, name FROM orders) SELECT id, na FROM cte";
const cursor = sql.indexOf(" FROM cte");
const items = buildSqlCompletionItems(sql, cursor, {
tables: [],
columnsByTable: new Map([
[
"orders",
[
{ name: "id", table: "orders", dataType: "int" },
{ name: "name", table: "orders", dataType: "text" },
],
],
[
"cte",
[
{ name: "id", table: "cte" },
{ name: "name", table: "cte" },
],
],
]),
});
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "name", type: "column" })]));
expect(items.filter((item) => item.type === "column").map((item) => item.label)).not.toContain("orders.name");
});
it("keeps the CTE body's own tables while completing inside that body", () => {
const sql = "WITH cte AS (SELECT id, na FROM orders) SELECT * FROM cte";
const cursor = sql.indexOf(" FROM orders");
const context = getSqlCompletionContext(sql, cursor);
expect(context.referencedTables.map((table) => table.name)).toEqual(expect.arrayContaining(["orders", "cte"]));
});
it("keeps the underlying table when a CTE body projects an unresolved star", () => {
const sql = "WITH cte AS (SELECT * FROM orders) SELECT na FROM cte";
const cursor = sql.indexOf(" FROM cte");
const context = getSqlCompletionContext(sql, cursor);
expect(context.referencedTables.map((table) => table.name)).toEqual(expect.arrayContaining(["orders", "cte"]));
});
it("extracts subquery aliases and projected columns", () => {
const sql = "SELECT * FROM (SELECT id, name AS user_name FROM users) sq WHERE sq.";
const context = getSqlCompletionContext(sql, sql.length);
expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "sq", alias: "sq", columns: ["id", "user_name"] })]));
});
it("suggests columns for cross-database qualified table references", () => {
const sql = "SELECT * FROM current_orders WHERE reporting.orders.";
const context = getSqlCompletionContext(sql, sql.length);
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [],
columnsByTable: new Map([
[
"reporting.orders",
[
{ name: "id", table: "orders", schema: "reporting", dataType: "int" },
{ name: "status", table: "orders", schema: "reporting", dataType: "varchar" },
],
],
["archive.orders", [{ name: "archived_at", table: "orders", schema: "archive", dataType: "datetime" }]],
]),
});
expect(context.qualifier).toBe("reporting.orders");
expect(context.qualifierParts).toEqual(["reporting", "orders"]);
expect(context.suggestColumns).toBe(true);
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "id", type: "column" }), expect.objectContaining({ label: "status", type: "column" })]));
expect(items.some((item) => item.label === "archived_at")).toBe(false);
});
});
describe("sqlCompletion scoped metadata ranking", () => {
it("shows a table metadata detail in the completion item", () => {
const sql = "SELECT * FROM orders";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [{ name: "orders", type: "table", detail: "→ Customer orders" }],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(items).toEqual([expect.objectContaining({ label: "orders", detail: "→ Customer orders" })]);
});
it("hides the redundant table type while preserving view and schema details", () => {
const simpleItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, {
tables: [{ name: "orders", schema: "public", type: "table" }],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(simpleItems.find((item) => item.label === "orders")?.detail).toBeUndefined();
const viewItems = buildSqlCompletionItems("SELECT * FROM order_view", "SELECT * FROM order_view".length, {
tables: [{ name: "order_view", schema: "public", type: "view" }],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(viewItems.find((item) => item.label === "order_view")?.detail).toBe("view");
const duplicateItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, {
tables: [
{ name: "orders", schema: "archive", type: "table" },
{ name: "orders", schema: "sales", type: "table" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(duplicateItems.map((item) => item.detail).sort()).toEqual(["archive.orders", "sales.orders"]);
const annotatedDuplicateItems = buildSqlCompletionItems("SELECT * FROM orders", "SELECT * FROM orders".length, {
tables: [
{ name: "orders", schema: "archive", type: "table", detail: "→ Archived orders" },
{ name: "orders", schema: "sales", type: "table", detail: "→ Current orders" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(annotatedDuplicateItems.map((item) => item.detail).sort()).toEqual(["archive.orders → Archived orders", "sales.orders → Current orders"]);
});
it("ranks exact and prefix table matches ahead of contains/fuzzy matches", () => {
const sql = "SELECT * FROM Temp";
const items = buildSqlCompletionItems(sql, sql.length, {
dialect: "sqlserver",
tables: [
{ name: "ArchiveTempTable", schema: "dbo", type: "table" },
{ name: "TempAudit", schema: "dbo", type: "table" },
{ name: "Temp", schema: "dbo", type: "table" },
{ name: "Template", schema: "dbo", type: "table" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(items.map((item) => item.label).slice(0, 3)).toEqual(["Temp", "Template", "TempAudit"]);
expect(items.some((item) => item.label === "ArchiveTempTable")).toBe(true);
});
it("keeps large table catalogs bounded", () => {
const tables = Array.from({ length: 500 }, (_, index) => ({ name: `TempTable_${String(index).padStart(3, "0")}`, schema: "dbo", type: "table" as const }));
const sql = "SELECT * FROM Temp";
const items = buildSqlCompletionItems(sql, sql.length, { dialect: "sqlserver", tables, columnsByTable: new Map() }).filter((item) => item.type === "table");
expect(items.length).toBeLessThanOrEqual(200);
expect(items[0]?.label).toBe("TempTable_000");
});
it("ranks real Oracle tables before built-in table functions in FROM contexts", () => {
const sql = "SELECT * FROM ";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "oracle",
tables: [{ name: "ORDERS_10K", schema: "DBX_TEST", type: "table" }],
columnsByTable: new Map(),
});
expect(items.findIndex((item) => item.label === "ORDERS_10K")).toBeLessThan(items.findIndex((item) => item.label === "TABLE"));
});
it("qualifies same-name PostgreSQL tables from different schemas", () => {
const sql = "SELECT * FROM shared";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "postgres",
dialect: "postgres",
tables: [
{ name: "shared", schema: "public", type: "table" },
{ name: "shared", schema: "reporting", type: "table" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(items).toHaveLength(2);
expect(items.map((item) => item.apply).sort()).toEqual(["public.shared", "reporting.shared"]);
});
it("qualifies same-name tables for generic metadata providers", () => {
const sql = "SELECT * FROM orders";
const items = buildSqlCompletionItems(sql, sql.length, {
tables: [
{ name: "orders", schema: "archive", type: "table" },
{ name: "orders", schema: "sales", type: "table" },
],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(items.map((item) => item.apply).sort()).toEqual(["archive.orders", "sales.orders"]);
});
it("preserves Oracle current-schema and SQL Server unique-table insertion", () => {
const oracleItems = buildSqlCompletionItems("SELECT * FROM ORDERS", "SELECT * FROM ORDERS".length, {
databaseType: "oracle",
tables: [
{ name: "ORDERS", schema: "APP", type: "table" },
{ name: "ORDERS", schema: "REPORTING", type: "table" },
],
columnsByTable: new Map(),
currentSchema: "APP",
}).filter((item) => item.type === "table");
const sqlServerItems = buildSqlCompletionItems("SELECT * FROM Orders", "SELECT * FROM Orders".length, {
databaseType: "sqlserver",
dialect: "sqlserver",
tables: [{ name: "Orders", schema: "dbo", type: "table" }],
columnsByTable: new Map(),
}).filter((item) => item.type === "table");
expect(oracleItems.map((item) => item.apply).sort()).toEqual(["ORDERS", "REPORTING.ORDERS"]);
expect(sqlServerItems).toEqual([expect.objectContaining({ label: "Orders", apply: "Orders" })]);
});
});
describe("shouldAllowSqlCompletionTrigger", () => {
const typingFacts = (overrides: Partial<SqlCompletionTriggerFacts> = {}): SqlCompletionTriggerFacts => ({
origin: "typing",
hasIdentifierPrefix: false,
qualifierTriggered: false,
useDatabasePrefix: null,
...overrides,
});
const explicitFacts = (overrides: Partial<SqlCompletionTriggerFacts> = {}): SqlCompletionTriggerFacts => ({
origin: "explicit",
hasIdentifierPrefix: false,
qualifierTriggered: false,
useDatabasePrefix: null,
...overrides,
});
describe("explicit", () => {
it("allows explicit completion in any mode", () => {
expect(shouldAllowSqlCompletionTrigger("manual", explicitFacts())).toBe(true);
expect(shouldAllowSqlCompletionTrigger("require-prefix", explicitFacts())).toBe(true);
expect(shouldAllowSqlCompletionTrigger("positional", explicitFacts())).toBe(true);
});
});
describe("manual", () => {
it("rejects all typing completions", () => {
expect(shouldAllowSqlCompletionTrigger("manual", typingFacts())).toBe(false);
expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ hasIdentifierPrefix: true }))).toBe(false);
expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ qualifierTriggered: true }))).toBe(false);
expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ useDatabasePrefix: "m" }))).toBe(false);
expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ positionalEligible: true }))).toBe(false);
});
});
describe("require-prefix", () => {
it("allows when identifier prefix is non-empty", () => {
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ hasIdentifierPrefix: true }))).toBe(true);
});
it("allows when qualifier is triggered (dot with qualifier)", () => {
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ qualifierTriggered: true }))).toBe(true);
});
it("allows when useDatabasePrefix is non-empty", () => {
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "m" }))).toBe(true);
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "Bar" }))).toBe(true);
});
it("rejects empty prefix, no qualifier, no useDatabasePrefix", () => {
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts())).toBe(false);
});
it("rejects empty useDatabasePrefix (USE<space> without prefix)", () => {
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "" }))).toBe(false);
});
it("does not use positionalEligible", () => {
// Even if positionalEligible is true, require-prefix ignores it.
expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ positionalEligible: true }))).toBe(false);
});
});
describe("positional", () => {
it("allows when positionalEligible is true", () => {
expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ positionalEligible: true }))).toBe(true);
});
it("allows when useDatabasePrefix is set (even empty)", () => {
expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ useDatabasePrefix: "" }))).toBe(true);
expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ useDatabasePrefix: "m" }))).toBe(true);
});
it("rejects when positionalEligible is false and no useDatabasePrefix", () => {
expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ positionalEligible: false }))).toBe(false);
});
it("rejects when positionalEligible is undefined and no useDatabasePrefix", () => {
expect(shouldAllowSqlCompletionTrigger("positional", typingFacts())).toBe(false);
});
});
});
describe("originForTypedSqlCompletionStart", () => {
it("starts a new automatic session as typing", () => {
expect(originForTypedSqlCompletionStart(null)).toBe("typing");
});
it("preserves the origin of an active completion session", () => {
expect(originForTypedSqlCompletionStart("typing")).toBe("typing");
expect(originForTypedSqlCompletionStart("explicit")).toBe("explicit");
});
});
describe("originForSqlCompletionProvider", () => {
it("classifies an unmarked provider call from CodeMirror", () => {
expect(originForSqlCompletionProvider(null, false)).toBe("typing");
expect(originForSqlCompletionProvider(null, true)).toBe("explicit");
});
it("preserves the active session independently of the current provider flag", () => {
expect(originForSqlCompletionProvider("typing", true)).toBe("typing");
expect(originForSqlCompletionProvider("explicit", false)).toBe("explicit");
});
});