Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/trilean-sql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions packages/trilean-sql/src/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/trilean-sql/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export function compilePredicateNode(
bindings.set(referenceKey, binding);
return binding;
},
sqliteRegexpAvailable: options.sqliteRegexpAvailable,
};

const unpushable = findUnpushableNodeKind(node, memoised);
Expand Down
63 changes: 63 additions & 0 deletions packages/trilean-sql/src/guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions packages/trilean-sql/src/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` },
Expand Down
6 changes: 6 additions & 0 deletions packages/trilean-sql/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/trilean-sql/test/integration/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading