Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,42 @@ or exempted with a reason.
Tests preserve documented behavior: arithmetic chains follow SQL precedence, not call
order, and `windowFunnel` with `strict_order` rejects intervening events.

## Fuzzing

`tests/fuzz/` generates random inputs with fast-check and checks them against oracles that do
not trust the builder:

- `literals.fuzz.test.ts`: a random value of each column type reaches the database twice. One
copy goes through the database's own JSON parser as the reference, the other through the
builder, both as a comparison literal and as a param in the SELECT list. The two must agree.
- `queries.fuzz.test.ts`: random query plans (joins, nested conditions, grouping, having,
ordering, paging, subqueries) run on the core fixture and must match `model.ts`, a JS
evaluator with SQL three-valued logic. A plan that reports `single-tenant` must have read
one org's rows only.
- `compile.fuzz.test.ts`: needs no database. Valid plans always compile, compile
deterministically, leak no `undefined`, `NaN` or `__PARAM_`, and number Postgres
placeholders `$1..$n`. Any JS value placed where a value goes is compiled or refused with a
`QueryBuilderError`, never thrown and never a defect.

`bun run test` runs a small budget (a few seconds). Use `bun run fuzz` for longer runs:

```sh
FUZZ_SCALE=50 bun run fuzz # 50× every property's default run count
FUZZ_RUNS=10000 bun run fuzz -t "plans match the model"
FUZZ_VERBOSE=1 bun run fuzz # print checked / skipped counts per property
```

A failure prints the shrunk input, the SQL it compiled to, the database's reply, and a
`FUZZ_SEED=… FUZZ_PATH=…` command that replays it. Pin the input in
`tests/fuzz/regressions.ts`, which is replayed before any random input.

Bugs found but not yet fixed live in `tests/fuzz/known-issues.ts`. Each entry recognises
the inputs that reach its bug and gives a reason, so the suite stays green while the rest of
the input space keeps being fuzzed. After fixing a bug, delete its entry and run with
`FUZZ_STRICT=1`, which ignores every entry, to confirm nothing else matched it. Differences
between the databases that are not bugs are encoded in the model instead, each with the
difference it represents.

## Version and CI policy

Every push to `main` and every pull request runs the complete release check against
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
"test": "vitest run && node scripts/check-doc-citations.mjs && node scripts/check-exports-documented.mjs && node scripts/check-doc-examples.mjs",
"prepublishOnly": "bun run test:release",
"test:clickhouse": "bun run build && bun scripts/test-clickhouse.ts",
"fuzz": "bun run build && vitest run tests/fuzz",
"test:package": "bun run build && bun scripts/check-package.ts",
"test:release": "bun scripts/test-clickhouse.ts --release",
"changeset": "changeset",
Expand All @@ -126,6 +127,7 @@
"@types/node": "^22.10.2",
"effect": "4.0.0",
"expect-type": "^1.3.0",
"fast-check": "^4.9.0",
"tsdown": "^0.22.14",
"typescript": "^7.0.2",
"vitest": "^4.1.9"
Expand Down
2 changes: 1 addition & 1 deletion tests/core-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const fixtureColumns = {
} satisfies Record<string, readonly FixtureColumn[]>

