fix(completion): mask strings and comments in clause detection

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-27 09:21:54 +08:00
1 parent caaa8932a1
commit 4a3d63b09a
4 files changed
+530 -45

No files matched your search

@@ -730,6 +730,207 @@ describe("sqlCompletion scoped context classification", () => {
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;
@@ -742,6 +943,24 @@ describe("sqlCompletion scoped context classification", () => {
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);