From 8b8892832fa314c89d56de05fa576b8ed84f5756 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 3 Sep 2026 15:59:08 +0100 Subject: [PATCH] test(trilean-sql): check the same compiled fragments against PGlite, which needs no Docker PGlite is PostgreSQL compiled to WebAssembly and run in process, so every construct the postgres dialect emits reaches the same parser and planner, but nothing measured that: the dialect's only executing suite was the container-backed one. pglite.test.ts is that suite's parity harness case for case -- the same seeded subjects fixture and subjectOptions, the same dual execution of each tree through the compiled SQL and through evaluatePredicate per row, the same adversarial set (NULL propagation and absorption, the empty memberOf encoding under negation, NaN, injection via a value and via a hostile columnFor identifier, the deep mixed tree) -- driven through PGlite's own in-process query() instead of testcontainers and pg. Kept a near-verbatim copy rather than a harness parameterised over both engines: agreement by construction is the one thing a parity suite cannot establish. It also makes the three-valued claim measurable on a machine with no Docker daemon, which until now no suite was. --- packages/trilean-sql/README.md | 4 +- packages/trilean-sql/package.json | 1 + .../test/integration/pglite.test.ts | 484 ++++++++++++++++++ packages/trilean-sql/vitest.config.ts | 8 +- pnpm-lock.yaml | 8 + 5 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 packages/trilean-sql/test/integration/pglite.test.ts diff --git a/packages/trilean-sql/README.md b/packages/trilean-sql/README.md index b21c8d0..5f385bf 100644 --- a/packages/trilean-sql/README.md +++ b/packages/trilean-sql/README.md @@ -138,9 +138,9 @@ Left undeclared, these compile, and the divergence is real but invisible. That i The unit suite asserts compiled SQL text and parameter arrays per node kind. It cannot, on its own, establish anything about three-valued behaviour: `("age" > $1)` is only indeterminate-preserving because of what PostgreSQL's planner does with a `NULL` age, which is a fact about PostgreSQL rather than about the string. -So the integration suite (`pnpm test:integration`) starts a real PostgreSQL server in an ephemeral container, seeds a table whose rows carry real `NULL`s, and for every case executes the compiled fragment as a `WHERE` clause *and* evaluates the same tree through trilean's own `evaluatePredicate` once per row, asserting the two agree on which rows match and which do not. Agreement on absence matters as much as on presence: the case that distinguishes three-valued logic from two-valued is the row that appears in neither a predicate nor its negation. +So the integration suite (`pnpm test:integration`) seeds a table whose rows carry real `NULL`s, and for every case executes the compiled fragment as a `WHERE` clause *and* evaluates the same tree through trilean's own `evaluatePredicate` once per row, asserting the two agree on which rows match and which do not. Agreement on absence matters as much as on presence: the case that distinguishes three-valued logic from two-valued is the row that appears in neither a predicate nor its negation. -It needs a working Docker daemon. +That suite runs case for case against two engines. `postgres.test.ts` uses a real PostgreSQL server started as an ephemeral container, and needs a working Docker daemon. `pglite.test.ts` uses [PGlite](https://pglite.dev), which is PostgreSQL itself compiled to WebAssembly and run in process rather than a reimplementation of it, and needs nothing beyond Node — so the parity claim above is measurable on any machine, Docker or not, and PGlite is a verified target of the `postgres` dialect rather than an assumed one. ## Licence diff --git a/packages/trilean-sql/package.json b/packages/trilean-sql/package.json index 28bbe42..e93ea8e 100644 --- a/packages/trilean-sql/package.json +++ b/packages/trilean-sql/package.json @@ -79,6 +79,7 @@ ], "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", + "@electric-sql/pglite": "^0.5.8", "@exadev/eslint-config": "^2.10.2", "@testcontainers/postgresql": "^12.1.0", "@types/node": "^26.4.0", diff --git a/packages/trilean-sql/test/integration/pglite.test.ts b/packages/trilean-sql/test/integration/pglite.test.ts new file mode 100644 index 0000000..7739027 --- /dev/null +++ b/packages/trilean-sql/test/integration/pglite.test.ts @@ -0,0 +1,484 @@ +import { PGlite } from "@electric-sql/pglite"; +import type { + ComputedValue, + JsonValue, + PredicateNode, + Resolution, + Resolvers, +} from "trilean"; +import { evaluatePredicate } from "trilean"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { subjectOptions } from "../../src/test-support/columns"; + +/** + * The same measured claim as `postgres.test.ts`, against PGlite instead of a server in a container. + * + * PGlite is PostgreSQL itself compiled to WebAssembly and run in this process, not a reimplementation of it or a dialect of its own, so the compiler needs no `dialect` value for it: every construct emitted under `dialect: "postgres"` -- `$N::type` casts, `~`/`!~`, double-quoted identifiers, `NULL::boolean` -- is parsed by the same parser and evaluated by the same planner. That is the claim this file exists to establish rather than assume, which is why it is a full parity suite rather than a smoke test: each case compiles a tree, executes the fragment as a real `WHERE` clause, and compares the rows against the ones trilean's own evaluator judges `definite(true)` for the same tree, exactly as the container-backed suite does. + * + * Keeping it a near-verbatim structural copy is deliberate. A shared harness parameterised over both engines would make the two suites agree by construction, and agreement by construction is the one thing this cannot establish -- the point is that two independently-driven executions of the same compiled SQL reach the same rows. The divergence between the files is therefore confined to how a connection is opened and a statement is run. + * + * It also needs no Docker daemon, so unlike the container-backed suite it runs anywhere Node does. + */ + +const SCHEMA = ` + CREATE TABLE subjects ( + id text PRIMARY KEY, + age double precision, + name text, + active boolean, + joined timestamptz, + note text + ); +`; + +interface SubjectRow { + id: string; + age: number | null; + name: string | null; + active: boolean | null; + joined: string | null; + note: string | null; +} + +/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ +const SUBJECTS: readonly SubjectRow[] = [ + { + id: "ada", + age: 30, + name: "ada", + active: true, + joined: "2020-01-01T00:00:00Z", + note: "hello", + }, + { + id: "grace", + age: 12, + name: "grace", + active: false, + joined: "2024-06-01T12:00:00Z", + note: "hi", + }, + { + id: "lin", + age: null, + name: "lin", + active: true, + joined: "2021-03-03T00:00:00Z", + note: null, + }, + { + id: "unknown", + age: 45, + name: null, + active: null, + joined: null, + note: null, + }, +]; + +/** + * Resolves a reference key against one row, mapping a NULL column to `found: false`. + * + * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. + */ +function resolversFor(row: Readonly): Resolvers { + const known: Record = { + ...(row.age !== null && { + age: { kind: "number", value: row.age }, + }), + ...(row.name !== null && { name: { kind: "text", value: row.name } }), + ...(row.note !== null && { note: { kind: "text", value: row.note } }), + ...(row.active !== null && { + active: { kind: "boolean", value: row.active }, + }), + ...(row.joined !== null && { + joined: { kind: "instant", value: row.joined }, + }), + }; + + return { + resolveValue: async (key: JsonValue) => { + const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( + value === undefined ? { found: false } : { found: true, value }, + ); + }, + resolveLookup: () => { + throw new Error("no tree in this suite uses a lookup"); + }, + resolveCollection: () => { + throw new Error("no tree in this suite uses a collection"); + }, + }; +} + +let db: PGlite; + +beforeAll(async () => { + // No connection string, no port, no container: an in-memory database that exists for the lifetime of this process. + db = new PGlite(); + await db.exec(SCHEMA); + for (const row of SUBJECTS) { + await db.query( + "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", + [row.id, row.age, row.name, row.active, row.joined, row.note], + ); + } +}); + +afterAll(async () => { + await db.close(); +}); + +async function selectMatching(node: PredicateNode): Promise { + const compiled = compilePredicateNode(node, subjectOptions); + const result = await db.query<{ id: string }>( + `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, + compiled.params, + ); + return result.rows.map((row) => row.id); +} + +async function evaluatorMatching(node: PredicateNode): Promise { + const matched: string[] = []; + for (const row of SUBJECTS) { + const evaluation = await evaluatePredicate( + node, + undefined, + resolversFor(row), + ); + if (evaluation.status === "definite" && evaluation.value) { + matched.push(row.id); + } + } + return matched.sort(); +} + +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. */ +async function agreeingRows(node: PredicateNode): Promise { + const [viaSql, viaEvaluator] = await Promise.all([ + selectMatching(node), + evaluatorMatching(node), + ]); + expect(viaSql).toEqual(viaEvaluator); + return viaSql; +} + +describe("comparisons against a column that can be NULL", () => { + const olderThan18: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }; + + it("excludes the row whose age is unknown", async () => { + await expect(agreeingRows(olderThan18)).resolves.toEqual([ + "ada", + "unknown", + ]); + }); + + it("still excludes it under negation, which two-valued logic could not do", async () => { + // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in PostgreSQL exactly as `not(indeterminate)` is indeterminate in trilean. + await expect( + agreeingRows({ kind: "not", operand: olderThan18 }), + ).resolves.toEqual(["grace"]); + }); + + it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { + await expect( + agreeingRows({ + kind: "anyOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "lin" }, + }, + ], + }), + ).resolves.toEqual(["ada", "lin", "unknown"]); + }); + + it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { + // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. + await expect( + agreeingRows({ + kind: "not", + operand: { + kind: "allOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "nobody" }, + }, + ], + }, + }), + ).resolves.toEqual(["ada", "grace", "lin"]); + }); + + it("compares instants across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("compares booleans for equality across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: true }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); +}); + +describe("exists", () => { + const hasNote: PredicateNode = { + kind: "exists", + operand: { kind: "reference", key: "note" }, + }; + + it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { + const present = await agreeingRows(hasNote); + const absent = await agreeingRows({ kind: "not", operand: hasNote }); + expect(present).toEqual(["ada", "grace"]); + expect(absent).toEqual(["lin", "unknown"]); + expect([...present, ...absent].sort()).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + }); +}); + +describe("textCompare", () => { + it("matches a pattern with PostgreSQL's own regular-expression operator", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }), + ).resolves.toEqual(["ada", "grace"]); + }); + + it("leaves a NULL operand unknown under a negated match", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "notMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).resolves.toEqual(["grace", "lin"]); + }); +}); + +describe("memberOf", () => { + it("matches a candidate list", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "ada" }, + { kind: "textLiteral", value: "nobody" }, + ], + }), + ).resolves.toEqual(["ada"]); + }); + + it("leaves NOT IN unknown for a NULL operand", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "ada" }], + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { + // Executed rather than asserted as a string, because `(x IS NULL AND NULL::boolean)` is only the right encoding if PostgreSQL really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. + const node: PredicateNode = { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual([]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + ["ada", "grace", "lin"], + ); + }); + + it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { + const node: PredicateNode = { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + [], + ); + }); +}); + +describe("degenerate and adversarial fragments", () => { + it("executes a comparison between two literals, which needs both placeholders typed", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + }); + + it("executes an empty allOf and anyOf as their identities", async () => { + await expect( + agreeingRows({ kind: "allOf", operands: [] }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + await expect( + agreeingRows({ kind: "anyOf", operands: [] }), + ).resolves.toEqual([]); + }); + + it("refuses NaN, and measures the divergence that refusal exists to prevent", async () => { + // The refusal is in the guard, so this could have been a unit test -- but a unit test could only assert that NaN is refused, not that refusing it was right. What makes it right is a fact about PostgreSQL: it defines NaN as equal to itself, so `NaN = NaN` there is TRUE and would have selected the whole table, while trilean's `===` makes the same tree match nothing. Both halves are measured here, and the first of them is a fact about PGlite worth measuring separately from the server: a WASM build could in principle have shipped a different float comparison, and a driver that could not bind NaN at all -- as better-sqlite3 cannot -- would substitute NULL and produce the opposite row set rather than the same one. + const node: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: Number.NaN }, + right: { kind: "numberLiteral", value: Number.NaN }, + }; + expect(() => compilePredicateNode(node, subjectOptions)).toThrow( + /cannot compile 'numberLiteral'/, + ); + + const wouldHaveMatched = await db.query<{ id: string }>( + "SELECT id FROM subjects WHERE ($1::double precision = $2::double precision) ORDER BY id", + [Number.NaN, Number.NaN], + ); + expect(wouldHaveMatched.rows.map((row) => row.id)).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + await expect(evaluatorMatching(node)).resolves.toEqual([]); + }); + + it("treats an injection attempt as data and leaves the table standing", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { + kind: "textLiteral", + value: "ada'; DROP TABLE subjects; --", + }, + }), + ).resolves.toEqual([]); + + const surviving = await db.query<{ count: string }>( + "SELECT count(*)::text AS count FROM subjects", + ); + expect(surviving.rows[0]?.count).toBe(String(SUBJECTS.length)); + }); + + it("neutralises a hostile column name into one identifier the server rejects", async () => { + // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that PostgreSQL reads it as a single inert identifier: this executes it, and the server refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. + const hostile: SqlCompileOptions = { + dialect: "postgres", + columnFor: () => ({ column: `name" = name OR "1` }), + }; + const compiled = compilePredicateNode( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + hostile, + ); + expect(compiled.sql).toBe(`("name"" = name OR ""1" = $1::text)`); + + await expect( + db.query( + `SELECT id FROM subjects WHERE ${compiled.sql}`, + compiled.params, + ), + ).rejects.toThrow(/does not exist/i); + }); +}); + +describe("a tree deep enough to mix every supported kind", () => { + it("agrees with the evaluator row for row", async () => { + const node: PredicateNode = { + kind: "anyOf", + operands: [ + { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }, + right: { + kind: "not", + operand: { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "grace" }], + }, + }, + }, + { + kind: "allOf", + operands: [ + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { + kind: "or", + left: { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "note" }, + right: { kind: "textLiteral", value: "^h" }, + }, + right: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + }, + ], + }, + ], + }; + + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace"]); + }); +}); diff --git a/packages/trilean-sql/vitest.config.ts b/packages/trilean-sql/vitest.config.ts index 569aa81..cd60a12 100644 --- a/packages/trilean-sql/vitest.config.ts +++ b/packages/trilean-sql/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from "vitest/config"; -// Two projects, mirroring packages/trilean's own split: "unit" covers the compiler itself (pure string and parameter production, no I/O), "integration" runs the compiled fragments against a real PostgreSQL server started as an ephemeral container. +// Two projects, mirroring packages/trilean's own split: "unit" covers the compiler itself (pure string and parameter production, no I/O), "integration" runs the compiled fragments against a real PostgreSQL engine. // -// The integration project is where the three-valued-logic claim is actually tested rather than asserted: a compiled fragment's truth table is only meaningful once PostgreSQL's own planner has evaluated it against real NULLs, so those tests execute SQL rather than comparing strings. It needs a working Docker daemon (testcontainers), which is why it is a separate project and a separate CI job rather than part of the default `pnpm test`. +// The integration project is where the three-valued-logic claim is actually tested rather than asserted: a compiled fragment's truth table is only meaningful once PostgreSQL's own planner has evaluated it against real NULLs, so those tests execute SQL rather than comparing strings. Every file under test/integration/ belongs to it, and the two there today run the same parity suite against two ways of reaching that planner: postgres.test.ts against a server started as an ephemeral container, which needs a working Docker daemon (testcontainers), and pglite.test.ts against PGlite, the same PostgreSQL compiled to WebAssembly and run in process, which needs nothing beyond Node. The Docker requirement of the first is why the project as a whole is separate from the default `pnpm test` and has its own CI job. +// +// One project rather than one per engine, because they are the same tests: `vitest run --project integration` is the whole integration surface, and a third engine is a new file rather than new configuration. export default defineConfig({ test: { coverage: { @@ -22,7 +24,7 @@ export default defineConfig({ test: { name: "integration", include: ["test/integration/**/*.test.ts"], - // Starting the container, applying the schema and seeding it happens once for the whole file; a cold `docker pull` on a runner with no cached image dominates that. The default 5s would fail on image pull alone. + // Starting the engine, applying the schema and seeding it happens once for the whole file; a cold `docker pull` on a runner with no cached image dominates that. The default 5s would fail on image pull alone. PGlite needs a fraction of it -- instantiating a WASM module rather than pulling an image -- but the timeout is the project's, and a generous one costs a suite that passes nothing. testTimeout: 120_000, hookTimeout: 300_000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf025bc..ed2d1cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: '@arethetypeswrong/cli': specifier: ^0.18.5 version: 0.18.5 + '@electric-sql/pglite': + specifier: ^0.5.8 + version: 0.5.8 '@exadev/eslint-config': specifier: ^2.10.2 version: 2.10.4(eslint@10.9.1(jiti@2.6.1))(typescript-eslint@8.69.0(eslint@10.9.1(jiti@2.6.1))(typescript@6.0.3))(typescript@6.0.3) @@ -406,6 +409,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@electric-sql/pglite@0.5.8': + resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -4174,6 +4180,8 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@electric-sql/pglite@0.5.8': {} + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1