Skip to content

Add trilean-sql: compile a predicate tree to a parameterised PostgreSQL WHERE fragment - #14

Merged
Mearman merged 7 commits into
mainfrom
feat/trilean-sql-package
Sep 3, 2026
Merged

Add trilean-sql: compile a predicate tree to a parameterised PostgreSQL WHERE fragment#14
Mearman merged 7 commits into
mainfrom
feat/trilean-sql-package

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stacked on #13 — base is refactor/monorepo-restructure, not main, since this adds a second workspace package and needs the workspace layout that PR introduces. Review/merge #13 first.

What this adds

packages/trilean-sql: takes a trilean PredicateNode and returns { sql, params } — a self-contained parameterised boolean expression you can drop into a WHERE, HAVING, CHECK, or a partial index predicate. The point is to let the database apply a rule across a whole table rather than fetching every row and evaluating the tree once per row.

The three-valued logic isn't reimplemented on top of SQL. PostgreSQL's AND/OR/NOT over TRUE/FALSE/NULL are Kleene's strong tables, which are the tables trilean's own connectives implement, and a comparison against a NULL column yields NULL exactly where the evaluator returns indeterminate from an unresolved reference. A row dropped by WHERE because its condition was unknown is dropped for the same reason a subject the evaluator declines to judge is not accepted. No indeterminacy column, no sentinel, no CASE scaffolding.

Two entry points: compilePredicateNode(node, options), and findUnpushableNodeKind(node, options?) for callers that want to choose between pushdown and in-process evaluation without provoking an exception.

Refusal, not degradation

The guard is an allow-list walk, so a node kind added to trilean later is refused by default rather than falling through to whatever branch happened to be last. some, every, fold, lookup, call, delegate, treeReference, conditional, accumulator, arithmetic, negate, durationLiteral, complexLiteral all throw UnsupportedNodeError with the offending node's real path. There is no best-effort fragment and no silently dropped conjunct — the failure mode where an unbacked kind quietly compiles to nothing, leaving a WHERE clause more permissive than the tree it claims to stand for, is the specific thing this design exists to prevent.

Testing

The unit suite asserts compiled SQL text and parameter arrays. That can't establish anything about three-valued behaviour on its own: ("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 starts a real PostgreSQL container, seeds a table carrying real NULLs, and for every case executes the compiled fragment as a WHERE clause and runs the same tree through evaluatePredicate once per row, asserting the two agree on which rows match and which don't. Agreement on absence matters as much as presence — the case that separates three-valued from two-valued logic is the row appearing in neither a predicate nor its negation. Needs Docker; it's a separate vitest project and a separate CI job for that reason.

One real divergence found and fixed while verifying

