Files
dbx/apps/desktop/src/lib/__tests__/sql/groupByColumnMapping.spec.ts
T

176 lines
9.2 KiB
TypeScript
Vendored

import { describe, expect, it } from "vitest";
import { analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveSourceColumnsByOrdinal, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
/**
* Mirrors queryStore.editableQuerySources: single-source analyses store the
* source on the analysis root, so tests normalize it exactly like the store
* before resolving result columns by ordinal.
*/
function displayTableSources(analysis: EditableQueryInfo, columns: readonly string[]): Array<{ source: EditableQuerySource; columns: { name: string }[] }> {
const source: EditableQuerySource = analysis.sources?.length
? analysis.sources[0]!
: {
key: `${analysis.tableAlias ?? analysis.tableName}:0`,
catalog: analysis.catalog,
catalogQuoted: analysis.catalogQuoted,
schema: analysis.schema,
schemaQuoted: analysis.schemaQuoted,
tableName: analysis.tableName,
tableNameQuoted: analysis.tableNameQuoted,
alias: analysis.tableAlias,
};
return [{ source, columns: columns.map((name) => ({ name })) }];
}
/**
* GROUP BY / HAVING queries stay read-only (`aggregation`) but still expose a
* display-only structural analysis so the result grid can resolve comments for
* result columns that map directly back to a base-table column. Aggregate,
* computed and ambiguous projections must stay unresolved (no guessed comment).
*/
describe("grouped query display column mapping", () => {
describe("display structure analysis", () => {
it("parses projections and sources for a GROUP BY query without making it editable", () => {
const editability = analyzeEditableQueryEditability("SELECT department, COUNT(*) AS total FROM users GROUP BY department");
expect(editability).toEqual({ editable: false, reason: "aggregation" });
const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.tableName).toBe("users");
expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]);
expect(analysis.columns[0]).toMatchObject({ sourceName: "department" });
expect(analysis.groupByColumns).toEqual([expect.objectContaining({ sourceName: "department" })]);
expect(analysis.hasHavingClause).toBeUndefined();
expect(analysis.hasWindowClause).toBeUndefined();
// Aggregate expression has no direct source column.
expect(analysis.columns[1]?.sourceName).toBeUndefined();
});
it("parses HAVING queries without an explicit GROUP BY", () => {
const editability = analyzeEditableQueryEditability("SELECT department, COUNT(*) AS total FROM users GROUP BY department HAVING COUNT(*) > 1");
expect(editability).toEqual({ editable: false, reason: "aggregation" });
const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department HAVING COUNT(*) > 1");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]);
expect(analysis.hasHavingClause).toBe(true);
});
it("records window functions so mutation safety can reject them", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS row_number, COUNT(*) FROM users GROUP BY id");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.groupByColumns).toEqual([expect.objectContaining({ sourceName: "id" })]);
expect(analysis.hasWindowClause).toBe(true);
});
it("records a top-level RIGHT JOIN so a nullable root source cannot become editable", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT u.id, COUNT(*) FROM users u RIGHT OUTER JOIN orders o ON o.user_id = u.id GROUP BY u.id");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.hasRightJoinClause).toBe(true);
});
it("parses multi-source grouped queries with per-source qualifiers", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT u.department, COUNT(o.id) AS order_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.department");
expect(analysis).not.toBeNull();
if (!analysis) return;
expect(analysis.sources!.map((source) => source.tableName)).toEqual(["users", "orders"]);
expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "order_count"]);
expect(analysis.columns[0]?.sourceKey).toBe("u:0");
});
it("returns null for unparseable / dangerous display shapes", () => {
expect(analyzeSelectStructureForDisplay("WITH x AS (SELECT 1) SELECT * FROM x")).toBeNull();
expect(analyzeSelectStructureForDisplay("SELECT a FROM (SELECT 1 AS a) t")).toBeNull();
expect(analyzeSelectStructureForDisplay("INSERT INTO t VALUES (1)")).toBeNull();
});
});
describe("ordinal resolution", () => {
it("maps direct physical columns and leaves aggregate expressions unresolved (T1)", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department");
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "id"]), 2);
expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]);
});
it("resolves aliased physical columns back to the base column (T2)", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT department AS dept, COUNT(*) AS total FROM users GROUP BY department");
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "id"]), 2);
expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]);
});
it("maps multiple grouped physical columns (T5)", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT department, status, COUNT(*) FROM users GROUP BY department, status");
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "status", "id"]), 3);
expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, { sourceKey: "users:0", sourceColumn: "status" }, undefined]);
});
it("never lets an aggregate expression inherit its operand's comment (T6/T7)", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT DATE(created_at) AS day, SUM(amount) AS total_amount FROM orders GROUP BY DATE(created_at)");
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["created_at", "amount", "id"]), 2);
expect(resolved).toEqual([undefined, undefined]);
});
it("resolves qualified grouped columns across joined sources (T4) and keeps aggregates unresolved", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT u.department, COUNT(o.id) AS order_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.department");
expect(analysis).not.toBeNull();
if (!analysis) return;
const usersSource = analysis.sources![0]!;
const ordersSource = analysis.sources![1]!;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
analysis,
[
{ source: usersSource, columns: [{ name: "id" }, { name: "department" }] },
{ source: ordersSource, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] },
],
2,
);
expect(resolved).toEqual([{ sourceKey: "u:0", sourceColumn: "department" }, undefined]);
});
it("keeps quoted identifier semantics for grouped columns (T8)", () => {
const analysis = analyzeSelectStructureForDisplay('SELECT "ID", COUNT(*) AS total FROM users GROUP BY "ID"');
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal("postgres", analysis, displayTableSources(analysis, ["id", "ID"]), 2);
// Quoted "ID" matches the physical quoted column exactly; unquoted id stays distinct.
expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "ID" }, undefined]);
});
it("leaves ambiguous unqualified grouped columns unresolved (T9)", () => {
const analysis = analyzeSelectStructureForDisplay("SELECT id, COUNT(*) AS total FROM users u JOIN orders o ON o.user_id = u.id GROUP BY id");
expect(analysis).not.toBeNull();
if (!analysis) return;
const resolved = resolveSourceColumnsByOrdinal(
"mysql",
analysis,
[
{ source: analysis.sources![0]!, columns: [{ name: "id" }, { name: "department" }] },
{ source: analysis.sources![1]!, columns: [{ name: "id" }, { name: "user_id" }] },
],
2,
);
expect(resolved).toEqual([undefined, undefined]);
});
});
});