From e3ffe925566feb1f894446f01dcd3751d561b84c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 6 Sep 2026 06:00:17 +0100 Subject: [PATCH] feat(trilean-sql): refuse matches/notMatches at compile time when SQLite REGEXP is unavailable DIALECT_CONFIG.sqlite always compiled textCompare's matches/notMatches to REGEXP/NOT REGEXP, which only works when the caller can register a regexp(pattern, value) function on the connection. Some SQLite-wire-compatible targets, such as Cloudflare D1's Workers Binding API, have no hook for registering one at all, so the emitted SQL always failed at query execution time with "no such function: REGEXP" instead of being caught at compile time the way every other unpushable shape is. Add sqliteRegexpAvailable to SqlCompileOptions, defaulting to true so existing callers see no change. Set to false, it makes findUnpushableNodeKind report matches/notMatches as unpushable under the sqlite dialect and compilePredicateNode throw UnsupportedNodeError for the same tree, so a caller targeting a registration-less connection falls back to in-process evaluation instead of shipping a query guaranteed to fail. The flag has no effect under postgres, which matches natively with ~/!~ and never needs a registered function. --- packages/trilean-sql/README.md | 4 +- packages/trilean-sql/src/compile.test.ts | 57 +++++++++++++++++ packages/trilean-sql/src/compile.ts | 1 + packages/trilean-sql/src/guard.test.ts | 63 +++++++++++++++++++ packages/trilean-sql/src/guard.ts | 11 ++++ packages/trilean-sql/src/options.ts | 6 ++ .../test/integration/sqlite.test.ts | 15 +++++ 7 files changed, 156 insertions(+), 1 deletion(-) diff --git a/packages/trilean-sql/README.md b/packages/trilean-sql/README.md index 5be5e28..61e1216 100644 --- a/packages/trilean-sql/README.md +++ b/packages/trilean-sql/README.md @@ -123,7 +123,7 @@ What compiles, what is refused, and the row-for-row agreement with `evaluatePred Two things a SQLite caller has to supply that a PostgreSQL caller does not, both of which fail loudly rather than silently: -- **A `REGEXP` function**, if the tree uses `matches` or `notMatches`. See [Regular expressions](#regular-expressions). +- **A `REGEXP` function**, if the tree uses `matches` or `notMatches` — unless you set `sqliteRegexpAvailable: false`, in which case those two are refused at compile time instead. See [Regular expressions](#regular-expressions). - **Booleans bound as `0`/`1`.** SQLite has no boolean type, and drivers do not agree on whether a JS boolean is bindable at all — better-sqlite3 rejects one outright (*"SQLite3 can only bind numbers, strings, bigints, buffers, and null"*). `params` carries the tree's own literals unchanged in every dialect, so converting them is the binding caller's job: `params.map((v) => (typeof v === "boolean" ? Number(v) : v))`. A dialect this version does not implement is refused by name, from `compilePredicateNode` and `findUnpushableNodeKind` alike, with `UnknownDialectError`. `SqlDialect` is a closed union so TypeScript source cannot reach that, but a dialect read from configuration and asserted into the union at the boundary can, and reporting such a tree as pushable would promise a compilation that cannot happen. @@ -173,6 +173,8 @@ db.function("regexp", (pattern, text) => Both details are load-bearing rather than stylistic. Returning `null` for a NULL argument is what keeps the third value intact: SQLite does not propagate NULL through a user function on its own, so one answering `0` for a NULL value would make `NOT REGEXP` answer `TRUE` for a row whose value is unknown — the two-valued collapse this package exists to avoid. Returning `1`/`0` rather than a JS boolean is what better-sqlite3 accepts; a boolean is rejected from a user function (*"returned an invalid value"*) for the same reason it is rejected as a bound parameter. +Some SQLite-wire-compatible targets have no way to register a function at all — Cloudflare D1's Workers Binding API is the motivating case, with no hook for it in its API and [cloudflare/workers-sdk#2802](https://github.com/cloudflare/workers-sdk/issues/2802) still open. Against a target like that, the registration above is simply not possible, and compiling `matches`/`notMatches` to `REGEXP`/`NOT REGEXP` anyway produces SQL that always fails at query execution with `no such function: REGEXP` rather than failing at compile time the way every other unpushable shape does. Set `sqliteRegexpAvailable: false` in `SqlCompileOptions` for a target like this, and the SQLite dialect refuses `matches`/`notMatches` with `UnsupportedNodeError` — and `findUnpushableNodeKind` reports them unpushable — at compile time instead, so the caller falls back to in-process evaluation the same way it would for any other unpushable node. It defaults to `true`, so a caller with a registered function (better-sqlite3, say) sees no change. It has no effect under the `postgres` dialect, which never needs a registered function in the first place. + ## Tests 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. diff --git a/packages/trilean-sql/src/compile.test.ts b/packages/trilean-sql/src/compile.test.ts index 953f690..8986e67 100644 --- a/packages/trilean-sql/src/compile.test.ts +++ b/packages/trilean-sql/src/compile.test.ts @@ -611,6 +611,63 @@ describe("the sqlite dialect", () => { }); }); +describe("sqliteRegexpAvailable", () => { + const patternMatch: PredicateNode = { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }; + + it("still compiles to REGEXP when the flag is unset, preserving existing behaviour", () => { + expect(compile(patternMatch, sqliteSubjectOptions)).toEqual({ + sql: '("name" REGEXP ?)', + params: ["^a"], + }); + }); + + it("still compiles to REGEXP when the flag is explicitly true", () => { + expect( + compile(patternMatch, { + ...sqliteSubjectOptions, + sqliteRegexpAvailable: true, + }), + ).toEqual({ sql: '("name" REGEXP ?)', params: ["^a"] }); + }); + + it("refuses matches/notMatches at compile time once the flag is false", () => { + const options: SqlCompileOptions = { + ...sqliteSubjectOptions, + sqliteRegexpAvailable: false, + }; + expect(() => compile(patternMatch, options)).toThrow(UnsupportedNodeError); + expect(() => + compile({ ...patternMatch, op: "notMatches" }, options), + ).toThrow(UnsupportedNodeError); + }); + + it("agrees with findUnpushableNodeKind rather than only compilePredicateNode's own check", () => { + const options: SqlCompileOptions = { + ...sqliteSubjectOptions, + sqliteRegexpAvailable: false, + }; + expect(findUnpushableNodeKind(patternMatch, options)).toMatchObject({ + kind: "textCompare", + path: "$", + }); + expect(() => compile(patternMatch, options)).toThrow(UnsupportedNodeError); + }); + + it("has no effect on the postgres dialect, which matches natively with '~'", () => { + expect( + compile(patternMatch, { + ...subjectOptions, + sqliteRegexpAvailable: false, + }), + ).toEqual({ sql: '("name" ~ $1::text)', params: ["^a"] }); + }); +}); + describe("a dialect this version does not implement", () => { // `SqlDialect` is closed, so this is what a caller reading the name from configuration and asserting it into the union at the boundary reaches -- the only way an unimplemented name gets this far, and the reason the assertion is here rather than in the source under test. const unimplemented = "mysql" as SqlDialect; diff --git a/packages/trilean-sql/src/compile.ts b/packages/trilean-sql/src/compile.ts index 79e9f5c..f7ea913 100644 --- a/packages/trilean-sql/src/compile.ts +++ b/packages/trilean-sql/src/compile.ts @@ -239,6 +239,7 @@ export function compilePredicateNode( bindings.set(referenceKey, binding); return binding; }, + sqliteRegexpAvailable: options.sqliteRegexpAvailable, }; const unpushable = findUnpushableNodeKind(node, memoised); diff --git a/packages/trilean-sql/src/guard.test.ts b/packages/trilean-sql/src/guard.test.ts index bc8d536..f2853f3 100644 --- a/packages/trilean-sql/src/guard.test.ts +++ b/packages/trilean-sql/src/guard.test.ts @@ -332,6 +332,69 @@ describe("operand kinds trilean and PostgreSQL would answer differently", () => }); }); +describe("sqliteRegexpAvailable", () => { + const patternMatch: PredicateNode = { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }; + + const negatedPatternMatch: PredicateNode = { + ...patternMatch, + op: "notMatches", + }; + + it.each([ + ["unset", sqliteSubjectOptions], + ["true", { ...sqliteSubjectOptions, sqliteRegexpAvailable: true }], + ])( + "leaves matches/notMatches pushable when the flag is %s", + (_label, options) => { + expect(findUnpushableNodeKind(patternMatch, options)).toBeUndefined(); + expect( + findUnpushableNodeKind(negatedPatternMatch, options), + ).toBeUndefined(); + }, + ); + + it.each([patternMatch, negatedPatternMatch])( + "refuses '%s' under sqlite once the flag is false", + (node) => { + expect( + findUnpushableNodeKind(node, { + ...sqliteSubjectOptions, + sqliteRegexpAvailable: false, + }), + ).toMatchObject({ kind: "textCompare", path: "$" }); + }, + ); + + it("has no effect on the postgres dialect, which never needs it", () => { + expect( + findUnpushableNodeKind(patternMatch, { + ...subjectOptions, + sqliteRegexpAvailable: false, + }), + ).toBeUndefined(); + }); + + it("leaves an equals/notEquals textCompare pushable regardless of the flag", () => { + const equality: PredicateNode = { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }; + expect( + findUnpushableNodeKind(equality, { + ...sqliteSubjectOptions, + sqliteRegexpAvailable: false, + }), + ).toBeUndefined(); + }); +}); + describe("refusal reasons are worded for the dialect they describe", () => { // Which trees are refused is a property of the divergence, not of the dialect: every pairing below is answered definitely by both engines and wrong-typed by trilean, so both dialects refuse all of them. What changes is the explanation, and each dialect's has to name the mechanism that actually applies to it -- a reason describing PostgreSQL's NaN ordering would be simply false about SQLite, which has no NaN at all. diff --git a/packages/trilean-sql/src/guard.ts b/packages/trilean-sql/src/guard.ts index 2cb57b2..6c13a98 100644 --- a/packages/trilean-sql/src/guard.ts +++ b/packages/trilean-sql/src/guard.ts @@ -331,6 +331,17 @@ function findUnpushablePredicate( return undefined; } case "textCompare": { + if ( + (node.op === "matches" || node.op === "notMatches") && + options?.dialect === "sqlite" && + options.sqliteRegexpAvailable === false + ) { + return { + kind: node.kind, + path, + reason: `options.sqliteRegexpAvailable is false: the caller has stated no regexp(pattern, value) function can be registered on this SQLite target, so '${node.op}' would compile to SQL that fails at query execution time with "no such function: REGEXP" instead of at compile time`, + }; + } const operands = [ { node: node.left, path: `${path}.left` }, { node: node.right, path: `${path}.right` }, diff --git a/packages/trilean-sql/src/options.ts b/packages/trilean-sql/src/options.ts index eeb1ee5..8a2334f 100644 --- a/packages/trilean-sql/src/options.ts +++ b/packages/trilean-sql/src/options.ts @@ -27,6 +27,12 @@ export interface SqlCompileOptions { * Only string reference keys reach it: trilean allows any JSON value as a key, and a non-string one is refused as unpushable before this is called. Throwing from here is how a caller rejects a key it has no column for -- the exception propagates out of `compilePredicateNode` unchanged, rather than being wrapped or swallowed. */ columnFor: (referenceKey: string) => SqlColumnBinding; + /** + * Whether the SQLite target can resolve a `regexp(pattern, value)` function for `textCompare`'s `matches`/`notMatches` to compile to. Ignored under the `postgres` dialect, which matches patterns with its own native `~`/`!~` operators and never needs this. + * + * Defaults to `true`, preserving the historical behaviour of always compiling to `REGEXP`/`NOT REGEXP`, which is correct for a driver a caller can register a function on (better-sqlite3, for instance). Set explicitly to `false` for a SQLite-wire-compatible target with no such registration hook -- Cloudflare D1 is the motivating case -- so `matches`/`notMatches` are refused by `UnsupportedNodeError` at compile time instead of compiling to SQL that fails at query execution with "no such function: REGEXP". See the "Regular expressions" section in README.md. + */ + sqliteRegexpAvailable?: boolean; } export interface CompiledSql { diff --git a/packages/trilean-sql/test/integration/sqlite.test.ts b/packages/trilean-sql/test/integration/sqlite.test.ts index 2e17e2f..e0a428d 100644 --- a/packages/trilean-sql/test/integration/sqlite.test.ts +++ b/packages/trilean-sql/test/integration/sqlite.test.ts @@ -369,6 +369,21 @@ describe("textCompare", () => { bare.close(); } }); + + it("refuses matches at compile time, before ever reaching a connection, when sqliteRegexpAvailable is false", () => { + // The target this option exists for -- Cloudflare D1 and any other SQLite-wire-compatible engine with no way to register a function at all -- can never pass the previous test's registration step, so the failure above is not merely undesirable there, it is unavoidable. This is the same tree failing the same way, but caught at `compilePredicateNode` itself rather than surfacing as a query error against a real connection. + expect(() => + compilePredicateNode( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + { ...sqliteSubjectOptions, sqliteRegexpAvailable: false }, + ), + ).toThrow(/cannot compile 'textCompare'/i); + }); }); describe("memberOf", () => {