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" })])); }); it("offers DuckDB-specific query and statement keywords", () => { const expectedKeywords = ["QUALIFY", "SUMMARIZE", "PIVOT", "UNPIVOT", "ASOF", "POSITIONAL", "FROM"]; for (const keyword of expectedKeywords) { const prefix = keyword.slice(0, 4); const sql = keyword === "FROM" || keyword === "SUMMARIZE" ? prefix : `SELECT * FROM items ${prefix}`; const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: new Map(), databaseType: "duckdb", }); expect(items, keyword).toEqual(expect.arrayContaining([expect.objectContaining({ label: keyword, type: "keyword" })])); } }); it("does not expose DuckDB-only keywords to other databases", () => { const sql = "SELECT * FROM items qua"; const items = buildSqlCompletionItems(sql, sql.length, { tables: [], columnsByTable: new Map(), databaseType: "postgres", }); expect(items.some((item) => item.label === "QUALIFY")).toBe(false); }); }); 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("never adds generated aliases to Cassandra table completions", () => { const sql = "SELECT * FROM ord"; const items = buildSqlCompletionItems(sql, sql.length, { tables: [{ name: "order_items", type: "table" }], columnsByTable: new Map(), databaseType: "cassandra", autoAliasTables: true, }); const table = items.find((item) => item.label === "order_items" && item.type === "table"); expect(table?.apply).toBe("order_items"); }); it("does not suggest table aliases for Cassandra", () => { const sql = "SELECT * FROM order_items "; const items = buildSqlCompletionItems(sql, sql.length, { tables: [{ name: "order_items", type: "table" }], columnsByTable: new Map(), databaseType: "cassandra", }); expect(items.some((item) => item.type === "snippet" && item.detail === "alias for order_items")).toBe(false); }); 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 => ({ origin: "typing", hasIdentifierPrefix: false, qualifierTriggered: false, useDatabasePrefix: null, ...overrides, }); const explicitFacts = (overrides: Partial = {}): 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 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"); }); }); describe("select alias visibility", () => { it("prioritizes SELECT aliases inside HAVING for MySQL", () => { const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql", dialect: "mysql" }); expect(context.prioritizeSelectAliases).toBe(true); expect(context.selectAliases).toContain("cnt"); }); it("prioritizes SELECT aliases inside ORDER BY even with later clauses", () => { const sql = "SELECT SUM(price) AS total FROM orders ORDER BY total LIMIT 10"; const cursor = sql.indexOf("total", sql.indexOf("ORDER BY")) + "total".length; const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql", dialect: "mysql" }); expect(context.prioritizeSelectAliases).toBe(true); expect(context.selectAliases).toContain("total"); }); it("does not prioritize SELECT aliases inside WHERE", () => { const sql = "SELECT id AS uid FROM orders WHERE uid > "; const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql", dialect: "mysql" }); expect(context.prioritizeSelectAliases).toBe(false); expect(context.selectAliases).toEqual([]); }); it("offers HAVING alias completion items", () => { const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cn"; const items = buildSqlCompletionItems(sql, sql.length, { dialect: "mysql", tables: [{ name: "orders", type: "table" }], columnsByTable: new Map([["orders", [{ name: "g" } as never]]]), }); expect(items.some((item) => item.label === "cnt")).toBe(true); }); it("does not prioritize HAVING aliases for dialects that reject them", () => { const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; for (const databaseType of ["postgres", "sqlserver", "db2", "oracle"] as const) { const context = getSqlCompletionContext(sql, sql.length, { databaseType }); expect(context.prioritizeSelectAliases, databaseType).toBe(false); expect(context.selectAliases, databaseType).toEqual([]); } }); it("keeps HAVING alias priority for SQLite-family and DuckDB dialects", () => { const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; for (const databaseType of ["duckdb", "sqlite", "doris", "bigquery"] as const) { const context = getSqlCompletionContext(sql, sql.length, { databaseType }); expect(context.prioritizeSelectAliases, databaseType).toBe(true); expect(context.selectAliases, databaseType).toContain("cnt"); } }); it("does not anchor alias visibility on identifiers containing HAVING", () => { const sql = "SELECT id AS uid FROM orders WHERE having_count > 1"; const context = getSqlCompletionContext(sql, sql.length, { databaseType: "mysql" }); expect(context.prioritizeSelectAliases).toBe(false); expect(context.selectAliases).toEqual([]); }); it("keeps alias visibility in ORDER BY after an identifier containing HAVING", () => { const sql = "SELECT id AS uid FROM orders WHERE having_count > 1 ORDER BY uid"; const cursor = sql.length; const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql" }); expect(context.prioritizeSelectAliases).toBe(true); expect(context.selectAliases).toContain("uid"); }); it("keeps HAVING alias priority for unlisted dialects (deny-list polarity)", () => { const sql = "SELECT COUNT(*) AS cnt, g FROM orders GROUP BY g HAVING cnt > "; for (const databaseType of ["spark", "databricks", "snowflake", "hive"] as const) { const context = getSqlCompletionContext(sql, sql.length, { databaseType }); expect(context.prioritizeSelectAliases, databaseType).toBe(true); expect(context.selectAliases, databaseType).toContain("cnt"); } // No database type at all keeps the permissive legacy behavior. const untyped = getSqlCompletionContext(sql, sql.length, {}); expect(untyped.prioritizeSelectAliases).toBe(true); expect(untyped.selectAliases).toContain("cnt"); }); });