From 53bbcc9db6db51fdf9437f40a85b479e367c3387 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Thu, 15 Oct 2026 11:30:57 +0800 Subject: [PATCH 1/2] fix: distinguish quoted column qualifiers --- .../__tests__/semantic-diagnostics.test.ts | 24 +++++++++++++++++++ src/sql/semantic-diagnostics.ts | 24 +++++++++++-------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/sql/__tests__/semantic-diagnostics.test.ts b/src/sql/__tests__/semantic-diagnostics.test.ts index e982d77..540778b 100644 --- a/src/sql/__tests__/semantic-diagnostics.test.ts +++ b/src/sql/__tests__/semantic-diagnostics.test.ts @@ -2,6 +2,7 @@ import { type Diagnostic, forceLinting, forEachDiagnostic } from "@codemirror/li import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it, vi } from "vitest"; +import { NodeSqlParser } from "../parser.js"; import { sqlSchemaFacet } from "../schema-facet.js"; import { exportedForTesting, @@ -65,6 +66,29 @@ describe("sqlSemanticLinter", () => { }); }); +describe("quoted column qualifiers", () => { + it.each([ + ['SELECT "my.table".id FROM my."table" CROSS JOIN "my.table"', null], + ['SELECT my."table".other_column FROM "my.table" CROSS JOIN my."table"', null], + ['SELECT "my.table".other_column FROM my."table" CROSS JOIN "my.table"', "other_column"], + ['SELECT my."table".id FROM "my.table" CROSS JOIN my."table"', "id"], + ])("distinguishes quoted and qualified names in %s", async (doc, unknownColumn) => { + const schema = { "my.table": ["id"], my: { table: ["other_column"] } }; + const parser = new NodeSqlParser({ + getParserOptions: () => ({ database: "PostgreSQL" }), + }); + const parsed = await parser.parse(doc, { state: EditorState.create({ doc }) }); + expect(parsed.success).toBe(true); + const diagnostics = await lint(doc, { schema, parser }); + if (unknownColumn === null) { + expect(diagnostics).toEqual([]); + } else { + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toContain(`Column '${unknownColumn}' not found`); + } + }); +}); + describe("unknown tables", () => { it("flags a table missing from the schema", async () => { const doc = "SELECT * FROM usres"; diff --git a/src/sql/semantic-diagnostics.ts b/src/sql/semantic-diagnostics.ts index 5b551a0..7df8c76 100644 --- a/src/sql/semantic-diagnostics.ts +++ b/src/sql/semantic-diagnostics.ts @@ -404,14 +404,13 @@ function getColumnName(ref: AstNode): string | null { return null; } -function getColumnQualifier(ref: AstNode): string | null { +function getColumnQualifier(ref: AstNode): readonly string[] | null { if (typeof ref.table !== "string" || ref.table.length === 0) { return null; } - if (typeof ref.db === "string" && ref.db.length > 0) { - return `${ref.db}.${ref.table}`; - } - return ref.table; + return [ref.db, ref.schema, ref.table].filter( + (segment): segment is string => typeof segment === "string" && segment.length > 0, + ); } /** Extracts column names when the namespace node is a column array */ @@ -519,20 +518,25 @@ function analyzeStatementAst(ast: AstNode, schema: SQLNamespace): SemanticFindin const qualifier = getColumnQualifier(ref); if (qualifier) { - const lowerQualifier = qualifier.toLowerCase(); + const segments = qualifier.map((segment) => segment.toLowerCase()); + const unqualifiedName = segments.length === 1 ? segments[0] : undefined; + const aliasMatch = unqualifiedName + ? resolved.find((entry) => entry.source.alias?.toLowerCase() === unqualifiedName) + : undefined; const match = - resolved.find((entry) => entry.source.alias?.toLowerCase() === lowerQualifier) ?? + aliasMatch ?? resolved.find( (entry) => - entry.source.name.toLowerCase() === lowerQualifier || - entry.source.path.join(".").toLowerCase() === lowerQualifier, + entry.source.name.toLowerCase() === unqualifiedName || + (entry.source.path.length === segments.length && + entry.source.path.every((segment, i) => segment.toLowerCase() === segments[i])), ); // An unresolved qualifier may be an outer-scope alias; skip it if (match?.columns && !includesIgnoreCase(match.columns, name)) { addFinding({ kind: "unknownColumn", identifier: name, - message: `Column '${name}' not found in table '${match.source.path.join(".") || qualifier}'`, + message: `Column '${name}' not found in table '${match.source.path.join(".") || qualifier.join(".")}'`, }); } continue; From cd94061cf43b0e336afc62d55f59ee5623de60ac Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Thu, 15 Oct 2026 11:52:50 +0800 Subject: [PATCH 2/2] test: cover quoted alias qualifier collisions --- src/sql/__tests__/semantic-diagnostics.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sql/__tests__/semantic-diagnostics.test.ts b/src/sql/__tests__/semantic-diagnostics.test.ts index 540778b..00d64a6 100644 --- a/src/sql/__tests__/semantic-diagnostics.test.ts +++ b/src/sql/__tests__/semantic-diagnostics.test.ts @@ -72,8 +72,10 @@ describe("quoted column qualifiers", () => { ['SELECT my."table".other_column FROM "my.table" CROSS JOIN my."table"', null], ['SELECT "my.table".other_column FROM my."table" CROSS JOIN "my.table"', "other_column"], ['SELECT my."table".id FROM "my.table" CROSS JOIN my."table"', "id"], + ['SELECT "my.table".id FROM my."table" CROSS JOIN users AS "my.table"', null], + ['SELECT my."table".other_column FROM users AS "my.table" CROSS JOIN my."table"', null], ])("distinguishes quoted and qualified names in %s", async (doc, unknownColumn) => { - const schema = { "my.table": ["id"], my: { table: ["other_column"] } }; + const schema = { users: ["id"], "my.table": ["id"], my: { table: ["other_column"] } }; const parser = new NodeSqlParser({ getParserOptions: () => ({ database: "PostgreSQL" }), });