dbx/apps/desktop/src/lib/__tests__/sqlSemanticModel.spec.ts

157 lines
9.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns } from "@/lib/sqlSemanticCompletion";
import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sqlSemanticFixtures";
import { buildSqlSemanticModel } from "@/lib/sqlSemanticModel";
describe("sqlSemanticModel baseline fixtures", () => {
for (const fixture of SQL_SEMANTIC_BASELINE_FIXTURES) {
it(fixture.name, () => {
const { sql, cursor } = sqlFixtureCursor(fixture.sql);
const model = buildSqlSemanticModel(sql, cursor, { databaseType: fixture.databaseType });
const scope = sqlSemanticCompletionScope(model);
expect(model.statement.kind).toBe(fixture.expected.statementKind);
expect(model.cursorIntent.kind).toBe(fixture.expected.cursorKind);
expect(scope.kind).toBe(fixture.expected.completionScope);
expect(model.cursorIntent.prefix).toBe(fixture.expected.prefix);
expect(model.cursorIntent.qualifierParts).toEqual(fixture.expected.qualifierParts ?? []);
expect(model.cursorIntent.confidence).toBe(fixture.expected.confidence);
for (const expectedSource of fixture.expected.rowSources ?? []) {
const expectedObject = Object.fromEntries(Object.entries(expectedSource).filter(([, value]) => value !== undefined));
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining(expectedObject)]));
}
if (fixture.expected.completionLabels) {
const labels = [...sqlSemanticLocalColumnsByTable(model).values()].flat().map((column) => column.name);
expect(labels).toEqual(expect.arrayContaining(fixture.expected.completionLabels));
}
});
}
it("does not mix row sources from inactive statements", () => {
const { sql, cursor } = sqlFixtureCursor("select * from users u; select * from orders o where o.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources.some((source) => source.name === "orders")).toBe(true);
expect(model.rowSources.some((source) => source.name === "users")).toBe(false);
});
it("does not expose CTE body tables as outer query row sources", () => {
const { sql, cursor } = sqlFixtureCursor("WITH recent_orders AS (SELECT id FROM orders) SELECT * FROM recent_orders ro WHERE ro.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources.some((source) => source.name === "recent_orders")).toBe(true);
expect(model.rowSources.some((source) => source.name === "orders")).toBe(false);
});
it("does not expose subquery body tables as outer query row sources", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM (SELECT id FROM users) sq WHERE sq.|");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "sq", kind: "subquery" })]));
expect(model.rowSources.some((source) => source.name === "users")).toBe(false);
});
it("suppresses completion inside string literals without metadata scope", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT 'u.|' FROM users");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("suppressed");
expect(scope.useRemoteMetadata).toBe(false);
});
it("classifies table references after comma-separated table lists", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM users u, ord|");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("table");
expect(model.cursorIntent.prefix).toBe("ord");
expect(scope.kind).toBe("table");
});
it("classifies alias-qualified star with replacement range", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT u.*| FROM users u");
const model = buildSqlSemanticModel(sql, cursor);
expect(model.cursorIntent.kind).toBe("star");
expect(model.cursorIntent.prefix).toBe("*");
expect(model.cursorIntent.qualifierParts).toEqual(["u"]);
expect(sql.slice(model.cursorIntent.replacementRange.start, model.cursorIntent.replacementRange.end)).toBe("*");
});
it("returns low-confidence keyword fallback for unknown SQL", () => {
const { sql, cursor } = sqlFixtureCursor("explain analyze |");
const model = buildSqlSemanticModel(sql, cursor);
const scope = sqlSemanticCompletionScope(model);
expect(model.cursorIntent.kind).toBe("keyword");
expect(model.cursorIntent.confidence).toBe("low");
expect(scope.useRemoteMetadata).toBe(false);
});
it("exposes PostgreSQL projection aliases in ORDER BY but not WHERE", () => {
const orderBy = sqlFixtureCursor("select total_amount as total from orders order by to|");
const where = sqlFixtureCursor("select total_amount as total from orders where to|");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(orderBy.sql, orderBy.cursor, { databaseType: "postgres" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(where.sql, where.cursor, { databaseType: "postgres" })).map((column) => column.name)).not.toContain("total");
});
it("exposes MySQL projection aliases in GROUP BY and HAVING", () => {
const groupBy = sqlFixtureCursor("select total_amount as total from orders group by to|");
const having = sqlFixtureCursor("select total_amount as total from orders having to|");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(having.sql, having.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
});
it("keeps dialect-specific identifier normalization and qualifier scopes", () => {
const sqlServer = sqlFixtureCursor("SELECT * FROM [dbo].[Users] u WHERE u.|");
const postgres = sqlFixtureCursor('SELECT total AS "Order Total" FROM "Sales"."Orders" o ORDER BY "Order|');
const mysql = sqlFixtureCursor("SELECT * FROM `analytics`.`events` e WHERE e.|");
const sqlite = sqlFixtureCursor("SELECT * FROM main.users u WHERE u.|");
expect(buildSqlSemanticModel(sqlServer.sql, sqlServer.cursor, { databaseType: "sqlserver" }).rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["dbo"], alias: "u" }));
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(postgres.sql, postgres.cursor, { databaseType: "postgres" })).map((column) => column.name)).toContain("Order Total");
expect(buildSqlSemanticModel(mysql.sql, mysql.cursor, { databaseType: "mysql" }).rowSources[0]).toEqual(expect.objectContaining({ name: "events", qualifierParts: ["analytics"], alias: "e" }));
expect(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" }).rowSources[0]).toEqual(expect.objectContaining({ name: "users", qualifierParts: ["main"], alias: "u" }));
});
it("covers SQL Server case-insensitive bracket and multi-part qualifier contexts", () => {
const { sql, cursor } = sqlFixtureCursor("SELECT * FROM [ServerOne].[AppDb].[dbo].[Users] U WHERE u.na|");
const model = buildSqlSemanticModel(sql, cursor, { databaseType: "sqlserver" });
expect(model.rowSources[0]).toEqual(expect.objectContaining({ name: "Users", qualifierParts: ["ServerOne", "AppDb", "dbo"], alias: "U" }));
expect(model.cursorIntent.kind).toBe("alias_column");
expect(model.cursorIntent.qualifierParts).toEqual(["u"]);
});
it("covers PostgreSQL lower-case folding with CTEs and ORDER BY projection aliases", () => {
const { sql, cursor } = sqlFixtureCursor("WITH RecentOrders AS (SELECT id, total FROM orders) SELECT total AS total_alias FROM RecentOrders ro ORDER BY total_|");
const model = buildSqlSemanticModel(sql, cursor, { databaseType: "postgres" });
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "recentorders", alias: "ro", columns: ["id", "total"] })]));
expect(sqlSemanticProjectionAliasColumns(model).map((column) => column.name)).toContain("total_alias");
});
it("covers MySQL database-qualified backticks and projection alias visibility", () => {
const groupBy = sqlFixtureCursor("SELECT amount AS total FROM `analytics`.`events` e GROUP BY to|");
const where = sqlFixtureCursor("SELECT amount AS total FROM `analytics`.`events` e WHERE to|");
expect(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" }).rowSources[0]).toEqual(expect.objectContaining({ name: "events", qualifierParts: ["analytics"], alias: "e" }));
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(groupBy.sql, groupBy.cursor, { databaseType: "mysql" })).map((column) => column.name)).toContain("total");
expect(sqlSemanticProjectionAliasColumns(buildSqlSemanticModel(where.sql, where.cursor, { databaseType: "mysql" })).map((column) => column.name)).not.toContain("total");
});
it("covers SQLite and DuckDB schema-light local row-source behavior", () => {
const sqlite = sqlFixtureCursor("SELECT * FROM main.users u WHERE u.|");
const duckdb = sqlFixtureCursor("SELECT * FROM read_csv('users.csv') csv WHERE csv.|");
expect(sqlSemanticCompletionScope(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" })).useRemoteMetadata).toBe(true);
expect(buildSqlSemanticModel(duckdb.sql, duckdb.cursor, { databaseType: "duckdb" }).rowSources[0]).toEqual(expect.objectContaining({ kind: "table_function", name: "csv", alias: "csv" }));
});
});