NaN. trilean compares numbers with ===, under which NaN equals nothing including itself, so a tree comparing NaN matches no subject at all. PostgreSQL defines NaN as equal to itself and greater than every other double, so the same tree compiled to SQL selected every row — a silent, total inversion. Measured against a real server, then refused in the guard, with the integration test asserting both halves (that it's refused, and that PostgreSQL really does answer NaN = NaN as TRUE, which is what makes refusing it right). Infinities are deliberately not refused alongside it — both engines order them identically.

It's reachable despite NumberLiteralNodeSchema rejecting NaN, because the compiler takes the inferred PredicateNode type and TypeScript's number includes NaN; a tree built in code rather than parsed never meets that schema.

Two limits the docs now state explicitly

The "compiles to SQL that agrees row for row, or throws" guarantee is about the tree's structure. Two things sit outside it, both properties of a string operand's content rather than of any node kind, so neither is reachable by a walk over kinds:

  • A matches/notMatches pattern is matched by the server in PostgreSQL's regular-expression language, not ECMAScript's. Measured: \bada\b matches "ada" in ECMAScript and nothing in PostgreSQL (where \b is a backspace); [[:alpha:]]+ matches every name in PostgreSQL and none in ECMAScript. The Regular expressions section already described the hazard — the Refusal section now says that's where the guarantee stops.
  • An instantLiteral is parsed by PostgreSQL rather than by Date, so one carrying no UTC offset is read in the database session's time zone where trilean reads it in the Node process's, and one PostgreSQL can't parse raises a query error at execution time rather than UnsupportedNodeError. Pass offset-bearing ISO-8601.

Scope

PostgreSQL only, and options.dialect is the string literal "postgres" rather than a defaulted field, so a second dialect would be a new value there rather than a change of behaviour for callers who never said which one they meant. Nothing claims SQLite, D1, or MySQL support.

@Mearman
Mearman marked this pull request as ready for review September 3, 2026 11:21
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-03T11:28:56.884751Z b2705b3 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman
Mearman force-pushed the refactor/monorepo-restructure branch from 3974747 to 2f2fc4a Compare September 3, 2026 13:29
@Mearman
Mearman force-pushed the feat/trilean-sql-package branch from b2705b3 to fb2a48e Compare September 3, 2026 13:32
Base automatically changed from refactor/monorepo-restructure to main September 3, 2026 13:32
Adds packages/trilean-sql, a compiler from trilean's PredicateNode to a
boolean SQL expression that can be dropped into a WHERE clause, so a rule
stored as data is applied by the database over a whole table instead of
being evaluated in process once per row.

The third truth value is delegated to SQL rather than reimplemented on top
of it. SQL's AND/OR/NOT over TRUE/FALSE/NULL are Kleene's strong tables,
which are the tables trilean's own combineAnd/combineOr/not implement, and
a comparison against a NULL column yields NULL exactly where the evaluator
returns indeterminate from an unresolved reference. So a row dropped by
WHERE because its condition was unknown is dropped for the same reason, and
by the same rule, as a subject the evaluator declines to judge. No
indeterminacy column, sentinel or CASE scaffolding is emitted.

Every literal becomes a bind parameter; only structure, operators and quoted
identifiers reach the SQL text. A column name cannot be parameterised, so it
is always emitted double-quoted with embedded quotes doubled, which makes an
arbitrary name inert rather than executable.

findUnpushableNodeKind walks the tree against an allow-list and reports the
first node the compiler will not translate, and compilePredicateNode throws
UnsupportedNodeError on any result rather than emitting a partial fragment.
An allow-list rather than a deny-list, so a node kind added to trilean later
is refused by default instead of falling through to whatever branch happened
to be last. It also refuses shapes whose kind is supported but whose meaning
is not portable: a unit-tagged reference or literal, a non-string reference
key, and the operand pairings trilean calls wrong-type where PostgreSQL
would coerce its way to a definite answer.

allowBuilds in pnpm-workspace.yaml gains an entry for each install script the
new dependency tree introduces. pnpm 11 fails an install while any script is
neither allowed nor refused, so all three are recorded as refused: they
arrive under testcontainers and each is optional to the way this workspace
uses it.
… server

The unit suites assert compiled SQL text and parameter arrays per node kind,
which establishes what the compiler emits but nothing about how it behaves:
("age" > $1) is only indeterminate-preserving because of what PostgreSQL's
planner does with a NULL age, and that is a fact about PostgreSQL rather than
about the string.

So the integration suite starts a real PostgreSQL server in an ephemeral
container, seeds a table whose rows carry real NULLs, and for every case both
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. Rows resolve a NULL column to
found: false, so the evaluator is given exactly the knowledge PostgreSQL has
and any disagreement is the compiler's.

Agreement on absence carries the weight: the case that separates three-valued
logic from two-valued is the row that appears in neither a predicate nor its
negation, which is asserted directly, alongside Kleene absorption in both
directions, the NULL-preserving encodings of an empty memberOf, and an
injection attempt that returns no rows and leaves the table standing.
…semantics

Gives the package its own README covering the API, the node-to-SQL table,
why placeholders are always cast, and why an empty memberOf candidate list
compiles to a NULL-preserving form rather than a bare constant.

Two caveats are stated rather than left to be discovered. Declaring paramType
does not change the emitted SQL, but it is the only thing that makes the
operand-kind divergences detectable, so leaving it out means those comparisons
compile and answer differently from the evaluator with nothing to signal it.
And matches/notMatches are handed to PostgreSQL's own regular-expression
operators, whose language is close to ECMAScript's but not the same one.

The workspace README's package table and layout gain the new package.
…compare oppositely

trilean compares numbers with `===`, under which NaN equals nothing including
itself, so a tree comparing NaN matches no subject at all. PostgreSQL defines
NaN as equal to itself and greater than every other double, so the same tree
compiled to SQL selected every row of the table instead -- a silent, total
inversion of the predicate's meaning, and the one double-precision value the
two engines disagree about.

Infinities are deliberately not refused alongside it: both engines order them
identically and compare them equal to themselves, so they translate faithfully.

Reachable despite `NumberLiteralNodeSchema` rejecting NaN, because the compiler
takes the inferred `PredicateNode` type -- TypeScript's `number` includes NaN --
and a tree built in code rather than parsed never meets that schema.

The integration test measures both halves rather than asserting the refusal
alone: a unit test can only show that NaN is refused, not that refusing it was
right, and what makes it right is PostgreSQL's own answer to `NaN = NaN`.
… is refused, not dropped

The existing refusal tests place the unsupported node one level below the root.
The failure mode worth ruling out is the deeper one: a branch with no
translation contributing nothing to the fragment, leaving a WHERE clause
strictly more permissive than the tree it claims to stand for. These bury
`some`, `every` and a `fold`-bearing comparison under an `and`, then an `anyOf`,
then a `not`, and assert the error names the offending kind and its real path.
…r node kinds can see

"Either the whole tree compiles to SQL that agrees with evaluatePredicate row
for row, or UnsupportedNodeError is thrown" was stated as an absolute, and two
measured cases contradict it. Both are properties of a string operand's own
content rather than of any node's kind, so neither is reachable by the
allow-list walk that backs the rest of the claim.

A `matches` pattern is matched by the server in PostgreSQL's regular-expression
language: `\bada\b` matches "ada" in ECMAScript and nothing in PostgreSQL, where
`\b` is a backspace, and `[[:alpha:]]+` matches every name in PostgreSQL and no
name in ECMAScript. The Regular expressions section already described the
hazard; the Refusal section now says that is where the guarantee stops.

An `instantLiteral` is parsed by PostgreSQL rather than by `Date`, so one
carrying no UTC offset is read in the database session's time zone where trilean
reads it in the Node process's, and one PostgreSQL cannot parse raises a query
error at execution time rather than UnsupportedNodeError (trilean answers
indeterminate for the same string).
… ran

The Test job uploaded one hardcoded path, packages/trilean's, from when the
workspace held a single package. With a second package that path is both
incomplete and, on most pull requests, absent: `pnpm test:coverage` runs through
turbo with $TURBO_FLAGS, which carries --affected, so a package no commit in the
PR touched does not run and writes no report.

`fail-on-error: false` does not cover that. It governs the upload call, while a
missing input file fails the action before it reaches one -- the job exited 1 on
`Coverage file not found` with every one of its 64 tests passing.

One upload per package, each labelled by the package it came from, each gated on
its own report existing.
@Mearman
Mearman force-pushed the feat/trilean-sql-package branch from fb2a48e to 4696313 Compare September 3, 2026 13:33
@Mearman
Mearman merged commit 500b44b into main Sep 3, 2026
13 checks passed
@Mearman
Mearman deleted the feat/trilean-sql-package branch September 3, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant