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
20 changes: 15 additions & 5 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,28 @@ 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:
`tests/fuzz/` generates schema-shaped inputs with Effect Arbitrary and SQL plans
with fast-check, checking them against independent oracles:

- `literals.fuzz.test.ts`: a random value of each column type reaches the database twice. One
- `literals.fuzz.test.ts`: a random value of each catalogued column type reaches each target 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
ordering, paging, subqueries) run on the core fixture and generated tables 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.
- `codecs.fuzz.test.ts`: Effect `TestSchema` verifies composed codec round trips;
schema-generated rows survive compiled encoding/decoding, and malformed wire rows
fail with a typed decode error.
- `migrations*.fuzz.test.ts`: SQLite/D1 and Postgres transitions are compared with
freshly created schemas and independent seed-row expectations, using varied text,
numeric, JSON, array and binary payloads as supported by each suite.
- `registry.fuzz.test.ts`: every dialect and public type export must have a literal
property or an explicit exemption.

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

Expand All @@ -119,7 +127,9 @@ FUZZ_VERBOSE=1 bun run fuzz # print checked / skipped counts per propert
```

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
`FUZZ_SEED=… FUZZ_PATH=…` command that replays it. Effect-native properties print an
opaque token instead; use `FUZZ_REPLAY=…` with the failing test name. See [the fuzz guide](../tests/fuzz/README.md) for generator
choices and replay details. 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
Expand Down
43 changes: 39 additions & 4 deletions tests/fuzz/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# The fuzzer

Property-based tests for the query builder, run on real databases. They catch
Property-based tests for the query builder and codecs, run with Effect Arbitrary
and fast-check. Database properties run on real engines. 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.

Expand All @@ -17,12 +18,46 @@ and are skipped otherwise. Postgres runs in-process on PGlite.

| File | Property | Oracle |
| --- | --- | --- |
| `queries.fuzz.test.ts` | random plans, and `unionAll`s of them (branch and union ORDER BY / LIMIT / OFFSET), return the rows `model.ts` computes | a JS model of SQL, per target |
| `queries.fuzz.test.ts` | random plans and `unionAll`s, over both the core fixture and generated empty/duplicate/nullable/multi-tenant data, return the rows `model.ts` computes | a JS model of SQL, per target |
| `writes.fuzz.test.ts` | random sequences of inserts (multi-row, defaulted cells, INSERT … SELECT, ON CONFLICT), updates and deletes, with RETURNING, leave a scratch table as the model says; unsupported writes are refused | a JS model of the table's rows |
| `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 |
| `migrations.fuzz.test.ts` | a SQLite migration between two random schemas (with table and column renames), run on seeded rows on node:sqlite and on D1 (one batch per migration), gives a fresh create's PRAGMA shape and keeps the rows; a drizzle-kit snapshot of any schema imports with nothing to generate | a fresh create, the seeded rows, and our own entities |
| `registry.fuzz.test.ts` | every `Dialect` in `src/` is registered here | — |
| `migrations.fuzz.test.ts` | a SQLite migration between two random schemas (with table and column renames), run on seeded rows on node:sqlite and on D1 (one batch per migration), gives a fresh create's PRAGMA shape and keeps varied text, numeric and binary payloads; a drizzle-kit snapshot of any schema imports with nothing to generate | a fresh create, the seeded rows, and our own entities |
| `migrations.postgres.fuzz.test.ts` | random Postgres table/column/default/nullability/index transitions reach a fresh create's shape and preserve scalar, JSON and array rows | PGlite catalogs and independent seed values |
| `codecs.fuzz.test.ts` | schema-derived values round-trip through composed codecs and compiled row codecs; malformed wire rows fail with a typed decode error | Effect `TestSchema`, schema equivalence and explicit wire corruptions |
| `registry.fuzz.test.ts` | every dialect and public type export has fuzz coverage or an explicit reason | public exports and the literal catalog |

## Choosing a generator

Use Effect's native `Arbitrary.schema` for schema-shaped values: nested JSON,
UUIDs, booleans, brands and codec properties. `TestSchema.Asserts.verifyRoundTrip`
checks compositional encode/decode behavior. The shared harness accepts native
Effect arbitraries directly, preserving shrinking and replay; do not sample them
into fast-check constants, which loses their shrink tree.

