Add trilean-sql: compile a predicate tree to a parameterised PostgreSQL WHERE fragment - #14
Merged
Merged
Conversation
Mearman
marked this pull request as ready for review
September 3, 2026 11:21
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Mearman
force-pushed
the
refactor/monorepo-restructure
branch
from
September 3, 2026 13:29
3974747 to
2f2fc4a
Compare
Mearman
force-pushed
the
feat/trilean-sql-package
branch
from
September 3, 2026 13:32
b2705b3 to
fb2a48e
Compare
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
force-pushed
the
feat/trilean-sql-package
branch
from
September 3, 2026 13:33
fb2a48e to
4696313
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #13 — base is
refactor/monorepo-restructure, notmain, 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 trileanPredicateNodeand returns{ sql, params }— a self-contained parameterised boolean expression you can drop into aWHERE,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/NOToverTRUE/FALSE/NULLare 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 returnsindeterminatefrom an unresolved reference. A row dropped byWHEREbecause 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, noCASEscaffolding.Two entry points:
compilePredicateNode(node, options), andfindUnpushableNodeKind(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,complexLiteralall throwUnsupportedNodeErrorwith 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 aWHEREclause 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 NULLage, 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
WHEREclause and runs the same tree throughevaluatePredicateonce 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 answerNaN = NaNas TRUE, which is what makes refusing it right). Infinities are deliberately not refused alongside it — both engines order them identically.It's reachable despite
NumberLiteralNodeSchemarejecting NaN, because the compiler takes the inferredPredicateNodetype and TypeScript'snumberincludes 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:
matches/notMatchespattern is matched by the server in PostgreSQL's regular-expression language, not ECMAScript's. Measured:\bada\bmatches "ada" in ECMAScript and nothing in PostgreSQL (where\bis 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.instantLiteralis parsed by PostgreSQL rather than byDate, 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 thanUnsupportedNodeError. Pass offset-bearing ISO-8601.Scope
PostgreSQL only, and
options.dialectis 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.