Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/sql/__tests__/semantic-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,6 +66,31 @@ describe("sqlSemanticLinter", () => {
});
});

describe("quoted column qualifiers", () => {
it.each([
Comment thread
Light2Dark marked this conversation as resolved.
['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"],
['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 = { users: ["id"], "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";
Expand Down
24 changes: 14 additions & 10 deletions src/sql/semantic-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
Light2Dark marked this conversation as resolved.
(segment): segment is string => typeof segment === "string" && segment.length > 0,
);
}

/** Extracts column names when the namespace node is a column array */
Expand Down Expand Up @@ -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;
Expand Down
Loading