Native properties discard inputs during generation with `Arbitrary.filter` (or
schema checks). Effect's checker has no predicate-level discard result: throwing
`skip()` or `skipKnown()` inside a native predicate fails with a diagnostic. Keep
oracle limitations in native generator filters; rejected candidates do not consume
the run budget, and exhausting generation fails instead of passing with no checks.

Keep fast-check's constrained generators for SQL domains, adversarial strings and
well-typed query/migration plans. A descriptor's wire codec can be broader than
its storage type: `UInt8.schema` accepts finite numbers, while valid SQL inputs
must be integers from 0 to 255. Schema-derived tests complement the independent
database/model oracles rather than replacing them.

Both engines use `FUZZ_RUNS`, `FUZZ_SCALE` and `FUZZ_SEED`. Effect failures print an
opaque replay token; select the failing property and replay with:

```sh
FUZZ_REPLAY='…' bun run fuzz -t 'compiled row codec rejects malformed wire \(postgres\)'
```

`FUZZ_PATH` belongs to fast-check; `FUZZ_REPLAY` belongs to Effect. Replay tokens
are version-specific. Pin important inputs in `regressions.ts` as well. A property
whose every input is discarded now fails, including known-issue skips; hitting a
configured time budget also fails rather than reporting partial coverage as success.

## Adding a dialect

Expand Down
104 changes: 104 additions & 0 deletions tests/fuzz/codecs.fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Schema properties exercise codec composition without a database. SQL-domain
// generators stay separate: a wire number schema is wider than an SQL integer.
import { Arbitrary, Effect, Exit, Schema } from "effect"
import { TestSchema } from "effect/testing"
import { describe, expect, it } from "vitest"
import * as CH from "@maple-dev/effect-orm/clickhouse"
import * as PG from "@maple-dev/effect-orm/postgres"
import * as SQLite from "@maple-dev/effect-orm/sqlite"
import { dialects } from "./dialects"
import { effectOptions, property, withContext } from "./harness"
import { normalize } from "./values"

const boundedText = Schema.String.check(Schema.isMaxLength(32))
const Id = Schema.NonEmptyString.check(Schema.isMaxLength(16)).pipe(Schema.brand("FuzzId"))
const payload = Schema.Struct({
id: Id,
message: boundedText,
values: Schema.Array(Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: -1000, maximum: 1000 })))).check(Schema.isMaxLength(6)),
metadata: Schema.Record(boundedText, Schema.NullOr(boundedText)),
})
const composed = CH.array(CH.nullable(CH.brand(CH.string, Id)))
const transformed = CH.custom("String", Schema.FiniteFromString)
const cases = {
"ClickHouse numeric wire": CH.int64.schema,
"Postgres numeric wire": PG.int8.schema,
"ClickHouse nullable branded arrays": composed.schema,
"ClickHouse maps of arrays": CH.map(CH.string, CH.array(CH.nullable(CH.string))).schema,
"custom transformed values": transformed.schema,
"Postgres jsonb payload": PG.jsonb(payload).schema,
"SQLite json payload": SQLite.json(payload).schema,
"SQLite binary wire": SQLite.blob.schema,
"Postgres nested nullable arrays": PG.array(PG.array(PG.nullable(PG.text))).schema,
} satisfies Record<string, Schema.Codec<any, any>>

describe("Effect codec round-trips", () => {
for (const [name, schema] of Object.entries(cases)) {
it(`${name} survives encode and decode`, () => new TestSchema.Asserts(schema).verifyRoundTrip(effectOptions(100)))
}
})

const row = Schema.Struct({ id: Id, payload, transformed: Schema.FiniteFromString })
const equivalentRows = Schema.toEquivalence(Schema.Array(row))
const columns = {
id: CH.brand(CH.string, Id),
payload: CH.custom("String", Schema.fromJsonString(payload)),
transformed,
}

