diff --git a/tests/fuzz/compile.fuzz.test.ts b/tests/fuzz/compile.fuzz.test.ts index 7f2d6d2..48b46ff 100644 --- a/tests/fuzz/compile.fuzz.test.ts +++ b/tests/fuzz/compile.fuzz.test.ts @@ -24,7 +24,7 @@ const stringsIn = (value: unknown): Array => const serialize = (value: unknown) => JSON.stringify(value, (_, v) => (typeof v === "bigint" ? `${v}n` : v instanceof Date ? `Date(${v.getTime()})` : v)) const compiles = (dialect: FuzzDialect, input: Plan) => { - const known = knownPlanIssue({ target: dialect.name, plan: input }) + const known = knownPlanIssue({ target: dialect.name, plan: input, executed: false }) if (known !== undefined) skipKnown(known) const ctx = scopeContext(dialect) const first = compilePlan(dialect, ctx, input) diff --git a/tests/fuzz/known-issues.ts b/tests/fuzz/known-issues.ts index 833ddc7..cb553bc 100644 --- a/tests/fuzz/known-issues.ts +++ b/tests/fuzz/known-issues.ts @@ -41,15 +41,38 @@ export const knownHostileIssue = (input: HostileInput): string | undefined => // Query plans (queries.fuzz.test.ts) -import type { Plan, UnionPlan } from "./plan" +import { foldConstant } from "./model" +import type { Expr, Plan, UnionPlan } from "./plan" export interface PlanInput { /** The target name (`dialects/*.ts`), or the dialect name for compile-only properties. */ readonly target: string readonly plan: Plan + /** Whether the plan runs on a database: a server bug only matters to properties that run it. */ + readonly executed: boolean } -const planIssues: ReadonlyArray> = [] +/** A `%` anywhere in the plan whose divisor folds to -2, -4, -8, …: the divisor is always the node's `r`. */ +const negativePowerOfTwoModulo = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(negativePowerOfTwoModulo) + if (value === null || typeof value !== "object") return false + const e = value as Expr + if (e.k === "arith" && e.op === "mod") { + const d = foldConstant(e.r) + if (d !== undefined && Number.isInteger(d) && d <= -2 && Number.isInteger(Math.log2(-d))) return true + } + return Object.values(value).some(negativePowerOfTwoModulo) +} + +const planIssues: ReadonlyArray> = [{ + id: "clickhouse-26.2-negative-power-of-two-modulo", + // Server bug, not the builder's: on 26.2.19.43 a column % a constant -2^k is wrong for a negative + // dividend — `SELECT (toInt64(number) - 5) % -2 FROM numbers(1)` is 1, not -1, and `% (0 - 2)` the + // same. `% -3`, `% 2` and a constant dividend are right; 26.8 is right throughout. The target does + // not know its server version, so this skips every ClickHouse target: delete it once CI drops 26.2. + reason: "ClickHouse 26.2 computes a column % a negative power-of-two constant wrongly", + matches: (input) => input.executed && input.target.startsWith("clickhouse") && negativePowerOfTwoModulo(input.plan), +}] export const knownPlanIssue = (input: PlanInput): string | undefined => firstMatch(planIssues, input) @@ -60,20 +83,7 @@ export interface UnionInput { readonly plan: UnionPlan } -/** A `%` by a negative constant anywhere in the input's plans. */ -const negativeModulo = (value: unknown): boolean => - Array.isArray(value) ? value.some(negativeModulo) - : value !== null && typeof value === "object" - ? ((value as { k?: string; op?: string; r?: unknown }).k === "arith" && (value as { op?: string }).op === "mod" && typeof (value as { r?: unknown }).r === "number" && (value as { r: number }).r < 0) - || Object.values(value).some(negativeModulo) - : false - -const unionIssues: ReadonlyArray> = [{ - id: "clickhouse-26.2-union-negative-modulo", - // Server bug, not the builder's: on 26.2, `SELECT A % -4 FROM t UNION ALL SELECT 0 FROM t` with A = -5 - // returns 3, not -1. 26.8 returns -1. Delete this entry once CI no longer runs 26.2. - reason: "ClickHouse 26.2 computes % by a negative divisor wrongly under UNION ALL", - matches: (input) => input.target.startsWith("clickhouse") && negativeModulo(input.plan), -}] +// Each branch is also checked against the plan issues above. +const unionIssues: ReadonlyArray> = [] export const knownUnionIssue = (input: UnionInput): string | undefined => firstMatch(unionIssues, input) diff --git a/tests/fuzz/model.ts b/tests/fuzz/model.ts index dfc66f1..4cf1338 100644 --- a/tests/fuzz/model.ts +++ b/tests/fuzz/model.ts @@ -328,6 +328,29 @@ const constant = (expr: Expr): boolean => { } } +/** + * The number a constant arithmetic expression folds to, as the SQL text parses + * it (see {@link asParsed}), or `undefined` when it reads a column or divides + * by zero. `/` is float division, as on ClickHouse. + */ +export const foldConstant = (e: Expr | number): number | undefined => { + if (typeof e === "number") return e + if (e.k === "num") return e.v + if (e.k !== "arith") return undefined + const p = parsed.has(e) ? e : asParsed(e) + if (p.k !== "arith") return foldConstant(p) + const l = foldConstant(p.l) + const r = foldConstant(p.r) + if (l === undefined || r === undefined) return undefined + switch (p.op) { + case "add": return l + r + case "sub": return l - r + case "mul": return l * r + case "div": return r === 0 ? undefined : l / r + case "mod": return r === 0 ? undefined : l % r + } +} + export const evaluate = (plan: Plan, sem: Semantics, fixture: Fixture = coreFixture): ModelResult => { const env: Env = { sem, plan, joined: plan.join !== undefined } // A database may evaluate any expression on any row — or fold a constant one at plan time — before @@ -336,7 +359,13 @@ export const evaluate = (plan: Plan, sem: Semantics, fixture: Fixture = coreFixt // A constant expression never reads this row. Reuse a real fixture row so the evaluator // can check folded subexpressions even when the input fixture or its inner join is empty. const constantRow: Row = { o: coreFixture.orders[0]! } - for (const e of all) if (constant(e)) evalExpr(env, constantRow, e) + // Check the chains as the SQL parses them, not as the plan nests them: `Id + 1 % 0` holds the + // constant `1 % 0` although no plan node is constant. ClickHouse also runs `%` on an empty + // block, so `Id % 0` fails over no rows; a constant zero divisor is refused on any data. + for (const e of all.flatMap((e) => ariths(e.k === "arith" ? asParsed(e) : e))) { + if (constant(e)) evalExpr(env, constantRow, e) + if (e.k === "arith" && (e.op === "div" || e.op === "mod") && foldConstant(e.r) === 0) skip("division by zero") + } for (const row of sourceRows(env, fixture)) for (const e of all) evalExpr(env, row, e) // A filter pushed below the join reads every order, even one an inner join would drop. if (plan.join !== undefined) for (const o of fixture.orders) for (const e of all) evalExpr(env, { o }, e) diff --git a/tests/fuzz/queries.fuzz.test.ts b/tests/fuzz/queries.fuzz.test.ts index 780d171..f3ae197 100644 --- a/tests/fuzz/queries.fuzz.test.ts +++ b/tests/fuzz/queries.fuzz.test.ts @@ -33,7 +33,7 @@ const canonical = (rows: ReadonlyArray): Array => const sorted = (rows: Array) => rows.map((r) => JSON.stringify(r)).sort() const matchesModel = (dialect: FuzzDialect, target: FuzzTarget, input: Plan, data?: Fixture) => { - const known = knownPlanIssue({ target: target.name, plan: input }) + const known = knownPlanIssue({ target: target.name, plan: input, executed: true }) if (known !== undefined) skipKnown(known) return checkRows(target, input.orgId, evaluate(input, target.semantics, data), compilePlan(dialect, data === undefined ? dialect.context : fixtureContext(dialect, data), input), compilePlan(dialect, scopeContext(dialect), input), input.where) } @@ -42,7 +42,7 @@ const unionMatchesModel = (dialect: FuzzDialect, target: FuzzTarget, input: Unio const knownUnion = knownUnionIssue({ target: target.name, plan: input }) if (knownUnion !== undefined) skipKnown(knownUnion) for (const branch of input.branches) { - const known = knownPlanIssue({ target: target.name, plan: branch }) + const known = knownPlanIssue({ target: target.name, plan: branch, executed: true }) if (known !== undefined) skipKnown(known) } return checkRows( @@ -111,14 +111,22 @@ describe("generated fixture queries", () => { ["empty orders", { orders: [], customers: [] }, undefined], ["empty inner join", { orders: [{ OrgId: "org_1", Id: 1, Customer: "acme", Amount: 0, Status: "paid", Note: null, Created: Date.UTC(2026, 0, 1) }], customers: [] }, "inner"], ] as const) { - it(`constant division is excluded over ${label} (${target.name})`, () => { - const input: Plan = { - orgId: "org_1", join, where: [], wrap: false, crossTenant: false, - shape: { k: "rows", select: [["id", { k: "col", col: "Id" }], ["bad", { k: "arith", op: "div", l: { k: "num", v: 1 }, r: 0 }]], distinct: false, orderBy: [] }, - } - expect(() => evaluate(input, target.semantics, data)).toThrow(Skip) - expect(() => evaluate(input, target.semantics, data)).toThrow("division by zero") - }) + for (const [what, bad] of [ + ["1 / 0", { k: "arith", op: "div", l: { k: "num", v: 1 }, r: 0 }], + // `(Id + 1) % 0` in the plan is `Id + 1 % 0` in the SQL: the constant `1 % 0` is only there once parsed. + ["Id + 1 % 0", { k: "arith", op: "mod", l: { k: "arith", op: "add", l: { k: "col", col: "Id" }, r: 1 }, r: { k: "num", v: 0 } }], + // ClickHouse runs `%` on an empty block too, so a zero divisor fails with no rows to read. + ["Id % 0", { k: "arith", op: "mod", l: { k: "col", col: "Id" }, r: 0 }], + ] as const) { + it(`constant division ${what} is excluded over ${label} (${target.name})`, () => { + const input: Plan = { + orgId: "org_1", join, where: [], wrap: false, crossTenant: false, + shape: { k: "rows", select: [["id", { k: "col", col: "Id" }], ["bad", bad]], distinct: false, orderBy: [] }, + } + expect(() => evaluate(input, target.semantics, data)).toThrow(Skip) + expect(() => evaluate(input, target.semantics, data)).toThrow("division by zero") + }) + } } it.skipIf(!target.available)(`empty generated tables (${target.name})`, () => matchesModel(dialect, target, { orgId: "org_1", where: [], shape: { k: "rows", select: [["id", { k: "col", col: "Id" }]], distinct: false, orderBy: [] }, wrap: false, crossTenant: false,