176 lines
9.2 KiB
TypeScript
Vendored
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]);
|
|
});
|
|
});
|
|
});
|