describe("Effect generated compiled row codecs", () => {
for (const dialect of dialects) {
// These are wire-only codec tests; the custom descriptors need no SQL storage counterpart.
const table = dialect.module.table("codec_rows", { external: true, columns })
const compiled = Effect.runSync(dialect.module.compile(dialect.module.from(table).select()))
const name = `compiled row codec retains generated values (${dialect.name})`
it(name, () => property(name, Arbitrary.schema(row), async (value) => {
const wire = await Effect.runPromise(compiled.encodeRows([value]))
const decoded = await Effect.runPromise(compiled.decodeRows(wire))
await withContext({ wire, decoded }, () => {
expect(equivalentRows(decoded, [value])).toBe(true)
})
}, { runs: 100 }))

const invalidName = `compiled row codec rejects malformed wire (${dialect.name})`
it(invalidName, () => property(invalidName, Arbitrary.all({
value: Arbitrary.schema(row),
corruption: Arbitrary.schema(Schema.Literals(["missing", "null", "json", "brand", "number"])),
}), async ({ value, corruption }) => {
const wire = await Effect.runPromise(compiled.encodeRows([value]))
const bad = { ...wire[0] }
switch (corruption) {
case "missing": delete bad.id; break
case "null": bad.payload = null; break
case "json": bad.payload = "{"; break
case "brand": bad.id = ""; break
case "number": bad.transformed = "not a number"; break
}
const exit = await Effect.runPromiseExit(compiled.decodeRows([wire[0]!, bad]))
await withContext({ corruption, bad, exit }, () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(exit.cause.reasons).toHaveLength(1)
const reason = exit.cause.reasons[0]!
expect(reason._tag).toBe("Fail")
if (reason._tag !== "Fail") return
expect(reason.error._tag).toBe("@maple-dev/effect-orm/CompiledQueryDecodeError")
expect(reason.error.rowIndex).toBe(1)
})
}, { runs: 100 }))
}
})

// A round trip uses the encoder's preferred union branch. Check alternate
// producer forms independently so quoted numeric wire values are covered too.
describe("Effect generated numeric wire representations", () => {
for (const [name, type] of [["clickhouse", CH.float64], ["postgres", PG.float8]] as const) {
const table = CH.table("numeric_rows", { external: true, columns: { value: type } })
const compiled = (name === "postgres" ? PG.compileUnsafe : CH.compileUnsafe)(CH.from(table).select())
const label = `quoted and unquoted numeric wire agree (${name})`
it(label, () => property(label, Arbitrary.schema(Schema.Finite), async (value) => {
const decoded = await Effect.runPromise(compiled.decodeRows([{ value }, { value: String(value) }]))
expect(normalize(decoded)).toEqual(normalize([{ value }, { value }]))
}, { runs: 100 }))
}
})
16 changes: 13 additions & 3 deletions tests/fuzz/dialects/clickhouse.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// 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 { Arbitrary, Schema } from "effect"
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 { dateTimeWire, finite, int32, nastyString, nullOr, utc, wireDateTime, 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
Expand Down Expand Up @@ -101,7 +102,7 @@ const jsonSource = (sqlType: string) => (json: string) =>
const literal = (
id: string,
type: CH.CHType<string, any, any>,
value: fc.Arbitrary<unknown>,
value: LiteralType["value"],
options: { wire?: (value: unknown) => unknown; comparable?: boolean; source?: (value: unknown) => string } = {},
): LiteralType => ({
id: `clickhouse:${id}`,
Expand All @@ -115,8 +116,12 @@ const literal = (
const nasty = nastyString(strings)
const literals: ReadonlyArray<LiteralType> = [
literal("String", CH.string, nasty),
literal("brand(String)", CH.brand(CH.string, Schema.String.pipe(Schema.brand("FuzzText"))), nasty),
literal("custom(Int32)", CH.custom("Int32", CH.int32.schema), int32),
literal("Int32", CH.int32, int32),
literal("Int64", CH.int64, fc.maxSafeInteger()),
literal("UInt8", CH.uint8, fc.nat({ max: 255 })),
literal("UInt16", CH.uint16, fc.nat({ max: 65535 })),
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
Expand All @@ -128,11 +133,16 @@ const literals: ReadonlyArray<LiteralType> = [
return `SELECT reinterpretAsFloat64(toUInt64(${view.getBigUint64(0)})) AS v`
},
}),
literal("Bool", CH.bool, fc.boolean()),
literal("Bool", CH.bool, Arbitrary.schema(CH.bool.schema)),
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("DateTimeString", CH.dateTimeString, fc.integer({ min: MIN_DT / 1000, max: MAX_DT / 1000 }).map((s) => wireDateTime(s * 1000, false))),
literal("DateTime64String", CH.dateTime64String, fc.integer({ min: MIN_DT64, max: MAX_DT64 }).map((ms) => wireDateTime(ms, true))),
literal("LowCardinality(String)", CH.lowCardinality(CH.string), nasty),
literal("SimpleAggregateFunction(max, Int64)", CH.simpleAggregateFunction("max", CH.int64), fc.maxSafeInteger()),
literal("DateTime64(0)", CH.precision(CH.dateTime64, 0), fc.integer({ min: MIN_DT64 / 1000, max: Math.floor(MAX_DT64 / 1000) }).map((s) => utc(s * 1000)), { wire: dateTimeWire(false) }),
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 })),
Expand Down
29 changes: 23 additions & 6 deletions tests/fuzz/dialects/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Postgres: an in-process PGlite, so it always runs.
import fc from "fast-check"
import { DateTime } from "effect"
import { Arbitrary, DateTime, Schema } 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 { finite, int32, jsonPayload, jsonPayloadSchema, nastyString, nullOr, utc, type LiteralType } from "../values"
import { runWith, type FuzzDialect, type StringRules } from "./types"