type Value = string | number | null
const fixtureRows: Record<keyof typeof fixtureColumns, ReadonlyArray<ReadonlyArray<Value>>> = {
export const fixtureRows: Record<keyof typeof fixtureColumns, ReadonlyArray<ReadonlyArray<Value>>> = {
orders: [
["org_1", 1, "acme", 10, "paid", "rush", "2026-01-01 00:00:00.000"],
["org_1", 2, "acme", 20, "paid", null, "2026-01-01 00:10:00.000"],
Expand Down
201 changes: 201 additions & 0 deletions tests/fuzz/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// A plan as builder calls, for either 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 { CoreContext } from "../core-cases"
import type { Agg, Cond, Expr, Operand, Plan } from "./plan"

const pad = (n: number, width = 2) => String(n).padStart(width, "0")
/** `YYYY-MM-DD hh:mm:ss.sss` in UTC: the string form a DateTime comparison accepts. */
export const timeString = (ms: number): string => {
const d = new Date(ms)
return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(), 3)}`
}

/** Every param operand in the plan, in a fixed walk order, with the name it compiles as. */
const nameParams = (plan: Plan): { readonly names: Map<Operand, string>; readonly params: Record<string, unknown> } => {
const names = new Map<Operand, string>()
const params: Record<string, unknown> = { orgId: plan.orgId }
const operand = (op: Operand) => {
if (op.k === "expr") return expr(op.e)
if (op.k !== "param") return
const name = `p${names.size}`
names.set(op, name)
params[name] = op.kind === "dateTime" ? DateTime.makeUnsafe(op.v as number) : op.v
}
const expr = (e: Expr): void => {
if (e.k === "arith") {
expr(e.l)
if (typeof e.r !== "number") expr(e.r)
} else if (e.k === "coalesce") {
expr(e.e)
expr(e.fallback)
}
}
const cond = (c: Cond): void => {
switch (c.k) {
case "cmp":
expr(c.l)
return operand(c.r)
case "like":
case "in":
return expr(c.k === "like" ? c.l : c.e)
case "null":
return expr(c.e)
case "between":
expr(c.e)
operand(c.lo)
return operand(c.hi)
case "and":
case "or":
return c.cs.forEach(cond)
case "not":
return cond(c.c)
}
}
const agg = (a: Agg) => (a.k === "countIf" ? cond(a.c) : a.k === "count" ? undefined : expr(a.e))
plan.where.forEach(cond)
if (plan.shape.k === "rows") plan.shape.select.forEach(([, e]) => expr(e))
else {
plan.shape.keys.forEach(([, e]) => expr(e))
plan.shape.aggs.forEach(([, a]) => agg(a))
plan.shape.having.forEach((h) => agg(h.agg))
}
return { names, params }
}

export interface Built {
readonly query: CH.CHQuery<any, any, any>
readonly params: Record<string, unknown>
}

export const buildPlan = (ctx: CoreContext, plan: Plan): Built => {
const { names, params } = nameParams(plan)

const expr = ($: any, e: Expr): any => {
switch (e.k) {
case "col":
return $[e.col]
case "jcol":
return $.c[e.col]
case "num":
case "str":
return CH.lit(e.v as never)
case "arith":
return expr($, e.l)[e.op](typeof e.r === "number" ? e.r : expr($, e.r))
case "coalesce":
return ctx.fn.coalesce(expr($, e.e), expr($, e.fallback))
}
}
const operand = ($: any, op: Operand): any => {
switch (op.k) {
case "value":
return op.v
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)!)
case "expr":
return expr($, op.e)
}
}
const cond = ($: any, c: Cond): any => {
switch (c.k) {
case "cmp":
return expr($, c.l)[c.op](operand($, c.r))
case "like":
return expr($, c.l)[c.op](c.pattern)
case "null":
return expr($, c.e)[c.op]()
case "between":
return expr($, c.e)[c.neg ? "notBetween" : "between"](operand($, c.lo), operand($, c.hi))
case "in":
return expr($, c.e)[c.neg ? "notIn" : "in_"](...c.values)
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)
return parts.slice(1).reduce((acc, part) => acc[c.k](part), parts[0])
}
case "not":
return CH.not(cond($, c.c))
case "const":
return CH.rawCond(c.v ? "1 = 1" : "1 = 0")
case "tenant":
return (c.side === "orders" ? $ : $.c).OrgId.eq(CH.param.string("orgId"))
}
}
const agg = ($: any, a: Agg): any => {
switch (a.k) {
case "count":
return ctx.fn.count()
case "countIf":
return ctx.fn.countIf(cond($, a.c))
case "sum":
case "min":
case "max":
return ctx.fn[a.k](expr($, a.e))
}
}

let q: any = ctx.from(ctx.orders)
if (plan.join !== undefined) {
q = q[plan.join === "inner" ? "innerJoin" : "leftJoin"](ctx.customers, "c", (o: any, c: any) =>
o.Customer.eq(c.Name).and(o.OrgId.eq(c.OrgId)),
)
}
const shape = plan.shape
if (shape.k === "rows") {
q = q.select(($: any) => Object.fromEntries(shape.select.map(([alias, e]) => [alias, expr($, e)])))
if (shape.distinct) q = q.distinct()
} else {
q = q.select(($: any) => ({
...Object.fromEntries(shape.keys.map(([alias, e]) => [alias, expr($, e)])),
...Object.fromEntries(shape.aggs.map(([alias, a]) => [alias, agg($, a)])),
}))
if (shape.keys.length > 0) q = q.groupBy(...shape.keys.map(([alias]) => alias))
if (shape.having.length > 0) q = q.having(($: any) => shape.having.map((h) => agg($, h.agg)[h.op](h.v)))
}
if (plan.where.length > 0) q = q.where(($: any) => plan.where.map((c) => cond($, c)))
if (shape.orderBy.length > 0) q = q.orderBy(...shape.orderBy.map(([alias, dir]) => [alias, dir]))
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 === "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)
}
return { query: q, params }
}

export type Compiled =
| { readonly _tag: "Compiled"; readonly compiled: CH.CompiledQuery<any> }
| { readonly _tag: "Refused"; readonly error: string }
| { readonly _tag: "Defect"; readonly error: string }
| { readonly _tag: "Threw"; readonly error: string }

/**
* Compile through the Effect API and classify the outcome. A typed
* `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 => {
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<CH.CompiledQuery<any>, unknown>)
if (Exit.isSuccess(exit)) return { _tag: "Compiled", compiled: exit.value }
const failure = exit.cause.reasons.find((reason) => reason._tag === "Fail")
return failure !== undefined
? { _tag: "Refused", error: String((failure as any).error?.message ?? failure) }
: { _tag: "Defect", error: String(exit.cause) }
} catch (error) {
return { _tag: "Threw", error: error instanceof Error ? `${error.name}: ${error.message}\n${error.stack}` : String(error) }
}
}
Loading
Loading