feat(trilean-sql): compile a real SQLite dialect alongside PostgreSQL - #20
Merged
Conversation
Mearman
marked this pull request as ready for review
September 3, 2026 15:31
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. |
`SqlCompileOptions.dialect` widens from the literal `"postgres"` to `SqlDialect`, and the three things that genuinely differ between the two engines move behind a `DialectConfig` record read once per compilation: the regular-expression operator `matches`/`notMatches` emits (`~`/`!~` against `REGEXP`/`NOT REGEXP`), the placeholder form (`$N::type` against a bare `?`), and the boolean annotation on the bare `NULL` an empty `memberOf` compiles to (`::boolean` against nothing, SQLite having no boolean type to annotate). Everything else the compiler emits is ANSI-standard and stays unbranched: the six comparison operators, `=`/`<>`, the connectives, `IN`/`NOT IN`, `IS NOT NULL`, and double-quoted identifiers with an embedded quote doubled. The PostgreSQL dialect's compiled output is unchanged, byte for byte. Which nodes the guard refuses is unchanged too, and identical for both dialects: SQLite's type affinity produces the same silent definite answers where trilean returns wrong-type, so every refusal carries over. Only the reason text varies, and the NaN refusal needed its own wording rather than PostgreSQL's reused: SQLite has no NaN at all, and a driver binding one substitutes SQL NULL, so `NaN = NaN` is indeterminate there rather than definitely false -- a divergence in the opposite direction from PostgreSQL's NaN-equals-itself.
The engine the SQLite integration suite runs compiled fragments against, in memory and in process, so that suite needs no Docker daemon the way the PostgreSQL one does. Pinned to 12.x rather than 13.x deliberately: 13 requires Node 22 or newer, which is narrower than the `>=20` this package declares it supports, and 12.11.1's own engines range still covers that floor. It is a native addon, so its install script has to be allowed in `pnpm-workspace.yaml` -- that script is what fetches the binding the module cannot load without, unlike the three refused entries beside it, whose scripts are optional to how this workspace uses them.
The same dual-execution parity harness the PostgreSQL suite uses, over the same fixture: compile a tree, run the fragment as a real `WHERE` clause, separately evaluate the same tree through `evaluatePredicate` once per row, and assert the two agree on which rows match and which do not. It is more than a second run of an already-proven suite because SQLite reaches its three-valued behaviour from a different starting point -- no boolean type, no timestamp type, no NaN, and affinity that coerces where PostgreSQL rejects. Three cases measure the divergences the guard's inherited refusals exist to prevent rather than only asserting that each fires: NaN binding as SQL NULL (so the negated comparison matches nothing where trilean matches every row), a TEXT-affinity column compared lexicographically against a number (`'9' > 5` true, `'10' > 5` false), and a boolean ordered as the integer it is stored as. Two properties of the connection are load-bearing and asserted rather than assumed: an unregistered `REGEXP` fails as a query error naming the missing function rather than answering wrongly, and a registered one must return NULL for a NULL argument, since SQLite does not propagate NULL through a user function and one answering 0 would make `NOT REGEXP` true for a row whose value is unknown.
…ment A Dialects section covering what differs (the two regex operators, the uncast `?` placeholder, the missing boolean annotation) and what does not, plus the two things a SQLite caller has to supply that a PostgreSQL caller does not: a `regexp` function, and booleans bound as 0/1, since drivers do not agree that a JS boolean is bindable at all. The Regular expressions section now carries the concrete better-sqlite3 registration, with both of its non-obvious details spelled out -- return NULL for a NULL argument or `NOT REGEXP` answers TRUE for a row whose value is unknown, and return 1/0 rather than a boolean, which the driver rejects from a user function. An unregistered `REGEXP` is a query error naming the missing function, which is what puts this in the same class as the regex-dialect and instant-parsing caveats already documented for PostgreSQL rather than making it a hole in the compile-time guarantee. Refusal now states that the whole refusal set applies to both dialects and only the reason text varies, with the NaN entry describing each engine's own mechanism.
Both public entry points index a per-dialect table with `options.dialect`, and neither checked that the dialect has an entry. A name outside the union reached `compilePredicateNode` as `TypeError: Cannot read properties of undefined (reading 'matches')`, naming an internal field rather than the dialect, and reached `findUnpushableNodeKind` as the answer `undefined` for a tree the compiler then failed on -- so the one question that function exists to answer, whether a tree can be pushed down, was answered wrongly. `SqlDialect` is a closed union, so this is unreachable from TypeScript source naming a dialect literally. It is reachable from a dialect read from configuration and asserted into the union at the boundary, which is how a dialect is realistically supplied, and is the same class of input the package already validates by name elsewhere: a `columnFor` result that is not a usable identifier, and an unrecognised node kind. `UnknownDialectError` joins the two existing error classes, carrying the offending name and the implemented ones as fields, and `DIALECT_CONFIG`'s own keys are the list, so implementing a dialect cannot leave the check behind.
Mearman
force-pushed
the
feat/sqlite-dialect
branch
from
September 3, 2026 15:50
bbb4b58 to
3993184
Compare
|
🎉 This PR is included in version 1.1.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.
Adds a real SQLite dialect to
trilean-sql, soSqlCompileOptions.dialectbecomes"postgres" | "sqlite"rather than a single literal. D1 is SQLite, which is the domain that motivated this package existing, so this is the second dialect the field's doc comment was always anticipating.What actually differs between the two
Three things, and nothing else, which is why this is a small
DialectConfigrecord inoptions.tsrather than a module per dialect or aDialectinterface with a method per node kind:matches/notMatchesemitREGEXP/NOT REGEXPinstead of~/!~?instead of$N::type— SQLite binds by position in emission order and has no type to cast toNULLin an emptymemberOfloses its::booleanannotation, since SQLite has no boolean type to annotateEverything else the compiler emits is ANSI-standard and identical in both engines: the connectives, the six comparison operators,
=/<>,IN/NOT IN,IS NOT NULL, and double-quoted identifiers with an embedded quote doubled. Branching anywhere else would be a branch that can never change the output.PostgreSQL's compiled output is unchanged, byte for byte. Beyond the existing unit assertions (which assert exact SQL text) and the existing container-backed
postgres.test.tsstill passing untouched, this was checked differentially: 40,000 randomly generated trees compiled throughmain's builtdistand this branch's, comparing both the compiled{sql, params}and everyfindUnpushableNodeKindresult, with and without options — 80,000 comparisons, zero differences.Why the guard refuses the same set for both
The refusal set carries over from PostgreSQL unchanged in structure, and that inheritance is the part that needed evidence rather than assertion — it would be worth nothing if SQLite happened to agree with trilean where PostgreSQL does not. Each one was measured against a real
better-sqlite3connection, andtest/integration/sqlite.test.tskeeps measuring it:compare. ATEXT-affinity column compared against the number 5 is compared as text:'9' > 5is true,'10' > 5is not. SQLite's coercion is worse here than PostgreSQL's, not better — no error, no warning, just the wrong rows.gtanswers definitely where trilean has no ordering at all.'abc' > 5, with no column or affinity involved, answerstruerather than erroring.NULL:typeof(?)bound withNaNanswers'null', andNaN = NaNis therefore indeterminate rather than definitely false. That looks like agreement until you negate it, at which point trilean's definitetruematches every row and SQLite'sNULLstill matches none. So the SQLite refusal carries its own wording rather than PostgreSQL's "NaN is equal to itself and greater than every other double", which would be simply false about SQLite.The refusal structure is shared:
guard.tskeeps one walk and one refusal per divergence, parameterised by aDialectDivergencerecord supplying only the reason text. There is no second copy of the walk.Infinities are still deliberately not refused, and that was checked rather than assumed:
better-sqlite3binds them asreal,Inf = Infis 1, and ordering against finite values matches trilean's.What a SQLite caller has to supply
Two things, both of which fail loudly rather than silently, and both documented in the README:
REGEXPfunction, if the tree usesmatches/notMatches. SQLite reservesREGEXPas syntax for aregexp(pattern, value)function it does not itself provide. An unregistered one is a query error —no such function: REGEXP— never a fragment that quietly matches nothing, which is what makes leaving it to the caller acceptable rather than a hole in the guarantee. The README gives the concretedb.function("regexp", ...)registration, including the two load-bearing details: returnnullfor a NULL argument (SQLite does not propagate NULL through a user function on its own, and one answering0would makeNOT REGEXPtrue for a row whose value is unknown — exactly the two-valued collapse this package exists to avoid), and return1/0rather than a JS boolean, which better-sqlite3 rejects from a user function.0/1.paramscarries the tree's own literals unchanged in every dialect, and better-sqlite3 rejects a JS boolean outright.Instants are the caveat to be deliberate about: SQLite has no timestamp type, so an
instantLiteralis compared as text. Offset-bearing ISO-8601 in a single common offset sorts chronologically and compares correctly; mixed offsets do not. This is the SQLite counterpart of the existing PostgreSQL session-time-zone caveat, and the same advice ("pass offset-bearing ISO-8601") resolves both.One fix on top of the dialect work
bbb4b58fixes a defect the dialect dispatch introduced. Both entry points index a per-dialect table withoptions.dialectand neither checked the dialect has an entry, so a name outside the union — unreachable from TypeScript source naming a dialect literally, but entirely reachable from a dialect read from configuration and asserted into the union at the boundary — surfaced asTypeError: Cannot read properties of undefined (reading 'matches')fromcompilePredicateNode, and, worse, as the answerundefinedfromfindUnpushableNodeKindfor a tree the compiler then failed on. Reporting a tree as pushable is a promise that it will compile; under a dialect that does not exist it cannot. Both now refuse by name withUnknownDialectError, which carries the offending name and the implemented ones and takes its list fromDIALECT_CONFIG's own keys, so implementing a dialect cannot leave the check behind. Before this branch there was nothing to get wrong here —mainignored the field entirely — so it is a regression this branch introduced rather than a pre-existing gap.Out of scope, deliberately
No third dialect. No
ORDER BY/JOIN/collection-quantifier support —some/every/foldstay refused for both. No bundled or auto-registeredregexpfunction; that stays the caller's documented responsibility.Verification
Run from a clean clone of this branch, not from cached state:
pnpm build,pnpm typecheck,pnpm lintcleanpnpm test— 89 unit tests intrilean-sql(the pre-existing PostgreSQL string assertions untouched and passing), 444 intrileanpnpm test:integration— 42 intrilean-sql:postgres.test.tsunchanged and green against a real container,sqlite.test.tsgreen in memorymain-vs-branch PostgreSQL comparison described aboveWHEREclauses against a seededbetter-sqlite3table, and compared row for row againstevaluatePredicateover the same rows — zero disagreementscolumnForname (both anOR-injection and a statement-break attempt) becomes one quoted identifier the engine rejects as missing, with the table still standing.tool-versionspins Node 22, which is what CI uses, so better-sqlite3 resolves a prebuilt binary rather than compiling from source. Its install script is allowed inpnpm-workspace.yamlfor that reason — it is a native addon, and refusing the script would leave the package installed and unloadable.