From e75c334cabae5112eb7ed6a4837fb903109f14b7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 25 Oct 2026 17:51:03 +0100 Subject: [PATCH 1/2] test(fuzz): one registry of dialects, so every suite fuzzes every dialect Each fuzz suite used to branch on "clickhouse" | "postgres" by name, and the reference model encoded each database's rules as target-name checks. Adding a dialect meant editing all of them. Now a dialect is one `FuzzDialect` in tests/fuzz/dialects/: its package entry, fixture context, string rules, literal catalog and targets. Each target states its `Semantics` (NULL ordering, integer division, modulo, unmatched LEFT JOIN columns, empty aggregates, LIKE case), and the model reads only those. A registry test fails when a `Dialect` in src/ has no fuzz entry. LIKE patterns now also come from fixture values edited letter by letter (re-cased, wildcarded, escaped). The old letter-soup patterns almost never matched a row, so case and escape rules could not change a result: fuzzing a SQLite dialect ran 6000 plans without noticing its LIKE ignores case and has no default escape. With these patterns it fails in under 1000. Adds tests/fuzz/README.md (how to add a dialect, writing generators that find bugs, what to do with a failure) and a nightly 20x soak workflow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fuzz.yml | 53 ++++++++++++ tests/fuzz/README.md | 82 ++++++++++++++++++ tests/fuzz/build.ts | 42 +++++---- tests/fuzz/compile.fuzz.test.ts | 58 ++++++------- tests/fuzz/contexts.ts | 20 ----- tests/fuzz/db.ts | 97 --------------------- tests/fuzz/dialects/clickhouse.ts | 136 ++++++++++++++++++++++++++++++ tests/fuzz/dialects/index.ts | 18 ++++ tests/fuzz/dialects/postgres.ts | 91 ++++++++++++++++++++ tests/fuzz/dialects/types.ts | 113 +++++++++++++++++++++++++ tests/fuzz/known-issues.ts | 18 ++-- tests/fuzz/literals.fuzz.test.ts | 35 ++++---- tests/fuzz/model.ts | 38 ++++----- tests/fuzz/plan.ts | 41 +++++++-- tests/fuzz/queries.fuzz.test.ts | 37 ++++---- tests/fuzz/registry.fuzz.test.ts | 24 ++++++ tests/fuzz/values.ts | 128 +++++----------------------- 17 files changed, 681 insertions(+), 350 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 tests/fuzz/README.md delete mode 100644 tests/fuzz/contexts.ts delete mode 100644 tests/fuzz/db.ts create mode 100644 tests/fuzz/dialects/clickhouse.ts create mode 100644 tests/fuzz/dialects/index.ts create mode 100644 tests/fuzz/dialects/postgres.ts create mode 100644 tests/fuzz/dialects/types.ts create mode 100644 tests/fuzz/registry.fuzz.test.ts diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..0f10956 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,53 @@ +name: Fuzz + +# A soak of every fuzz property on every target. `bun run test` runs the same +# properties at their smoke budget on each PR; this runs them at 20×, nightly, +# so a rare counterexample is found here and not by a user. A failure prints +# the replay command and the input to pin (tests/fuzz/README.md). +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + scale: + description: "FUZZ_SCALE: multiplies each property's runs" + default: "20" + +permissions: + contents: read + +jobs: + soak: + name: Soak / ClickHouse ${{ matrix.clickhouse }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + clickhouse: ["26.2.19.43", "26.8.2.7"] + services: + clickhouse: + image: clickhouse/clickhouse-server:${{ matrix.clickhouse }} + env: + CLICKHOUSE_USER: maple + CLICKHOUSE_PASSWORD: maple + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ports: + - 8123:8123 + options: >- + --health-cmd "clickhouse-client --user maple --password maple --query 'SELECT 1'" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + - run: bun install --frozen-lockfile + - name: Fuzz every target + env: + EFFECT_ORM_CLICKHOUSE_URL: http://127.0.0.1:8123 + EFFECT_ORM_CLICKHOUSE_USER: maple + EFFECT_ORM_CLICKHOUSE_PASSWORD: maple + FUZZ_SCALE: ${{ inputs.scale || '20' }} + FUZZ_VERBOSE: "1" + run: bun run fuzz diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..25a6b4f --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,82 @@ +# The fuzzer + +Property-based tests for the query builder, run on real databases. They catch +what hand-written cases miss: SQL that runs but returns the wrong rows, escaping +bugs, and values the builder throws on instead of refusing. + +```sh +bun run test # every property at its smoke budget (part of the normal suite) +bun run fuzz # the fuzz suites only +FUZZ_SCALE=20 bun run fuzz # a soak: 20× each property's runs (the nightly workflow) +``` + +ClickHouse targets run when `EFFECT_ORM_CLICKHOUSE_URL` (and `_USER`, `_PASSWORD`) are set +and are skipped otherwise. Postgres runs in-process on PGlite. + +## Suites + +| File | Property | Oracle | +| --- | --- | --- | +| `queries.fuzz.test.ts` | random plans return the rows `model.ts` computes | a JS model of SQL, per target | +| `literals.fuzz.test.ts` | every column type's values survive as a literal and as a param | the database's own JSON parser | +| `compile.fuzz.test.ts` | plans compile deterministically, with no leaked JS or misaligned placeholders; hostile values are refused with a typed error | invariants | +| `registry.fuzz.test.ts` | every `Dialect` in `src/` is registered here | — | + +## Adding a dialect + +Write `dialects/.ts` exporting a `FuzzDialect` (see `dialects/types.ts`) +and add it to `dialects/index.ts`. `registry.fuzz.test.ts` fails until you do. +Every suite then runs on it with no other edit. + +A `FuzzDialect` gives: + +- `module`: the package entry (`/postgres`, …) the plans are built and compiled with. +- `context`: the core fixture as CTEs over literal rows (`tests/core-cases.ts`), + so every database reads the same rows. +- `strings` and `ilike`: what the generators may produce for it. +- `literals`: its column types, each with a way to build the reference row + *without* the builder (usually the database parsing JSON). +- `targets`: the databases it runs on. Each target states its `Semantics`, the + points where databases legitimately disagree (NULL ordering, integer division, + what an unmatched LEFT JOIN column holds, LIKE case rules, …). The model reads + only these. **Never branch on a target's name in `model.ts`.** If a database + differs in a new way, add a field to `Semantics` and set it for every target. + +A difference belongs in `Semantics` only when it is the database's documented +behaviour and the builder cannot reasonably hide it. When the builder *could* +make the dialects agree, the difference is a bug: record it in +`known-issues.ts` and fix it. + +## Writing generators that find bugs + +- **Derive values from the fixture.** A random string almost never equals a + fixture value, so `eq` and `LIKE` over random strings test only the empty + result. `plan.ts` mixes in fixture values (`FIXTURE_STRINGS`), the fixture's + numbers (`smallInt`), times around `Created`, and LIKE patterns made by + editing fixture values (re-cased, wildcarded, escaped). That last change is + what made SQLite's case-insensitive, escape-less LIKE visible. +- **Keep plans as data.** The generator never calls the builder: `plan.ts` makes + a `Plan`, `build.ts` turns it into builder calls, `model.ts` evaluates it. A + failure prints the shrunk plan, and the plan can be pinned. +- **Well-typed by construction.** Expressions are generated by sort, so a + refusal from the builder is a finding, not noise. +- **Check that inputs are judged.** Run with `FUZZ_VERBOSE=1`: each property + prints how many inputs it checked and why it skipped the rest. A property that + skips most of its inputs is testing nothing. + +## When a property fails + +The report prints the shrunk input, the SQL, the outcome, and a replay command: + +```sh +FUZZ_SEED=… FUZZ_PATH=… bun run fuzz -t 'plans match the model \(postgres, plain strings\)' +``` + +1. Decide whose bug it is: the builder, the model, or the database. +2. Builder bug: fix it, then pin the input in `regressions.ts` so it is replayed + before any random input from then on. +3. Not fixable now: add an entry to `known-issues.ts` with a reason someone can act + on. The suite stays green and the rest of the input space keeps being fuzzed. + `FUZZ_STRICT=1` (or `FUZZ_STRICT=`) turns entries back on to check a fix. +4. Model bug or a legitimate database difference: fix `model.ts`, or add a + `Semantics` field (see above). diff --git a/tests/fuzz/build.ts b/tests/fuzz/build.ts index c0e4ebd..b52c86c 100644 --- a/tests/fuzz/build.ts +++ b/tests/fuzz/build.ts @@ -1,13 +1,13 @@ -// A plan as builder calls, for either dialect. +// A plan as builder calls, for any dialect. // // The builder is driven through its public, typed surface exactly as an // application would; `any` here only erases the static types a random plan // cannot have. Params are named up front from the plan's structure so that // compiling the same plan twice resolves the same names to the same values. import { DateTime, Effect, Exit } from "effect" -import * as CH from "@maple-dev/effect-orm/clickhouse" -import * as PG from "@maple-dev/effect-orm/postgres" +import type * as CH from "@maple-dev/effect-orm/clickhouse" import type { CoreContext } from "../core-cases" +import type { FuzzDialect } from "./dialects/types" import type { Agg, Cond, Expr, Operand, Plan } from "./plan" const pad = (n: number, width = 2) => String(n).padStart(width, "0") @@ -74,7 +74,8 @@ export interface Built { readonly params: Record } -export const buildPlan = (ctx: CoreContext, plan: Plan): Built => { +export const buildPlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan): Built => { + const { lit, param, and, or, not, rawCond, fromQuery } = dialect.module const { names, params } = nameParams(plan) const expr = ($: any, e: Expr): any => { @@ -85,7 +86,7 @@ export const buildPlan = (ctx: CoreContext, plan: Plan): Built => { return $.c[e.col] case "num": case "str": - return CH.lit(e.v as never) + return lit(e.v as never) case "arith": return expr($, e.l)[e.op](typeof e.r === "number" ? e.r : expr($, e.r)) case "coalesce": @@ -99,7 +100,7 @@ export const buildPlan = (ctx: CoreContext, plan: Plan): Built => { case "time": return op.form === "DateTime" ? DateTime.makeUnsafe(op.ms) : op.form === "Date" ? new Date(op.ms) : timeString(op.ms) case "param": - return (CH.param as any)[op.kind](names.get(op)!) + return (param as any)[op.kind](names.get(op)!) case "expr": return expr($, op.e) } @@ -119,15 +120,15 @@ export const buildPlan = (ctx: CoreContext, plan: Plan): Built => { case "and": case "or": { const parts = c.cs.map((x) => cond($, x)) - if (c.style === "fn") return (c.k === "and" ? CH.and : CH.or)(...parts) + if (c.style === "fn") return (c.k === "and" ? and : or)(...parts) return parts.slice(1).reduce((acc, part) => acc[c.k](part), parts[0]) } case "not": - return CH.not(cond($, c.c)) + return not(cond($, c.c)) case "const": - return CH.rawCond(c.v ? "1 = 1" : "1 = 0") + return rawCond(c.v ? "1 = 1" : "1 = 0") case "tenant": - return (c.side === "orders" ? $ : $.c).OrgId.eq(CH.param.string("orgId")) + return (c.side === "orders" ? $ : $.c).OrgId.eq(param.string("orgId")) } } const agg = ($: any, a: Agg): any => { @@ -166,14 +167,24 @@ export const buildPlan = (ctx: CoreContext, plan: Plan): Built => { if (shape.limit !== undefined) q = q.limit(shape.limit) if (shape.offset !== undefined) q = q.offset(shape.offset) if (plan.crossTenant) q = q.crossTenant() - if (plan.wrap === "all") q = CH.fromQuery(q, "w").select() + if (plan.wrap === "all") q = fromQuery(q, "w").select() if (plan.wrap === "names") { const names = shape.k === "rows" ? shape.select.map(([alias]) => alias) : [...shape.keys, ...shape.aggs].map(([alias]) => alias) - q = CH.fromQuery(q, "w").select(...names) + q = fromQuery(q, "w").select(...names) } return { query: q, params } } +/** + * The dialect's context over the plain tables, with no fixture CTEs. + * + * The fixture CTEs are bare SQL the builder cannot see into, and a CTE named + * `orders` shadows the `orders` table, so a query compiled against them never + * reports a meaningful tenant scope. Compiled here, the scope the builder + * reports is the one a real application gets. + */ +export const scopeContext = (dialect: FuzzDialect): CoreContext => ({ ...dialect.context, from: (table) => dialect.module.from(table) as any }) + export type Compiled = | { readonly _tag: "Compiled"; readonly compiled: CH.CompiledQuery } | { readonly _tag: "Refused"; readonly error: string } @@ -185,11 +196,10 @@ export type Compiled = * `QueryBuilderError` is `Refused`; a die is a `Defect`; an exception that * escapes `compile` — or the builder calls before it — is `Threw`. */ -export const compilePlan = (ctx: CoreContext, plan: Plan): Compiled => { +export const compilePlan = (dialect: FuzzDialect, ctx: CoreContext, plan: Plan): Compiled => { try { - const { query, params } = buildPlan(ctx, plan) - const compile = ctx.dialect === "clickhouse" ? CH.compile : PG.compile - const exit = Effect.runSyncExit((compile as any)(query, params) as Effect.Effect, unknown>) + const { query, params } = buildPlan(dialect, ctx, plan) + const exit = Effect.runSyncExit(dialect.module.compile(query, params)) if (Exit.isSuccess(exit)) return { _tag: "Compiled", compiled: exit.value } const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail") return failure !== undefined diff --git a/tests/fuzz/compile.fuzz.test.ts b/tests/fuzz/compile.fuzz.test.ts index 036c6a8..7f2d6d2 100644 --- a/tests/fuzz/compile.fuzz.test.ts +++ b/tests/fuzz/compile.fuzz.test.ts @@ -3,7 +3,7 @@ // plans compile cleanly a well-typed plan is never refused, and nothing throws // deterministic compiling the same plan twice gives the same SQL and params // no leaked JS no `undefined`, `[object …]`, `NaN` or `__PARAM_` in the SQL -// placeholders line up Postgres `$n` are exactly 1..parameters.length +// placeholders line up the dialect's own check, e.g. Postgres `$n` are exactly 1..parameters.length // tenant scope `.crossTenant()` is always cross-tenant; a top-level tenant // predicate on an unjoined query is always single-tenant // hostile values any JS value where a value goes is compiled or refused with a @@ -11,11 +11,9 @@ import { describe, expect, it } from "vitest" import fc from "fast-check" import { Effect, Exit } from "effect" -import * as CH from "@maple-dev/effect-orm/clickhouse" -import * as PG from "@maple-dev/effect-orm/postgres" import type { CoreContext } from "../core-cases" -import { scopeContexts as contexts } from "./contexts" -import { compilePlan } from "./build" +import { compilePlan, scopeContext } from "./build" +import { dialects, type FuzzDialect } from "./dialects" import { FuzzFailure, property, skipKnown } from "./harness" import { knownHostileIssue, knownPlanIssue } from "./known-issues" import { nastyOptions, ORDER_COLUMNS, plan, type OrderColumn, type Plan } from "./plan" @@ -25,10 +23,11 @@ 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 = (ctx: CoreContext, input: Plan) => { - const known = knownPlanIssue({ target: ctx.dialect, plan: input }) +const compiles = (dialect: FuzzDialect, input: Plan) => { + const known = knownPlanIssue({ target: dialect.name, plan: input }) if (known !== undefined) skipKnown(known) - const first = compilePlan(ctx, input) + const ctx = scopeContext(dialect) + const first = compilePlan(dialect, ctx, input) if (first._tag !== "Compiled") throw new FuzzFailure(`a valid plan was not compiled (${first._tag})`, { error: first.error }) const { compiled } = first const context = { sql: compiled.sql, parameters: serialize(compiled.parameters), tenantScope: compiled.tenantScope } @@ -36,7 +35,7 @@ const compiles = (ctx: CoreContext, input: Plan) => { throw new FuzzFailure(message, context) } - const second = compilePlan(ctx, input) + const second = compilePlan(dialect, ctx, input) if (second._tag !== "Compiled" || second.compiled.sql !== compiled.sql || serialize(second.compiled.parameters) !== serialize(compiled.parameters)) { fail("compiling the same plan twice gave different output") } @@ -46,12 +45,10 @@ const compiles = (ctx: CoreContext, input: Plan) => { if (compiled.sql.includes(token) && !strings.some((s) => s.includes(token))) fail(`the SQL contains ${JSON.stringify(token)}`) } - if (ctx.dialect === "postgres" && !strings.some((s) => s.includes("$"))) { - const used = new Set([...compiled.sql.matchAll(/\$(\d+)/g)].map((m) => Number(m[1]))) - const expected = Array.from({ length: compiled.parameters.length }, (_, i) => i + 1) - if (used.size !== expected.length || !expected.every((n) => used.has(n))) { - fail(`placeholders ${[...used].sort((a, b) => a - b).join(",")} do not match ${compiled.parameters.length} parameters`) - } + // A plan's own strings may contain placeholder syntax (`$1`, `?`) that is rightly bound, not counted. + if (dialect.checkPlaceholders !== undefined && !strings.some((s) => /[$?:{]/.test(s))) { + const problem = dialect.checkPlaceholders(compiled.sql, compiled.parameters.length) + if (problem !== undefined) fail(problem) } if (input.crossTenant && compiled.tenantScope !== "cross-tenant") fail(".crossTenant() did not make the query cross-tenant") @@ -62,9 +59,9 @@ const compiles = (ctx: CoreContext, input: Plan) => { } describe("compile properties", () => { - for (const ctx of Object.values(contexts)) { - const name = `plans compile cleanly (${ctx.dialect})` - it(name, () => property(name, plan(nastyOptions(ctx.dialect)), (input) => compiles(ctx, input), { runs: 300 }), 120_000) + for (const dialect of dialects) { + const name = `plans compile cleanly (${dialect.name})` + it(name, () => property(name, plan(nastyOptions(dialect)), (input) => compiles(dialect, input), { runs: 300 }), 120_000) } }) @@ -79,23 +76,24 @@ const hostile: fc.Arbitrary = fc.oneof( type HostileSite = "lit" | "add" | "eq" | "neq" | "gt" | "in_" | "notIn" | "between" | "like" | "param.int" | "param.float" | "param.string" | "param.bool" | "param.dateTime" | "param.of" | "select" export interface HostileInput { - readonly dialect: "clickhouse" | "postgres" + readonly dialect: string readonly column: OrderColumn readonly site: HostileSite readonly value: unknown } -const hostileQuery = (ctx: CoreContext, input: HostileInput) => { +const hostileQuery = (dialect: FuzzDialect, ctx: CoreContext, input: HostileInput) => { const { site, value, column } = input + const { lit, param } = dialect.module const params: Record = { orgId: "org_1", h: value } let q: any = ctx.from(ctx.orders) - if (site === "select") return { query: q.select(() => ({ id: CH.lit(1), bad: value })), params } - if (site === "lit") return { query: q.select(() => ({ id: CH.lit(value as never) })), params } + if (site === "select") return { query: q.select(() => ({ id: lit(1), bad: value })), params } + if (site === "lit") return { query: q.select(() => ({ id: lit(value as never) })), params } if (site === "add") return { query: q.select(($: any) => ({ id: $.Amount.add(value) })), params } q = q.select(($: any) => ({ id: $.Id })) if (site.startsWith("param.")) { const kind = site.slice("param.".length) - const marker = kind === "of" ? CH.param.of((ctx.orders as any).columns[column], "h") : (CH.param as any)[kind]("h") + const marker = kind === "of" ? param.of((ctx.orders as any).columns[column], "h") : (param as any)[kind]("h") return { query: q.where(($: any) => [$[column].eq(marker)]), params } } return { @@ -104,7 +102,7 @@ const hostileQuery = (ctx: CoreContext, input: HostileInput) => { } } -const hostileInput = (dialect: "clickhouse" | "postgres"): fc.Arbitrary => +const hostileInput = (dialect: string): fc.Arbitrary => fc.record({ dialect: fc.constant(dialect), column: fc.constantFrom(...(Object.keys(ORDER_COLUMNS) as OrderColumn[])), @@ -112,13 +110,13 @@ const hostileInput = (dialect: "clickhouse" | "postgres"): fc.Arbitrary { +const neverThrows = (dialect: FuzzDialect, input: HostileInput) => { const known = knownHostileIssue(input) if (known !== undefined) skipKnown(known) let exit: Exit.Exit try { - const { query, params } = hostileQuery(ctx, input) - exit = Effect.runSyncExit(((ctx.dialect === "clickhouse" ? CH.compile : PG.compile) as any)(query, params)) + const { query, params } = hostileQuery(dialect, scopeContext(dialect), input) + exit = Effect.runSyncExit(dialect.module.compile(query, params)) } catch (error) { throw new FuzzFailure("the builder threw instead of failing with a QueryBuilderError", { error: error instanceof Error ? `${error.name}: ${error.message}\n${error.stack?.split("\n").slice(1, 6).join("\n")}` : String(error), @@ -138,8 +136,8 @@ const neverThrows = (ctx: CoreContext, input: HostileInput) => { } describe("hostile values", () => { - for (const ctx of Object.values(contexts)) { - const name = `hostile values are refused, never thrown (${ctx.dialect})` - it(name, () => property(name, hostileInput(ctx.dialect), (input) => neverThrows(ctx, input), { runs: 500 }), 120_000) + for (const dialect of dialects) { + const name = `hostile values are refused, never thrown (${dialect.name})` + it(name, () => property(name, hostileInput(dialect.name), (input) => neverThrows(dialect, input), { runs: 500 }), 120_000) } }) diff --git a/tests/fuzz/contexts.ts b/tests/fuzz/contexts.ts deleted file mode 100644 index 870eb20..0000000 --- a/tests/fuzz/contexts.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Contexts for compiling plans. -// -// `contexts` are the core suite's: the fixture tables are CTEs over literal -// rows, so the same plan runs on every database. Those CTEs are bare SQL the -// builder cannot see into, and a CTE named `orders` shadows the `orders` table, -// so a query compiled against them never reports a meaningful tenant scope. -// -// `scopeContexts` compile the same plan against the plain tables, with no CTEs: -// the tenant scope the builder reports there is the one a real application -// gets, and the tenant oracle checks it against the rows the model says the -// query actually read. -import * as CH from "@maple-dev/effect-orm/clickhouse" -import { contexts as core, type CoreContext, type DialectName } from "../core-cases" - -export const contexts: Record = core - -export const scopeContexts: Record = { - clickhouse: { ...core.clickhouse, from: (table) => CH.from(table) as any }, - postgres: { ...core.postgres, from: (table) => CH.from(table) as any }, -} diff --git a/tests/fuzz/db.ts b/tests/fuzz/db.ts deleted file mode 100644 index b1d7dc4..0000000 --- a/tests/fuzz/db.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Run a compiled query on a real database and say what happened, without throwing. -// -// Oracles need to tell four outcomes apart — the builder refused, the database -// refused, the rows did not decode, or rows came back — so each is a tagged -// value rather than an exception the predicate would have to pick apart. -import { PGlite } from "@electric-sql/pglite" -import { Cause, Effect, Exit } from "effect" -import type * as CH from "@maple-dev/effect-orm/clickhouse" - -export type Outcome = - | { readonly _tag: "Rows"; readonly rows: ReadonlyArray; readonly wire: ReadonlyArray } - | { readonly _tag: "DatabaseError"; readonly message: string; readonly wire?: undefined } - | { readonly _tag: "DecodeError"; readonly message: string; readonly wire: ReadonlyArray } - -const decode = async (compiled: CH.CompiledQuery, wire: ReadonlyArray): Promise => { - const exit = await Effect.runPromiseExit(compiled.decodeRows(wire as ReadonlyArray>)) - return Exit.isSuccess(exit) - ? { _tag: "Rows", rows: exit.value, wire } - : { _tag: "DecodeError", message: Cause.pretty(exit.cause), wire } -} - -// Postgres - -let pglite: PGlite | undefined -/** One in-process Postgres per test file; PGlite takes the time zone from the host otherwise. */ -export const postgres = (): PGlite => (pglite ??= new PGlite({ postgresqlconf: "timezone = 'UTC'" })) -export const closePostgres = async (): Promise => { - await pglite?.close() - pglite = undefined -} - -export const runPostgres = async (compiled: CH.CompiledQuery): Promise => { - let wire: ReadonlyArray - try { - wire = (await postgres().query>(compiled.sql, [...compiled.parameters])).rows - } catch (error) { - return { _tag: "DatabaseError", message: error instanceof Error ? error.message : String(error) } - } - return decode(compiled, wire) -} - -// ClickHouse - -export const clickhouseEndpoint = process.env.EFFECT_ORM_CLICKHOUSE_URL -const user = process.env.EFFECT_ORM_CLICKHOUSE_USER ?? "default" -const password = process.env.EFFECT_ORM_CLICKHOUSE_PASSWORD ?? "" - -/** POST raw SQL; the text reply, or the server's error. */ -export const clickhouseText = async ( - sql: string, - settings: Record = {}, -): Promise<{ readonly ok: boolean; readonly text: string }> => { - const url = new URL(clickhouseEndpoint!) - for (const [key, value] of Object.entries({ default_format: "JSONEachRow", session_timezone: "UTC", ...settings })) { - url.searchParams.set(key, value) - } - // A soak sends tens of thousands of requests; a dropped socket is the harness's problem, not a finding. - for (let attempt = 1; ; attempt++) { - try { - const response = await fetch(url, { - method: "POST", - headers: { "X-ClickHouse-User": user, "X-ClickHouse-Key": password }, - body: sql, - signal: AbortSignal.timeout(10_000), - }) - return { ok: response.ok, text: await response.text() } - } catch (error) { - const code = (error as { cause?: { code?: string } }).cause?.code ?? String(error) - if (attempt >= 5) throw new Error(`ClickHouse unreachable after ${attempt} attempts (${code}) — infrastructure, not a finding`) - await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) - } - } -} - -export const runClickHouse = async ( - compiled: CH.CompiledQuery, - settings: Record = {}, -): Promise => { - const { ok, text } = await clickhouseText(compiled.sql, settings) - if (!ok) return { _tag: "DatabaseError", message: text.trim() } - const wire = text - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as unknown) - return decode(compiled, wire) -} - -/** What each fuzzed query runs on: Postgres always, ClickHouse when an endpoint is set. */ -export type Target = "postgres" | "clickhouse" | "clickhouse-join-nulls" - -export const run = (target: Target, compiled: CH.CompiledQuery): Promise => - target === "postgres" - ? runPostgres(compiled) - : runClickHouse(compiled, { - join_use_nulls: target === "clickhouse-join-nulls" ? "1" : "0", - output_format_json_quote_64bit_integers: target === "clickhouse-join-nulls" ? "1" : "0", - }) diff --git a/tests/fuzz/dialects/clickhouse.ts b/tests/fuzz/dialects/clickhouse.ts new file mode 100644 index 0000000..0ea4fba --- /dev/null +++ b/tests/fuzz/dialects/clickhouse.ts @@ -0,0 +1,136 @@ +// ClickHouse: live only when EFFECT_ORM_CLICKHOUSE_URL is set. Two targets, +// because `join_use_nulls` changes what an unmatched LEFT JOIN row holds. +import fc from "fast-check" +import * as CH from "@maple-dev/effect-orm/clickhouse" +import { clickhouseContext } from "../../core-cases" +import { dateTimeWire, finite, int32, nastyString, nullOr, utc, type LiteralType } from "../values" +import { decode, type FuzzDialect, type FuzzTarget, type Outcome, type Semantics, type StringRules } from "./types" + +export const clickhouseEndpoint = process.env.EFFECT_ORM_CLICKHOUSE_URL +const user = process.env.EFFECT_ORM_CLICKHOUSE_USER ?? "default" +const password = process.env.EFFECT_ORM_CLICKHOUSE_PASSWORD ?? "" + +/** POST raw SQL; the text reply, or the server's error. */ +export const clickhouseText = async ( + sql: string, + settings: Record = {}, +): Promise<{ readonly ok: boolean; readonly text: string }> => { + const url = new URL(clickhouseEndpoint!) + for (const [key, value] of Object.entries({ default_format: "JSONEachRow", session_timezone: "UTC", ...settings })) { + url.searchParams.set(key, value) + } + // A soak sends tens of thousands of requests; a dropped socket is the harness's problem, not a finding. + for (let attempt = 1; ; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { "X-ClickHouse-User": user, "X-ClickHouse-Key": password }, + body: sql, + signal: AbortSignal.timeout(10_000), + }) + return { ok: response.ok, text: await response.text() } + } catch (error) { + const code = (error as { cause?: { code?: string } }).cause?.code ?? String(error) + if (attempt >= 5) throw new Error(`ClickHouse unreachable after ${attempt} attempts (${code}) — infrastructure, not a finding`) + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } +} + +const runClickHouse = async (compiled: CH.CompiledQuery, settings: Record): Promise => { + const { ok, text } = await clickhouseText(compiled.sql, settings) + if (!ok) return { _tag: "DatabaseError", message: text.trim() } + const wire = text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as unknown) + return decode(compiled, wire) +} + +const semantics: Semantics = { + missingJoinColumn: "default", + integerDivision: "float", + modulo: "clickhouse", + nulls: { asc: "last", desc: "last" }, + // ClickHouse answers an aggregate over no rows with a default its optimizer moves around + // (`min(-1 + x)` is rewritten `-1 + min(x)`, so it is -1, not 0). + emptyAggregate: "unmodelled", + likeIgnoresCase: false, +} + +const target = (name: string, joinUseNulls: boolean): FuzzTarget => ({ + name, + available: clickhouseEndpoint !== undefined, + semantics: joinUseNulls ? { ...semantics, missingJoinColumn: "null" } : semantics, + run: (compiled) => + runClickHouse(compiled, { + join_use_nulls: joinUseNulls ? "1" : "0", + output_format_json_quote_64bit_integers: joinUseNulls ? "1" : "0", + }), +}) + +// Literals + +const strings: StringRules = { nul: true, leadingBom: true } +const MIN_DT = Date.UTC(1970, 0, 1) +const MAX_DT = Date.UTC(2106, 1, 7, 6, 28, 15) // DateTime is a UInt32 of seconds +const MIN_DT64 = Date.UTC(1900, 0, 1) +const MAX_DT64 = Date.UTC(2299, 11, 31, 23, 59, 59, 999) + +/** The reference row, parsed by ClickHouse's JSON reader rather than written by the builder. */ +const jsonSource = (sqlType: string) => (json: string) => + `SELECT v FROM format(JSONEachRow, 'v ${sqlType.replaceAll("'", "\\'")}', '${json.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}')` + +const literal = ( + id: string, + type: CH.CHType, + value: fc.Arbitrary, + options: { wire?: (value: unknown) => unknown; comparable?: boolean; source?: (value: unknown) => string } = {}, +): LiteralType => ({ + id: `clickhouse:${id}`, + type, + value, + wire: options.wire ?? ((v) => v), + source: (json, v) => (options.source ? options.source(v) : jsonSource(type.sql)(json)), + comparable: options.comparable ?? true, +}) + +const nasty = nastyString(strings) +const literals: ReadonlyArray = [ + literal("String", CH.string, nasty), + literal("Int32", CH.int32, int32), + literal("Int64", CH.int64, fc.maxSafeInteger()), + literal("UInt32", CH.uint32, fc.nat({ max: 2 ** 32 - 1 })), + literal("UInt64", CH.uint64, fc.maxSafeNat()), + // ClickHouse's text float parser (JSON input, `toFloat64('…')`) can be 1 ULP off unless + // precise_float_parsing is on, so the reference row is built from the IEEE bits instead. + literal("Float64", CH.float64, finite, { + source: (v) => { + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, v as number) + return `SELECT reinterpretAsFloat64(toUInt64(${view.getBigUint64(0)})) AS v` + }, + }), + literal("Bool", CH.bool, fc.boolean()), + literal("DateTime", CH.dateTime, fc.integer({ min: MIN_DT / 1000, max: MAX_DT / 1000 }).map((s) => utc(s * 1000)), { + wire: dateTimeWire(false), + }), + literal("DateTime64", CH.dateTime64, fc.integer({ min: MIN_DT64, max: MAX_DT64 }).map(utc), { wire: dateTimeWire(true) }), + literal("Nullable(String)", CH.nullable(CH.string), nullOr(nasty)), + literal("Nullable(Int64)", CH.nullable(CH.int64), nullOr(fc.maxSafeInteger())), + literal("Array(String)", CH.array(CH.string), fc.array(nasty, { maxLength: 4 })), + literal("Array(Nullable(Int64))", CH.array(CH.nullable(CH.int64)), fc.array(nullOr(fc.maxSafeInteger()), { maxLength: 4 })), + literal("Array(Array(Int32))", CH.array(CH.array(CH.int32)), fc.array(fc.array(int32, { maxLength: 3 }), { maxLength: 3 })), + literal("Map(String, String)", CH.map(CH.string, CH.string), fc.dictionary(nasty, nasty, { maxKeys: 3 }), { comparable: false }), + literal("Map(String, Int64)", CH.map(CH.string, CH.int64), fc.dictionary(nasty, fc.maxSafeInteger(), { maxKeys: 3 }), { comparable: false }), +] + +export const clickhouse: FuzzDialect = { + name: "clickhouse", + module: CH as never, + context: clickhouseContext, + strings, + ilike: true, + literals, + targets: [target("clickhouse", false), target("clickhouse-join-nulls", true)], +} diff --git a/tests/fuzz/dialects/index.ts b/tests/fuzz/dialects/index.ts new file mode 100644 index 0000000..166513a --- /dev/null +++ b/tests/fuzz/dialects/index.ts @@ -0,0 +1,18 @@ +// The dialect registry. Every fuzz suite iterates it; add a dialect here. +import { clickhouse } from "./clickhouse" +import { postgres } from "./postgres" +import type { FuzzDialect, FuzzTarget } from "./types" + +export const dialects: ReadonlyArray = [clickhouse, postgres] + +/** Every target, with the dialect whose SQL it runs. */ +export const targets: ReadonlyArray<{ readonly dialect: FuzzDialect; readonly target: FuzzTarget }> = dialects.flatMap((dialect) => + dialect.targets.map((target) => ({ dialect, target })), +) + +/** Close whatever the targets opened; call from `afterAll`. */ +export const closeAll = async (): Promise => { + await Promise.all(dialects.map((dialect) => dialect.close?.())) +} + +export type { FuzzDialect, FuzzTarget, Outcome, Semantics } from "./types" diff --git a/tests/fuzz/dialects/postgres.ts b/tests/fuzz/dialects/postgres.ts new file mode 100644 index 0000000..cb10bdc --- /dev/null +++ b/tests/fuzz/dialects/postgres.ts @@ -0,0 +1,91 @@ +// Postgres: an in-process PGlite, so it always runs. +import fc from "fast-check" +import { DateTime } from "effect" +import { PGlite } from "@electric-sql/pglite" +import * as PG from "@maple-dev/effect-orm/postgres" +import { postgresContext } from "../../core-cases" +import { finite, int32, nastyString, nullOr, utc, type LiteralType } from "../values" +import { runWith, type FuzzDialect, type StringRules } from "./types" + +let pglite: PGlite | undefined +/** One in-process Postgres per test file; PGlite takes the time zone from the host otherwise. */ +const database = (): PGlite => (pglite ??= new PGlite({ postgresqlconf: "timezone = 'UTC'" })) + +// Literals + +// Postgres `text` cannot hold U+0000, and PGlite's TextDecoder drops a leading BOM. +const strings: StringRules = { nul: false, leadingBom: false } +// PGlite hands timestamps to JS as `Date`s built with two-digit-year rules, so +// years 0–99 come back as 1900–1999 before the builder sees them. +const MIN_TS = Date.UTC(100, 0, 1) +const MAX_TS = Date.UTC(9999, 11, 31, 23, 59, 59, 999) + +/** The reference row, parsed by Postgres from jsonb rather than written by the builder. */ +const json = (text: string) => `CAST('${text.replaceAll("'", "''")}' AS jsonb)` +const scalarSource = (sqlType: string) => (text: string) => `SELECT CAST(${json(text)}->>'v' AS ${sqlType}) AS "v"` +const arraySource = (sqlType: string) => (text: string) => + `SELECT CAST(ARRAY(SELECT jsonb_array_elements_text(${json(text)}->'v')) AS ${sqlType}[]) AS "v"` + +const literal = ( + id: string, + type: PG.PgType, + value: fc.Arbitrary, + options: { wire?: (value: unknown) => unknown; array?: boolean } = {}, +): LiteralType => ({ + id: `postgres:${id}`, + type, + value, + wire: options.wire ?? ((v) => v), + source: options.array ? arraySource(type.sql.replace(/\[\]$/, "")) : scalarSource(type.sql), + comparable: true, +}) + +const nasty = nastyString(strings) +const literals: ReadonlyArray = [ + literal("text", PG.text, nasty), + literal("int4", PG.int4, int32), + literal("int8", PG.int8, fc.maxSafeInteger()), + literal("float8", PG.float8, finite), + literal("boolean", PG.bool, fc.boolean()), + literal("timestamptz", PG.timestamptz, fc.integer({ min: MIN_TS, max: MAX_TS }).map(utc), { + wire: (value) => new Date(DateTime.toEpochMillis(value as DateTime.Utc)).toISOString(), + }), + literal("nullable(text)", PG.nullable(PG.text), nullOr(nasty)), + literal("nullable(int8)", PG.nullable(PG.int8), nullOr(fc.maxSafeInteger())), + literal("text[]", PG.array(PG.text), fc.array(nasty, { maxLength: 4 }), { array: true }), +] + +export const postgres: FuzzDialect = { + name: "postgres", + module: PG as never, + context: postgresContext, + strings, + ilike: true, + checkPlaceholders: (sql, count) => { + const used = new Set([...sql.matchAll(/\$(\d+)/g)].map((m) => Number(m[1]))) + const expected = Array.from({ length: count }, (_, i) => i + 1) + return used.size === expected.length && expected.every((n) => used.has(n)) + ? undefined + : `placeholders ${[...used].sort((a, b) => a - b).join(",")} do not match ${count} parameters` + }, + literals, + targets: [ + { + name: "postgres", + available: true, + semantics: { + missingJoinColumn: "null", + integerDivision: "truncate", + modulo: "truncate", + nulls: { asc: "last", desc: "first" }, + emptyAggregate: "null", + likeIgnoresCase: false, + }, + run: (compiled) => runWith(compiled, async (sql, parameters) => (await database().query>(sql, [...parameters])).rows), + }, + ], + close: async () => { + await pglite?.close() + pglite = undefined + }, +} diff --git a/tests/fuzz/dialects/types.ts b/tests/fuzz/dialects/types.ts new file mode 100644 index 0000000..3e23e06 --- /dev/null +++ b/tests/fuzz/dialects/types.ts @@ -0,0 +1,113 @@ +// What the fuzz suites need to know about a dialect: one value per dialect, +// registered in `./index.ts`. Every suite loops over the registry, so adding a +// dialect is one file here and one line there, not an edit in every suite. +// +// A dialect has one or more *targets*: a database the compiled SQL runs on, with +// the semantics the reference model needs for it (ClickHouse is two targets, +// because `join_use_nulls` changes what a LEFT JOIN fills). See ../README.md. +import { Cause, Effect, Exit } from "effect" +import type * as CH from "@maple-dev/effect-orm/clickhouse" +import type { CoreContext } from "../../core-cases" +import type { LiteralType } from "../values" + +export type Outcome = + | { readonly _tag: "Rows"; readonly rows: ReadonlyArray; readonly wire: ReadonlyArray } + | { readonly _tag: "DatabaseError"; readonly message: string; readonly wire?: undefined } + | { readonly _tag: "DecodeError"; readonly message: string; readonly wire: ReadonlyArray } + +/** Decode wire rows with the compiled query's own decoder, as `Database.run` would. */ +export const decode = async (compiled: CH.CompiledQuery, wire: ReadonlyArray): Promise => { + const exit = await Effect.runPromiseExit(compiled.decodeRows(wire as ReadonlyArray>)) + return Exit.isSuccess(exit) + ? { _tag: "Rows", rows: exit.value, wire } + : { _tag: "DecodeError", message: Cause.pretty(exit.cause), wire } +} + +/** Run raw SQL text and report a database refusal as an outcome, not an exception. */ +export const runWith = async ( + compiled: CH.CompiledQuery, + query: (sql: string, parameters: ReadonlyArray) => Promise> | ReadonlyArray, +): Promise => { + let wire: ReadonlyArray + try { + wire = await query(compiled.sql, compiled.parameters) + } catch (error) { + return { _tag: "DatabaseError", message: error instanceof Error ? error.message : String(error) } + } + return decode(compiled, wire) +} + +/** + * Where a database answers differently from another, and the difference is the + * database's, not the builder's. The reference model reads only these, never a + * target's name, so a new target states its semantics instead of patching the model. + */ +export interface Semantics { + /** A column of an unmatched LEFT JOIN row: SQL NULL, or the column type's default (`''`, `0`). */ + readonly missingJoinColumn: "null" | "default" + /** `/` over two integers: truncating integer division, or float division. */ + readonly integerDivision: "truncate" | "float" + /** + * `%`: `truncate` is JS `%` on any operands; `clickhouse` declines what ClickHouse computes its own + * way (signed with unsigned, floats); `integer` casts both operands to integers first (SQLite). + */ + readonly modulo: "truncate" | "clickhouse" | "integer" + /** Where NULL sorts, per direction, when ORDER BY names no NULLS FIRST/LAST. */ + readonly nulls: { readonly asc: "first" | "last"; readonly desc: "first" | "last" } + /** sum/min/max over no rows in a global aggregate: NULL, or a default the model does not predict. */ + readonly emptyAggregate: "null" | "unmodelled" + /** Whether `LIKE` ignores ASCII case (SQLite does). */ + readonly likeIgnoresCase: boolean +} + +export interface FuzzTarget { + /** Unique across dialects; appears in property names and `known-issues.ts`. */ + readonly name: string + /** False when the database is not reachable here (an env var is unset): the target's live tests skip. */ + readonly available: boolean + readonly semantics: Semantics + readonly run: (compiled: CH.CompiledQuery) => Promise +} + +/** The parts of a dialect entry (`/clickhouse`, `/postgres`, …) the suites drive. */ +export interface DialectModule { + readonly compile: (query: any, params?: Record) => Effect.Effect, unknown> + readonly table: (name: string, definition: any) => CH.Table + readonly from: typeof CH.from + readonly param: typeof CH.param + readonly lit: typeof CH.lit + readonly and: typeof CH.and + readonly or: typeof CH.or + readonly not: typeof CH.not + readonly rawCond: typeof CH.rawCond + readonly fromQuery: typeof CH.fromQuery +} + +export interface FuzzDialect { + readonly name: string + readonly module: DialectModule + /** The fixture tables as CTEs over literal rows, so the same plan reads the same rows everywhere. */ + readonly context: CoreContext + /** What strings the dialect can store and hand back unchanged (see `nastyString`). */ + readonly strings: StringRules + /** Whether the dialect has `ILIKE`; plans use it only where it exists. */ + readonly ilike: boolean + /** + * Checks the placeholders in compiled SQL line up with its parameters, or + * returns why not. Skipped when a plan's own strings could contain one. + */ + readonly checkPlaceholders?: (sql: string, parameterCount: number) => string | undefined + /** Column types the literal round-trip property covers. */ + readonly literals: ReadonlyArray + /** The databases this dialect's SQL runs on. The first is the literal suite's. */ + readonly targets: ReadonlyArray + /** Release a connection or process the targets opened. */ + readonly close?: () => Promise +} + +export interface StringRules { + /** Whether a string may contain U+0000. */ + readonly nul: boolean + /** Whether a string may start with a BOM (some drivers strip it). */ + readonly leadingBom: boolean +} diff --git a/tests/fuzz/known-issues.ts b/tests/fuzz/known-issues.ts index 52fec6a..f170eb8 100644 --- a/tests/fuzz/known-issues.ts +++ b/tests/fuzz/known-issues.ts @@ -24,10 +24,7 @@ export interface LiteralInput { readonly path: LiteralPath } -const literalIssues: ReadonlyArray> = [ - - -] +const literalIssues: ReadonlyArray> = [] /** The id of the open issue this input would hit, if any. */ export const knownLiteralIssue = (input: LiteralInput): string | undefined => @@ -37,23 +34,18 @@ export const knownLiteralIssue = (input: LiteralInput): string | undefined => import type { HostileInput } from "./compile.fuzz.test" -const hostileIssues: ReadonlyArray> = [ - - - - -] +const hostileIssues: ReadonlyArray> = [] export const knownHostileIssue = (input: HostileInput): string | undefined => firstMatch(hostileIssues, input) // Query plans (queries.fuzz.test.ts) -import type { Target } from "./db" import type { Plan } from "./plan" export interface PlanInput { - readonly target: Target + /** The target name (`dialects/*.ts`), or the dialect name for compile-only properties. */ + readonly target: string readonly plan: Plan } @@ -79,7 +71,7 @@ const planIssues: ReadonlyArray> = [ "`Number of arguments for function \"and\" should be at least 2` — the common-expression rewrite leaves a " + "one-argument and(). `SETTINGS optimize_extract_common_expressions = 0` avoids it.", matches: ({ target, plan }) => { - if (target === "postgres") return false + if (!target.startsWith("clickhouse")) return false const top = conjuncts(plan.where) const disjuncts = (c: any): Array => (c?.k === "or" ? c.cs.flatMap(disjuncts) : [c]) // Fails when every disjunct of a nested OR contains a top-level conjunct `w` (as `w`, or inside an AND): diff --git a/tests/fuzz/literals.fuzz.test.ts b/tests/fuzz/literals.fuzz.test.ts index 791a44f..9f69e3d 100644 --- a/tests/fuzz/literals.fuzz.test.ts +++ b/tests/fuzz/literals.fuzz.test.ts @@ -15,33 +15,31 @@ // edges of a type's range, and nested arrays and maps. import { afterAll, describe, expect, it } from "vitest" import { Effect, Exit } from "effect" -import * as CH from "@maple-dev/effect-orm/clickhouse" -import * as PG from "@maple-dev/effect-orm/postgres" -import { clickhouseEndpoint, closePostgres, run } from "./db" +import type * as CH from "@maple-dev/effect-orm/clickhouse" +import { closeAll, dialects, type FuzzDialect } from "./dialects" import { FuzzFailure, property, skip, skipKnown, withContext } from "./harness" import { knownLiteralIssue, type LiteralPath } from "./known-issues" -import { literalTypes, normalize, referenceJson, wellFormed, type LiteralType } from "./values" +import { normalize, referenceJson, wellFormed, type LiteralType } from "./values" -afterAll(closePostgres) +afterAll(closeAll) -const build = (literal: LiteralType, value: unknown, path: LiteralPath) => { - const M = literal.dialect === "clickhouse" ? CH : PG - const src = (M.table as typeof CH.table)("src", { external: true, columns: { v: literal.type } }) +const build = (dialect: FuzzDialect, literal: LiteralType, value: unknown, path: LiteralPath) => { + const M = dialect.module + const src = M.table("src", { external: true, columns: { v: literal.type } }) const base = (M.from(src) as any).withCTE("src", literal.source(referenceJson(literal.wire(value)), value)) if (path === "select") return base.select(($: any) => ({ v: $.v, p: M.param.of(literal.type, "p") })) return base.select(($: any) => ({ v: $.v })).where(($: any) => [value === null ? $.v.isNull() : $.v.eq(value)]) } -const roundTrip = async (literal: LiteralType, value: unknown, path: LiteralPath): Promise => { +const roundTrip = async (dialect: FuzzDialect, literal: LiteralType, value: unknown, path: LiteralPath): Promise => { // The reference row goes through JSON, which cannot carry a lone surrogate the way SQL text does. if (!wellFormed(value)) skip("lone surrogate") const known = knownLiteralIssue({ literal, value, path }) if (known !== undefined) skipKnown(known) - const M = literal.dialect === "clickhouse" ? CH : PG - const exit = Effect.runSyncExit((M.compile as any)(build(literal, value, path), { p: value })) + const exit = Effect.runSyncExit(dialect.module.compile(build(dialect, literal, value, path), { p: value })) if (Exit.isFailure(exit)) throw new FuzzFailure("the builder refused a valid value", { value, cause: String(exit.cause) }) const compiled = exit.value as CH.CompiledQuery - const outcome = await run(literal.dialect, compiled) + const outcome = await dialect.targets[0]!.run(compiled) await withContext({ sql: compiled.sql, parameters: compiled.parameters, outcome }, () => { expect(outcome._tag).toBe("Rows") if (outcome._tag !== "Rows") return @@ -51,12 +49,13 @@ const roundTrip = async (literal: LiteralType, value: unknown, path: LiteralPath } describe("literal round-trip", () => { - for (const literal of literalTypes) { - const live = literal.dialect === "postgres" || clickhouseEndpoint !== undefined - for (const path of ["compare", "select"] as const) { - if (path === "compare" && !literal.comparable) continue - const name = `${literal.id} ${path}` - it.skipIf(!live)(name, () => property(name, literal.value, (value) => roundTrip(literal, value, path), { runs: 40 }), 120_000) + for (const dialect of dialects) { + for (const literal of dialect.literals) { + for (const path of ["compare", "select"] as const) { + if (path === "compare" && !literal.comparable) continue + const name = `${literal.id} ${path}` + it.skipIf(!dialect.targets[0]!.available)(name, () => property(name, literal.value, (value) => roundTrip(dialect, literal, value, path), { runs: 40 }), 120_000) + } } } }) diff --git a/tests/fuzz/model.ts b/tests/fuzz/model.ts index 3136d52..68f7889 100644 --- a/tests/fuzz/model.ts +++ b/tests/fuzz/model.ts @@ -5,11 +5,12 @@ // query that runs cleanly but answers wrong (a lost parenthesis, a negation // applied to the wrong side, NULL handled like a value) shows up as a row diff. // -// Where the databases genuinely differ, the model says so per target rather -// than papering over it; each such rule names the difference it encodes. +// Where the databases genuinely differ, the model reads the target's +// `Semantics` (dialects/types.ts) rather than its name, so a new dialect states +// its rules instead of patching this file. // Inputs the model cannot judge raise `skip` with a reason. import { fixtureRows } from "../core-cases" -import type { Target } from "./db" +import type { Semantics } from "./dialects/types" import { skip } from "./harness" import type { Agg, AggShape, ArithOp, Cond, Expr, Operand, Plan, RowsShape } from "./plan" @@ -54,7 +55,7 @@ interface Row { } interface Env { - readonly target: Target + readonly sem: Semantics readonly plan: Plan readonly joined: boolean } @@ -130,8 +131,8 @@ const evalExpr = (env: Env, row: Row, e: Expr): V => { return row.o[e.col] case "jcol": if (row.c !== undefined) return row.c[e.col] - // ClickHouse with join_use_nulls=0 fills a missing row with column defaults. - return env.target === "clickhouse" ? "" : null + // e.g. ClickHouse with join_use_nulls=0 fills a missing row with column defaults. + return env.sem.missingJoinColumn === "default" ? "" : null case "num": case "str": return e.v @@ -152,10 +153,11 @@ const evalExpr = (env: Env, row: Row, e: Expr): V => { case "div": if (r === 0) skip("division by zero") // `/` is float division on ClickHouse, integer division of two Postgres integers. - return env.target === "postgres" ? Math.trunc(l / r) : l / r + return env.sem.integerDivision === "truncate" ? Math.trunc(l / r) : l / r case "mod": if (r === 0) skip("division by zero") - if (env.target !== "postgres") { + if (env.sem.modulo === "integer" && [chKind(e.l), chKind(e.r)].includes("float")) skip("% casting a float operand to an integer") + if (env.sem.modulo === "clickhouse") { const kinds = new Set([chKind(e.l), chKind(e.r)]) if (kinds.has("unsigned") && kinds.has("signed")) skip("ClickHouse % across signed and unsigned") // Not IEEE fmod: `3 % (27 / 99)` is 0 on ClickHouse, 2.2e-16 by fmod. @@ -168,7 +170,7 @@ const evalExpr = (env: Env, row: Row, e: Expr): V => { } } -/** Strings compare by UTF-8 bytes on both targets (ClickHouse always, Postgres under the C collation). */ +/** Strings compare by UTF-8 bytes on every target (ClickHouse always, Postgres under the C collation). */ const encoder = new TextEncoder() const compareStrings = (a: string, b: string): number => { const x = encoder.encode(a) @@ -219,7 +221,7 @@ const evalCond = (env: Env, row: Row, c: Cond): Bool => { case "const": return c.v case "tenant": { - const org = c.side === "orders" ? row.o.OrgId : row.c?.OrgId ?? (env.target === "clickhouse" ? "" : null) + const org = c.side === "orders" ? row.o.OrgId : row.c?.OrgId ?? (env.sem.missingJoinColumn === "default" ? "" : null) return org === null ? null : org === env.plan.orgId } case "not": @@ -257,7 +259,7 @@ const evalCond = (env: Env, row: Row, c: Cond): Bool => { if (l === null) return null // Case folding beyond ASCII and `_` over multi-byte text differ between the two; not modelled. if (/[^\x00-\x7f]/.test(l)) skip("LIKE over non-ASCII text") - const matched = likeRegex(c.pattern, c.op === "ilike").test(l) + const matched = likeRegex(c.pattern, c.op === "ilike" || env.sem.likeIgnoresCase).test(l) return c.op === "notLike" ? !matched : matched } case "between": { @@ -290,9 +292,7 @@ const sourceRows = (env: Env): ReadonlyArray => type OutRow = Record -const nullsFirst = (target: Target, dir: "asc" | "desc") => - // Postgres: NULLS LAST ascending, NULLS FIRST descending. ClickHouse: NULLS LAST either way. - target === "postgres" && dir === "desc" +const nullsFirst = (sem: Semantics, dir: "asc" | "desc") => sem.nulls[dir] === "first" const sortRows = (env: Env, rows: Array, orderBy: ReadonlyArray) => rows.sort((a, b) => { @@ -301,7 +301,7 @@ const sortRows = (env: Env, rows: Array, orderBy: ReadonlyArray, agg: Agg, global: boolean): return rows.filter((row) => evalCond(env, row, agg.c) === true).length default: { const values = rows.map((row) => evalExpr(env, row, agg.e)).filter((v): v is number => v !== null) - // Postgres answers an aggregate over no rows with NULL. ClickHouse answers with a default that its - // optimizer moves around (`min(-1 + x)` is rewritten `-1 + min(x)`, so it is -1, not 0): not modelled. - if (values.length === 0) return global && env.target !== "postgres" ? skip("ClickHouse aggregate over no rows") : null + if (values.length === 0) return global && env.sem.emptyAggregate === "unmodelled" ? skip("aggregate over no rows") : null if (agg.k === "sum") return values.reduce((a, b) => a + b, 0) return agg.k === "min" ? Math.min(...values) : Math.max(...values) } @@ -350,8 +348,8 @@ const ariths = (value: unknown): Array => { return [...own, ...Object.values(value).flatMap(ariths)] } -export const evaluate = (plan: Plan, target: Target): ModelResult => { - const env: Env = { target, plan, joined: plan.join !== undefined } +export const evaluate = (plan: Plan, sem: Semantics): 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 // filtering decides it was not needed. So a division by zero anywhere is a division by zero. const all = ariths(plan) diff --git a/tests/fuzz/plan.ts b/tests/fuzz/plan.ts index 8c91e23..175cd8d 100644 --- a/tests/fuzz/plan.ts +++ b/tests/fuzz/plan.ts @@ -9,6 +9,7 @@ // so a plan is well-typed by construction, and the builder refusing one is a // finding rather than noise. import fc from "fast-check" +import type { FuzzDialect } from "./dialects/types" import { nastyString, wellFormed } from "./values" // The fixture (tests/core-cases.ts): five orders, four customers, two orgs. @@ -106,6 +107,8 @@ export interface Plan { export interface GenOptions { /** Strings for comparisons and LIKE patterns: anything, or only what sorts the same everywhere. */ readonly strings: fc.Arbitrary + /** Whether the dialect has ILIKE (`FuzzDialect.ilike`). */ + readonly ilike: boolean } /** Mostly the fixture's own numbers (Id 1–5, Amount 5/7/10/20/99), so comparisons split the rows. */ @@ -183,11 +186,33 @@ const operand = (sort: Sort, joined: boolean, options: GenOptions): fc.Arbitrary { weight: 1, arbitrary: expr(sort === "nstr" ? "str" : sort, joined, 1, options).map((e): Operand => ({ k: "expr", e })) }, ) -/** LIKE patterns over the fixture's letters; `\` only ever escapes `%`, `_` or `\`, which both databases agree on. */ -const likePattern = fc +/** LIKE patterns over the fixture's letters; `\` only ever escapes `%`, `_` or `\`, which every database must agree on. */ +const soupPattern = fc .array(fc.constantFrom("%", "_", "\\%", "\\_", "\\\\", "a", "c", "e", "m", "i", "o", "p", "AC", "G", "'", "s"), { maxLength: 5 }) .map((parts) => parts.join("")) +/** + * A fixture value, edited letter by letter: re-cased, or replaced by `_` or `%`; + * a literal `_` or `%` in the value is escaped. Unlike letter soup, these + * patterns select rows, so case and escape rules change the result where + * dialects differ. A pattern that matches nothing tests nothing. + */ +const fixturePattern = fc + .tuple(fc.constantFrom(...FIXTURE_STRINGS), fc.array(fc.constantFrom("keep", "keep", "keep", "upper", "under", "pct"), { minLength: 1, maxLength: 9 })) + .map(([value, edits]) => + [...value] + .map((ch, i) => { + const edit = edits[i % edits.length] + if (edit === "upper") return ch.toUpperCase() + if (edit === "under") return "_" + if (edit === "pct") return "%" + return ch === "_" || ch === "%" || ch === "\\" ? `\\${ch}` : ch + }) + .join(""), + ) + +const likePattern = fc.oneof(soupPattern, fixturePattern) + const cond = (joined: boolean, depth: number, options: GenOptions): fc.Arbitrary => { const sorts: ReadonlyArray = ["num", "str", "nstr", "time"] const leaf = fc.oneof( @@ -202,7 +227,7 @@ const cond = (joined: boolean, depth: number, options: GenOptions): fc.Arbitrary fc.constantFrom("str", "nstr").chain((sort) => fc.record({ k: fc.constant("like" as const), - op: fc.constantFrom("like" as const, "notLike" as const, "ilike" as const), + op: fc.constantFrom("like" as const, "notLike" as const, ...(options.ilike ? (["ilike"] as const) : [])), l: expr(sort, joined, 0, options), pattern: likePattern, }), @@ -332,10 +357,12 @@ export const plan = (options: GenOptions): fc.Arbitrary => }) /** Every string the oracle compares by byte order: plain, so collation never enters. */ -export const plainOptions: GenOptions = { +export const plainOptions = (dialect: FuzzDialect): GenOptions => ({ strings: fc.string({ unit: fc.constantFrom(..."abcdeghiklmnoprstuwxy_' -"), maxLength: 8 }), -} + ilike: dialect.ilike, +}) /** Strings from the nasty generator: for escaping, where the model only needs equality. */ -export const nastyOptions = (dialect: "clickhouse" | "postgres" | "both"): GenOptions => ({ - strings: nastyString(dialect).filter((s) => wellFormed(s)), +export const nastyOptions = (dialect: FuzzDialect): GenOptions => ({ + strings: nastyString(dialect.strings).filter((s) => wellFormed(s)), + ilike: dialect.ilike, }) diff --git a/tests/fuzz/queries.fuzz.test.ts b/tests/fuzz/queries.fuzz.test.ts index eee2907..49730f1 100644 --- a/tests/fuzz/queries.fuzz.test.ts +++ b/tests/fuzz/queries.fuzz.test.ts @@ -5,20 +5,18 @@ // // tenant soundness a query that reports "single-tenant" read rows of one org only // -// Postgres (PGlite) always runs. ClickHouse runs twice when an endpoint is set, -// once per `join_use_nulls`, because that setting changes what a LEFT JOIN fills. +// Every target in the registry (dialects/index.ts) runs; one whose database is +// not reachable here is skipped, not dropped, so the run says so. import { afterAll, describe, expect, it } from "vitest" -import type { CoreContext } from "../core-cases" -import { contexts, scopeContexts } from "./contexts" -import { compilePlan } from "./build" -import { clickhouseEndpoint, closePostgres, run, type Target } from "./db" +import { compilePlan, scopeContext } from "./build" +import { closeAll, targets, type FuzzDialect, type FuzzTarget } from "./dialects" import { FuzzFailure, property, skipKnown, withContext } from "./harness" import { knownPlanIssue } from "./known-issues" import { evaluate } from "./model" import { nastyOptions, plainOptions, plan, type Plan } from "./plan" import { normalize } from "./values" -afterAll(closePostgres) +afterAll(closeAll) /** Rows in a form that compares across float summation order: non-integers to 12 significant digits. */ const canonical = (rows: ReadonlyArray): Array => @@ -29,17 +27,17 @@ const canonical = (rows: ReadonlyArray): Array => ) const sorted = (rows: Array) => rows.map((r) => JSON.stringify(r)).sort() -const matchesModel = async (ctx: CoreContext, target: Target, input: Plan) => { - const known = knownPlanIssue({ target, plan: input }) +const matchesModel = async (dialect: FuzzDialect, target: FuzzTarget, input: Plan) => { + const known = knownPlanIssue({ target: target.name, plan: input }) if (known !== undefined) skipKnown(known) - const expected = evaluate(input, target) - const result = compilePlan(ctx, input) + const expected = evaluate(input, target.semantics) + const result = compilePlan(dialect, dialect.context, input) if (result._tag !== "Compiled") throw new FuzzFailure(`a valid plan was not compiled (${result._tag})`, { error: result.error }) const { compiled } = result // The scope a real application would get: the same plan over the plain tables. - const scoped = compilePlan(scopeContexts[ctx.dialect], input) + const scoped = compilePlan(dialect, scopeContext(dialect), input) const tenantScope = scoped._tag === "Compiled" ? scoped.compiled.tenantScope : undefined - const outcome = await run(target, compiled) + const outcome = await target.run(compiled) await withContext({ sql: compiled.sql, parameters: compiled.parameters, tenantScope, outcome, expected: expected.rows }, () => { expect(outcome._tag).toBe("Rows") if (outcome._tag !== "Rows") return @@ -54,17 +52,14 @@ const matchesModel = async (ctx: CoreContext, target: Target, input: Plan) => { }) } -const targets: ReadonlyArray = clickhouseEndpoint ? ["postgres", "clickhouse", "clickhouse-join-nulls"] : ["postgres"] - describe("queries match the model", () => { - for (const target of targets) { - const ctx = contexts[target === "postgres" ? "postgres" : "clickhouse"] + for (const { dialect, target } of targets) { for (const [label, options] of [ - ["plain strings", plainOptions], - ["nasty strings", nastyOptions(ctx.dialect)], + ["plain strings", plainOptions(dialect)], + ["nasty strings", nastyOptions(dialect)], ] as const) { - const name = `plans match the model (${target}, ${label})` - it(name, () => property(name, plan(options), (input) => matchesModel(ctx, target, input), { runs: 100 }), 300_000) + const name = `plans match the model (${target.name}, ${label})` + it.skipIf(!target.available)(name, () => property(name, plan(options), (input) => matchesModel(dialect, target, input), { runs: 100 }), 300_000) } } }) diff --git a/tests/fuzz/registry.fuzz.test.ts b/tests/fuzz/registry.fuzz.test.ts new file mode 100644 index 0000000..df6b02d --- /dev/null +++ b/tests/fuzz/registry.fuzz.test.ts @@ -0,0 +1,24 @@ +// Every dialect the package defines is fuzzed. +// +// A dialect that compiles SQL but is missing from `dialects/index.ts` would pass +// every suite by never being run. This finds each `Dialect` value in src/ and +// fails until the registry has a `FuzzDialect` of the same name. +import { readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { expect, it } from "vitest" +import { dialects } from "./dialects" + +const sourceFiles = (dir: string): Array => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".ts") && !entry.name.includes(".test") ? [path] : [] + }) + +const defined = sourceFiles(join(import.meta.dirname, "../../src")).flatMap((file) => + [...readFileSync(file, "utf8").matchAll(/export const \w+: Dialect = \{[\s\S]*?\bname: "([^"]+)"/g)].map((m) => m[1]!), +) + +it("every dialect in src/ is registered with the fuzzer", () => { + expect(defined.length).toBeGreaterThan(0) + expect(new Set(dialects.map((d) => d.name))).toEqual(new Set(defined)) +}) diff --git a/tests/fuzz/values.ts b/tests/fuzz/values.ts index 1a929d2..b3c559c 100644 --- a/tests/fuzz/values.ts +++ b/tests/fuzz/values.ts @@ -1,10 +1,10 @@ -// Value generators, and the column types the literal oracle round-trips. +// Value generators and helpers shared by every dialect's literal catalog +// (`dialects/*.ts`): strings that break SQL writers, times, and normalising +// decoded values for comparison. import fc from "fast-check" import { DateTime } from "effect" -import * as CH from "@maple-dev/effect-orm/clickhouse" -import * as PG from "@maple-dev/effect-orm/postgres" - -export type Dialect = "clickhouse" | "postgres" +import type * as CH from "@maple-dev/effect-orm/clickhouse" +import type { StringRules } from "./dialects/types" // Strings @@ -21,20 +21,23 @@ const NASTY = [ " ", "", "​", "é", "é", "😀", "👩‍👩‍👧", "\ud800", "\udfff", "Ω", "İ", "ß", ] -/** Any string, biased hard towards the fragments above. `\0` only where the dialect stores it. */ -export const nastyString = (dialect: Dialect | "both"): fc.Arbitrary => { +/** Strings every dialect stores unchanged. */ +export const portableStrings: StringRules = { nul: false, leadingBom: false } + +/** Any string, biased hard towards the fragments above, within what the dialect can store. */ +export const nastyString = (rules: StringRules): fc.Arbitrary => { const unit = fc.oneof( { weight: 3, arbitrary: fc.constantFrom(...NASTY) }, { weight: 2, arbitrary: fc.string({ unit: "binary", maxLength: 3 }) }, { weight: 2, arbitrary: fc.string({ unit: fc.constantFrom(..."abcxyz019 "), maxLength: 4 }) }, - ...(dialect === "clickhouse" ? [{ weight: 1, arbitrary: fc.constant("\0") }] : []), + ...(rules.nul ? [{ weight: 1, arbitrary: fc.constant("\0") }] : []), ) return fc .array(unit, { maxLength: 6 }) .map((parts) => parts.join("")) - // Postgres `text` cannot hold U+0000, and PGlite's TextDecoder drops a leading BOM: + // e.g. Postgres `text` cannot hold U+0000, and PGlite's TextDecoder drops a leading BOM: // the database's and the driver's limits, not the builder's. - .filter((s) => dialect === "clickhouse" || (!s.includes("\0") && !s.startsWith("\ufeff"))) + .filter((s) => (rules.nul || !s.includes("\0")) && (rules.leadingBom || !s.startsWith("\ufeff"))) } /** Strings whose byte order every target agrees on: lowercase ASCII and a few symbols. */ @@ -42,19 +45,10 @@ export const plainString = fc.string({ unit: fc.constantFrom(..."abcdeghiklmnopr // Times -const MIN_DT = Date.UTC(1970, 0, 1) -const MAX_DT = Date.UTC(2106, 1, 7, 6, 28, 15) // DateTime is a UInt32 of seconds -const MIN_DT64 = Date.UTC(1900, 0, 1) -const MAX_DT64 = Date.UTC(2299, 11, 31, 23, 59, 59, 999) -// PGlite hands timestamps to JS as `Date`s built with two-digit-year rules, so -// years 0–99 come back as 1900–1999 before the builder sees them. -const MIN_PG = Date.UTC(100, 0, 1) -const MAX_PG = Date.UTC(9999, 11, 31, 23, 59, 59, 999) - -const utc = (ms: number): DateTime.Utc => DateTime.makeUnsafe(ms) +export const utc = (ms: number): DateTime.Utc => DateTime.makeUnsafe(ms) const pad = (n: number, width = 2) => String(n).padStart(width, "0") /** `YYYY-MM-DD hh:mm:ss[.sss]`, written here rather than by the code under test. */ -const wireDateTime = (ms: number, fraction: boolean): string => { +export const wireDateTime = (ms: number, fraction: boolean): string => { const d = new Date(ms) const base = `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}` return fraction ? `${base}.${pad(d.getUTCMilliseconds(), 3)}` : base @@ -114,8 +108,8 @@ export const wellFormed = (value: unknown): boolean => { } export interface LiteralType { + /** `:`; appears in property names, `regressions.ts` and `known-issues.ts`. */ readonly id: string - readonly dialect: Dialect readonly type: CH.CHType readonly value: fc.Arbitrary /** The value as the JSON the database parses. */ @@ -125,90 +119,8 @@ export interface LiteralType { readonly comparable: boolean } -const chSource = (sqlType: string) => (json: string) => - `SELECT v FROM format(JSONEachRow, 'v ${sqlType.replaceAll("'", "\\'")}', '${json.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}')` - -const pgJson = (json: string) => `CAST('${json.replaceAll("'", "''")}' AS jsonb)` -const pgScalar = (sqlType: string) => (json: string) => `SELECT CAST(${pgJson(json)}->>'v' AS ${sqlType}) AS "v"` -const pgArray = (sqlType: string) => (json: string) => - `SELECT CAST(ARRAY(SELECT jsonb_array_elements_text(${pgJson(json)}->'v')) AS ${sqlType}[]) AS "v"` - -const ch = ( - id: string, - type: CH.CHType, - value: fc.Arbitrary, - options: { wire?: (value: unknown) => unknown; comparable?: boolean; source?: (value: unknown) => string } = {}, -): LiteralType => ({ - id: `clickhouse:${id}`, - dialect: "clickhouse", - type, - value, - wire: options.wire ?? ((v) => v), - source: (json, v) => (options.source ? options.source(v) : chSource(type.sql)(json)), - comparable: options.comparable ?? true, -}) -const pg = ( - id: string, - type: CH.CHType, - value: fc.Arbitrary, - options: { wire?: (value: unknown) => unknown; array?: boolean } = {}, -): LiteralType => ({ - id: `postgres:${id}`, - dialect: "postgres", - type, - value, - wire: options.wire ?? ((v) => v), - source: options.array ? pgArray(type.sql.replace(/\[\]$/, "")) : pgScalar(type.sql), - comparable: true, -}) - -const finite = fc.double({ noNaN: true, noDefaultInfinity: true }) -const int32 = fc.integer({ min: -(2 ** 31), max: 2 ** 31 - 1 }) -const nullOr = (arb: fc.Arbitrary) => fc.option(arb, { nil: null, freq: 4 }) -const dateTimeWire = (fraction: boolean) => (value: unknown) => +export const finite = fc.double({ noNaN: true, noDefaultInfinity: true }) +export const int32 = fc.integer({ min: -(2 ** 31), max: 2 ** 31 - 1 }) +export const nullOr = (arb: fc.Arbitrary) => fc.option(arb, { nil: null, freq: 4 }) +export const dateTimeWire = (fraction: boolean) => (value: unknown) => wireDateTime(DateTime.toEpochMillis(value as DateTime.Utc), fraction) - -export const literalTypes: ReadonlyArray = [ - ch("String", CH.string, nastyString("clickhouse")), - ch("Int32", CH.int32, int32), - ch("Int64", CH.int64, fc.maxSafeInteger()), - ch("UInt32", CH.uint32, fc.nat({ max: 2 ** 32 - 1 })), - ch("UInt64", CH.uint64, fc.maxSafeNat()), - // ClickHouse's text float parser (JSON input, `toFloat64('…')`) can be 1 ULP off unless - // precise_float_parsing is on, so the reference row is built from the IEEE bits instead. - ch("Float64", CH.float64, finite, { - source: (v) => { - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, v as number) - return `SELECT reinterpretAsFloat64(toUInt64(${view.getBigUint64(0)})) AS v` - }, - }), - ch("Bool", CH.bool, fc.boolean()), - ch("DateTime", CH.dateTime, fc.integer({ min: MIN_DT / 1000, max: MAX_DT / 1000 }).map((s) => utc(s * 1000)), { - wire: dateTimeWire(false), - }), - ch("DateTime64", CH.dateTime64, fc.integer({ min: MIN_DT64, max: MAX_DT64 }).map(utc), { wire: dateTimeWire(true) }), - ch("Nullable(String)", CH.nullable(CH.string), nullOr(nastyString("clickhouse"))), - ch("Nullable(Int64)", CH.nullable(CH.int64), nullOr(fc.maxSafeInteger())), - ch("Array(String)", CH.array(CH.string), fc.array(nastyString("clickhouse"), { maxLength: 4 })), - ch("Array(Nullable(Int64))", CH.array(CH.nullable(CH.int64)), fc.array(nullOr(fc.maxSafeInteger()), { maxLength: 4 })), - ch("Array(Array(Int32))", CH.array(CH.array(CH.int32)), fc.array(fc.array(int32, { maxLength: 3 }), { maxLength: 3 })), - ch("Map(String, String)", CH.map(CH.string, CH.string), fc.dictionary(nastyString("clickhouse"), nastyString("clickhouse"), { maxKeys: 3 }), { - comparable: false, - }), - ch("Map(String, Int64)", CH.map(CH.string, CH.int64), fc.dictionary(nastyString("clickhouse"), fc.maxSafeInteger(), { maxKeys: 3 }), { - comparable: false, - }), - - pg("text", PG.text, nastyString("postgres")), - pg("int4", PG.int4, int32), - pg("int8", PG.int8, fc.maxSafeInteger()), - pg("float8", PG.float8, finite), - pg("boolean", PG.bool, fc.boolean()), - pg("timestamptz", PG.timestamptz, fc.integer({ min: MIN_PG, max: MAX_PG }).map(utc), { - wire: (value) => new Date(DateTime.toEpochMillis(value as DateTime.Utc)).toISOString(), - }), - pg("nullable(text)", PG.nullable(PG.text), nullOr(nastyString("postgres"))), - pg("nullable(int8)", PG.nullable(PG.int8), nullOr(fc.maxSafeInteger())), - pg("text[]", PG.array(PG.text), fc.array(nastyString("postgres"), { maxLength: 4 }), { array: true }), -] From dc48b1defaa99f66111df1a57ab702f354682225 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 25 Oct 2026 17:57:56 +0100 Subject: [PATCH 2/2] test(fuzz): escape a re-cased wildcard in fixture LIKE patterns; drop checkout credentials Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fuzz.yml | 2 ++ tests/fuzz/plan.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 0f10956..ff68dc5 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -41,6 +41,8 @@ jobs: --health-retries 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - run: bun install --frozen-lockfile - name: Fuzz every target diff --git a/tests/fuzz/plan.ts b/tests/fuzz/plan.ts index 175cd8d..3e9c7a3 100644 --- a/tests/fuzz/plan.ts +++ b/tests/fuzz/plan.ts @@ -203,10 +203,10 @@ const fixturePattern = fc [...value] .map((ch, i) => { const edit = edits[i % edits.length] - if (edit === "upper") return ch.toUpperCase() if (edit === "under") return "_" if (edit === "pct") return "%" - return ch === "_" || ch === "%" || ch === "\\" ? `\\${ch}` : ch + const kept = edit === "upper" ? ch.toUpperCase() : ch + return kept === "_" || kept === "%" || kept === "\\" ? `\\${kept}` : kept }) .join(""), )