let pglite: PGlite | undefined
Expand All @@ -29,27 +29,44 @@ const arraySource = (sqlType: string) => (text: string) =>
const literal = (
id: string,
type: PG.PgType<string, any, any>,
value: fc.Arbitrary<unknown>,
options: { wire?: (value: unknown) => unknown; array?: boolean } = {},
value: LiteralType["value"],
options: { wire?: (value: unknown) => unknown; array?: boolean; source?: (json: string) => string; normalizeValue?: (value: unknown) => unknown } = {},
): LiteralType => ({
id: `postgres:${id}`,
type,
value,
wire: options.wire ?? ((v) => v),
source: options.array ? arraySource(type.sql.replace(/\[\]$/, "")) : scalarSource(type.sql),
source: options.source ?? (options.array ? arraySource(type.sql.replace(/\[\]$/, "")) : scalarSource(type.sql)),
comparable: true,
normalizeValue: options.normalizeValue,
})

const nasty = nastyString(strings)
const literals: ReadonlyArray<LiteralType> = [
literal("text", PG.text, nasty),
literal("brand(text)", PG.brand(PG.text, Schema.String.pipe(Schema.brand("FuzzText"))), nasty),
literal("custom(int4)", PG.custom("int4", PG.int4.schema), int32),
literal("uuid", PG.uuid, Arbitrary.schema(Schema.String.check(Schema.isUUID())).pipe(Arbitrary.map((value) => value.toLowerCase()))),
literal("int2", PG.int2, fc.integer({ min: -(2 ** 15), max: 2 ** 15 - 1 })),
literal("int4", PG.int4, int32),
literal("int8", PG.int8, fc.maxSafeInteger()),
// Postgres prints float4's shortest round-trippable decimal, which a JS driver
// parses as float64. Compare in float32 precision, including subnormal/extreme values.
literal("float4", PG.float4, fc.float({ noNaN: true, noDefaultInfinity: true }), {
normalizeValue: (value) => Math.fround(value as number),
}),
literal("float8", PG.float8, finite),
literal("boolean", PG.bool, fc.boolean()),
literal("numeric", PG.numeric, finite),
literal("boolean", PG.bool, Arbitrary.schema(PG.bool.schema)),
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("timestamptzMillis", PG.timestamptzMillis, fc.integer({ min: MIN_TS, max: MAX_TS }), {
wire: (value) => new Date(value as number).toISOString(),
}),
literal("jsonb", PG.jsonb(jsonPayloadSchema), jsonPayload(strings), {
source: (text) => `SELECT ${json(text)}->'v' AS "v"`,
}),
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 }),
Expand Down
Loading
Loading