From c4fd3c4487d97c4bc5fc07648891b570d084ff0f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:22:32 +0100 Subject: [PATCH 01/12] feat(trilean-sql): add collectionFor option for correlated-table references SqlCollectionBinding maps a some/every/fold node's collection key onto a correlated child table (table, join, and a per-collection columnFor for references inside item/filter), the collection-level counterpart of the existing columnFor mapping for a plain reference. Left unset, a tree using one of those kinds is refused exactly as before; this option is what a caller opts into to have it pushed down instead. --- packages/trilean-sql/src/options.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/trilean-sql/src/options.ts b/packages/trilean-sql/src/options.ts index 086b32c..b251af3 100644 --- a/packages/trilean-sql/src/options.ts +++ b/packages/trilean-sql/src/options.ts @@ -18,6 +18,15 @@ export interface SqlColumnBinding { paramType?: SqlParamType; } +export interface SqlCollectionBinding { + /** The correlated child table, dot-qualified exactly like `SqlColumnBinding.column` -- each dot-separated segment is emitted as its own double-quoted identifier. */ + table: string; + /** Raw boolean SQL relating one row of `table` to the outer row, e.g. `"attrs"."nodeId" = "graph_nodes"."id" AND "attrs"."attrName" = 'voltageLevel'`. Caller-authored rather than assembled from a join-column pair, because only the caller knows the real join shape -- a single FK, a composite key, or (the EAV driving case) an FK plus a literal discriminator. */ + join: string; + /** Resolves a reference key *inside* this collection's `item`/`filter` -- swapped in for the outer `columnFor` while compiling them, mirroring how trilean's own evaluator re-points `EvaluationContext` at the collection item (see `evaluator.ts`'s `resolveParticipatingItems`). */ + columnFor: (referenceKey: string) => SqlColumnBinding; +} + export interface SqlCompileOptions { /** Which dialect to emit. It is a required field rather than a default so that a caller states the engine it is compiling for, instead of inheriting whichever one this package happened to implement first. */ dialect: SqlDialect; @@ -27,6 +36,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; + /** + * Maps a `some`/`every`/`fold` node's `collection` key onto a correlated table. Called once per collection occurrence. + * + * Only string collection keys reach it, for the same reason only string reference keys reach `columnFor`. A tree that never uses `some`, `every`, or `fold(max|min)` never calls this -- leaving it unset is fully backward compatible. A tree that does use one of those kinds without this set is refused with `UnsupportedNodeError`, the same refused outcome those kinds already had before this option existed. `fold(reduce)` is refused unconditionally regardless of this option: it threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation. + */ + collectionFor?: (collectionKey: string) => SqlCollectionBinding; /** * 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 has its own separate `postgresRegexpPushdown` gate below. * From 3408eed59e989f7ff40b669fa6421711b69667a4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:22:49 +0100 Subject: [PATCH 02/12] feat(trilean-sql): add InvalidCollectionTableError for a bad collectionFor table Mirrors InvalidColumnError for the correlated table a some/every/fold node compiles against: a table name is an identifier that has to be written into the statement text rather than bound as a parameter, so this rejects the two shapes quoting cannot rescue, an empty name and an empty dot-separated segment. --- packages/trilean-sql/src/errors.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/trilean-sql/src/errors.ts b/packages/trilean-sql/src/errors.ts index 2f6469a..84c73c7 100644 --- a/packages/trilean-sql/src/errors.ts +++ b/packages/trilean-sql/src/errors.ts @@ -74,3 +74,22 @@ export class InvalidColumnError extends TrileanSqlError { this.column = column; } } + +/** + * A `collectionFor` result whose `table` cannot be rendered as a SQL identifier. + * + * Mirrors `InvalidColumnError` exactly, for the correlated table a `some`/`every`/`fold` node compiles against instead of a mapped column: `table` is likewise an identifier that has to be written into the statement text rather than bound as a parameter, so this rejects the same two shapes quoting cannot rescue -- an empty name and an empty dot-separated segment. + */ +export class InvalidCollectionTableError extends TrileanSqlError { + readonly collectionKey: string; + readonly table: string; + + constructor(collectionKey: string, table: string, reason: string) { + super( + `collectionFor(${JSON.stringify(collectionKey)}) returned ${JSON.stringify(table)}, which is not a usable table identifier: ${reason}`, + ); + this.name = "InvalidCollectionTableError"; + this.collectionKey = collectionKey; + this.table = table; + } +} From 219cbadb5bc66cb879db01aefc60cb936cff9074 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:23:04 +0100 Subject: [PATCH 03/12] feat(trilean-sql): push some/every/fold(max|min) through the pushability guard findUnpushablePredicate's some/every case and findUnpushableExpression's fold case now resolve the collection via a new resolveCollectionForGuard helper instead of refusing outright: a string collection key with collectionFor set recurses into item/filter (or combiner.item) using the resolved binding's own columnFor, exactly mirroring how the evaluator re-points its EvaluationContext at the collection item. Without options, or without collectionFor set, the refusal is unchanged from before. fold(reduce) is refused unconditionally regardless of collectionFor: it threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation. fold(max|min) additionally refuses a projected item whose static kind is text or boolean. SQL's MAX/MIN would happily order text lexicographically or booleans as 0/1 the moment two or more rows participate, where trilean's own compareValues refuses to order either past a single item -- a divergence a fixed pair of operands could prove but a collection's real cardinality, only known at query time, cannot rule out, so it is refused unconditionally rather than only when two or more rows are proven to participate. findUnpushableExpression now takes the compile options as a parameter (previously it took none at all), threaded through its four existing call sites, so a fold buried inside a comparison, textCompare, memberOf, or exists operand can resolve its own collection correctly. --- packages/trilean-sql/src/guard.ts | 137 +++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/trilean-sql/src/guard.ts b/packages/trilean-sql/src/guard.ts index 2d406e9..a5604a0 100644 --- a/packages/trilean-sql/src/guard.ts +++ b/packages/trilean-sql/src/guard.ts @@ -1,6 +1,11 @@ import type { ExpressionNode, PredicateNode } from "trilean"; import { parseRegex } from "trilean-regex"; -import type { SqlCompileOptions, SqlDialect, SqlParamType } from "./options"; +import type { + SqlCollectionBinding, + SqlCompileOptions, + SqlDialect, + SqlParamType, +} from "./options"; import { assertImplementedDialect } from "./options"; import { renderPostgresPattern, @@ -100,10 +105,50 @@ function staticValueKindOf( : STATIC_KIND_OF_PARAM_TYPE[paramType]; } +/** What {@link resolveCollectionForGuard} hands back: either the resolved binding's own `columnFor`, wrapped so the caller can swap it in for the outer one while walking `item`/`filter`, or the same `UnpushableNode` shape every other refusal in this file returns. */ +type CollectionResolution = + { readonly itemOptions: SqlCompileOptions | undefined } | UnpushableNode; + +/** + * Resolves a `some`/`every`/`fold` node's `collection` onto the options a walk of its `item`/`filter` should use, or explains why it cannot be resolved. + * + * `options === undefined` is the one case that is not a refusal: a purely structural walk (no `options` at all) has no `columnFor` either, and every other options-dependent check in this file already answers "cannot tell, assume it passes" in that mode (see `staticValueKindOf`) -- refusing collection resolution specifically would make this the one exception to that convention rather than a consistent extension of it. + */ +function resolveCollectionForGuard( + kind: string, + collection: unknown, + path: string, + options: SqlCompileOptions | undefined, +): CollectionResolution { + if (typeof collection !== "string") { + return { + kind, + path, + reason: + "only a string collection key can be mapped to a correlated table; this collection is a non-string JSON value", + }; + } + if (options === undefined) return { itemOptions: undefined }; + if (options.collectionFor === undefined) { + return { + kind, + path, + reason: + "no 'collectionFor' option is set: describe how this collection maps onto a correlated table ({ table, join, columnFor }) to compile this node, or it falls back to in-process evaluation", + }; + } + const binding: Readonly = + options.collectionFor(collection); + return { + itemOptions: { ...options, columnFor: binding.columnFor }, + }; +} + function findUnpushableExpression( node: ExpressionNode, path: string, divergence: Readonly, + options: SqlCompileOptions | undefined, ): UnpushableNode | undefined { // Read before the switch narrows `node` to `never` in its default branch, where the kind is still what the report needs to name. const unrecognisedKind: string = node.kind; @@ -182,13 +227,58 @@ function findUnpushableExpression( reason: "conditional evaluation is not implemented in this version of the compiler", }; - case "fold": - return { - kind: node.kind, + case "fold": { + if (node.combiner.mode === "reduce") { + return { + kind: node.kind, + path, + reason: + "a 'reduce' fold threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation", + }; + } + const resolved = resolveCollectionForGuard( + node.kind, + node.collection, path, - reason: - "a fold ranges over a collection the caller's resolvers supply, which is not this query's row set", - }; + options, + ); + if ("reason" in resolved) return resolved; + const { itemOptions } = resolved; + if (node.filter !== undefined) { + const unpushableFilter = findUnpushablePredicate( + node.filter, + `${path}.filter`, + itemOptions, + divergence, + ); + if (unpushableFilter !== undefined) return unpushableFilter; + } + const itemPath = `${path}.combiner.item`; + const unpushableItem = findUnpushableExpression( + node.combiner.item, + itemPath, + divergence, + itemOptions, + ); + if (unpushableItem !== undefined) return unpushableItem; + // A fold(max|min) orders participating items the same way `compare`'s gt/gte/lt/lte does, and trilean's own `compareValues` refuses to order text or booleans regardless of how many items end up participating at runtime -- see the ORDERING_OPERATORS/boolean check in the 'compare' case of findUnpushablePredicate below, which this mirrors. Unlike a single fixed pair of operands, cardinality here is only known at runtime (the resolver's own row count for this collection), so a divergence detectable for *any* possible cardinality has to be refused unconditionally: SQL's MAX/MIN would happily order text lexicographically or booleans as 0/1 the moment two or more rows participate, where trilean goes indeterminate ("wrong-type") the moment a second item is compared. + const itemKind = staticValueKindOf(node.combiner.item, itemOptions); + if (itemKind === "text") { + return { + kind: node.kind, + path: itemPath, + reason: `a '${node.combiner.mode}' fold orders participating items by comparing them, and trilean never orders text values ('compare' returns wrong-type for text; use 'textCompare') -- but ${divergence.textOrdering(itemPath)}`, + }; + } + if (itemKind === "boolean") { + return { + kind: node.kind, + path: itemPath, + reason: `a '${node.combiner.mode}' fold orders participating items by comparing them, and booleans have no ordering in trilean -- whereas ${divergence.booleanOrdering}`, + }; + } + return undefined; + } case "accumulator": return { kind: node.kind, @@ -305,6 +395,7 @@ function findUnpushablePredicate( operand.node, operand.path, divergence, + options, ); if (unpushable !== undefined) return unpushable; } @@ -356,6 +447,7 @@ function findUnpushablePredicate( operand.node, operand.path, divergence, + options, ); if (unpushable !== undefined) return unpushable; const staticKind = staticValueKindOf(operand.node, options); @@ -420,6 +512,7 @@ function findUnpushablePredicate( operand.node, operand.path, divergence, + options, ); if (unpushable !== undefined) return unpushable; } @@ -430,15 +523,33 @@ function findUnpushablePredicate( node.operand, `${path}.operand`, divergence, + options, ); case "some": - case "every": - return { - kind: node.kind, + case "every": { + const resolved = resolveCollectionForGuard( + node.kind, + node.collection, path, - reason: - "quantification ranges over a collection the caller's resolvers supply, which is not this query's row set", - }; + options, + ); + if ("reason" in resolved) return resolved; + const { itemOptions } = resolved; + const unpushableItem = findUnpushablePredicate( + node.item, + `${path}.item`, + itemOptions, + divergence, + ); + if (unpushableItem !== undefined) return unpushableItem; + if (node.filter === undefined) return undefined; + return findUnpushablePredicate( + node.filter, + `${path}.filter`, + itemOptions, + divergence, + ); + } case "treeReference": return { kind: node.kind, From 5c06cd491164315c24e36d49c8a561a43dc16eec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:24:20 +0100 Subject: [PATCH 04/12] feat(trilean-sql): compile some/every to a correlated subquery Resolves the collection through the new compileCollection helper (quoting the correlated table via a new quoteTable, mirroring quoteColumn) and compiles item/filter against it, then aggregates each participating row's own vote with MAX/CASE to match the evaluator's OR/AND-fold absorption exactly: some is true the moment any row's item is true regardless of another row's indeterminacy, indeterminate only once no row voted true and at least one participated indeterminately, false otherwise -- every is the mirror image. item and filter are each compiled exactly once, never inlined twice, both for correctness (a NULL-propagating expression's meaning would otherwise differ between occurrences) and because a dialect's own bare placeholder cannot safely be reused. collectionFor is memoised for the duration of one compilation the same way columnFor already is, including a nested per-binding memoisation of each resolved binding's own columnFor. fold still refuses unconditionally for now, via the same safety net that already covers a guard/compiler allow-list mismatch elsewhere in this file -- its own compilation follows in the next commit. --- packages/trilean-sql/src/compile.ts | 121 +++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/trilean-sql/src/compile.ts b/packages/trilean-sql/src/compile.ts index 91c7a57..88bb683 100644 --- a/packages/trilean-sql/src/compile.ts +++ b/packages/trilean-sql/src/compile.ts @@ -1,12 +1,19 @@ import type { ComparisonOperator, + EveryNode, ExpressionNode, + FoldNode, PredicateNode, + SomeNode, TextCompareNode, TextComparisonOperator, } from "trilean"; import { parseRegex } from "trilean-regex"; -import { InvalidColumnError, UnsupportedNodeError } from "./errors"; +import { + InvalidCollectionTableError, + InvalidColumnError, + UnsupportedNodeError, +} from "./errors"; import { findUnpushableNodeKind } from "./guard"; import { renderPostgresPattern, @@ -15,6 +22,7 @@ import { import type { CompiledSql, DialectConfig, + SqlCollectionBinding, SqlColumnBinding, SqlCompileOptions, SqlParamType, @@ -106,6 +114,27 @@ function quoteColumn( .join("."); } +/** + * Renders a `collectionFor` result's `table` as a SQL identifier, the same way `quoteColumn` renders a column: each dot-separated segment double-quoted, with any embedded double quote doubled. + * + * Takes the already-resolved table string rather than the whole `SqlCollectionBinding`, since every call site here has already destructured it -- unlike `quoteColumn`, which reads `binding.column` itself because its own call site still has the whole `SqlColumnBinding` in hand. + */ +function quoteTable(collectionKey: string, table: string): string { + const segments = table.split("."); + if (segments.some((segment) => segment.length === 0)) { + throw new InvalidCollectionTableError( + collectionKey, + table, + table.length === 0 + ? "the name is empty" + : "a dot-separated segment is empty", + ); + } + return segments + .map((segment) => `"${segment.replaceAll('"', '""')}"`) + .join("."); +} + function bindingOf( context: CompileContext, node: ExpressionNode, @@ -132,6 +161,40 @@ function refuse(kind: string, layer: "expression" | "predicate"): never { }); } +/** + * The shared setup for `some`/`every`/`fold`: resolves `node.collection` to a quoted correlated table via `options.collectionFor`, builds the `CompileContext` its `item`/`filter`/`combiner.item` should compile against (the outer `columnFor` swapped for the resolved binding's own, mirroring how trilean's own evaluator re-points `EvaluationContext` at the collection item), and compiles `filter` eagerly since every caller needs it. + * + * The `typeof node.collection !== "string" || options.collectionFor === undefined` check is a drift safety net, not the primary defence: `findUnpushableNodeKind` already refused a non-string collection or a missing `collectionFor` before compilation ever started (see `resolveCollectionForGuard` in guard.ts), so reaching it in a correct build means the guard's allow-list has drifted from what this file actually compiles -- the same class of safety net `refuse` provides everywhere else in this module. + */ +function compileCollection( + node: SomeNode | EveryNode | FoldNode, + context: CompileContext, + layer: "expression" | "predicate", +): { + table: string; + join: string; + itemContext: CompileContext; + filterSql: string | undefined; +} { + if ( + typeof node.collection !== "string" || + context.options.collectionFor === undefined + ) { + return refuse(node.kind, layer); + } + const binding = context.options.collectionFor(node.collection); + const table = quoteTable(node.collection, binding.table); + const itemContext: CompileContext = { + ...context, + options: { ...context.options, columnFor: binding.columnFor }, + }; + const filterSql = + node.filter === undefined + ? undefined + : compilePredicate(node.filter, itemContext); + return { table, join: binding.join, itemContext, filterSql }; +} + /** * Compiles a `portableMatches`/`portableNotMatches` node's pattern operand: parses `node.right`'s text as a `trilean-regex` pattern and translates it into this compilation's own dialect's native pattern syntax (see `portable-pattern.ts`), binding the *translated* string as the placeholder rather than the original pattern text -- from the database's point of view this is an ordinary match against a pattern in its own syntax, not `trilean-regex`'s. * @@ -174,6 +237,7 @@ function compileExpression( case "booleanLiteral": case "instantLiteral": return placeholder(context, node.value, PARAM_TYPE_OF_LITERAL[node.kind]); + case "fold": case "durationLiteral": case "complexLiteral": case "arithmetic": @@ -181,7 +245,6 @@ function compileExpression( case "call": case "lookup": case "conditional": - case "fold": case "accumulator": case "delegate": case "treeReference": @@ -241,7 +304,35 @@ function compilePredicate( // `exists` is the one predicate trilean never returns indeterminate for, and `IS NOT NULL` is likewise the one comparison SQL never returns NULL from -- so this is an exact translation rather than a NULL-propagating one, and a NULL column under `exists` is FALSE here just as an unresolved reference is `definite(false)` there. return `(${compileExpression(node.operand, context)} IS NOT NULL)`; case "some": - case "every": + case "every": { + const { table, join, itemContext, filterSql } = compileCollection( + node, + context, + "predicate", + ); + const itemSql = compilePredicate(node.item, itemContext); + const filterColumn = filterSql ?? "TRUE"; + const participating = + `SELECT "filter_ok", "item_ok" FROM ` + + `(SELECT ${filterColumn} AS "filter_ok", ${itemSql} AS "item_ok" FROM ${table} WHERE ${join}) AS "t" ` + + `WHERE "filter_ok" IS NULL OR "filter_ok"`; + if (node.kind === "some") { + // A TRUE vote from any genuinely include+true item wins outright, matching the evaluator's OR-fold absorption (`combineOr`): a definite true absorbs an indeterminate vote from elsewhere in the same collection. Only once no row voted true does an indeterminate participant (a filter-indeterminate row, or one whose item evaluation is itself NULL) make the whole thing indeterminate; with neither, every vote was a clean false (or there were no participating rows at all, `some`'s own empty-collection identity), so the result is false. + return ( + `(SELECT CASE ` + + `WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ` + + `WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ` + + `ELSE FALSE END FROM (${participating}) AS "v")` + ); + } + // The mirror image for `every`'s AND-fold absorption (`combineAnd`): a definite false from any participating row wins outright regardless of any other row's indeterminacy, then an indeterminate participant makes the rest indeterminate, and only once neither has happened -- every vote true, or no participating rows at all, `every`'s own empty-collection identity -- is the result true. + return ( + `(SELECT CASE ` + + `WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" IS NOT NULL AND NOT "item_ok" THEN 1 ELSE 0 END) = 1 THEN FALSE ` + + `WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ` + + `ELSE TRUE END FROM (${participating}) AS "v")` + ); + } case "treeReference": break; } @@ -267,6 +358,9 @@ export function compilePredicateNode( // `columnFor` is called by the guard walk and again while compiling, so it is memoised for the duration of one compilation -- a caller's mapping may be a lookup of real cost, and it must not matter how many times the compiler happens to ask. const bindings = new Map(); + // `collectionFor` gets the identical treatment, for the identical reason -- called once by the guard walk (`resolveCollectionForGuard`) and again while compiling (`compileCollection`). Each resolved binding's own `columnFor` is memoised too, in its own per-collection-key `Map`, since it is itself just as liable to be a lookup of real cost and is likewise called at least twice per reference inside that collection's `item`/`filter`. + const collectionBindings = new Map(); + const collectionFor = options.collectionFor; const memoised: SqlCompileOptions = { dialect: options.dialect, postgresRegexpPushdown: options.postgresRegexpPushdown, @@ -278,6 +372,27 @@ export function compilePredicateNode( return binding; }, sqliteRegexpAvailable: options.sqliteRegexpAvailable, + ...(collectionFor !== undefined && { + collectionFor: (collectionKey: string): SqlCollectionBinding => { + const cached = collectionBindings.get(collectionKey); + if (cached !== undefined) return cached; + const binding = collectionFor(collectionKey); + const columnBindings = new Map(); + const memoisedBinding: SqlCollectionBinding = { + table: binding.table, + join: binding.join, + columnFor: (referenceKey: string): SqlColumnBinding => { + const cachedColumn = columnBindings.get(referenceKey); + if (cachedColumn !== undefined) return cachedColumn; + const columnBinding = binding.columnFor(referenceKey); + columnBindings.set(referenceKey, columnBinding); + return columnBinding; + }, + }; + collectionBindings.set(collectionKey, memoisedBinding); + return memoisedBinding; + }, + }), }; const unpushable = findUnpushableNodeKind(node, memoised); From 7921f84cf9caf792be0d8d1146c064bda75c006f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:24:46 +0100 Subject: [PATCH 05/12] feat(trilean-sql): compile fold max/min combiners Aggregates the projected item across the correlated collection with MAX/MIN directly, going NULL (indeterminate) the moment any participating row's filter or projected value is itself indeterminate -- matching the evaluator's own short-circuit for fold, which unlike some/every's OR/AND has no absorbing value at all. An empty participating set falls out of the same aggregate for free: MAX/MIN over zero rows is NULL in SQL, the same indeterminate result the evaluator reaches by its own separate domain-error path for a fold with nothing to seed a running extremum from. fold(reduce) stays refused unconditionally, with or without collectionFor set: it threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation. --- packages/trilean-sql/src/compile.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/trilean-sql/src/compile.ts b/packages/trilean-sql/src/compile.ts index 88bb683..10067aa 100644 --- a/packages/trilean-sql/src/compile.ts +++ b/packages/trilean-sql/src/compile.ts @@ -237,7 +237,27 @@ function compileExpression( case "booleanLiteral": case "instantLiteral": return placeholder(context, node.value, PARAM_TYPE_OF_LITERAL[node.kind]); - case "fold": + case "fold": { + if (node.combiner.mode === "reduce") + return refuse(node.kind, "expression"); // guard already refused this; drift safety net + const { table, join, itemContext, filterSql } = compileCollection( + node, + context, + "expression", + ); + const itemValueSql = compileExpression(node.combiner.item, itemContext); + const filterColumn = filterSql ?? "TRUE"; + const participating = + `SELECT "filter_ok", "item_value" FROM ` + + `(SELECT ${filterColumn} AS "filter_ok", ${itemValueSql} AS "item_value" FROM ${table} WHERE ${join}) AS "t" ` + + `WHERE "filter_ok" IS NULL OR "filter_ok"`; + const aggregate = node.combiner.mode === "max" ? "MAX" : "MIN"; + return ( + `(SELECT CASE ` + + `WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_value" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ` + + `ELSE ${aggregate}("item_value") END FROM (${participating}) AS "v")` + ); + } case "durationLiteral": case "complexLiteral": case "arithmetic": From b270171af5134bfa9ff4c5b2f23bcf6950353894 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:25:01 +0100 Subject: [PATCH 06/12] docs(trilean-sql): document collectionFor and the some/every/fold translation Adds a Collections section covering SqlCollectionBinding, the some/every vote-aggregation and fold max/min aggregation each compile to, and the fold(reduce)-is-always-refused and text/boolean-ordering refusal cases. Moves some, every, and fold(max|min) from the refused table to the compiles table, and updates the API reference and error list to match. --- packages/trilean-sql/README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/trilean-sql/README.md b/packages/trilean-sql/README.md index da1eb70..f00b44e 100644 --- a/packages/trilean-sql/README.md +++ b/packages/trilean-sql/README.md @@ -80,6 +80,8 @@ Returns `{ sql, params }`. Throws rather than approximating; see [Refusal](#refu `options.postgresRegexpPushdown`, `false` by default, governs whether `matches`/`notMatches` may compile to PostgreSQL's own `~`/`!~` at all. See [Regular expressions](#regular-expressions) for why the default refuses them. +`options.collectionFor(collectionKey)` maps a `some`/`every`/`fold` node's `collection` key onto `{ table, join, columnFor }` — see [Collections](#collections). Optional: a tree that never uses one of those kinds never calls it, and leaving it unset is fully backward compatible with every version before it existed. + ### `findUnpushableNodeKind(node, options?)` Returns `{ kind, path, reason }` for the first node the compiler will not translate, or `undefined` if the whole tree is pushable. `compilePredicateNode` runs it first and throws on any result, so call it yourself only to *choose* between pushdown and in-process evaluation without provoking an exception: @@ -95,7 +97,7 @@ Passing `options` widens the check: without them the walk is purely structural; ### Errors -`UnsupportedNodeError` (carrying `nodeKind`, `path`, `reason`) and `InvalidColumnError` (carrying `referenceKey`, `column`), both extending `TrileanSqlError`. +`UnsupportedNodeError` (carrying `nodeKind`, `path`, `reason`), `InvalidColumnError` (carrying `referenceKey`, `column`) and `InvalidCollectionTableError` (carrying `collectionKey`, `table`), all extending `TrileanSqlError`. ## What compiles @@ -107,6 +109,8 @@ Passing `options` widens the check: without them the walk is purely structural; | `textCompare` | `=`, `<>`; `matches`/`notMatches` only once `postgresRegexpPushdown` opts in, otherwise refused (see [Regular expressions](#regular-expressions)); `~`/`!~` against a translated pattern for `portableMatches`/`portableNotMatches`, no opt-in needed | `=`, `<>`; `REGEXP`/`NOT REGEXP` for `matches`/`notMatches`; `GLOB`/`NOT GLOB` against a translated pattern for `portableMatches`/`portableNotMatches` | | `memberOf` | `IN` / `NOT IN`, one parameter per candidate | same | | `exists` | `IS NOT NULL` | same | +| `some`, `every` | a correlated scalar subquery over `collectionFor`'s table, combining each participating row's own vote with `MAX`/`CASE` to match the evaluator's OR/AND-fold absorption exactly (see [Collections](#collections)); refused unless `collectionFor` maps the `collection` key | same | +| `fold` (`max`/`min`) | the same correlated subquery, aggregating each participating item's own projected value with `MAX`/`MIN`, `NULL` the moment any participating item (or its `filter`) is indeterminate (see [Collections](#collections)); refused unless `collectionFor` maps the `collection` key. `fold` (`reduce`) is always refused — see [Refusal](#refusal) | same | | `reference` | the mapped column, as a quoted identifier | same | | `textLiteral`, `numberLiteral`, `booleanLiteral`, `instantLiteral` | a bind parameter, cast to `text`, `double precision`, `boolean`, `timestamptz` | a bare `?`, uncast | @@ -116,6 +120,26 @@ PostgreSQL placeholders are always cast. That is not decoration: PostgreSQL reje An empty `memberOf` candidate list is worth a note, because `IN ()` is a syntax error and the two constants it is tempting to fold to are both wrong. An empty `in` is false and an empty `notIn` is true only once the operand itself is known, and both stay unknown while it is `NULL`. The compiled forms — `(x IS NULL AND NULL::boolean)` and `(x IS NOT NULL OR NULL::boolean)`, and the same two without the cast under SQLite, which has no boolean type to annotate — reproduce that exactly, which a bare `FALSE`/`TRUE` would not, most visibly under a surrounding `NOT`. +## Collections + +`some`, `every`, and `fold` (`max`/`min`) range over a `collection` the caller's resolvers supply — in `trilean` itself an opaque key resolved to an arbitrary in-memory list at evaluation time, with no assumption it is this query's row set at all. This package can push one down only when the caller states that the collection *is* a correlated table: `options.collectionFor(collectionKey)` returns + +```ts +interface SqlCollectionBinding { + table: string; // dot-qualified, quoted exactly like a mapped column's `column` + join: string; // raw boolean SQL relating one row of `table` to the outer row + columnFor: (referenceKey: string) => SqlColumnBinding; // resolves a reference *inside* item/filter +} +``` + +`join` is caller-authored rather than assembled from a join-column pair, because only the caller knows the real join shape — a single foreign key, a composite key, or an entity-attribute-value table's foreign key plus a literal discriminator (`"attrs"."nodeId" = "graph_nodes"."id" AND "attrs"."attrName" = 'voltageLevel'`). `columnFor` is a second, independent mapping from the outer one: it resolves a reference inside the collection's own `item`/`filter`, exactly as trilean's own evaluator re-points its `EvaluationContext` at the collection item before evaluating either. A tree that never uses `some`, `every`, or `fold(max|min)` never calls `collectionFor` — leaving it unset is fully backward compatible. A tree that does use one of those kinds without it set is refused with `UnsupportedNodeError`, the same refused outcome those kinds had before this option existed. + +`some`/`every` compile to a correlated scalar subquery: `SELECT filter, item FROM table WHERE join`, filtered down to the participating rows (`filter IS NULL OR filter`, dropping only the rows `filter` excluded outright), then combined with `MAX`/`CASE` to match the evaluator's own OR/AND-fold absorption exactly — `some` is true the moment any participating row's `item` is true, regardless of another row's indeterminacy; indeterminate only once no row voted true and at least one participated indeterminately (an indeterminate `filter`, or an indeterminate `item`); false once neither, including an empty participating set (no rows, or every row filtered out), which is `some`'s own identity. `every` is the mirror image: false the moment any participating row's `item` is definitely false, indeterminate only once neither that nor "every row voted true" holds, and true — including on an empty participating set — otherwise. + +`fold(max|min)` compiles the same correlated subquery over the projected value instead of a predicate, and aggregates with `MAX`/`MIN` directly: `NULL` (indeterminate) the moment any participating row's `filter` or projected value is itself indeterminate — matching the evaluator's `firstFilterIndeterminate`/per-item short-circuit exactly, which has no absorbing value the way `some`/`every`'s OR/AND does — and the aggregate otherwise, including `NULL` from an empty participating set, matching the evaluator's own `domain-error` indeterminate for a fold with nothing to seed a running extremum from. `fold(reduce)` is always refused, with or without `collectionFor` set: it threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation. + +A `fold(max|min)` whose projected item is statically known to be text or boolean is refused, the same way a `compare` against one of those kinds is (see [Refusal](#refusal)): trilean's own ordering (`compareValues`) refuses to order either, for exactly one item it would happily return definite, but the moment a second item participates it goes indeterminate — a divergence undetectable from a fixed pair of operands the way `compare`'s is, since a collection's real cardinality is only known at query time, so it is refused unconditionally rather than only when two or more rows are proven to participate. + ## Dialects The dialects differ in three places, and nowhere else. `matches`/`notMatches` compile to each engine's own regular-expression operator; placeholders are `$N::type` under PostgreSQL and a bare `?` under SQLite; and the bare `NULL` in an empty `memberOf` carries a `::boolean` annotation only where there is a boolean type to annotate. Everything else the compiler emits — the connectives, the six comparison operators, `=`/`<>`, `IN`/`NOT IN`, `IS NOT NULL`, quoted identifiers — is ANSI-standard and identical. @@ -149,7 +173,7 @@ A `matches`/`notMatches` pattern would be the same kind of limit — matched by Every refusal below applies to both dialects. What changes with the dialect is the `reason` text, which names the mechanism that actually applies to the engine you are compiling for — a `findUnpushableNodeKind` call given no `options` has no dialect to read and describes PostgreSQL, the one these refusals were first derived against. -**Kinds this version does not translate.** `some`, `every`, `fold`: these range over a collection the caller's resolvers supply, which is not the query's row set. `lookup`, `call`, `delegate`, `treeReference`: each is resolved by something the database has no access to — the caller's resolvers, its function registry, an external system. `conditional`: not implemented here. `accumulator`: only meaningful inside a `reduce` fold. `arithmetic` and `negate`: these carry and combine units, and pushing them down would drop that dimensional analysis without saying so. `durationLiteral`: trilean compares durations by normalising both operands to milliseconds, with no column-level equivalent to normalise against. `complexLiteral`: neither engine has a complex type. +**Kinds this version does not translate.** `some`, `every`, `fold(max|min)`: refused unless `options.collectionFor` maps the `collection` key onto a correlated table — see [Collections](#collections). `fold(reduce)`: always refused, `collectionFor` or not — it threads an arbitrary combine expression through the collection in a caller-chosen order, which has no general SQL translation. `lookup`, `call`, `delegate`, `treeReference`: each is resolved by something the database has no access to — the caller's resolvers, its function registry, an external system. `conditional`: not implemented here. `accumulator`: only meaningful inside a `reduce` fold. `arithmetic` and `negate`: these carry and combine units, and pushing them down would drop that dimensional analysis without saying so. `durationLiteral`: trilean compares durations by normalising both operands to milliseconds, with no column-level equivalent to normalise against. `complexLiteral`: neither engine has a complex type. **Shapes refused despite a supported kind.** A `reference` whose key is not a string, since there is nothing to map. A `reference` or `numberLiteral` carrying a `unit`: a unit on a reference asserts that the resolved value carries the same one, and a column has no unit for that assertion to be checked against. A `numberLiteral` of `NaN`, which trilean compares with `===` — under which NaN equals nothing including itself — and neither engine reproduces, for opposite reasons. PostgreSQL defines NaN as equal to itself and greater than every other double, so `NaN = NaN` selects every row there and none here. SQLite has no NaN at all and a driver binding one substitutes SQL `NULL`, so the same comparison is *indeterminate* there and matches nothing — which looks like agreement until you negate it, at which point trilean's definite `true` matches every row and SQLite's `NULL` still matches none. Infinities are not refused alongside it; every engine here orders them identically. @@ -159,6 +183,7 @@ Every refusal below applies to both dialects. What changes with the dialect is t - An ordering `compare` (`gt`/`gte`/`lt`/`lte`) against a boolean. trilean has no order for booleans; PostgreSQL orders `false` before `true`, and SQLite orders the integers 0 and 1 it stores them as. - A `textCompare` against a non-text operand, which trilean treats as `wrong-type`. - Any comparison whose operands are of different declared kinds — a number against an instant, say. trilean calls that `wrong-type`; both engines may coerce one to the other and answer definitely. +- A `fold(max|min)`'s projected item against text or boolean. trilean has no order for either (`compareValues` returns `wrong-type` the moment a second item is compared); `MAX`/`MIN` happily orders text lexicographically and booleans as `0`/`1` the moment two or more rows participate. Unlike the other pairings above, this one is refused unconditionally rather than only when a fixed pair of operands proves the mismatch, because a collection's real cardinality is only known at query time. Left undeclared, these compile, and the divergence is real but invisible. That is the whole argument for supplying `paramType`. From 83ade8ad317501925eaaa22df53f8ea364d23da7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:25:16 +0100 Subject: [PATCH 07/12] test(trilean-sql): cover some/every/fold SQL compilation Unit level: relocates some/every/fold(max|min) out of the unconditional refusal lists into a dedicated guard.test.ts describe block covering the collectionFor-absent/collectionFor-present split, the non-string collection key, filter/item resolving against the collection's own columnFor rather than the outer one, and the new text/boolean fold ordering refusal; compile.test.ts asserts the exact compiled SQL text for some/every (with and without a filter, and a conjunctive item locking in the single-witness-row shape) and fold max/min, plus fold(reduce)'s unconditional refusal. Integration level: adds a shared subject_tags correlated-table fixture (src/test-support/columns.ts) and a matching schema/seed/resolveCollection extension to all three integration suites, seeded to exercise an empty collection, a subject where every tag passes a threshold, one where only some do, one pairing a clean vote with an unknown-weight tag, and one whose sole tag has an unknown weight -- then runs some/every/fold trees against a real connection in each dialect and checks the matched rows agree with evaluatePredicate exactly, including the and-over-range hazard a naive per-column translation (rather than one combined boolean per row) would get wrong. --- packages/trilean-sql/src/compile.test.ts | 201 +++++++++++- packages/trilean-sql/src/guard.test.ts | 295 +++++++++++++++++- .../trilean-sql/src/test-support/columns.ts | 50 ++- .../test/integration/pglite.test.ts | 259 ++++++++++++++- .../test/integration/postgres.test.ts | 259 ++++++++++++++- .../test/integration/sqlite.test.ts | 283 ++++++++++++++++- 6 files changed, 1316 insertions(+), 31 deletions(-) diff --git a/packages/trilean-sql/src/compile.test.ts b/packages/trilean-sql/src/compile.test.ts index 96175fb..9ce62b0 100644 --- a/packages/trilean-sql/src/compile.test.ts +++ b/packages/trilean-sql/src/compile.test.ts @@ -350,6 +350,202 @@ describe("exists", () => { }); }); +// A small mapping local to this file's own compile-level assertions, distinct from the integration suites' `subject_tags` fixture -- these cases assert exact compiled text rather than measuring against a real connection, so they need no seeded data, only a table/join/columnFor shape to compile against. +const SCORE_THRESHOLD = 5; +const TAG_LABEL = "urgent"; +const RANGE_LOW = 2; +const RANGE_HIGH = 8; + +const tagsOptions: SqlCompileOptions = { + dialect: "postgres", + columnFor: (referenceKey) => { + throw new Error( + `no outer reference expected in a 'tags' collection tree; got '${referenceKey}'`, + ); + }, + collectionFor: (collectionKey) => { + if (collectionKey !== "tags") { + throw new Error(`no collection mapped for '${collectionKey}'`); + } + return { + table: "tags", + join: `"tags"."subjectId" = "subjects"."id"`, + columnFor: (referenceKey) => { + if (referenceKey === "label") + return { column: "label", paramType: "text" }; + if (referenceKey === "score") + return { column: "score", paramType: "number" }; + throw new Error(`no column mapped for '${referenceKey}'`); + }, + }; + }, +}; + +const scoreAboveThreshold: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, +}; + +describe("some", () => { + it("compiles without a filter to a correlated subquery over TRUE as the filter column", () => { + expect( + compile( + { kind: "some", collection: "tags", item: scoreAboveThreshold }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT TRUE AS "filter_ok", ("score" > $1::double precision) AS "item_ok" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [SCORE_THRESHOLD], + }); + }); + + it("compiles a filter into its own participating-row column, ahead of the item's own placeholders", () => { + expect( + compile( + { + kind: "some", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "label" }, + right: { kind: "textLiteral", value: TAG_LABEL }, + }, + item: scoreAboveThreshold, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT ("label" = $1::text) AS "filter_ok", ("score" > $2::double precision) AS "item_ok" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [TAG_LABEL, SCORE_THRESHOLD], + }); + }); +}); + +describe("every", () => { + it("compiles a conjunctive item as one combined boolean per participating row, not split across separate checks", () => { + // The load-bearing shape: `and(gte(score, RANGE_LOW), lte(score, RANGE_HIGH))` compiles to a single "item_ok" column per row, so a row straddling the range on two different rows can never wrongly satisfy it the way two independent correlated EXISTS checks could. + expect( + compile( + { + kind: "every", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" IS NOT NULL AND NOT "item_ok" THEN 1 ELSE 0 END) = 1 THEN FALSE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE TRUE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT TRUE AS "filter_ok", (("score" >= $1::double precision) AND ("score" <= $2::double precision)) AS "item_ok" ' + + 'FROM "tags" WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [RANGE_LOW, RANGE_HIGH], + }); + }); +}); + +describe("fold", () => { + it("compiles 'max' to a correlated MAX over the projected item, NULL the moment any participant is indeterminate", () => { + expect( + compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "max", + item: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '((SELECT CASE WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_value" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE MAX("item_value") END FROM (SELECT "filter_ok", "item_value" FROM ' + + '(SELECT TRUE AS "filter_ok", "score" AS "item_value" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v") = $1::double precision)', + params: [SCORE_THRESHOLD], + }); + }); + + it("compiles 'min' identically but for the aggregate function", () => { + const compiled = compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ); + expect(compiled.sql).toContain('MIN("item_value")'); + expect(compiled.sql).not.toContain('MAX("item_value")'); + }); + + it("refuses 'reduce' unconditionally, even with collectionFor set", () => { + expect(() => + compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ), + ).toThrow(/cannot compile 'fold'.*no general SQL translation/s); + }); +}); + describe("parameters", () => { it("numbers placeholders in emission order across a nested tree", () => { const node: PredicateNode = { @@ -521,8 +717,9 @@ describe("refusal", () => { kind: "fold", collection: "xs", combiner: { - mode: "max", - item: { kind: "numberLiteral", value: LOWER_BOUND }, + mode: "reduce", + initial: { kind: "numberLiteral", value: LOWER_BOUND }, + combine: { kind: "numberLiteral", value: LOWER_BOUND }, }, }, right: { kind: "numberLiteral", value: UPPER_BOUND }, diff --git a/packages/trilean-sql/src/guard.test.ts b/packages/trilean-sql/src/guard.test.ts index 5c66bce..95200ba 100644 --- a/packages/trilean-sql/src/guard.test.ts +++ b/packages/trilean-sql/src/guard.test.ts @@ -1,6 +1,7 @@ import type { ExpressionNode, PredicateNode } from "trilean"; import { describe, expect, it, vi } from "vitest"; import { findUnpushableNodeKind } from "./guard"; +import type { SqlCompileOptions } from "./options"; import { sqliteSubjectOptions, subjectOptions, @@ -51,18 +52,6 @@ describe("supported trees", () => { describe("predicate kinds this version does not translate", () => { it.each([ - [ - "some", - { kind: "some", collection: "xs", item: ageOver } satisfies PredicateNode, - ], - [ - "every", - { - kind: "every", - collection: "xs", - item: ageOver, - } satisfies PredicateNode, - ], [ "treeReference", { kind: "treeReference", key: "other" } satisfies PredicateNode, @@ -122,7 +111,11 @@ describe("expression kinds this version does not translate", () => { { kind: "fold", collection: "xs", - combiner: { mode: "max", item: { kind: "numberLiteral", value: 1 } }, + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "numberLiteral", value: 1 }, + }, }, ], ["accumulator", { kind: "accumulator" }], @@ -145,6 +138,282 @@ describe("expression kinds this version does not translate", () => { }); }); +describe("quantification over a collection", () => { + const tagsOptions: SqlCompileOptions = { + dialect: "postgres", + columnFor: subjectOptions.columnFor, + collectionFor: (collectionKey) => { + if (collectionKey !== "tags") { + throw new Error(`no collection mapped for '${collectionKey}'`); + } + return { + table: "tags", + join: `"tags"."subjectId" = "subjects"."id"`, + columnFor: (referenceKey) => { + if (referenceKey === "score") + return { column: "score", paramType: "number" }; + if (referenceKey === "label") + return { column: "label", paramType: "text" }; + if (referenceKey === "flagged") + return { column: "flagged", paramType: "boolean" }; + if (referenceKey === "note") return { column: "note" }; + throw new Error(`no column mapped for '${referenceKey}'`); + }, + }; + }, + }; + + const scoreAboveOne: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: 1 }, + }; + + it.each(["some", "every"] as const)( + "refuses '%s' when collectionFor is not set", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: scoreAboveOne }, + subjectOptions, + ), + ).toMatchObject({ + kind, + path: "$", + reason: expect.stringContaining("collectionFor") as unknown, + }); + }, + ); + + it.each(["some", "every"] as const)( + "is pushable once collectionFor maps the collection", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: scoreAboveOne }, + tagsOptions, + ), + ).toBeUndefined(); + }, + ); + + it.each(["max", "min"] as const)( + "refuses fold('%s') when collectionFor is not set", + (mode) => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode, item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + subjectOptions, + ), + ).toMatchObject({ + kind: "fold", + reason: expect.stringContaining("collectionFor") as unknown, + }); + }, + ); + + it.each(["max", "min"] as const)( + "is pushable once collectionFor maps the collection, for fold('%s')", + (mode) => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode, item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toBeUndefined(); + }, + ); + + it("refuses fold('reduce') unconditionally, even once collectionFor maps the collection", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + reason: expect.stringContaining("no general SQL translation") as unknown, + }); + }); + + it("refuses a non-string collection key, even with collectionFor set", () => { + expect( + findUnpushableNodeKind( + { kind: "some", collection: { nested: "key" }, item: scoreAboveOne }, + tagsOptions, + ), + ).toMatchObject({ + kind: "some", + reason: expect.stringContaining("non-string") as unknown, + }); + }); + + it("resolves item/filter against the collection's own columnFor, not the outer one", () => { + // "age" is an outer column subjectOptions maps but tagsOptions' own collection-level columnFor does not -- a mapping error here proves item is actually walked using the resolved binding's own columnFor, not silently skipped. + expect(() => + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 1 }, + }, + }, + tagsOptions, + ), + ).toThrow(/no column mapped for 'age'/); + }); + + it("refuses an unsupported node kind buried inside filter, not only inside item", () => { + expect( + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + filter: { kind: "treeReference", key: "other" }, + item: scoreAboveOne, + }, + tagsOptions, + ), + ).toMatchObject({ kind: "treeReference", path: "$.filter" }); + }); + + it("is pushable with both a filter and an item, once collectionFor is set", () => { + expect( + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "label" }, + right: { kind: "textLiteral", value: "urgent" }, + }, + item: scoreAboveOne, + }, + tagsOptions, + ), + ).toBeUndefined(); + }); + + it("refuses a fold('max'|'min') whose projected item is text, which trilean never orders", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "max", + item: { kind: "reference", key: "label" }, + }, + }, + right: { kind: "textLiteral", value: "z" }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + path: "$.left.combiner.item", + reason: expect.stringContaining("never orders text values") as unknown, + }); + }); + + it("refuses a fold('max'|'min') whose projected item is boolean, which trilean never orders", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "min", + item: { kind: "reference", key: "flagged" }, + }, + }, + right: { kind: "booleanLiteral", value: true }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + path: "$.left.combiner.item", + reason: expect.stringContaining("booleans have no ordering") as unknown, + }); + }); + + it("cannot detect a fold('max'|'min') text/boolean ordering mismatch against an item with no declared paramType", () => { + // The identical limitation `compare` already accepts for an undeclared column, applied to fold's own item: without a declared paramType there is nothing to check the ordering divergence against. Compared against a number, not text, so the outer `compare`'s own text-ordering check (unrelated to the one this test targets) cannot itself be what causes the refusal. + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "note" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toBeUndefined(); + }); + + it.each(["some", "every"] as const)( + "'%s' is treated as structurally pushable when called without options at all, matching the 'assume it passes' convention every other options-dependent check in this file already follows", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: ageOver }, + undefined, + ), + ).toBeUndefined(); + }, + ); +}); + describe("references the compiler cannot map", () => { it("refuses a non-string reference key", () => { expect( diff --git a/packages/trilean-sql/src/test-support/columns.ts b/packages/trilean-sql/src/test-support/columns.ts index cb52e38..647e269 100644 --- a/packages/trilean-sql/src/test-support/columns.ts +++ b/packages/trilean-sql/src/test-support/columns.ts @@ -1,4 +1,8 @@ -import type { SqlColumnBinding, SqlCompileOptions } from "../options"; +import type { + SqlCollectionBinding, + SqlColumnBinding, + SqlCompileOptions, +} from "../options"; /** * The schema the unit tests and every integration suite compile against, so a fragment asserted as a string in one is the same fragment executed against a real engine in the others. @@ -37,3 +41,47 @@ export const subjectOptionsWithPostgresRegexp: SqlCompileOptions = { ...subjectOptions, postgresRegexpPushdown: true, }; + +/** + * The one correlated child table every integration suite seeds alongside `subjects`, purely to exercise `some`/`every`/`fold` against a real connection -- a subject's own tags, each carrying an optional `weight`. `tag` and `weight` both declare a `paramType`, matching `SUBJECT_COLUMNS`'s own convention of describing every column an integration suite actually compares by kind. + */ +export const SUBJECT_TAG_COLUMNS: Readonly> = { + tag: { column: "tag", paramType: "text" }, + weight: { column: "weight", paramType: "number" }, +}; + +function columnForSubjectTag(referenceKey: string): SqlColumnBinding { + const binding = SUBJECT_TAG_COLUMNS[referenceKey]; + if (binding === undefined) { + throw new Error(`no column mapped for reference key '${referenceKey}'`); + } + return binding; +} + +/** Maps the one collection key every integration suite's trees use, `"tags"`, onto `subject_tags`, correlated to the outer `subjects` row by `subjectId`. */ +export function collectionForSubjectTags( + collectionKey: string, +): SqlCollectionBinding { + if (collectionKey !== "tags") { + throw new Error( + `no collection mapped for collection key '${collectionKey}'`, + ); + } + return { + table: "subject_tags", + join: `"subject_tags"."subjectId" = "subjects"."id"`, + columnFor: columnForSubjectTag, + }; +} + +/** `subjectOptions` with `collectionFor` supplied, for the integration suites' `some`/`every`/`fold` parity tests. */ +export const subjectOptionsWithTags: SqlCompileOptions = { + ...subjectOptions, + collectionFor: collectionForSubjectTags, +}; + +/** The same mapping compiled for SQLite. */ +export const sqliteSubjectOptionsWithTags: SqlCompileOptions = { + ...sqliteSubjectOptions, + collectionFor: collectionForSubjectTags, +}; diff --git a/packages/trilean-sql/test/integration/pglite.test.ts b/packages/trilean-sql/test/integration/pglite.test.ts index cb67b4f..aa16b7b 100644 --- a/packages/trilean-sql/test/integration/pglite.test.ts +++ b/packages/trilean-sql/test/integration/pglite.test.ts @@ -13,6 +13,7 @@ import type { SqlCompileOptions } from "../../src/options"; import { subjectOptions, subjectOptionsWithPostgresRegexp, + subjectOptionsWithTags, } from "../../src/test-support/columns"; /** @@ -36,6 +37,18 @@ const SCHEMA = ` ); `; +/** + * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id text PRIMARY KEY, + "subjectId" text NOT NULL, + tag text, + weight double precision + ); +`; + interface SubjectRow { id: string; age: number | null; @@ -81,10 +94,34 @@ const SUBJECTS: readonly SubjectRow[] = [ }, ]; +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Resolves a reference key against one row, mapping a NULL column to `found: false`. * * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. */ function resolversFor(row: Readonly): Resolvers { const known: Record = { @@ -102,7 +139,26 @@ function resolversFor(row: Readonly): Resolvers { }; return { - resolveValue: async (key: JsonValue) => { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } const value = typeof key === "string" ? known[key] : undefined; return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, @@ -111,8 +167,14 @@ function resolversFor(row: Readonly): Resolvers { resolveLookup: () => { throw new Error("no tree in this suite uses a lookup"); }, - resolveCollection: () => { - throw new Error("no tree in this suite uses a collection"); + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); }, }; } @@ -123,12 +185,19 @@ beforeAll(async () => { // No connection string, no port, no container: an in-memory database that exists for the lifetime of this process. db = new PGlite(); await db.exec(SCHEMA); + await db.exec(TAGS_SCHEMA); for (const row of SUBJECTS) { await db.query( "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", [row.id, row.age, row.name, row.active, row.joined, row.note], ); } + for (const tagRow of TAGS) { + await db.query( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, + [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], + ); + } }); afterAll(async () => { @@ -518,6 +587,190 @@ describe("degenerate and adversarial fragments", () => { }); }); +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, subjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + subjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows(every, subjectOptionsWithTags); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + subjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, subjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, subjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + subjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + describe("a tree deep enough to mix every supported kind", () => { it("agrees with the evaluator row for row", async () => { const node: PredicateNode = { diff --git a/packages/trilean-sql/test/integration/postgres.test.ts b/packages/trilean-sql/test/integration/postgres.test.ts index dbf9fbf..9055608 100644 --- a/packages/trilean-sql/test/integration/postgres.test.ts +++ b/packages/trilean-sql/test/integration/postgres.test.ts @@ -17,6 +17,7 @@ import type { SqlCompileOptions } from "../../src/options"; import { subjectOptions, subjectOptionsWithPostgresRegexp, + subjectOptionsWithTags, } from "../../src/test-support/columns"; /** @@ -36,6 +37,18 @@ const SCHEMA = ` ); `; +/** + * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id text PRIMARY KEY, + "subjectId" text NOT NULL, + tag text, + weight double precision + ); +`; + interface SubjectRow { id: string; age: number | null; @@ -81,10 +94,34 @@ const SUBJECTS: readonly SubjectRow[] = [ }, ]; +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Resolves a reference key against one row, mapping a NULL column to `found: false`. * * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. */ function resolversFor(row: Readonly): Resolvers { const known: Record = { @@ -102,7 +139,26 @@ function resolversFor(row: Readonly): Resolvers { }; return { - resolveValue: async (key: JsonValue) => { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } const value = typeof key === "string" ? known[key] : undefined; return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, @@ -111,8 +167,14 @@ function resolversFor(row: Readonly): Resolvers { resolveLookup: () => { throw new Error("no tree in this suite uses a lookup"); }, - resolveCollection: () => { - throw new Error("no tree in this suite uses a collection"); + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); }, }; } @@ -125,12 +187,19 @@ beforeAll(async () => { client = new pg.Client({ connectionString: container.getConnectionUri() }); await client.connect(); await client.query(SCHEMA); + await client.query(TAGS_SCHEMA); for (const row of SUBJECTS) { await client.query( "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", [row.id, row.age, row.name, row.active, row.joined, row.note], ); } + for (const tagRow of TAGS) { + await client.query( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, + [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], + ); + } }); afterAll(async () => { @@ -522,6 +591,190 @@ describe("degenerate and adversarial fragments", () => { }); }); +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, subjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + subjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows(every, subjectOptionsWithTags); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + subjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, subjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, subjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + subjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + describe("a tree deep enough to mix every supported kind", () => { it("agrees with the evaluator row for row", async () => { const node: PredicateNode = { diff --git a/packages/trilean-sql/test/integration/sqlite.test.ts b/packages/trilean-sql/test/integration/sqlite.test.ts index 6e042a4..147d78e 100644 --- a/packages/trilean-sql/test/integration/sqlite.test.ts +++ b/packages/trilean-sql/test/integration/sqlite.test.ts @@ -10,7 +10,10 @@ import { evaluatePredicate } from "trilean"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { compilePredicateNode } from "../../src/compile"; import type { SqlCompileOptions } from "../../src/options"; -import { sqliteSubjectOptions } from "../../src/test-support/columns"; +import { + sqliteSubjectOptions, + sqliteSubjectOptionsWithTags, +} from "../../src/test-support/columns"; /** * The SQLite counterpart of `postgres.test.ts`, and the same claim measured rather than asserted: every case compiles a tree, executes the fragment as a real `WHERE` clause against a real SQLite connection, and compares the rows it returns against the rows trilean's own evaluator judges `definite(true)` for the same tree. @@ -44,6 +47,18 @@ const COERCION_SCHEMA = ` ); `; +/** + * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `sqliteSubjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- matching the same quoted-identifier convention the PostgreSQL/PGlite suites use for it. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id TEXT PRIMARY KEY, + "subjectId" TEXT NOT NULL, + tag TEXT, + weight REAL + ); +`; + interface SubjectRow { id: string; age: number | null; @@ -89,10 +104,34 @@ const SUBJECTS: readonly SubjectRow[] = [ }, ]; +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Resolves a reference key against one row, mapping a NULL column to `found: false`. * * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge SQLite has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. */ function resolversFor(row: Readonly): Resolvers { const known: Record = { @@ -110,7 +149,26 @@ function resolversFor(row: Readonly): Resolvers { }; return { - resolveValue: async (key: JsonValue) => { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } const value = typeof key === "string" ? known[key] : undefined; return Promise.resolve( value === undefined ? { found: false } : { found: true, value }, @@ -119,8 +177,14 @@ function resolversFor(row: Readonly): Resolvers { resolveLookup: () => { throw new Error("no tree in this suite uses a lookup"); }, - resolveCollection: () => { - throw new Error("no tree in this suite uses a collection"); + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); }, }; } @@ -161,6 +225,7 @@ beforeAll(() => { db.exec(SCHEMA); db.exec(COERCION_SCHEMA); + db.exec(TAGS_SCHEMA); const insert = db.prepare( "INSERT INTO subjects (id, age, name, active, joined, note) VALUES (?, ?, ?, ?, ?, ?)", @@ -182,14 +247,24 @@ beforeAll(() => { ); insertCoercion.run("nine", "9", 1); insertCoercion.run("ten", "10", FALSE_AS_INTEGER); + + const insertTag = db.prepare( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES (?, ?, ?, ?)`, + ); + for (const tagRow of TAGS) { + insertTag.run(tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight); + } }); afterAll(() => { db.close(); }); -function selectMatching(node: PredicateNode): string[] { - const compiled = compilePredicateNode(node, sqliteSubjectOptions); +function selectMatching( + node: PredicateNode, + options: Readonly = sqliteSubjectOptions, +): string[] { + const compiled = compilePredicateNode(node, options); const rows = db .prepare( `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, @@ -213,9 +288,12 @@ async function evaluatorMatching(node: PredicateNode): Promise { return matched.sort(); } -/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. */ -async function agreeingRows(node: PredicateNode): Promise { - const viaSql = selectMatching(node); +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `sqliteSubjectOptions`; the quantifier suite passes `sqliteSubjectOptionsWithTags` instead. */ +async function agreeingRows( + node: PredicateNode, + options: Readonly = sqliteSubjectOptions, +): Promise { + const viaSql = selectMatching(node, options); const viaEvaluator = await evaluatorMatching(node); expect(viaSql).toEqual(viaEvaluator); return viaSql; @@ -686,6 +764,193 @@ describe("the divergences the guard's refusals exist to prevent", () => { }); }); +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, sqliteSubjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + sqliteSubjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows( + every, + sqliteSubjectOptionsWithTags, + ); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + sqliteSubjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, sqliteSubjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, sqliteSubjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + sqliteSubjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + describe("a tree deep enough to mix every supported kind", () => { it("agrees with the evaluator row for row", async () => { const node: PredicateNode = { From b82fbacd4187f2229b6411a3a357ab249690b8ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:55:10 +0100 Subject: [PATCH 08/12] test(trilean-sql): split compile.test.ts into topic-scoped files under the 800-line cap compile.test.ts had grown to 954 lines of real code, over the new max-lines cap. Splits it by tested concern into four files (connectives through exists, some/every/fold, parameters through refusal, and the per-dialect and unimplemented-dialect behaviour), each comfortably under the cap. Extracts the compile() wrapper and the shared age fixtures (ADULT_AGE, ageOver, and their siblings) -- used across most of the original file rather than confined to one topic -- into a new compile-test-helpers.ts every split file imports from. --- .../trilean-sql/src/compile-test-helpers.ts | 26 + .../trilean-sql/src/compile.dialects.test.ts | 311 +++++ .../trilean-sql/src/compile.mechanics.test.ts | 219 ++++ .../src/compile.predicates.test.ts | 326 +++++ .../src/compile.quantifiers.test.ts | 199 ++++ packages/trilean-sql/src/compile.test.ts | 1050 ----------------- 6 files changed, 1081 insertions(+), 1050 deletions(-) create mode 100644 packages/trilean-sql/src/compile-test-helpers.ts create mode 100644 packages/trilean-sql/src/compile.dialects.test.ts create mode 100644 packages/trilean-sql/src/compile.mechanics.test.ts create mode 100644 packages/trilean-sql/src/compile.predicates.test.ts create mode 100644 packages/trilean-sql/src/compile.quantifiers.test.ts delete mode 100644 packages/trilean-sql/src/compile.test.ts diff --git a/packages/trilean-sql/src/compile-test-helpers.ts b/packages/trilean-sql/src/compile-test-helpers.ts new file mode 100644 index 0000000..360755e --- /dev/null +++ b/packages/trilean-sql/src/compile-test-helpers.ts @@ -0,0 +1,26 @@ +import type { PredicateNode } from "trilean"; +import { compilePredicateNode } from "./compile"; +import type { SqlCompileOptions } from "./options"; +import { subjectOptions } from "./test-support/columns"; + +/** Compiles against `subjectOptions` by default, so each split `compile.*.test.ts` file below only has to name a dialect/column configuration explicitly when it actually differs from the shared default. */ +export function compile( + node: PredicateNode, + options: Readonly = subjectOptions, +) { + return compilePredicateNode(node, options); +} + +// Named rather than written twice, so each case's expected `params` is the same value the tree was built from rather than a literal that could drift away from it. +export const ADULT_AGE = 18; +export const SAMPLE_AGE = 40; +export const EXCLUDED_AGE = 7; +export const LOWER_BOUND = 1; +export const UPPER_BOUND = 4; + +export const ageOver: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: ADULT_AGE }, +}; diff --git a/packages/trilean-sql/src/compile.dialects.test.ts b/packages/trilean-sql/src/compile.dialects.test.ts new file mode 100644 index 0000000..bcec878 --- /dev/null +++ b/packages/trilean-sql/src/compile.dialects.test.ts @@ -0,0 +1,311 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "./compile"; +import { UnknownDialectError, UnsupportedNodeError } from "./errors"; +import { findUnpushableNodeKind } from "./guard"; +import type { SqlCompileOptions, SqlDialect } from "./options"; +import { + sqliteSubjectOptions, + subjectOptionsWithPostgresRegexp, +} from "./test-support/columns"; +import { + ADULT_AGE, + ageOver, + compile, + EXCLUDED_AGE, + LOWER_BOUND, +} from "./compile-test-helpers"; + +describe("the sqlite dialect", () => { + function compileSqlite(node: PredicateNode) { + return compile(node, sqliteSubjectOptions); + } + + it("renders every placeholder as a bare '?', with no number and no cast", () => { + // SQLite binds by position in emission order rather than by an index written into the text, and it has no type to cast a parameter to. Asserted across a nested tree because the numbering is exactly what a bare '?' drops: the three parameters below are told apart only by the order they appear in. + expect( + compileSqlite({ + kind: "allOf", + operands: [ + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: LOWER_BOUND }, + }, + { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "b" }, + { kind: "textLiteral", value: "c" }, + ], + }, + ], + }), + ).toEqual({ + sql: '(("age" > ?) AND ("name" IN (?, ?)))', + params: [LOWER_BOUND, "b", "c"], + }); + }); + + it("compares two literals without either side needing a type", () => { + // The case PostgreSQL cannot execute uncast at all. SQLite answers it from the bound values themselves, so there is nothing to annotate. + expect( + compileSqlite({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).toEqual({ sql: "(? < ?)", params: [1, 2] }); + }); + + it("renders an instant literal as a plain parameter, with no timestamp type to cast to", () => { + expect( + compileSqlite({ + kind: "compare", + op: "gte", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2020-01-01T00:00:00+02:00" }, + }), + ).toEqual({ + sql: '("joined" >= ?)', + params: ["2020-01-01T00:00:00+02:00"], + }); + }); + + it.each([ + ["equals", "="], + ["notEquals", "<>"], + ["matches", "REGEXP"], + ["notMatches", "NOT REGEXP"], + ] as const)("compiles textCompare '%s' to '%s'", (op, sqlOperator) => { + // `=` and `<>` are ANSI-standard and identical to the PostgreSQL dialect's; only the two pattern operators differ, and SQLite's are the reserved REGEXP syntax for a function the connection registers itself. + expect( + compileSqlite({ + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).toEqual({ sql: `("name" ${sqlOperator} ?)`, params: ["^a"] }); + }); + + it.each([ + ["portableMatches", "GLOB"], + ["portableNotMatches", "NOT GLOB"], + ] as const)( + "compiles '%s' to '%s' against the pattern translated into a GLOB wildcard", + (op, sqlOperator) => { + expect( + compileSqlite({ + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a.*c$" }, + }), + ).toEqual({ + sql: `("name" ${sqlOperator} ?)`, + params: ["a*c"], + }); + }, + ); + + it("refuses a portableMatches pattern outside GLOB's reachable subset", () => { + // Alternation has no GLOB equivalent at all (see portable-pattern.ts's own reachable-subset doc comment) -- this falls back to in-process evaluation rather than compiling to something that answers a different question. + expect(() => + compileSqlite({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "cat|dog" }, + }), + ).toThrow(/GLOB has no alternation operator/); + }); + + it("compiles an empty candidate list without a boolean annotation on the NULL", () => { + // SQLite has no boolean type to annotate, and the annotation is not what the encoding depends on: the integration suite executes both of these and gets the same three-valued answers the `::boolean` forms give PostgreSQL. + expect( + compileSqlite({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }), + ).toEqual({ sql: '("name" IS NULL AND NULL)', params: [] }); + + expect( + compileSqlite({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }), + ).toEqual({ sql: '("name" IS NOT NULL OR NULL)', params: [] }); + }); + + it("emits the dialect-neutral structure identically to PostgreSQL", () => { + // Everything the two dialects share, in one tree: the connectives, the six comparison operators, `IS NOT NULL`, `NOT IN`, and double-quoted identifiers. The only difference between this expectation and the PostgreSQL one is the placeholders. + const node: PredicateNode = { + kind: "not", + operand: { + kind: "and", + left: ageOver, + right: { + kind: "or", + left: { kind: "exists", operand: { kind: "reference", key: "note" } }, + right: { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "age" }, + candidates: [{ kind: "numberLiteral", value: EXCLUDED_AGE }], + }, + }, + }, + }; + + expect(compileSqlite(node).sql).toBe( + '(NOT (("age" > ?) AND (("note" IS NOT NULL) OR ("age" NOT IN (?)))))', + ); + expect(compile(node).sql).toBe( + '(NOT (("age" > $1::double precision) AND (("note" IS NOT NULL) OR ("age" NOT IN ($2::double precision)))))', + ); + }); + + it("quotes and neutralises identifiers exactly as the PostgreSQL dialect does", () => { + // Double-quoting with an embedded quote doubled is ANSI-standard, so the injection defence is the same string in both dialects rather than a per-dialect rule. + const hostile: SqlCompileOptions = { + dialect: "sqlite", + columnFor: () => ({ column: 'note"; DROP TABLE subjects; --' }), + }; + expect( + compile( + { kind: "exists", operand: { kind: "reference", key: "anything" } }, + hostile, + ).sql, + ).toBe('("note""; DROP TABLE subjects; --" IS NOT NULL)'); + }); + + it("compiles an empty allOf and anyOf to the same identities", () => { + expect(compileSqlite({ kind: "allOf", operands: [] }).sql).toBe("(TRUE)"); + expect(compileSqlite({ kind: "anyOf", operands: [] }).sql).toBe("(FALSE)"); + }); +}); + +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, { + ...subjectOptionsWithPostgresRegexp, + 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; + const mysqlOptions: SqlCompileOptions = { + dialect: unimplemented, + columnFor: () => ({ column: "age", paramType: "number" }), + }; + + const anyTree: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: ADULT_AGE }, + }; + + it("is refused by name, not as an internal error from an empty table lookup", () => { + expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow( + UnknownDialectError, + ); + expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow( + /unknown dialect "mysql": this version compiles "postgres", "sqlite"/, + ); + }); + + it("carries the offending name and the implemented ones as fields", () => { + try { + compilePredicateNode(anyTree, mysqlOptions); + expect.unreachable("compiling an unimplemented dialect must throw"); + } catch (error) { + expect(error).toBeInstanceOf(UnknownDialectError); + expect(error).toMatchObject({ + dialect: "mysql", + implemented: ["postgres", "sqlite"], + }); + } + }); + + it("is refused before the tree is walked, so the dialect is what gets reported", () => { + // A tree the guard would object to on its own. The dialect is the earlier problem and has to be the one named, since every refusal reason the walk could produce describes an engine that is not the one asked for. + expect(() => + compilePredicateNode( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: Number.NaN }, + }, + mysqlOptions, + ), + ).toThrow(UnknownDialectError); + }); + + it("never reports such a tree as pushable, which would promise a compilation that cannot happen", () => { + expect(() => findUnpushableNodeKind(anyTree, mysqlOptions)).toThrow( + UnknownDialectError, + ); + }); +}); diff --git a/packages/trilean-sql/src/compile.mechanics.test.ts b/packages/trilean-sql/src/compile.mechanics.test.ts new file mode 100644 index 0000000..1feb99a --- /dev/null +++ b/packages/trilean-sql/src/compile.mechanics.test.ts @@ -0,0 +1,219 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it, vi } from "vitest"; +import { InvalidColumnError, UnsupportedNodeError } from "./errors"; +import type { SqlCompileOptions } from "./options"; +import { + ageOver, + compile, + LOWER_BOUND, + UPPER_BOUND, +} from "./compile-test-helpers"; + +describe("parameters", () => { + it("numbers placeholders in emission order across a nested tree", () => { + const node: PredicateNode = { + kind: "allOf", + operands: [ + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: LOWER_BOUND }, + }, + { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "b" }, + { kind: "textLiteral", value: "c" }, + ], + }, + { + kind: "compare", + op: "lt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: UPPER_BOUND }, + }, + ], + }; + + expect(compile(node)).toEqual({ + sql: '(("age" > $1::double precision) AND ("name" IN ($2::text, $3::text)) AND ("age" < $4::double precision))', + params: [LOWER_BOUND, "b", "c", UPPER_BOUND], + }); + }); + + it("never writes a literal into the SQL text", () => { + const injection = "'; DROP TABLE subjects; --"; + const compiled = compile({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: injection }, + }); + + expect(compiled.sql).not.toContain("DROP"); + expect(compiled.sql).toBe('("name" = $1::text)'); + expect(compiled.params).toEqual([injection]); + }); +}); + +describe("column identifiers", () => { + function optionsReturning(column: string): SqlCompileOptions { + return { dialect: "postgres", columnFor: () => ({ column }) }; + } + + const noteExists: PredicateNode = { + kind: "exists", + operand: { kind: "reference", key: "anything" }, + }; + + it("quotes each dot-separated segment separately", () => { + expect( + compile(noteExists, optionsReturning("public.subjects.note")).sql, + ).toBe('("public"."subjects"."note" IS NOT NULL)'); + }); + + it("neutralises a column name carrying a quote by doubling it", () => { + const compiled = compile( + noteExists, + optionsReturning('note"; DROP TABLE subjects; --'), + ); + expect(compiled.sql).toBe( + '("note""; DROP TABLE subjects; --" IS NOT NULL)', + ); + }); + + it("rejects an empty column name", () => { + expect(() => compile(noteExists, optionsReturning(""))).toThrow( + InvalidColumnError, + ); + }); + + it("rejects an empty dot-separated segment", () => { + expect(() => compile(noteExists, optionsReturning("public..note"))).toThrow( + InvalidColumnError, + ); + }); + + it("propagates an error thrown by columnFor unchanged", () => { + expect(() => + compile({ + kind: "exists", + operand: { kind: "reference", key: "unmapped" }, + }), + ).toThrow("no column mapped for reference key 'unmapped'"); + }); + + it("asks columnFor once per distinct reference key", () => { + const columnFor = vi.fn(() => ({ + column: "age", + paramType: "number" as const, + })); + compile( + { + kind: "and", + left: ageOver, + right: { + kind: "compare", + op: "lt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 65 }, + }, + }, + { dialect: "postgres", columnFor }, + ); + expect(columnFor).toHaveBeenCalledTimes(1); + }); +}); + +describe("refusal", () => { + it("throws UnsupportedNodeError carrying the offending kind and path", () => { + let thrown: unknown; + try { + compile({ + kind: "and", + left: ageOver, + right: { kind: "treeReference", key: "other" }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(UnsupportedNodeError); + if (!(thrown instanceof UnsupportedNodeError)) + throw new Error("unreachable"); + expect(thrown.name).toBe("UnsupportedNodeError"); + expect(thrown.nodeKind).toBe("treeReference"); + expect(thrown.path).toBe("$.right"); + expect(thrown.message).toContain( + "cannot compile 'treeReference' at $.right", + ); + }); + + it("emits nothing at all when it refuses", () => { + // The refusal is total: no partial fragment, no partially-populated parameter list, nothing a caller could mistake for a usable result. + expect(() => + compile({ + kind: "allOf", + operands: [ageOver, { kind: "some", collection: "xs", item: ageOver }], + }), + ).toThrow(UnsupportedNodeError); + }); + + it.each([ + ["some", { kind: "some", collection: "xs", item: ageOver }] satisfies [ + string, + PredicateNode, + ], + ["every", { kind: "every", collection: "xs", item: ageOver }] satisfies [ + string, + PredicateNode, + ], + [ + "fold", + { + kind: "compare", + op: "gt", + left: { + kind: "fold", + collection: "xs", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: LOWER_BOUND }, + combine: { kind: "numberLiteral", value: LOWER_BOUND }, + }, + }, + right: { kind: "numberLiteral", value: UPPER_BOUND }, + }, + ] satisfies [string, PredicateNode], + ])( + "refuses a '%s' buried several levels down rather than dropping that branch", + (kind, unsupported) => { + // The failure mode this rules out is the dangerous one: a branch the compiler has no translation for quietly contributing nothing to the fragment, leaving a WHERE clause strictly more permissive than the tree it claims to stand for. The burial is deliberate -- under an `and`, then an `anyOf`, then a `not` -- because a check that only looks at the root would pass every one of these. + let thrown: unknown; + try { + compile({ + kind: "and", + left: ageOver, + right: { + kind: "anyOf", + operands: [ + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { kind: "not", operand: unsupported }, + ], + }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(UnsupportedNodeError); + if (!(thrown instanceof UnsupportedNodeError)) + throw new Error("unreachable"); + expect(thrown.nodeKind).toBe(kind); + expect(thrown.path).toContain("$.right.operands[1].operand"); + }, + ); +}); diff --git a/packages/trilean-sql/src/compile.predicates.test.ts b/packages/trilean-sql/src/compile.predicates.test.ts new file mode 100644 index 0000000..329825e --- /dev/null +++ b/packages/trilean-sql/src/compile.predicates.test.ts @@ -0,0 +1,326 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { UnsupportedNodeError } from "./errors"; +import { subjectOptionsWithPostgresRegexp } from "./test-support/columns"; +import { + ADULT_AGE, + ageOver, + compile, + EXCLUDED_AGE, + SAMPLE_AGE, +} from "./compile-test-helpers"; + +describe("connectives", () => { + it("compiles and/or/not to their SQL counterparts", () => { + const node: PredicateNode = { + kind: "not", + operand: { + kind: "and", + left: ageOver, + right: { + kind: "or", + left: { kind: "exists", operand: { kind: "reference", key: "note" } }, + right: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: true }, + }, + }, + }, + }; + + expect(compile(node)).toEqual({ + sql: '(NOT (("age" > $1::double precision) AND (("note" IS NOT NULL) OR ("active" = $2::boolean))))', + params: [ADULT_AGE, true], + }); + }); + + it("compiles allOf and anyOf to n-ary AND and OR", () => { + const operands: PredicateNode[] = [ + ageOver, + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + ]; + + expect(compile({ kind: "allOf", operands }).sql).toBe( + '(("age" > $1::double precision) AND ("note" IS NOT NULL) AND ("name" = $2::text))', + ); + expect(compile({ kind: "anyOf", operands }).sql).toBe( + '(("age" > $1::double precision) OR ("note" IS NOT NULL) OR ("name" = $2::text))', + ); + }); + + it("compiles an empty allOf and anyOf to each connective's own identity", () => { + // Matching the evaluator, which folds allOf from definite(true) and anyOf from definite(false). + expect(compile({ kind: "allOf", operands: [] })).toEqual({ + sql: "(TRUE)", + params: [], + }); + expect(compile({ kind: "anyOf", operands: [] })).toEqual({ + sql: "(FALSE)", + params: [], + }); + }); +}); + +describe("compare", () => { + it.each([ + ["gt", ">"], + ["gte", ">="], + ["lt", "<"], + ["lte", "<="], + ["eq", "="], + ["neq", "<>"], + ] as const)("compiles '%s' to '%s'", (op, sqlOperator) => { + expect( + compile({ + kind: "compare", + op, + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: SAMPLE_AGE }, + }), + ).toEqual({ + sql: `("age" ${sqlOperator} $1::double precision)`, + params: [SAMPLE_AGE], + }); + }); + + it("casts an instant literal to timestamptz so an offset survives the comparison", () => { + expect( + compile({ + kind: "compare", + op: "gte", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2020-01-01T00:00:00+02:00" }, + }), + ).toEqual({ + sql: '("joined" >= $1::timestamptz)', + params: ["2020-01-01T00:00:00+02:00"], + }); + }); + + it("casts both sides when neither operand is a column", () => { + // PostgreSQL rejects `$1 < $2` outright -- it cannot determine either parameter's type -- so a literal-only comparison is only executable because every placeholder carries the cast its own literal kind implies. + expect( + compile({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).toEqual({ + sql: "($1::double precision < $2::double precision)", + params: [1, 2], + }); + }); + + it("casts by the literal's own kind, whether or not the column declares one", () => { + // `age` declares number and `note` declares nothing; the placeholder is identical either way, which is why the compiler does not consult the declaration when casting. + expect( + compile({ + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 1 }, + }).sql, + ).toBe('("age" > $1::double precision)'); + + expect( + compile({ + kind: "compare", + op: "gt", + left: { kind: "reference", key: "note" }, + right: { kind: "numberLiteral", value: 1 }, + }).sql, + ).toBe('("note" > $1::double precision)'); + }); +}); + +describe("textCompare", () => { + it.each([ + ["equals", "="], + ["notEquals", "<>"], + ] as const)("compiles '%s' to '%s'", (op, sqlOperator) => { + expect( + compile({ + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).toEqual({ + sql: `("name" ${sqlOperator} $1::text)`, + params: ["^a"], + }); + }); + + it.each([ + ["matches", "~"], + ["notMatches", "!~"], + ] as const)( + "compiles '%s' to '%s' once postgresRegexpPushdown is set true", + (op, sqlOperator) => { + expect( + compile( + { + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + subjectOptionsWithPostgresRegexp, + ), + ).toEqual({ + sql: `("name" ${sqlOperator} $1::text)`, + params: ["^a"], + }); + }, + ); + + it.each(["matches", "notMatches"] as const)( + "refuses '%s' against PostgreSQL by default, since PostgreSQL matches it under its own regular-expression dialect rather than trilean's ECMAScript one", + (op) => { + expect(() => + compile({ + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).toThrow(UnsupportedNodeError); + }, + ); + + it("compares two columns without producing a parameter", () => { + expect( + compile({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "reference", key: "note" }, + }), + ).toEqual({ sql: '("name" = "note")', params: [] }); + }); + + it.each([ + ["portableMatches", "~"], + ["portableNotMatches", "!~"], + ] as const)( + "compiles '%s' to '%s' against the pattern translated into PostgreSQL's own syntax", + (op, sqlOperator) => { + expect( + compile({ + kind: "textCompare", + op, + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a\\dc$" }, + }), + ).toEqual({ + sql: `("name" ${sqlOperator} $1::text)`, + // '\d' expands to a '[0-9]' character-class node in trilean-regex's own AST (see shorthand-classes.ts) before this compiler ever sees it, so the bound pattern is that expansion, not the original source text. + params: ["^a[0-9]c$"], + }); + }, + ); + + it("refuses portableMatches whose pattern is not a compile-time literal", () => { + expect(() => + compile({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "reference", key: "note" }, + }), + ).toThrow(/must be a literal, known at compile time/); + }); + + it("refuses portableMatches whose pattern is not valid trilean-regex syntax", () => { + expect(() => + compile({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "(a)" }, + }), + ).toThrow(/capturing groups are not supported/); + }); + + it("refuses portableMatches whose pattern's bound exceeds PostgreSQL's 0-255 limit", () => { + expect(() => + compile({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "a{256}" }, + }), + ).toThrow(/permit a bound of at most 255/); + }); +}); + +describe("memberOf", () => { + it("compiles 'in' to IN with one parameter per candidate", () => { + expect( + compile({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "ada" }, + { kind: "textLiteral", value: "grace" }, + ], + }), + ).toEqual({ + sql: '("name" IN ($1::text, $2::text))', + params: ["ada", "grace"], + }); + }); + + it("compiles 'notIn' to NOT IN", () => { + expect( + compile({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "age" }, + candidates: [{ kind: "numberLiteral", value: EXCLUDED_AGE }], + }), + ).toEqual({ + sql: '("age" NOT IN ($1::double precision))', + params: [EXCLUDED_AGE], + }); + }); + + it("compiles an empty candidate list to a form that still propagates the operand's NULL", () => { + // `IN ()` is a syntax error, and folding to a bare FALSE/TRUE would answer definitely for a NULL operand where the evaluator returns indeterminate. The integration suite executes both of these against a real server. + expect( + compile({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }), + ).toEqual({ sql: '("name" IS NULL AND NULL::boolean)', params: [] }); + + expect( + compile({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }), + ).toEqual({ sql: '("name" IS NOT NULL OR NULL::boolean)', params: [] }); + }); +}); + +describe("exists", () => { + it("compiles to IS NOT NULL", () => { + expect( + compile({ kind: "exists", operand: { kind: "reference", key: "note" } }), + ).toEqual({ sql: '("note" IS NOT NULL)', params: [] }); + }); +}); diff --git a/packages/trilean-sql/src/compile.quantifiers.test.ts b/packages/trilean-sql/src/compile.quantifiers.test.ts new file mode 100644 index 0000000..1bcad67 --- /dev/null +++ b/packages/trilean-sql/src/compile.quantifiers.test.ts @@ -0,0 +1,199 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import type { SqlCompileOptions } from "./options"; +import { compile } from "./compile-test-helpers"; + +const SCORE_THRESHOLD = 5; +const TAG_LABEL = "urgent"; +const RANGE_LOW = 2; +const RANGE_HIGH = 8; + +const tagsOptions: SqlCompileOptions = { + dialect: "postgres", + columnFor: (referenceKey) => { + throw new Error( + `no outer reference expected in a 'tags' collection tree; got '${referenceKey}'`, + ); + }, + collectionFor: (collectionKey) => { + if (collectionKey !== "tags") { + throw new Error(`no collection mapped for '${collectionKey}'`); + } + return { + table: "tags", + join: `"tags"."subjectId" = "subjects"."id"`, + columnFor: (referenceKey) => { + if (referenceKey === "label") + return { column: "label", paramType: "text" }; + if (referenceKey === "score") + return { column: "score", paramType: "number" }; + throw new Error(`no column mapped for '${referenceKey}'`); + }, + }; + }, +}; + +const scoreAboveThreshold: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, +}; + +describe("some", () => { + it("compiles without a filter to a correlated subquery over TRUE as the filter column", () => { + expect( + compile( + { kind: "some", collection: "tags", item: scoreAboveThreshold }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT TRUE AS "filter_ok", ("score" > $1::double precision) AS "item_ok" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [SCORE_THRESHOLD], + }); + }); + + it("compiles a filter into its own participating-row column, ahead of the item's own placeholders", () => { + expect( + compile( + { + kind: "some", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "label" }, + right: { kind: "textLiteral", value: TAG_LABEL }, + }, + item: scoreAboveThreshold, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT ("label" = $1::text) AS "filter_ok", ("score" > $2::double precision) AS "item_ok" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [TAG_LABEL, SCORE_THRESHOLD], + }); + }); +}); + +describe("every", () => { + it("compiles a conjunctive item as one combined boolean per participating row, not split across separate checks", () => { + // The load-bearing shape: `and(gte(score, RANGE_LOW), lte(score, RANGE_HIGH))` compiles to a single "item_ok" column per row, so a row straddling the range on two different rows can never wrongly satisfy it the way two independent correlated EXISTS checks could. + expect( + compile( + { + kind: "every", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" IS NOT NULL AND NOT "item_ok" THEN 1 ELSE 0 END) = 1 THEN FALSE ' + + 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE TRUE END FROM (SELECT "filter_ok", "item_ok" FROM ' + + '(SELECT TRUE AS "filter_ok", (("score" >= $1::double precision) AND ("score" <= $2::double precision)) AS "item_ok" ' + + 'FROM "tags" WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', + params: [RANGE_LOW, RANGE_HIGH], + }); + }); +}); + +describe("fold", () => { + it("compiles 'max' to a correlated MAX over the projected item, NULL the moment any participant is indeterminate", () => { + expect( + compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "max", + item: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ), + ).toEqual({ + sql: + '((SELECT CASE WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_value" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + + 'ELSE MAX("item_value") END FROM (SELECT "filter_ok", "item_value" FROM ' + + '(SELECT TRUE AS "filter_ok", "score" AS "item_value" FROM "tags" ' + + 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + + 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v") = $1::double precision)', + params: [SCORE_THRESHOLD], + }); + }); + + it("compiles 'min' identically but for the aggregate function", () => { + const compiled = compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ); + expect(compiled.sql).toContain('MIN("item_value")'); + expect(compiled.sql).not.toContain('MAX("item_value")'); + }); + + it("refuses 'reduce' unconditionally, even with collectionFor set", () => { + expect(() => + compile( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, + }, + tagsOptions, + ), + ).toThrow(/cannot compile 'fold'.*no general SQL translation/s); + }); +}); diff --git a/packages/trilean-sql/src/compile.test.ts b/packages/trilean-sql/src/compile.test.ts deleted file mode 100644 index 9ce62b0..0000000 --- a/packages/trilean-sql/src/compile.test.ts +++ /dev/null @@ -1,1050 +0,0 @@ -import type { PredicateNode } from "trilean"; -import { describe, expect, it, vi } from "vitest"; -import { compilePredicateNode } from "./compile"; -import { - InvalidColumnError, - UnknownDialectError, - UnsupportedNodeError, -} from "./errors"; -import { findUnpushableNodeKind } from "./guard"; -import type { SqlCompileOptions, SqlDialect } from "./options"; -import { - sqliteSubjectOptions, - subjectOptions, - subjectOptionsWithPostgresRegexp, -} from "./test-support/columns"; - -function compile( - node: PredicateNode, - options: Readonly = subjectOptions, -) { - return compilePredicateNode(node, options); -} - -// Named rather than written twice, so each case's expected `params` is the same value the tree was built from rather than a literal that could drift away from it. -const ADULT_AGE = 18; -const SAMPLE_AGE = 40; -const EXCLUDED_AGE = 7; -const LOWER_BOUND = 1; -const UPPER_BOUND = 4; - -const ageOver: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: ADULT_AGE }, -}; - -describe("connectives", () => { - it("compiles and/or/not to their SQL counterparts", () => { - const node: PredicateNode = { - kind: "not", - operand: { - kind: "and", - left: ageOver, - right: { - kind: "or", - left: { kind: "exists", operand: { kind: "reference", key: "note" } }, - right: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: true }, - }, - }, - }, - }; - - expect(compile(node)).toEqual({ - sql: '(NOT (("age" > $1::double precision) AND (("note" IS NOT NULL) OR ("active" = $2::boolean))))', - params: [ADULT_AGE, true], - }); - }); - - it("compiles allOf and anyOf to n-ary AND and OR", () => { - const operands: PredicateNode[] = [ - ageOver, - { kind: "exists", operand: { kind: "reference", key: "note" } }, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }, - ]; - - expect(compile({ kind: "allOf", operands }).sql).toBe( - '(("age" > $1::double precision) AND ("note" IS NOT NULL) AND ("name" = $2::text))', - ); - expect(compile({ kind: "anyOf", operands }).sql).toBe( - '(("age" > $1::double precision) OR ("note" IS NOT NULL) OR ("name" = $2::text))', - ); - }); - - it("compiles an empty allOf and anyOf to each connective's own identity", () => { - // Matching the evaluator, which folds allOf from definite(true) and anyOf from definite(false). - expect(compile({ kind: "allOf", operands: [] })).toEqual({ - sql: "(TRUE)", - params: [], - }); - expect(compile({ kind: "anyOf", operands: [] })).toEqual({ - sql: "(FALSE)", - params: [], - }); - }); -}); - -describe("compare", () => { - it.each([ - ["gt", ">"], - ["gte", ">="], - ["lt", "<"], - ["lte", "<="], - ["eq", "="], - ["neq", "<>"], - ] as const)("compiles '%s' to '%s'", (op, sqlOperator) => { - expect( - compile({ - kind: "compare", - op, - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: SAMPLE_AGE }, - }), - ).toEqual({ - sql: `("age" ${sqlOperator} $1::double precision)`, - params: [SAMPLE_AGE], - }); - }); - - it("casts an instant literal to timestamptz so an offset survives the comparison", () => { - expect( - compile({ - kind: "compare", - op: "gte", - left: { kind: "reference", key: "joined" }, - right: { kind: "instantLiteral", value: "2020-01-01T00:00:00+02:00" }, - }), - ).toEqual({ - sql: '("joined" >= $1::timestamptz)', - params: ["2020-01-01T00:00:00+02:00"], - }); - }); - - it("casts both sides when neither operand is a column", () => { - // PostgreSQL rejects `$1 < $2` outright -- it cannot determine either parameter's type -- so a literal-only comparison is only executable because every placeholder carries the cast its own literal kind implies. - expect( - compile({ - kind: "compare", - op: "lt", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }), - ).toEqual({ - sql: "($1::double precision < $2::double precision)", - params: [1, 2], - }); - }); - - it("casts by the literal's own kind, whether or not the column declares one", () => { - // `age` declares number and `note` declares nothing; the placeholder is identical either way, which is why the compiler does not consult the declaration when casting. - expect( - compile({ - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 1 }, - }).sql, - ).toBe('("age" > $1::double precision)'); - - expect( - compile({ - kind: "compare", - op: "gt", - left: { kind: "reference", key: "note" }, - right: { kind: "numberLiteral", value: 1 }, - }).sql, - ).toBe('("note" > $1::double precision)'); - }); -}); - -describe("textCompare", () => { - it.each([ - ["equals", "="], - ["notEquals", "<>"], - ] as const)("compiles '%s' to '%s'", (op, sqlOperator) => { - expect( - compile({ - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).toEqual({ - sql: `("name" ${sqlOperator} $1::text)`, - params: ["^a"], - }); - }); - - it.each([ - ["matches", "~"], - ["notMatches", "!~"], - ] as const)( - "compiles '%s' to '%s' once postgresRegexpPushdown is set true", - (op, sqlOperator) => { - expect( - compile( - { - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }, - subjectOptionsWithPostgresRegexp, - ), - ).toEqual({ - sql: `("name" ${sqlOperator} $1::text)`, - params: ["^a"], - }); - }, - ); - - it.each(["matches", "notMatches"] as const)( - "refuses '%s' against PostgreSQL by default, since PostgreSQL matches it under its own regular-expression dialect rather than trilean's ECMAScript one", - (op) => { - expect(() => - compile({ - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).toThrow(UnsupportedNodeError); - }, - ); - - it("compares two columns without producing a parameter", () => { - expect( - compile({ - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "reference", key: "note" }, - }), - ).toEqual({ sql: '("name" = "note")', params: [] }); - }); - - it.each([ - ["portableMatches", "~"], - ["portableNotMatches", "!~"], - ] as const)( - "compiles '%s' to '%s' against the pattern translated into PostgreSQL's own syntax", - (op, sqlOperator) => { - expect( - compile({ - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a\\dc$" }, - }), - ).toEqual({ - sql: `("name" ${sqlOperator} $1::text)`, - // '\d' expands to a '[0-9]' character-class node in trilean-regex's own AST (see shorthand-classes.ts) before this compiler ever sees it, so the bound pattern is that expansion, not the original source text. - params: ["^a[0-9]c$"], - }); - }, - ); - - it("refuses portableMatches whose pattern is not a compile-time literal", () => { - expect(() => - compile({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "reference", key: "note" }, - }), - ).toThrow(/must be a literal, known at compile time/); - }); - - it("refuses portableMatches whose pattern is not valid trilean-regex syntax", () => { - expect(() => - compile({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "(a)" }, - }), - ).toThrow(/capturing groups are not supported/); - }); - - it("refuses portableMatches whose pattern's bound exceeds PostgreSQL's 0-255 limit", () => { - expect(() => - compile({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "a{256}" }, - }), - ).toThrow(/permit a bound of at most 255/); - }); -}); - -describe("memberOf", () => { - it("compiles 'in' to IN with one parameter per candidate", () => { - expect( - compile({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "ada" }, - { kind: "textLiteral", value: "grace" }, - ], - }), - ).toEqual({ - sql: '("name" IN ($1::text, $2::text))', - params: ["ada", "grace"], - }); - }); - - it("compiles 'notIn' to NOT IN", () => { - expect( - compile({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "age" }, - candidates: [{ kind: "numberLiteral", value: EXCLUDED_AGE }], - }), - ).toEqual({ - sql: '("age" NOT IN ($1::double precision))', - params: [EXCLUDED_AGE], - }); - }); - - it("compiles an empty candidate list to a form that still propagates the operand's NULL", () => { - // `IN ()` is a syntax error, and folding to a bare FALSE/TRUE would answer definitely for a NULL operand where the evaluator returns indeterminate. The integration suite executes both of these against a real server. - expect( - compile({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [], - }), - ).toEqual({ sql: '("name" IS NULL AND NULL::boolean)', params: [] }); - - expect( - compile({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [], - }), - ).toEqual({ sql: '("name" IS NOT NULL OR NULL::boolean)', params: [] }); - }); -}); - -describe("exists", () => { - it("compiles to IS NOT NULL", () => { - expect( - compile({ kind: "exists", operand: { kind: "reference", key: "note" } }), - ).toEqual({ sql: '("note" IS NOT NULL)', params: [] }); - }); -}); - -// A small mapping local to this file's own compile-level assertions, distinct from the integration suites' `subject_tags` fixture -- these cases assert exact compiled text rather than measuring against a real connection, so they need no seeded data, only a table/join/columnFor shape to compile against. -const SCORE_THRESHOLD = 5; -const TAG_LABEL = "urgent"; -const RANGE_LOW = 2; -const RANGE_HIGH = 8; - -const tagsOptions: SqlCompileOptions = { - dialect: "postgres", - columnFor: (referenceKey) => { - throw new Error( - `no outer reference expected in a 'tags' collection tree; got '${referenceKey}'`, - ); - }, - collectionFor: (collectionKey) => { - if (collectionKey !== "tags") { - throw new Error(`no collection mapped for '${collectionKey}'`); - } - return { - table: "tags", - join: `"tags"."subjectId" = "subjects"."id"`, - columnFor: (referenceKey) => { - if (referenceKey === "label") - return { column: "label", paramType: "text" }; - if (referenceKey === "score") - return { column: "score", paramType: "number" }; - throw new Error(`no column mapped for '${referenceKey}'`); - }, - }; - }, -}; - -const scoreAboveThreshold: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "score" }, - right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, -}; - -describe("some", () => { - it("compiles without a filter to a correlated subquery over TRUE as the filter column", () => { - expect( - compile( - { kind: "some", collection: "tags", item: scoreAboveThreshold }, - tagsOptions, - ), - ).toEqual({ - sql: - '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + - 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + - 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + - '(SELECT TRUE AS "filter_ok", ("score" > $1::double precision) AS "item_ok" FROM "tags" ' + - 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + - 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', - params: [SCORE_THRESHOLD], - }); - }); - - it("compiles a filter into its own participating-row column, ahead of the item's own placeholders", () => { - expect( - compile( - { - kind: "some", - collection: "tags", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "label" }, - right: { kind: "textLiteral", value: TAG_LABEL }, - }, - item: scoreAboveThreshold, - }, - tagsOptions, - ), - ).toEqual({ - sql: - '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" THEN 1 ELSE 0 END) = 1 THEN TRUE ' + - 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + - 'ELSE FALSE END FROM (SELECT "filter_ok", "item_ok" FROM ' + - '(SELECT ("label" = $1::text) AS "filter_ok", ("score" > $2::double precision) AS "item_ok" FROM "tags" ' + - 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + - 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', - params: [TAG_LABEL, SCORE_THRESHOLD], - }); - }); -}); - -describe("every", () => { - it("compiles a conjunctive item as one combined boolean per participating row, not split across separate checks", () => { - // The load-bearing shape: `and(gte(score, RANGE_LOW), lte(score, RANGE_HIGH))` compiles to a single "item_ok" column per row, so a row straddling the range on two different rows can never wrongly satisfy it the way two independent correlated EXISTS checks could. - expect( - compile( - { - kind: "every", - collection: "tags", - item: { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "score" }, - right: { kind: "numberLiteral", value: RANGE_LOW }, - }, - right: { - kind: "compare", - op: "lte", - left: { kind: "reference", key: "score" }, - right: { kind: "numberLiteral", value: RANGE_HIGH }, - }, - }, - }, - tagsOptions, - ), - ).toEqual({ - sql: - '(SELECT CASE WHEN MAX(CASE WHEN "filter_ok" AND "item_ok" IS NOT NULL AND NOT "item_ok" THEN 1 ELSE 0 END) = 1 THEN FALSE ' + - 'WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_ok" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + - 'ELSE TRUE END FROM (SELECT "filter_ok", "item_ok" FROM ' + - '(SELECT TRUE AS "filter_ok", (("score" >= $1::double precision) AND ("score" <= $2::double precision)) AS "item_ok" ' + - 'FROM "tags" WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + - 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v")', - params: [RANGE_LOW, RANGE_HIGH], - }); - }); -}); - -describe("fold", () => { - it("compiles 'max' to a correlated MAX over the projected item, NULL the moment any participant is indeterminate", () => { - expect( - compile( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { - mode: "max", - item: { kind: "reference", key: "score" }, - }, - }, - right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, - }, - tagsOptions, - ), - ).toEqual({ - sql: - '((SELECT CASE WHEN MAX(CASE WHEN "filter_ok" IS NULL OR "item_value" IS NULL THEN 1 ELSE 0 END) = 1 THEN NULL ' + - 'ELSE MAX("item_value") END FROM (SELECT "filter_ok", "item_value" FROM ' + - '(SELECT TRUE AS "filter_ok", "score" AS "item_value" FROM "tags" ' + - 'WHERE "tags"."subjectId" = "subjects"."id") AS "t" ' + - 'WHERE "filter_ok" IS NULL OR "filter_ok") AS "v") = $1::double precision)', - params: [SCORE_THRESHOLD], - }); - }); - - it("compiles 'min' identically but for the aggregate function", () => { - const compiled = compile( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "min", item: { kind: "reference", key: "score" } }, - }, - right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, - }, - tagsOptions, - ); - expect(compiled.sql).toContain('MIN("item_value")'); - expect(compiled.sql).not.toContain('MAX("item_value")'); - }); - - it("refuses 'reduce' unconditionally, even with collectionFor set", () => { - expect(() => - compile( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { kind: "reference", key: "score" }, - }, - }, - right: { kind: "numberLiteral", value: SCORE_THRESHOLD }, - }, - tagsOptions, - ), - ).toThrow(/cannot compile 'fold'.*no general SQL translation/s); - }); -}); - -describe("parameters", () => { - it("numbers placeholders in emission order across a nested tree", () => { - const node: PredicateNode = { - kind: "allOf", - operands: [ - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: LOWER_BOUND }, - }, - { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "b" }, - { kind: "textLiteral", value: "c" }, - ], - }, - { - kind: "compare", - op: "lt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: UPPER_BOUND }, - }, - ], - }; - - expect(compile(node)).toEqual({ - sql: '(("age" > $1::double precision) AND ("name" IN ($2::text, $3::text)) AND ("age" < $4::double precision))', - params: [LOWER_BOUND, "b", "c", UPPER_BOUND], - }); - }); - - it("never writes a literal into the SQL text", () => { - const injection = "'; DROP TABLE subjects; --"; - const compiled = compile({ - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: injection }, - }); - - expect(compiled.sql).not.toContain("DROP"); - expect(compiled.sql).toBe('("name" = $1::text)'); - expect(compiled.params).toEqual([injection]); - }); -}); - -describe("column identifiers", () => { - function optionsReturning(column: string): SqlCompileOptions { - return { dialect: "postgres", columnFor: () => ({ column }) }; - } - - const noteExists: PredicateNode = { - kind: "exists", - operand: { kind: "reference", key: "anything" }, - }; - - it("quotes each dot-separated segment separately", () => { - expect( - compile(noteExists, optionsReturning("public.subjects.note")).sql, - ).toBe('("public"."subjects"."note" IS NOT NULL)'); - }); - - it("neutralises a column name carrying a quote by doubling it", () => { - const compiled = compile( - noteExists, - optionsReturning('note"; DROP TABLE subjects; --'), - ); - expect(compiled.sql).toBe( - '("note""; DROP TABLE subjects; --" IS NOT NULL)', - ); - }); - - it("rejects an empty column name", () => { - expect(() => compile(noteExists, optionsReturning(""))).toThrow( - InvalidColumnError, - ); - }); - - it("rejects an empty dot-separated segment", () => { - expect(() => compile(noteExists, optionsReturning("public..note"))).toThrow( - InvalidColumnError, - ); - }); - - it("propagates an error thrown by columnFor unchanged", () => { - expect(() => - compile({ - kind: "exists", - operand: { kind: "reference", key: "unmapped" }, - }), - ).toThrow("no column mapped for reference key 'unmapped'"); - }); - - it("asks columnFor once per distinct reference key", () => { - const columnFor = vi.fn(() => ({ - column: "age", - paramType: "number" as const, - })); - compile( - { - kind: "and", - left: ageOver, - right: { - kind: "compare", - op: "lt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 65 }, - }, - }, - { dialect: "postgres", columnFor }, - ); - expect(columnFor).toHaveBeenCalledTimes(1); - }); -}); - -describe("refusal", () => { - it("throws UnsupportedNodeError carrying the offending kind and path", () => { - let thrown: unknown; - try { - compile({ - kind: "and", - left: ageOver, - right: { kind: "treeReference", key: "other" }, - }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(UnsupportedNodeError); - if (!(thrown instanceof UnsupportedNodeError)) - throw new Error("unreachable"); - expect(thrown.name).toBe("UnsupportedNodeError"); - expect(thrown.nodeKind).toBe("treeReference"); - expect(thrown.path).toBe("$.right"); - expect(thrown.message).toContain( - "cannot compile 'treeReference' at $.right", - ); - }); - - it("emits nothing at all when it refuses", () => { - // The refusal is total: no partial fragment, no partially-populated parameter list, nothing a caller could mistake for a usable result. - expect(() => - compile({ - kind: "allOf", - operands: [ageOver, { kind: "some", collection: "xs", item: ageOver }], - }), - ).toThrow(UnsupportedNodeError); - }); - - it.each([ - ["some", { kind: "some", collection: "xs", item: ageOver }] satisfies [ - string, - PredicateNode, - ], - ["every", { kind: "every", collection: "xs", item: ageOver }] satisfies [ - string, - PredicateNode, - ], - [ - "fold", - { - kind: "compare", - op: "gt", - left: { - kind: "fold", - collection: "xs", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: LOWER_BOUND }, - combine: { kind: "numberLiteral", value: LOWER_BOUND }, - }, - }, - right: { kind: "numberLiteral", value: UPPER_BOUND }, - }, - ] satisfies [string, PredicateNode], - ])( - "refuses a '%s' buried several levels down rather than dropping that branch", - (kind, unsupported) => { - // The failure mode this rules out is the dangerous one: a branch the compiler has no translation for quietly contributing nothing to the fragment, leaving a WHERE clause strictly more permissive than the tree it claims to stand for. The burial is deliberate -- under an `and`, then an `anyOf`, then a `not` -- because a check that only looks at the root would pass every one of these. - let thrown: unknown; - try { - compile({ - kind: "and", - left: ageOver, - right: { - kind: "anyOf", - operands: [ - { kind: "exists", operand: { kind: "reference", key: "note" } }, - { kind: "not", operand: unsupported }, - ], - }, - }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(UnsupportedNodeError); - if (!(thrown instanceof UnsupportedNodeError)) - throw new Error("unreachable"); - expect(thrown.nodeKind).toBe(kind); - expect(thrown.path).toContain("$.right.operands[1].operand"); - }, - ); -}); - -describe("the sqlite dialect", () => { - function compileSqlite(node: PredicateNode) { - return compile(node, sqliteSubjectOptions); - } - - it("renders every placeholder as a bare '?', with no number and no cast", () => { - // SQLite binds by position in emission order rather than by an index written into the text, and it has no type to cast a parameter to. Asserted across a nested tree because the numbering is exactly what a bare '?' drops: the three parameters below are told apart only by the order they appear in. - expect( - compileSqlite({ - kind: "allOf", - operands: [ - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: LOWER_BOUND }, - }, - { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "b" }, - { kind: "textLiteral", value: "c" }, - ], - }, - ], - }), - ).toEqual({ - sql: '(("age" > ?) AND ("name" IN (?, ?)))', - params: [LOWER_BOUND, "b", "c"], - }); - }); - - it("compares two literals without either side needing a type", () => { - // The case PostgreSQL cannot execute uncast at all. SQLite answers it from the bound values themselves, so there is nothing to annotate. - expect( - compileSqlite({ - kind: "compare", - op: "lt", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }), - ).toEqual({ sql: "(? < ?)", params: [1, 2] }); - }); - - it("renders an instant literal as a plain parameter, with no timestamp type to cast to", () => { - expect( - compileSqlite({ - kind: "compare", - op: "gte", - left: { kind: "reference", key: "joined" }, - right: { kind: "instantLiteral", value: "2020-01-01T00:00:00+02:00" }, - }), - ).toEqual({ - sql: '("joined" >= ?)', - params: ["2020-01-01T00:00:00+02:00"], - }); - }); - - it.each([ - ["equals", "="], - ["notEquals", "<>"], - ["matches", "REGEXP"], - ["notMatches", "NOT REGEXP"], - ] as const)("compiles textCompare '%s' to '%s'", (op, sqlOperator) => { - // `=` and `<>` are ANSI-standard and identical to the PostgreSQL dialect's; only the two pattern operators differ, and SQLite's are the reserved REGEXP syntax for a function the connection registers itself. - expect( - compileSqlite({ - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).toEqual({ sql: `("name" ${sqlOperator} ?)`, params: ["^a"] }); - }); - - it.each([ - ["portableMatches", "GLOB"], - ["portableNotMatches", "NOT GLOB"], - ] as const)( - "compiles '%s' to '%s' against the pattern translated into a GLOB wildcard", - (op, sqlOperator) => { - expect( - compileSqlite({ - kind: "textCompare", - op, - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a.*c$" }, - }), - ).toEqual({ - sql: `("name" ${sqlOperator} ?)`, - params: ["a*c"], - }); - }, - ); - - it("refuses a portableMatches pattern outside GLOB's reachable subset", () => { - // Alternation has no GLOB equivalent at all (see portable-pattern.ts's own reachable-subset doc comment) -- this falls back to in-process evaluation rather than compiling to something that answers a different question. - expect(() => - compileSqlite({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "cat|dog" }, - }), - ).toThrow(/GLOB has no alternation operator/); - }); - - it("compiles an empty candidate list without a boolean annotation on the NULL", () => { - // SQLite has no boolean type to annotate, and the annotation is not what the encoding depends on: the integration suite executes both of these and gets the same three-valued answers the `::boolean` forms give PostgreSQL. - expect( - compileSqlite({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [], - }), - ).toEqual({ sql: '("name" IS NULL AND NULL)', params: [] }); - - expect( - compileSqlite({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [], - }), - ).toEqual({ sql: '("name" IS NOT NULL OR NULL)', params: [] }); - }); - - it("emits the dialect-neutral structure identically to PostgreSQL", () => { - // Everything the two dialects share, in one tree: the connectives, the six comparison operators, `IS NOT NULL`, `NOT IN`, and double-quoted identifiers. The only difference between this expectation and the PostgreSQL one is the placeholders. - const node: PredicateNode = { - kind: "not", - operand: { - kind: "and", - left: ageOver, - right: { - kind: "or", - left: { kind: "exists", operand: { kind: "reference", key: "note" } }, - right: { - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "age" }, - candidates: [{ kind: "numberLiteral", value: EXCLUDED_AGE }], - }, - }, - }, - }; - - expect(compileSqlite(node).sql).toBe( - '(NOT (("age" > ?) AND (("note" IS NOT NULL) OR ("age" NOT IN (?)))))', - ); - expect(compile(node).sql).toBe( - '(NOT (("age" > $1::double precision) AND (("note" IS NOT NULL) OR ("age" NOT IN ($2::double precision)))))', - ); - }); - - it("quotes and neutralises identifiers exactly as the PostgreSQL dialect does", () => { - // Double-quoting with an embedded quote doubled is ANSI-standard, so the injection defence is the same string in both dialects rather than a per-dialect rule. - const hostile: SqlCompileOptions = { - dialect: "sqlite", - columnFor: () => ({ column: 'note"; DROP TABLE subjects; --' }), - }; - expect( - compile( - { kind: "exists", operand: { kind: "reference", key: "anything" } }, - hostile, - ).sql, - ).toBe('("note""; DROP TABLE subjects; --" IS NOT NULL)'); - }); - - it("compiles an empty allOf and anyOf to the same identities", () => { - expect(compileSqlite({ kind: "allOf", operands: [] }).sql).toBe("(TRUE)"); - expect(compileSqlite({ kind: "anyOf", operands: [] }).sql).toBe("(FALSE)"); - }); -}); - -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, { - ...subjectOptionsWithPostgresRegexp, - 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; - const mysqlOptions: SqlCompileOptions = { - dialect: unimplemented, - columnFor: () => ({ column: "age", paramType: "number" }), - }; - - const anyTree: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: ADULT_AGE }, - }; - - it("is refused by name, not as an internal error from an empty table lookup", () => { - expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow( - UnknownDialectError, - ); - expect(() => compilePredicateNode(anyTree, mysqlOptions)).toThrow( - /unknown dialect "mysql": this version compiles "postgres", "sqlite"/, - ); - }); - - it("carries the offending name and the implemented ones as fields", () => { - try { - compilePredicateNode(anyTree, mysqlOptions); - expect.unreachable("compiling an unimplemented dialect must throw"); - } catch (error) { - expect(error).toBeInstanceOf(UnknownDialectError); - expect(error).toMatchObject({ - dialect: "mysql", - implemented: ["postgres", "sqlite"], - }); - } - }); - - it("is refused before the tree is walked, so the dialect is what gets reported", () => { - // A tree the guard would object to on its own. The dialect is the earlier problem and has to be the one named, since every refusal reason the walk could produce describes an engine that is not the one asked for. - expect(() => - compilePredicateNode( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: Number.NaN }, - }, - mysqlOptions, - ), - ).toThrow(UnknownDialectError); - }); - - it("never reports such a tree as pushable, which would promise a compilation that cannot happen", () => { - expect(() => findUnpushableNodeKind(anyTree, mysqlOptions)).toThrow( - UnknownDialectError, - ); - }); -}); From c3e3f6dfa10383264efaa0f90b50b1ab4d9f9b96 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:55:44 +0100 Subject: [PATCH 09/12] test(trilean-sql): split guard.test.ts into topic-scoped files under the 800-line cap guard.test.ts had grown to 896 lines, over the new max-lines cap. Splits it by tested concern into four files (supported/unsupported node kinds, quantification over a collection, references and cross-dialect operand handling, and SQLite/PostgreSQL regex-pushdown dialect behaviour), each comfortably under the cap. Extracts the shared ageOver fixture -- used across the first two split files -- into a new guard-test-helpers.ts both import from. --- .../trilean-sql/src/guard-test-helpers.ts | 9 + .../trilean-sql/src/guard.dialect.test.ts | 230 +++++ .../src/guard.quantification.test.ts | 282 ++++++ .../trilean-sql/src/guard.references.test.ts | 262 +++++ .../trilean-sql/src/guard.supported.test.ts | 128 +++ packages/trilean-sql/src/guard.test.ts | 896 ------------------ 6 files changed, 911 insertions(+), 896 deletions(-) create mode 100644 packages/trilean-sql/src/guard-test-helpers.ts create mode 100644 packages/trilean-sql/src/guard.dialect.test.ts create mode 100644 packages/trilean-sql/src/guard.quantification.test.ts create mode 100644 packages/trilean-sql/src/guard.references.test.ts create mode 100644 packages/trilean-sql/src/guard.supported.test.ts delete mode 100644 packages/trilean-sql/src/guard.test.ts diff --git a/packages/trilean-sql/src/guard-test-helpers.ts b/packages/trilean-sql/src/guard-test-helpers.ts new file mode 100644 index 0000000..c8eb961 --- /dev/null +++ b/packages/trilean-sql/src/guard-test-helpers.ts @@ -0,0 +1,9 @@ +import type { PredicateNode } from "trilean"; + +/** Shared across the split `guard.*.test.ts` files below -- a pushable comparison used as filler in trees whose real subject is something else entirely (an unrelated node kind, a collection wrapper). */ +export const ageOver: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, +}; diff --git a/packages/trilean-sql/src/guard.dialect.test.ts b/packages/trilean-sql/src/guard.dialect.test.ts new file mode 100644 index 0000000..315f870 --- /dev/null +++ b/packages/trilean-sql/src/guard.dialect.test.ts @@ -0,0 +1,230 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { findUnpushableNodeKind } from "./guard"; +import { + sqliteSubjectOptions, + subjectOptions, + subjectOptionsWithPostgresRegexp, +} from "./test-support/columns"; + +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 has its own separate postgresRegexpPushdown gate", () => { + expect( + findUnpushableNodeKind(patternMatch, { + ...subjectOptionsWithPostgresRegexp, + 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("PostgreSQL regular-expression pushdown", () => { + const matchesNode: PredicateNode = { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }; + + it("refuses 'matches' against PostgreSQL by default", () => { + expect(findUnpushableNodeKind(matchesNode, subjectOptions)).toMatchObject({ + kind: "textCompare", + path: "$", + }); + }); + + it("refuses 'notMatches' against PostgreSQL by default", () => { + expect( + findUnpushableNodeKind( + { ...matchesNode, op: "notMatches" }, + subjectOptions, + ), + ).toMatchObject({ kind: "textCompare", path: "$" }); + }); + + it("refuses 'matches' against PostgreSQL with no options at all, since the default dialect is PostgreSQL and the default is refusal", () => { + expect(findUnpushableNodeKind(matchesNode)).toMatchObject({ + kind: "textCompare", + path: "$", + }); + }); + + it("allows 'matches' against PostgreSQL once postgresRegexpPushdown is set true", () => { + expect( + findUnpushableNodeKind(matchesNode, subjectOptionsWithPostgresRegexp), + ).toBeUndefined(); + }); + + it("allows 'matches' against SQLite unconditionally, since this gate is specific to PostgreSQL's own regular-expression dialect", () => { + expect( + findUnpushableNodeKind(matchesNode, sqliteSubjectOptions), + ).toBeUndefined(); + }); + + it("does not refuse 'equals'/'notEquals' textCompare nodes, which carry no pattern", () => { + expect( + findUnpushableNodeKind({ ...matchesNode, op: "equals" }, subjectOptions), + ).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. + + const nanEquality: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: Number.NaN }, + }; + + const orderedText: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }; + + const orderedBoolean: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }; + + const crossKind: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "instantLiteral", value: "2020-01-01T00:00:00Z" }, + }; + + it.each([nanEquality, orderedText, orderedBoolean, crossKind])( + "refuses the same node at the same path in either dialect", + (node) => { + const viaPostgres = findUnpushableNodeKind(node, subjectOptions); + const viaSqlite = findUnpushableNodeKind(node, sqliteSubjectOptions); + expect(viaPostgres).toBeDefined(); + expect(viaSqlite).toMatchObject({ + kind: viaPostgres?.kind, + path: viaPostgres?.path, + }); + }, + ); + + it("explains a refused NaN by the substitution SQLite's drivers actually make", () => { + // The integration suite proves this one against a real connection: better-sqlite3 binds NaN as SQL NULL, so `NaN = NaN` is indeterminate rather than definitely false, and the negation trilean answers definitely true matches nothing at all. + expect(findUnpushableNodeKind(nanEquality, sqliteSubjectOptions)).toEqual({ + kind: "numberLiteral", + path: "$.right", + reason: + "NaN is equal to nothing in trilean, not even itself, whereas SQLite has no NaN at all and a driver binding one substitutes SQL NULL -- so 'NaN = NaN' is indeterminate there rather than definitely false, and its negation matches every row instead of none", + }); + }); + + it("explains a refused NaN by PostgreSQL's own definition of it in the other dialect", () => { + expect(findUnpushableNodeKind(nanEquality, subjectOptions)).toEqual({ + kind: "numberLiteral", + path: "$.right", + reason: + "NaN is equal to nothing in trilean, not even itself, whereas PostgreSQL defines NaN as equal to itself and greater than every other double", + }); + }); + + it("explains an ordered text operand by the coercion each engine performs", () => { + expect( + findUnpushableNodeKind(orderedText, sqliteSubjectOptions)?.reason, + ).toBe( + "'compare' never compares text in trilean -- it returns wrong-type and directs the caller to 'textCompare' -- but SQLite would answer definitely for the text operand at $.left, comparing it under the text affinity it applies to the other side ('9' > 5 is true there, while '10' > 5 is not)", + ); + expect(findUnpushableNodeKind(orderedText, subjectOptions)?.reason).toBe( + "'compare' never compares text in trilean -- it returns wrong-type and directs the caller to 'textCompare' -- but PostgreSQL would order the text operand at $.left collation-wise and answer definitely", + ); + }); + + it("explains an ordered boolean by how each engine represents one", () => { + expect( + findUnpushableNodeKind(orderedBoolean, sqliteSubjectOptions)?.reason, + ).toBe( + "booleans have no ordering in trilean, so 'gt' against the boolean operand at $.left is wrong-type there, whereas SQLite stores booleans as the integers 0 and 1 and orders them as integers", + ); + expect(findUnpushableNodeKind(orderedBoolean, subjectOptions)?.reason).toBe( + "booleans have no ordering in trilean, so 'gt' against the boolean operand at $.left is wrong-type there, whereas PostgreSQL orders false before true", + ); + }); + + it("explains a cross-kind comparison by the coercion each engine reaches for", () => { + expect( + findUnpushableNodeKind(crossKind, sqliteSubjectOptions)?.reason, + ).toContain( + "whereas SQLite's type affinity may coerce one to the other and answer definitely", + ); + expect(findUnpushableNodeKind(crossKind, subjectOptions)?.reason).toContain( + "whereas PostgreSQL may coerce one to the other and answer definitely", + ); + }); + + it("describes PostgreSQL when no options name a dialect at all", () => { + // A structural walk has no dialect to read. It refuses exactly what either dialect refuses -- the point of the walk is unchanged -- and names the dialect these refusals were first derived against rather than inventing a dialect-free phrasing that describes neither engine. + expect( + findUnpushableNodeKind({ + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: Number.NaN }, + right: { kind: "numberLiteral", value: Number.NaN }, + })?.reason, + ).toContain("PostgreSQL defines NaN as equal to itself"); + }); +}); diff --git a/packages/trilean-sql/src/guard.quantification.test.ts b/packages/trilean-sql/src/guard.quantification.test.ts new file mode 100644 index 0000000..fbeb720 --- /dev/null +++ b/packages/trilean-sql/src/guard.quantification.test.ts @@ -0,0 +1,282 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { findUnpushableNodeKind } from "./guard"; +import type { SqlCompileOptions } from "./options"; +import { subjectOptions } from "./test-support/columns"; +import { ageOver } from "./guard-test-helpers"; + +describe("quantification over a collection", () => { + const tagsOptions: SqlCompileOptions = { + dialect: "postgres", + columnFor: subjectOptions.columnFor, + collectionFor: (collectionKey) => { + if (collectionKey !== "tags") { + throw new Error(`no collection mapped for '${collectionKey}'`); + } + return { + table: "tags", + join: `"tags"."subjectId" = "subjects"."id"`, + columnFor: (referenceKey) => { + if (referenceKey === "score") + return { column: "score", paramType: "number" }; + if (referenceKey === "label") + return { column: "label", paramType: "text" }; + if (referenceKey === "flagged") + return { column: "flagged", paramType: "boolean" }; + if (referenceKey === "note") return { column: "note" }; + throw new Error(`no column mapped for '${referenceKey}'`); + }, + }; + }, + }; + + const scoreAboveOne: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "score" }, + right: { kind: "numberLiteral", value: 1 }, + }; + + it.each(["some", "every"] as const)( + "refuses '%s' when collectionFor is not set", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: scoreAboveOne }, + subjectOptions, + ), + ).toMatchObject({ + kind, + path: "$", + reason: expect.stringContaining("collectionFor") as unknown, + }); + }, + ); + + it.each(["some", "every"] as const)( + "is pushable once collectionFor maps the collection", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: scoreAboveOne }, + tagsOptions, + ), + ).toBeUndefined(); + }, + ); + + it.each(["max", "min"] as const)( + "refuses fold('%s') when collectionFor is not set", + (mode) => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode, item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + subjectOptions, + ), + ).toMatchObject({ + kind: "fold", + reason: expect.stringContaining("collectionFor") as unknown, + }); + }, + ); + + it.each(["max", "min"] as const)( + "is pushable once collectionFor maps the collection, for fold('%s')", + (mode) => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode, item: { kind: "reference", key: "score" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toBeUndefined(); + }, + ); + + it("refuses fold('reduce') unconditionally, even once collectionFor maps the collection", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "reference", key: "score" }, + }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + reason: expect.stringContaining("no general SQL translation") as unknown, + }); + }); + + it("refuses a non-string collection key, even with collectionFor set", () => { + expect( + findUnpushableNodeKind( + { kind: "some", collection: { nested: "key" }, item: scoreAboveOne }, + tagsOptions, + ), + ).toMatchObject({ + kind: "some", + reason: expect.stringContaining("non-string") as unknown, + }); + }); + + it("resolves item/filter against the collection's own columnFor, not the outer one", () => { + // "age" is an outer column subjectOptions maps but tagsOptions' own collection-level columnFor does not -- a mapping error here proves item is actually walked using the resolved binding's own columnFor, not silently skipped. + expect(() => + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + item: { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 1 }, + }, + }, + tagsOptions, + ), + ).toThrow(/no column mapped for 'age'/); + }); + + it("refuses an unsupported node kind buried inside filter, not only inside item", () => { + expect( + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + filter: { kind: "treeReference", key: "other" }, + item: scoreAboveOne, + }, + tagsOptions, + ), + ).toMatchObject({ kind: "treeReference", path: "$.filter" }); + }); + + it("is pushable with both a filter and an item, once collectionFor is set", () => { + expect( + findUnpushableNodeKind( + { + kind: "some", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "label" }, + right: { kind: "textLiteral", value: "urgent" }, + }, + item: scoreAboveOne, + }, + tagsOptions, + ), + ).toBeUndefined(); + }); + + it("refuses a fold('max'|'min') whose projected item is text, which trilean never orders", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "max", + item: { kind: "reference", key: "label" }, + }, + }, + right: { kind: "textLiteral", value: "z" }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + path: "$.left.combiner.item", + reason: expect.stringContaining("never orders text values") as unknown, + }); + }); + + it("refuses a fold('max'|'min') whose projected item is boolean, which trilean never orders", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { + mode: "min", + item: { kind: "reference", key: "flagged" }, + }, + }, + right: { kind: "booleanLiteral", value: true }, + }, + tagsOptions, + ), + ).toMatchObject({ + kind: "fold", + path: "$.left.combiner.item", + reason: expect.stringContaining("booleans have no ordering") as unknown, + }); + }); + + it("cannot detect a fold('max'|'min') text/boolean ordering mismatch against an item with no declared paramType", () => { + // The identical limitation `compare` already accepts for an undeclared column, applied to fold's own item: without a declared paramType there is nothing to check the ordering divergence against. Compared against a number, not text, so the outer `compare`'s own text-ordering check (unrelated to the one this test targets) cannot itself be what causes the refusal. + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "note" } }, + }, + right: { kind: "numberLiteral", value: 1 }, + }, + tagsOptions, + ), + ).toBeUndefined(); + }); + + it.each(["some", "every"] as const)( + "'%s' is treated as structurally pushable when called without options at all, matching the 'assume it passes' convention every other options-dependent check in this file already follows", + (kind) => { + expect( + findUnpushableNodeKind( + { kind, collection: "tags", item: ageOver }, + undefined, + ), + ).toBeUndefined(); + }, + ); +}); diff --git a/packages/trilean-sql/src/guard.references.test.ts b/packages/trilean-sql/src/guard.references.test.ts new file mode 100644 index 0000000..130ed27 --- /dev/null +++ b/packages/trilean-sql/src/guard.references.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { findUnpushableNodeKind } from "./guard"; +import { sqliteSubjectOptions, subjectOptions } from "./test-support/columns"; + +describe("references the compiler cannot map", () => { + it("refuses a non-string reference key", () => { + expect( + findUnpushableNodeKind( + { + kind: "exists", + operand: { kind: "reference", key: { nested: "key" } }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "reference", path: "$.operand" }); + }); + + it("refuses a reference declaring a unit, which no column can be checked against", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age", unit: { year: 1 } }, + right: { kind: "numberLiteral", value: 18 }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "reference", path: "$.left" }); + }); + + it("refuses a unit-tagged number literal", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18, unit: { year: 1 } }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "numberLiteral", path: "$.right" }); + }); + + it("refuses a NaN number literal, which the two engines compare oppositely", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: Number.NaN }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "numberLiteral", path: "$.right" }); + }); + + it("refuses a NaN candidate inside a memberOf, not only a comparison operand", () => { + expect( + findUnpushableNodeKind( + { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "age" }, + candidates: [ + { kind: "numberLiteral", value: 1 }, + { kind: "numberLiteral", value: Number.NaN }, + ], + }, + subjectOptions, + ), + ).toMatchObject({ kind: "numberLiteral", path: "$.candidates[1]" }); + }); + + it("allows an infinity, which both engines order and compare identically", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "gt", + left: { kind: "numberLiteral", value: Number.POSITIVE_INFINITY }, + right: { kind: "reference", key: "age" }, + }, + subjectOptions, + ), + ).toBeUndefined(); + }); +}); + +describe("operand kinds trilean and PostgreSQL would answer differently", () => { + it("refuses a compare whose operands are of different declared kinds", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: { kind: "instantLiteral", value: "2020-01-01T00:00:00Z" }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "compare", path: "$" }); + }); + + it("refuses a compare against text, which trilean directs to textCompare", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "compare", path: "$" }); + }); + + it("refuses an ordering comparison on booleans, which trilean has no order for", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "compare", path: "$" }); + }); + + it("allows equality on booleans, which trilean does define", () => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + subjectOptions, + ), + ).toBeUndefined(); + }); + + it("refuses a textCompare against a non-text operand", () => { + expect( + findUnpushableNodeKind( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "age" }, + right: { kind: "textLiteral", value: "18" }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "textCompare", path: "$" }); + }); + + it("allows a portableMatches pattern within both dialects' reachable subsets", () => { + expect( + findUnpushableNodeKind( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a.*c$" }, + }, + subjectOptions, + ), + ).toBeUndefined(); + expect( + findUnpushableNodeKind( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a.*c$" }, + }, + sqliteSubjectOptions, + ), + ).toBeUndefined(); + }); + + it("refuses a portableMatches pattern that is not a compile-time literal", () => { + const result = findUnpushableNodeKind( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "reference", key: "note" }, + }, + subjectOptions, + ); + expect(result).toMatchObject({ kind: "textCompare", path: "$" }); + expect(result?.reason).toContain( + "must be a literal, known at compile time", + ); + }); + + it("refuses a portableMatches pattern that is not valid trilean-regex syntax", () => { + expect( + findUnpushableNodeKind( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "(a)" }, + }, + subjectOptions, + ), + ).toMatchObject({ kind: "textCompare", path: "$.right" }); + }); + + it("refuses a portableMatches pattern outside SQLite's GLOB-reachable subset, but allows the identical pattern for PostgreSQL", () => { + const alternation = { + kind: "textCompare" as const, + op: "portableMatches" as const, + left: { kind: "reference" as const, key: "name" }, + right: { kind: "textLiteral" as const, value: "cat|dog" }, + }; + expect( + findUnpushableNodeKind(alternation, sqliteSubjectOptions), + ).toMatchObject({ kind: "textCompare", path: "$.right" }); + expect(findUnpushableNodeKind(alternation, subjectOptions)).toBeUndefined(); + }); + + it("refuses a memberOf whose candidates are not all of the operand's kind", () => { + expect( + findUnpushableNodeKind( + { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "age" }, + candidates: [ + { kind: "numberLiteral", value: 1 }, + { kind: "textLiteral", value: "two" }, + ], + }, + subjectOptions, + ), + ).toMatchObject({ kind: "memberOf", path: "$" }); + }); + + it("cannot detect a mismatch against a column with no declared paramType", () => { + // Not a gap to fix by guessing: without a declared type there is nothing to compare the literal's kind against. It is the concrete reason to declare paramType, and stating it as a test keeps the limitation deliberate. + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "note" }, + right: { kind: "numberLiteral", value: 1 }, + }, + subjectOptions, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/trilean-sql/src/guard.supported.test.ts b/packages/trilean-sql/src/guard.supported.test.ts new file mode 100644 index 0000000..5c7651f --- /dev/null +++ b/packages/trilean-sql/src/guard.supported.test.ts @@ -0,0 +1,128 @@ +import type { ExpressionNode, PredicateNode } from "trilean"; +import { describe, expect, it, vi } from "vitest"; +import { findUnpushableNodeKind } from "./guard"; +import { subjectOptions } from "./test-support/columns"; +import { ageOver } from "./guard-test-helpers"; + +describe("supported trees", () => { + it("passes a tree built only from the kinds the compiler translates", () => { + const node: PredicateNode = { + kind: "allOf", + operands: [ + { kind: "not", operand: ageOver }, + { + kind: "or", + left: { kind: "exists", operand: { kind: "reference", key: "note" } }, + right: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + }, + { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "age" }, + candidates: [{ kind: "numberLiteral", value: 3 }], + }, + ], + }; + + expect(findUnpushableNodeKind(node, subjectOptions)).toBeUndefined(); + }); + + it("does not consult columnFor when called without options", () => { + const columnFor = vi.fn(() => ({ column: "age" })); + expect(findUnpushableNodeKind(ageOver, undefined)).toBeUndefined(); + expect(columnFor).not.toHaveBeenCalled(); + }); +}); + +describe("predicate kinds this version does not translate", () => { + it.each([ + [ + "treeReference", + { kind: "treeReference", key: "other" } satisfies PredicateNode, + ], + ])("refuses '%s'", (kind, node) => { + expect(findUnpushableNodeKind(node, subjectOptions)).toMatchObject({ + kind, + path: "$", + }); + }); + + it("reports the path of a refused node nested inside the tree", () => { + expect( + findUnpushableNodeKind( + { + kind: "allOf", + operands: [ + ageOver, + { kind: "not", operand: { kind: "treeReference", key: "other" } }, + ], + }, + subjectOptions, + ), + ).toMatchObject({ kind: "treeReference", path: "$.operands[1].operand" }); + }); +}); + +describe("expression kinds this version does not translate", () => { + const unsupported: readonly [string, ExpressionNode][] = [ + ["durationLiteral", { kind: "durationLiteral", value: 5, unit: "min" }], + ["complexLiteral", { kind: "complexLiteral", re: 1, im: 2 }], + [ + "arithmetic", + { + kind: "arithmetic", + op: "add", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }, + ], + [ + "negate", + { kind: "negate", operand: { kind: "numberLiteral", value: 1 } }, + ], + ["call", { kind: "call", fn: "round", args: [] }], + ["lookup", { kind: "lookup", table: "rates", keys: [] }], + [ + "conditional", + { + kind: "conditional", + cases: [], + fallback: { kind: "numberLiteral", value: 0 }, + }, + ], + [ + "fold", + { + kind: "fold", + collection: "xs", + combiner: { + mode: "reduce", + initial: { kind: "numberLiteral", value: 0 }, + combine: { kind: "numberLiteral", value: 1 }, + }, + }, + ], + ["accumulator", { kind: "accumulator" }], + ["delegate", { kind: "delegate", system: "legacy", payload: null }], + ["treeReference", { kind: "treeReference", key: "other" }], + ]; + + it.each(unsupported)("refuses '%s' in a comparison operand", (kind, node) => { + expect( + findUnpushableNodeKind( + { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "age" }, + right: node, + }, + subjectOptions, + ), + ).toMatchObject({ kind, path: "$.right" }); + }); +}); diff --git a/packages/trilean-sql/src/guard.test.ts b/packages/trilean-sql/src/guard.test.ts deleted file mode 100644 index 95200ba..0000000 --- a/packages/trilean-sql/src/guard.test.ts +++ /dev/null @@ -1,896 +0,0 @@ -import type { ExpressionNode, PredicateNode } from "trilean"; -import { describe, expect, it, vi } from "vitest"; -import { findUnpushableNodeKind } from "./guard"; -import type { SqlCompileOptions } from "./options"; -import { - sqliteSubjectOptions, - subjectOptions, - subjectOptionsWithPostgresRegexp, -} from "./test-support/columns"; - -const ageOver: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, -}; - -describe("supported trees", () => { - it("passes a tree built only from the kinds the compiler translates", () => { - const node: PredicateNode = { - kind: "allOf", - operands: [ - { kind: "not", operand: ageOver }, - { - kind: "or", - left: { kind: "exists", operand: { kind: "reference", key: "note" } }, - right: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }, - }, - { - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "age" }, - candidates: [{ kind: "numberLiteral", value: 3 }], - }, - ], - }; - - expect(findUnpushableNodeKind(node, subjectOptions)).toBeUndefined(); - }); - - it("does not consult columnFor when called without options", () => { - const columnFor = vi.fn(() => ({ column: "age" })); - expect(findUnpushableNodeKind(ageOver, undefined)).toBeUndefined(); - expect(columnFor).not.toHaveBeenCalled(); - }); -}); - -describe("predicate kinds this version does not translate", () => { - it.each([ - [ - "treeReference", - { kind: "treeReference", key: "other" } satisfies PredicateNode, - ], - ])("refuses '%s'", (kind, node) => { - expect(findUnpushableNodeKind(node, subjectOptions)).toMatchObject({ - kind, - path: "$", - }); - }); - - it("reports the path of a refused node nested inside the tree", () => { - expect( - findUnpushableNodeKind( - { - kind: "allOf", - operands: [ - ageOver, - { kind: "not", operand: { kind: "treeReference", key: "other" } }, - ], - }, - subjectOptions, - ), - ).toMatchObject({ kind: "treeReference", path: "$.operands[1].operand" }); - }); -}); - -describe("expression kinds this version does not translate", () => { - const unsupported: readonly [string, ExpressionNode][] = [ - ["durationLiteral", { kind: "durationLiteral", value: 5, unit: "min" }], - ["complexLiteral", { kind: "complexLiteral", re: 1, im: 2 }], - [ - "arithmetic", - { - kind: "arithmetic", - op: "add", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }, - ], - [ - "negate", - { kind: "negate", operand: { kind: "numberLiteral", value: 1 } }, - ], - ["call", { kind: "call", fn: "round", args: [] }], - ["lookup", { kind: "lookup", table: "rates", keys: [] }], - [ - "conditional", - { - kind: "conditional", - cases: [], - fallback: { kind: "numberLiteral", value: 0 }, - }, - ], - [ - "fold", - { - kind: "fold", - collection: "xs", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { kind: "numberLiteral", value: 1 }, - }, - }, - ], - ["accumulator", { kind: "accumulator" }], - ["delegate", { kind: "delegate", system: "legacy", payload: null }], - ["treeReference", { kind: "treeReference", key: "other" }], - ]; - - it.each(unsupported)("refuses '%s' in a comparison operand", (kind, node) => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: node, - }, - subjectOptions, - ), - ).toMatchObject({ kind, path: "$.right" }); - }); -}); - -describe("quantification over a collection", () => { - const tagsOptions: SqlCompileOptions = { - dialect: "postgres", - columnFor: subjectOptions.columnFor, - collectionFor: (collectionKey) => { - if (collectionKey !== "tags") { - throw new Error(`no collection mapped for '${collectionKey}'`); - } - return { - table: "tags", - join: `"tags"."subjectId" = "subjects"."id"`, - columnFor: (referenceKey) => { - if (referenceKey === "score") - return { column: "score", paramType: "number" }; - if (referenceKey === "label") - return { column: "label", paramType: "text" }; - if (referenceKey === "flagged") - return { column: "flagged", paramType: "boolean" }; - if (referenceKey === "note") return { column: "note" }; - throw new Error(`no column mapped for '${referenceKey}'`); - }, - }; - }, - }; - - const scoreAboveOne: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "score" }, - right: { kind: "numberLiteral", value: 1 }, - }; - - it.each(["some", "every"] as const)( - "refuses '%s' when collectionFor is not set", - (kind) => { - expect( - findUnpushableNodeKind( - { kind, collection: "tags", item: scoreAboveOne }, - subjectOptions, - ), - ).toMatchObject({ - kind, - path: "$", - reason: expect.stringContaining("collectionFor") as unknown, - }); - }, - ); - - it.each(["some", "every"] as const)( - "is pushable once collectionFor maps the collection", - (kind) => { - expect( - findUnpushableNodeKind( - { kind, collection: "tags", item: scoreAboveOne }, - tagsOptions, - ), - ).toBeUndefined(); - }, - ); - - it.each(["max", "min"] as const)( - "refuses fold('%s') when collectionFor is not set", - (mode) => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode, item: { kind: "reference", key: "score" } }, - }, - right: { kind: "numberLiteral", value: 1 }, - }, - subjectOptions, - ), - ).toMatchObject({ - kind: "fold", - reason: expect.stringContaining("collectionFor") as unknown, - }); - }, - ); - - it.each(["max", "min"] as const)( - "is pushable once collectionFor maps the collection, for fold('%s')", - (mode) => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode, item: { kind: "reference", key: "score" } }, - }, - right: { kind: "numberLiteral", value: 1 }, - }, - tagsOptions, - ), - ).toBeUndefined(); - }, - ); - - it("refuses fold('reduce') unconditionally, even once collectionFor maps the collection", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { - mode: "reduce", - initial: { kind: "numberLiteral", value: 0 }, - combine: { kind: "reference", key: "score" }, - }, - }, - right: { kind: "numberLiteral", value: 1 }, - }, - tagsOptions, - ), - ).toMatchObject({ - kind: "fold", - reason: expect.stringContaining("no general SQL translation") as unknown, - }); - }); - - it("refuses a non-string collection key, even with collectionFor set", () => { - expect( - findUnpushableNodeKind( - { kind: "some", collection: { nested: "key" }, item: scoreAboveOne }, - tagsOptions, - ), - ).toMatchObject({ - kind: "some", - reason: expect.stringContaining("non-string") as unknown, - }); - }); - - it("resolves item/filter against the collection's own columnFor, not the outer one", () => { - // "age" is an outer column subjectOptions maps but tagsOptions' own collection-level columnFor does not -- a mapping error here proves item is actually walked using the resolved binding's own columnFor, not silently skipped. - expect(() => - findUnpushableNodeKind( - { - kind: "some", - collection: "tags", - item: { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 1 }, - }, - }, - tagsOptions, - ), - ).toThrow(/no column mapped for 'age'/); - }); - - it("refuses an unsupported node kind buried inside filter, not only inside item", () => { - expect( - findUnpushableNodeKind( - { - kind: "some", - collection: "tags", - filter: { kind: "treeReference", key: "other" }, - item: scoreAboveOne, - }, - tagsOptions, - ), - ).toMatchObject({ kind: "treeReference", path: "$.filter" }); - }); - - it("is pushable with both a filter and an item, once collectionFor is set", () => { - expect( - findUnpushableNodeKind( - { - kind: "some", - collection: "tags", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "label" }, - right: { kind: "textLiteral", value: "urgent" }, - }, - item: scoreAboveOne, - }, - tagsOptions, - ), - ).toBeUndefined(); - }); - - it("refuses a fold('max'|'min') whose projected item is text, which trilean never orders", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { - mode: "max", - item: { kind: "reference", key: "label" }, - }, - }, - right: { kind: "textLiteral", value: "z" }, - }, - tagsOptions, - ), - ).toMatchObject({ - kind: "fold", - path: "$.left.combiner.item", - reason: expect.stringContaining("never orders text values") as unknown, - }); - }); - - it("refuses a fold('max'|'min') whose projected item is boolean, which trilean never orders", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { - mode: "min", - item: { kind: "reference", key: "flagged" }, - }, - }, - right: { kind: "booleanLiteral", value: true }, - }, - tagsOptions, - ), - ).toMatchObject({ - kind: "fold", - path: "$.left.combiner.item", - reason: expect.stringContaining("booleans have no ordering") as unknown, - }); - }); - - it("cannot detect a fold('max'|'min') text/boolean ordering mismatch against an item with no declared paramType", () => { - // The identical limitation `compare` already accepts for an undeclared column, applied to fold's own item: without a declared paramType there is nothing to check the ordering divergence against. Compared against a number, not text, so the outer `compare`'s own text-ordering check (unrelated to the one this test targets) cannot itself be what causes the refusal. - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "note" } }, - }, - right: { kind: "numberLiteral", value: 1 }, - }, - tagsOptions, - ), - ).toBeUndefined(); - }); - - it.each(["some", "every"] as const)( - "'%s' is treated as structurally pushable when called without options at all, matching the 'assume it passes' convention every other options-dependent check in this file already follows", - (kind) => { - expect( - findUnpushableNodeKind( - { kind, collection: "tags", item: ageOver }, - undefined, - ), - ).toBeUndefined(); - }, - ); -}); - -describe("references the compiler cannot map", () => { - it("refuses a non-string reference key", () => { - expect( - findUnpushableNodeKind( - { - kind: "exists", - operand: { kind: "reference", key: { nested: "key" } }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "reference", path: "$.operand" }); - }); - - it("refuses a reference declaring a unit, which no column can be checked against", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age", unit: { year: 1 } }, - right: { kind: "numberLiteral", value: 18 }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "reference", path: "$.left" }); - }); - - it("refuses a unit-tagged number literal", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18, unit: { year: 1 } }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "numberLiteral", path: "$.right" }); - }); - - it("refuses a NaN number literal, which the two engines compare oppositely", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: Number.NaN }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "numberLiteral", path: "$.right" }); - }); - - it("refuses a NaN candidate inside a memberOf, not only a comparison operand", () => { - expect( - findUnpushableNodeKind( - { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "age" }, - candidates: [ - { kind: "numberLiteral", value: 1 }, - { kind: "numberLiteral", value: Number.NaN }, - ], - }, - subjectOptions, - ), - ).toMatchObject({ kind: "numberLiteral", path: "$.candidates[1]" }); - }); - - it("allows an infinity, which both engines order and compare identically", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "gt", - left: { kind: "numberLiteral", value: Number.POSITIVE_INFINITY }, - right: { kind: "reference", key: "age" }, - }, - subjectOptions, - ), - ).toBeUndefined(); - }); -}); - -describe("operand kinds trilean and PostgreSQL would answer differently", () => { - it("refuses a compare whose operands are of different declared kinds", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "instantLiteral", value: "2020-01-01T00:00:00Z" }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "compare", path: "$" }); - }); - - it("refuses a compare against text, which trilean directs to textCompare", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "compare", path: "$" }); - }); - - it("refuses an ordering comparison on booleans, which trilean has no order for", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "compare", path: "$" }); - }); - - it("allows equality on booleans, which trilean does define", () => { - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }, - subjectOptions, - ), - ).toBeUndefined(); - }); - - it("refuses a textCompare against a non-text operand", () => { - expect( - findUnpushableNodeKind( - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "age" }, - right: { kind: "textLiteral", value: "18" }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "textCompare", path: "$" }); - }); - - it("allows a portableMatches pattern within both dialects' reachable subsets", () => { - expect( - findUnpushableNodeKind( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a.*c$" }, - }, - subjectOptions, - ), - ).toBeUndefined(); - expect( - findUnpushableNodeKind( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a.*c$" }, - }, - sqliteSubjectOptions, - ), - ).toBeUndefined(); - }); - - it("refuses a portableMatches pattern that is not a compile-time literal", () => { - const result = findUnpushableNodeKind( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "reference", key: "note" }, - }, - subjectOptions, - ); - expect(result).toMatchObject({ kind: "textCompare", path: "$" }); - expect(result?.reason).toContain( - "must be a literal, known at compile time", - ); - }); - - it("refuses a portableMatches pattern that is not valid trilean-regex syntax", () => { - expect( - findUnpushableNodeKind( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "(a)" }, - }, - subjectOptions, - ), - ).toMatchObject({ kind: "textCompare", path: "$.right" }); - }); - - it("refuses a portableMatches pattern outside SQLite's GLOB-reachable subset, but allows the identical pattern for PostgreSQL", () => { - const alternation = { - kind: "textCompare" as const, - op: "portableMatches" as const, - left: { kind: "reference" as const, key: "name" }, - right: { kind: "textLiteral" as const, value: "cat|dog" }, - }; - expect( - findUnpushableNodeKind(alternation, sqliteSubjectOptions), - ).toMatchObject({ kind: "textCompare", path: "$.right" }); - expect(findUnpushableNodeKind(alternation, subjectOptions)).toBeUndefined(); - }); - - it("refuses a memberOf whose candidates are not all of the operand's kind", () => { - expect( - findUnpushableNodeKind( - { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "age" }, - candidates: [ - { kind: "numberLiteral", value: 1 }, - { kind: "textLiteral", value: "two" }, - ], - }, - subjectOptions, - ), - ).toMatchObject({ kind: "memberOf", path: "$" }); - }); - - it("cannot detect a mismatch against a column with no declared paramType", () => { - // Not a gap to fix by guessing: without a declared type there is nothing to compare the literal's kind against. It is the concrete reason to declare paramType, and stating it as a test keeps the limitation deliberate. - expect( - findUnpushableNodeKind( - { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "note" }, - right: { kind: "numberLiteral", value: 1 }, - }, - subjectOptions, - ), - ).toBeUndefined(); - }); -}); - -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 has its own separate postgresRegexpPushdown gate", () => { - expect( - findUnpushableNodeKind(patternMatch, { - ...subjectOptionsWithPostgresRegexp, - 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("PostgreSQL regular-expression pushdown", () => { - const matchesNode: PredicateNode = { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }; - - it("refuses 'matches' against PostgreSQL by default", () => { - expect(findUnpushableNodeKind(matchesNode, subjectOptions)).toMatchObject({ - kind: "textCompare", - path: "$", - }); - }); - - it("refuses 'notMatches' against PostgreSQL by default", () => { - expect( - findUnpushableNodeKind( - { ...matchesNode, op: "notMatches" }, - subjectOptions, - ), - ).toMatchObject({ kind: "textCompare", path: "$" }); - }); - - it("refuses 'matches' against PostgreSQL with no options at all, since the default dialect is PostgreSQL and the default is refusal", () => { - expect(findUnpushableNodeKind(matchesNode)).toMatchObject({ - kind: "textCompare", - path: "$", - }); - }); - - it("allows 'matches' against PostgreSQL once postgresRegexpPushdown is set true", () => { - expect( - findUnpushableNodeKind(matchesNode, subjectOptionsWithPostgresRegexp), - ).toBeUndefined(); - }); - - it("allows 'matches' against SQLite unconditionally, since this gate is specific to PostgreSQL's own regular-expression dialect", () => { - expect( - findUnpushableNodeKind(matchesNode, sqliteSubjectOptions), - ).toBeUndefined(); - }); - - it("does not refuse 'equals'/'notEquals' textCompare nodes, which carry no pattern", () => { - expect( - findUnpushableNodeKind({ ...matchesNode, op: "equals" }, subjectOptions), - ).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. - - const nanEquality: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: Number.NaN }, - }; - - const orderedText: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }; - - const orderedBoolean: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }; - - const crossKind: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "age" }, - right: { kind: "instantLiteral", value: "2020-01-01T00:00:00Z" }, - }; - - it.each([nanEquality, orderedText, orderedBoolean, crossKind])( - "refuses the same node at the same path in either dialect", - (node) => { - const viaPostgres = findUnpushableNodeKind(node, subjectOptions); - const viaSqlite = findUnpushableNodeKind(node, sqliteSubjectOptions); - expect(viaPostgres).toBeDefined(); - expect(viaSqlite).toMatchObject({ - kind: viaPostgres?.kind, - path: viaPostgres?.path, - }); - }, - ); - - it("explains a refused NaN by the substitution SQLite's drivers actually make", () => { - // The integration suite proves this one against a real connection: better-sqlite3 binds NaN as SQL NULL, so `NaN = NaN` is indeterminate rather than definitely false, and the negation trilean answers definitely true matches nothing at all. - expect(findUnpushableNodeKind(nanEquality, sqliteSubjectOptions)).toEqual({ - kind: "numberLiteral", - path: "$.right", - reason: - "NaN is equal to nothing in trilean, not even itself, whereas SQLite has no NaN at all and a driver binding one substitutes SQL NULL -- so 'NaN = NaN' is indeterminate there rather than definitely false, and its negation matches every row instead of none", - }); - }); - - it("explains a refused NaN by PostgreSQL's own definition of it in the other dialect", () => { - expect(findUnpushableNodeKind(nanEquality, subjectOptions)).toEqual({ - kind: "numberLiteral", - path: "$.right", - reason: - "NaN is equal to nothing in trilean, not even itself, whereas PostgreSQL defines NaN as equal to itself and greater than every other double", - }); - }); - - it("explains an ordered text operand by the coercion each engine performs", () => { - expect( - findUnpushableNodeKind(orderedText, sqliteSubjectOptions)?.reason, - ).toBe( - "'compare' never compares text in trilean -- it returns wrong-type and directs the caller to 'textCompare' -- but SQLite would answer definitely for the text operand at $.left, comparing it under the text affinity it applies to the other side ('9' > 5 is true there, while '10' > 5 is not)", - ); - expect(findUnpushableNodeKind(orderedText, subjectOptions)?.reason).toBe( - "'compare' never compares text in trilean -- it returns wrong-type and directs the caller to 'textCompare' -- but PostgreSQL would order the text operand at $.left collation-wise and answer definitely", - ); - }); - - it("explains an ordered boolean by how each engine represents one", () => { - expect( - findUnpushableNodeKind(orderedBoolean, sqliteSubjectOptions)?.reason, - ).toBe( - "booleans have no ordering in trilean, so 'gt' against the boolean operand at $.left is wrong-type there, whereas SQLite stores booleans as the integers 0 and 1 and orders them as integers", - ); - expect(findUnpushableNodeKind(orderedBoolean, subjectOptions)?.reason).toBe( - "booleans have no ordering in trilean, so 'gt' against the boolean operand at $.left is wrong-type there, whereas PostgreSQL orders false before true", - ); - }); - - it("explains a cross-kind comparison by the coercion each engine reaches for", () => { - expect( - findUnpushableNodeKind(crossKind, sqliteSubjectOptions)?.reason, - ).toContain( - "whereas SQLite's type affinity may coerce one to the other and answer definitely", - ); - expect(findUnpushableNodeKind(crossKind, subjectOptions)?.reason).toContain( - "whereas PostgreSQL may coerce one to the other and answer definitely", - ); - }); - - it("describes PostgreSQL when no options name a dialect at all", () => { - // A structural walk has no dialect to read. It refuses exactly what either dialect refuses -- the point of the walk is unchanged -- and names the dialect these refusals were first derived against rather than inventing a dialect-free phrasing that describes neither engine. - expect( - findUnpushableNodeKind({ - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: Number.NaN }, - right: { kind: "numberLiteral", value: Number.NaN }, - })?.reason, - ).toContain("PostgreSQL defines NaN as equal to itself"); - }); -}); From 1fe345eb5b45c8bab0a2df1ee5eb14625d5472e5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:56:11 +0100 Subject: [PATCH 10/12] test(trilean-sql): split sqlite.test.ts into topic-scoped files under the 800-line cap test/integration/sqlite.test.ts had grown to 1003 lines, over the new max-lines cap. Splits it by tested concern into three files (predicate compilation, degenerate/adversarial fragments and the divergences the guard's refusals exist to prevent, and the some/every/fold correlated- collection suite), each comfortably under the cap. Extracts the shared in-memory database lifecycle -- schema, seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the agreeingRows comparison every case is built on -- into a new sqlite-test-support.ts all three split files import from. Each split file still gets its own isolated vitest module instance, so each opens and closes its own connection via that shared beforeAll/afterAll exactly as the unsplit file did for itself. --- .../test/integration/sqlite-test-support.ts | 298 +++++ .../integration/sqlite.edge-cases.test.ts | 183 +++ .../integration/sqlite.predicates.test.ts | 307 +++++ .../integration/sqlite.quantifiers.test.ts | 242 ++++ .../test/integration/sqlite.test.ts | 1003 ----------------- 5 files changed, 1030 insertions(+), 1003 deletions(-) create mode 100644 packages/trilean-sql/test/integration/sqlite-test-support.ts create mode 100644 packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts create mode 100644 packages/trilean-sql/test/integration/sqlite.predicates.test.ts create mode 100644 packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts delete mode 100644 packages/trilean-sql/test/integration/sqlite.test.ts diff --git a/packages/trilean-sql/test/integration/sqlite-test-support.ts b/packages/trilean-sql/test/integration/sqlite-test-support.ts new file mode 100644 index 0000000..5757bb3 --- /dev/null +++ b/packages/trilean-sql/test/integration/sqlite-test-support.ts @@ -0,0 +1,298 @@ +import Database from "better-sqlite3"; +import type { + ComputedValue, + JsonValue, + PredicateNode, + Resolution, + Resolvers, +} from "trilean"; +import { evaluatePredicate } from "trilean"; +import { afterAll, beforeAll, expect } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { sqliteSubjectOptions } from "../../src/test-support/columns"; + +/** + * The shared harness the split `sqlite.*.test.ts` files below all import: the schema, the seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the `agreeingRows` comparison every case in those files is built on. + * + * Splitting the original single file by tested concern (predicates, edge cases, quantifiers) is purely a file-size matter -- vitest still gives each split file its own isolated module instance, so each one opens, seeds, and closes its own in-memory connection via the `beforeAll`/`afterAll` registered here, exactly as the unsplit file did for itself. + */ + +/** Exported for `sqlite.predicates.test.ts`'s own REGEXP-registration proofs, which need a second, bare connection sharing this same schema but not this file's registered `regexp` function or seeded rows. */ +export const SCHEMA = ` + CREATE TABLE subjects ( + id TEXT PRIMARY KEY, + age REAL, + name TEXT, + active INTEGER, + joined TEXT, + note TEXT + ); +`; + +/** + * A second, deliberately tiny table whose only purpose is the coercion proofs in `sqlite.edge-cases.test.ts`. + * + * They need a column whose declared affinity does the coercing -- affinity is a property of a column, and two bound parameters compared against each other have none -- and they need values chosen so that the coerced answer and the honest one differ. Keeping them out of `subjects` leaves that fixture identical in shape to the PostgreSQL suite's, so a case comparing the two suites is comparing like with like. + */ +const COERCION_SCHEMA = ` + CREATE TABLE coercion ( + label TEXT PRIMARY KEY, + numeric_text TEXT, + flag INTEGER + ); +`; + +/** + * The one correlated child table the `some`/`every`/`fold` parity suite in `sqlite.quantifiers.test.ts` needs, matching `sqliteSubjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- matching the same quoted-identifier convention the PostgreSQL/PGlite suites use for it. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id TEXT PRIMARY KEY, + "subjectId" TEXT NOT NULL, + tag TEXT, + weight REAL + ); +`; + +interface SubjectRow { + id: string; + age: number | null; + name: string | null; + active: boolean | null; + joined: string | null; + note: string | null; +} + +/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ +export const SUBJECTS: readonly SubjectRow[] = [ + { + id: "ada", + age: 30, + name: "ada", + active: true, + joined: "2020-01-01T00:00:00Z", + note: "hello", + }, + { + id: "grace", + age: 12, + name: "grace", + active: false, + joined: "2024-06-01T12:00:00Z", + note: "hi", + }, + { + id: "lin", + age: null, + name: "lin", + active: true, + joined: "2021-03-03T00:00:00Z", + note: null, + }, + { + id: "unknown", + age: 45, + name: null, + active: null, + joined: null, + note: null, + }, +]; + +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `sqlite.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Resolves a reference key against one row, mapping a NULL column to `found: false`. + * + * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge SQLite has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. + */ +function resolversFor(row: Readonly): Resolvers { + const known: Record = { + ...(row.age !== null && { + age: { kind: "number", value: row.age }, + }), + ...(row.name !== null && { name: { kind: "text", value: row.name } }), + ...(row.note !== null && { note: { kind: "text", value: row.note } }), + ...(row.active !== null && { + active: { kind: "boolean", value: row.active }, + }), + ...(row.joined !== null && { + joined: { kind: "instant", value: row.joined }, + }), + }; + + return { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } + const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( + value === undefined ? { found: false } : { found: true, value }, + ); + }, + resolveLookup: () => { + throw new Error("no tree in this suite uses a lookup"); + }, + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); + }, + }; +} + +/** The threshold the coercion proofs compare against: greater than the text '9' sorts, and less than the number 9 is, so a coerced comparison and an honest one disagree about it. */ +export const COERCION_THRESHOLD = 5; + +/** The lower of the two integers SQLite stores a boolean as, so `flag > FALSE_AS_INTEGER` is the ordering comparison trilean has no answer for. */ +export const FALSE_AS_INTEGER = 0; + +/** + * Maps a compiled parameter onto something SQLite can bind. + * + * The one value kind that needs it is `boolean`: SQLite has no boolean type, and better-sqlite3 refuses a JS boolean outright ("SQLite3 can only bind numbers, strings, bigints, buffers, and null") rather than coercing it. That is a property of the driver and the engine, not of the compiled fragment -- `compilePredicateNode` hands back the tree's own literals unchanged in every dialect -- so the conversion belongs to the caller binding them, which is what this suite is standing in for. It is a loud failure rather than a silent one, which is why the compiler leaves it to the caller; README.md documents it alongside the `REGEXP` registration. + */ +export function bindable(value: unknown): unknown { + return typeof value === "boolean" ? Number(value) : value; +} + +export let db: Database.Database; + +beforeAll(() => { + db = new Database(":memory:"); + /** + * SQLite reserves `REGEXP` as syntax for a `regexp(pattern, value)` function it does not itself provide, so the dialect's `matches`/`notMatches` only run on a connection that has registered one. Two details of this registration are load-bearing rather than incidental, and README.md documents both: + * + * It returns `null` when either argument is NULL. SQLite does not propagate NULL through a user function on its own, and a function that answered 0 for a NULL value would make `NOT REGEXP` answer TRUE for a row whose value is unknown -- exactly the two-valued collapse this package exists to avoid. + * + * It returns 1/0 rather than a JS boolean, which better-sqlite3 rejects from a user function ("returned an invalid value") for the same reason it rejects one as a bound parameter. + */ + db.function("regexp", (pattern: unknown, text: unknown) => + typeof pattern !== "string" || typeof text !== "string" + ? null + : new RegExp(pattern).test(text) + ? 1 + : 0, + ); + + db.exec(SCHEMA); + db.exec(COERCION_SCHEMA); + db.exec(TAGS_SCHEMA); + + const insert = db.prepare( + "INSERT INTO subjects (id, age, name, active, joined, note) VALUES (?, ?, ?, ?, ?, ?)", + ); + for (const row of SUBJECTS) { + insert.run( + row.id, + row.age, + row.name, + row.active === null ? null : Number(row.active), + row.joined, + row.note, + ); + } + + // '9' and '10' straddle 5 differently as text than as numbers, and 1 and 0 are what SQLite stores a boolean as. Both pairs are chosen so a coerced comparison and an honest one disagree. + const insertCoercion = db.prepare( + "INSERT INTO coercion (label, numeric_text, flag) VALUES (?, ?, ?)", + ); + insertCoercion.run("nine", "9", 1); + insertCoercion.run("ten", "10", FALSE_AS_INTEGER); + + const insertTag = db.prepare( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES (?, ?, ?, ?)`, + ); + for (const tagRow of TAGS) { + insertTag.run(tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight); + } +}); + +afterAll(() => { + db.close(); +}); + +function selectMatching( + node: PredicateNode, + options: Readonly = sqliteSubjectOptions, +): string[] { + const compiled = compilePredicateNode(node, options); + const rows = db + .prepare( + `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, + ) + .all(...compiled.params.map(bindable)); + return rows.map((row) => row.id); +} + +export async function evaluatorMatching( + node: PredicateNode, +): Promise { + const matched: string[] = []; + for (const row of SUBJECTS) { + const evaluation = await evaluatePredicate( + node, + undefined, + resolversFor(row), + ); + if (evaluation.status === "definite" && evaluation.value) { + matched.push(row.id); + } + } + return matched.sort(); +} + +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `sqliteSubjectOptions`; `sqlite.quantifiers.test.ts` passes `sqliteSubjectOptionsWithTags` instead. */ +export async function agreeingRows( + node: PredicateNode, + options: Readonly = sqliteSubjectOptions, +): Promise { + const viaSql = selectMatching(node, options); + const viaEvaluator = await evaluatorMatching(node); + expect(viaSql).toEqual(viaEvaluator); + return viaSql; +} diff --git a/packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts b/packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts new file mode 100644 index 0000000..a7a3b5d --- /dev/null +++ b/packages/trilean-sql/test/integration/sqlite.edge-cases.test.ts @@ -0,0 +1,183 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { sqliteSubjectOptions } from "../../src/test-support/columns"; +import { + agreeingRows, + bindable, + COERCION_THRESHOLD, + db, + evaluatorMatching, + FALSE_AS_INTEGER, + SUBJECTS, +} from "./sqlite-test-support"; + +describe("degenerate and adversarial fragments", () => { + it("executes a comparison between two literals, which needs no placeholder typed", async () => { + // The mirror of the PostgreSQL case: there, both placeholders must carry a cast or the server rejects the statement outright; here, two bare `?` are enough, which is why the SQLite dialect emits no cast at all. + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + }); + + it("executes an empty allOf and anyOf as their identities", async () => { + await expect( + agreeingRows({ kind: "allOf", operands: [] }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + await expect( + agreeingRows({ kind: "anyOf", operands: [] }), + ).resolves.toEqual([]); + }); + + it("treats an injection attempt as data and leaves the table standing", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { + kind: "textLiteral", + value: "ada'; DROP TABLE subjects; --", + }, + }), + ).resolves.toEqual([]); + + const surviving = db + .prepare<[], { count: number }>("SELECT count(*) AS count FROM subjects") + .get(); + expect(surviving?.count).toBe(SUBJECTS.length); + }); + + it("neutralises a hostile column name into one identifier the engine rejects", () => { + // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that SQLite reads it as a single inert identifier: this executes it, and the engine refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. + const hostile: SqlCompileOptions = { + dialect: "sqlite", + columnFor: () => ({ column: `name" = name OR "1` }), + }; + const compiled = compilePredicateNode( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + hostile, + ); + expect(compiled.sql).toBe(`("name"" = name OR ""1" = ?)`); + + expect(() => + db + .prepare(`SELECT id FROM subjects WHERE ${compiled.sql}`) + .all(...compiled.params.map(bindable)), + ).toThrow(/no such column/i); + }); +}); + +describe("the divergences the guard's refusals exist to prevent", () => { + /** + * Each case here refuses a tree and then measures, against this connection, the wrong answer the refusal avoided. The refusals themselves are inherited unchanged from the PostgreSQL dialect, and that inheritance is exactly what needs evidence: it would be worth nothing if SQLite's affinity system happened to agree with trilean where PostgreSQL's coercion does not. + */ + + it("refuses NaN, and measures the driver substitution that refusal exists to prevent", async () => { + // A divergence in the opposite direction from PostgreSQL's, which is why the reason text is the dialect's own rather than a shared one. SQLite has no NaN: better-sqlite3 binds one as SQL NULL, so `NaN = NaN` is indeterminate there and matches nothing -- which happens to look like agreement -- while its negation matches nothing either, where trilean's `not(definite(false))` is definitely true and matches every row. The negation is the case that makes the divergence visible, so both are measured. + const equality: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: Number.NaN }, + right: { kind: "numberLiteral", value: Number.NaN }, + }; + expect(() => compilePredicateNode(equality, sqliteSubjectOptions)).toThrow( + /cannot compile 'numberLiteral'/, + ); + + const bound = db + .prepare<[number], { storedType: string }>( + "SELECT typeof(?) AS storedType", + ) + .get(Number.NaN); + expect(bound?.storedType).toBe("null"); + + const equalityRows = db + .prepare<[number, number], { id: string }>( + "SELECT id FROM subjects WHERE (? = ?) ORDER BY id", + ) + .all(Number.NaN, Number.NaN); + expect(equalityRows.map((row) => row.id)).toEqual([]); + await expect(evaluatorMatching(equality)).resolves.toEqual([]); + + const negation: PredicateNode = { kind: "not", operand: equality }; + const negatedRows = db + .prepare<[number, number], { id: string }>( + "SELECT id FROM subjects WHERE (NOT (? = ?)) ORDER BY id", + ) + .all(Number.NaN, Number.NaN); + expect(negatedRows.map((row) => row.id)).toEqual([]); + await expect(evaluatorMatching(negation)).resolves.toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + }); + + it("refuses an ordered text operand, and measures the lexicographic answer that refusal exists to prevent", () => { + // 9 and 10 are both greater than 5. Compared under the column's own TEXT affinity, which SQLite applies to the numeric side rather than the other way round, '9' > '5' and '10' > '5' disagree -- so the row that comes back is the wrong one, with no error and no warning. trilean returns wrong-type for the same comparison and directs the caller to `textCompare`. + const orderedText: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }; + expect(() => + compilePredicateNode(orderedText, sqliteSubjectOptions), + ).toThrow(/cannot compile 'compare'/); + + const coerced = db + .prepare<[number], { label: string }>( + "SELECT label FROM coercion WHERE numeric_text > ? ORDER BY label", + ) + .all(COERCION_THRESHOLD); + expect(coerced.map((row) => row.label)).toEqual(["nine"]); + }); + + it("refuses an ordered boolean, and measures the integer ordering that refusal exists to prevent", () => { + // SQLite has no boolean type, so `active > false` is an ordering over the integers 0 and 1 and answers definitely. trilean has no ordering for booleans at all. + const orderedBoolean: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }; + expect(() => + compilePredicateNode(orderedBoolean, sqliteSubjectOptions), + ).toThrow(/cannot compile 'compare'/); + + const ordered = db + .prepare<[number], { label: string }>( + "SELECT label FROM coercion WHERE flag > ? ORDER BY label", + ) + .all(FALSE_AS_INTEGER); + expect(ordered.map((row) => row.label)).toEqual(["nine"]); + }); + + it("refuses a cross-kind comparison, and measures the coercion that refusal exists to prevent", () => { + // No column and no affinity involved: SQLite still answers, ordering every text value above every numeric one by storage class rather than reporting a type error. trilean calls the same comparison wrong-type. + const crossKindTree: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "name" }, + right: { kind: "numberLiteral", value: COERCION_THRESHOLD }, + }; + expect(() => + compilePredicateNode(crossKindTree, sqliteSubjectOptions), + ).toThrow(/cannot compile 'compare'/); + + const crossKind = db + .prepare<[string, number], { answer: number }>("SELECT (? > ?) AS answer") + .get("abc", COERCION_THRESHOLD); + expect(crossKind?.answer).toBe(1); + }); +}); diff --git a/packages/trilean-sql/test/integration/sqlite.predicates.test.ts b/packages/trilean-sql/test/integration/sqlite.predicates.test.ts new file mode 100644 index 0000000..7569d99 --- /dev/null +++ b/packages/trilean-sql/test/integration/sqlite.predicates.test.ts @@ -0,0 +1,307 @@ +import Database from "better-sqlite3"; +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import { sqliteSubjectOptions } from "../../src/test-support/columns"; +import { + agreeingRows, + bindable, + SCHEMA, + SUBJECTS, +} from "./sqlite-test-support"; + +describe("comparisons against a column that can be NULL", () => { + const olderThan18: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }; + + it("excludes the row whose age is unknown", async () => { + await expect(agreeingRows(olderThan18)).resolves.toEqual([ + "ada", + "unknown", + ]); + }); + + it("still excludes it under negation, which two-valued logic could not do", async () => { + // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in SQLite exactly as `not(indeterminate)` is indeterminate in trilean. + await expect( + agreeingRows({ kind: "not", operand: olderThan18 }), + ).resolves.toEqual(["grace"]); + }); + + it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { + await expect( + agreeingRows({ + kind: "anyOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "lin" }, + }, + ], + }), + ).resolves.toEqual(["ada", "lin", "unknown"]); + }); + + it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { + // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. + await expect( + agreeingRows({ + kind: "not", + operand: { + kind: "allOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "nobody" }, + }, + ], + }, + }), + ).resolves.toEqual(["ada", "grace", "lin"]); + }); + + it("compares instants across a NULL, as the ISO-8601 text SQLite stores them as", async () => { + // SQLite has no timestamp type: an instant is stored and compared as text. Offset-bearing ISO-8601 in a common offset sorts chronologically as a string, which is what makes this agree with the evaluator's own instant comparison rather than merely happening to. + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("compares booleans for equality across a NULL, as the integers SQLite stores them as", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: true }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); +}); + +describe("exists", () => { + const hasNote: PredicateNode = { + kind: "exists", + operand: { kind: "reference", key: "note" }, + }; + + it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { + const present = await agreeingRows(hasNote); + const absent = await agreeingRows({ kind: "not", operand: hasNote }); + expect(present).toEqual(["ada", "grace"]); + expect(absent).toEqual(["lin", "unknown"]); + expect([...present, ...absent].sort()).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + }); +}); + +describe("textCompare", () => { + it("matches a pattern through the registered REGEXP function", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }), + ).resolves.toEqual(["ada", "grace"]); + }); + + it("leaves a NULL operand unknown under a negated match", async () => { + // What a registered function has to get right, and the reason README.md spells the registration out rather than leaving it to the reader: `lin` is here because its name does not match, and `unknown` is absent because its name is not known. A regexp function that answered 0 for a NULL value instead of NULL would put `unknown` here too. + await expect( + agreeingRows({ + kind: "textCompare", + op: "notMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("fails loudly rather than answering wrongly when REGEXP is not registered", () => { + // The one thing the SQLite dialect asks of its caller, and the reason asking is acceptable: an unregistered REGEXP is a query error naming the missing function, not a fragment that quietly matches nothing. + const bare = new Database(":memory:"); + try { + bare.exec(SCHEMA); + const compiled = compilePredicateNode( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + sqliteSubjectOptions, + ); + expect(() => + bare + .prepare(`SELECT id FROM subjects WHERE ${compiled.sql}`) + .all(...compiled.params.map(bindable)), + ).toThrow(/no such function: REGEXP/i); + } finally { + 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); + }); +}); + +/** + * The equivalence claim `portableMatches`/`portableNotMatches` exist for, measured against real SQLite: compile a tree using a `trilean-regex` pattern, execute the translated `GLOB` wildcard against a real connection, and compare against `trilean-regex`'s own NFA matcher (via `evaluatePredicate`), not against `matches`/`notMatches`'s ECMAScript path. Unlike the `matches` suite above, no function registration is needed: `GLOB` is a core SQLite feature, which is the whole point of a portable grammar existing for this dialect at all. + * + * Every pattern below is deliberately within the reachable subset `portable-pattern.ts` documents (a literal run, `.`, a star of `.`, an edge anchor, a safe character class) -- a pattern outside it is refused by the guard before compilation, which `guard.test.ts`/`compile.test.ts` already cover as unit tests; this file measures only what a *pushed-down* fragment actually does once it reaches a real engine. + */ +describe("portableMatches/portableNotMatches (trilean-regex, translated to a GLOB wildcard)", () => { + it("matches a start-anchored literal prefix", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^gr" }, + }), + ).resolves.toEqual(["grace"]); + }); + + it("matches an end-anchored literal suffix", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "a$" }, + }), + ).resolves.toEqual(["ada"]); + }); + + it("matches '.' as exactly one wildcard character", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a.a$" }, + }), + ).resolves.toEqual(["ada"]); + }); + + it("matches a safe, non-negated character class", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^[ag]" }, + }), + ).resolves.toEqual(["ada", "grace"]); + }); + + it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableNotMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("fails to compile rather than answering wrongly when the pattern is outside GLOB's reachable subset", () => { + // No query is ever executed here -- unlike the REGEXP case above, this is a compile-time refusal (the guard's, run by compilePredicateNode itself), not a runtime one. Included in this file rather than only as a unit test to state plainly, next to the fragments that do compile, which shapes do not. + expect(() => + compilePredicateNode( + { + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada|grace" }, + }, + sqliteSubjectOptions, + ), + ).toThrow(/GLOB has no alternation operator/); + }); +}); + +describe("memberOf", () => { + it("matches a candidate list", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "ada" }, + { kind: "textLiteral", value: "nobody" }, + ], + }), + ).resolves.toEqual(["ada"]); + }); + + it("leaves NOT IN unknown for a NULL operand", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "ada" }], + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { + // The encoding PostgreSQL needs a `::boolean` on and SQLite does not, so this is where the missing annotation is shown not to matter. Executed rather than asserted as a string, because `(x IS NULL AND NULL)` is only the right encoding if SQLite really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. + const node: PredicateNode = { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual([]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + ["ada", "grace", "lin"], + ); + }); + + it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { + const node: PredicateNode = { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + [], + ); + }); +}); diff --git a/packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts b/packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts new file mode 100644 index 0000000..f6c4de2 --- /dev/null +++ b/packages/trilean-sql/test/integration/sqlite.quantifiers.test.ts @@ -0,0 +1,242 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { sqliteSubjectOptionsWithTags } from "../../src/test-support/columns"; +import { agreeingRows } from "./sqlite-test-support"; + +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + sqliteSubjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, sqliteSubjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + sqliteSubjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows( + every, + sqliteSubjectOptionsWithTags, + ); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + sqliteSubjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, sqliteSubjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, sqliteSubjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, sqliteSubjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + sqliteSubjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + +describe("a tree deep enough to mix every supported kind", () => { + it("agrees with the evaluator row for row", async () => { + const node: PredicateNode = { + kind: "anyOf", + operands: [ + { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }, + right: { + kind: "not", + operand: { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "grace" }], + }, + }, + }, + { + kind: "allOf", + operands: [ + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { + kind: "or", + left: { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "note" }, + right: { kind: "textLiteral", value: "^h" }, + }, + right: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + }, + ], + }, + ], + }; + + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace"]); + }); +}); diff --git a/packages/trilean-sql/test/integration/sqlite.test.ts b/packages/trilean-sql/test/integration/sqlite.test.ts deleted file mode 100644 index 147d78e..0000000 --- a/packages/trilean-sql/test/integration/sqlite.test.ts +++ /dev/null @@ -1,1003 +0,0 @@ -import Database from "better-sqlite3"; -import type { - ComputedValue, - JsonValue, - PredicateNode, - Resolution, - Resolvers, -} from "trilean"; -import { evaluatePredicate } from "trilean"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { compilePredicateNode } from "../../src/compile"; -import type { SqlCompileOptions } from "../../src/options"; -import { - sqliteSubjectOptions, - sqliteSubjectOptionsWithTags, -} from "../../src/test-support/columns"; - -/** - * The SQLite counterpart of `postgres.test.ts`, and the same claim measured rather than asserted: every case compiles a tree, executes the fragment as a real `WHERE` clause against a real SQLite connection, and compares the rows it returns against the rows trilean's own evaluator judges `definite(true)` for the same tree. - * - * Two things make this more than a copy. SQLite reaches its three-valued behaviour from a different starting point -- no boolean type, no timestamp type, no NaN, and a type-affinity system that coerces rather than rejects -- so agreement here is evidence about the dialect rather than a second run of an already-proven one. And the same affinity system is why the guard's refusals carry over unchanged: the last two describe blocks measure the divergences those refusals exist to prevent, against this connection, rather than asserting that a refusal fires. - * - * Unlike the PostgreSQL suite this needs no Docker: better-sqlite3 runs the engine in process against an in-memory database. - */ - -const SCHEMA = ` - CREATE TABLE subjects ( - id TEXT PRIMARY KEY, - age REAL, - name TEXT, - active INTEGER, - joined TEXT, - note TEXT - ); -`; - -/** - * A second, deliberately tiny table whose only purpose is the coercion proofs at the end of this file. - * - * They need a column whose declared affinity does the coercing -- affinity is a property of a column, and two bound parameters compared against each other have none -- and they need values chosen so that the coerced answer and the honest one differ. Keeping them out of `subjects` leaves that fixture identical in shape to the PostgreSQL suite's, so a case comparing the two suites is comparing like with like. - */ -const COERCION_SCHEMA = ` - CREATE TABLE coercion ( - label TEXT PRIMARY KEY, - numeric_text TEXT, - flag INTEGER - ); -`; - -/** - * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `sqliteSubjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- matching the same quoted-identifier convention the PostgreSQL/PGlite suites use for it. - */ -const TAGS_SCHEMA = ` - CREATE TABLE subject_tags ( - id TEXT PRIMARY KEY, - "subjectId" TEXT NOT NULL, - tag TEXT, - weight REAL - ); -`; - -interface SubjectRow { - id: string; - age: number | null; - name: string | null; - active: boolean | null; - joined: string | null; - note: string | null; -} - -/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ -const SUBJECTS: readonly SubjectRow[] = [ - { - id: "ada", - age: 30, - name: "ada", - active: true, - joined: "2020-01-01T00:00:00Z", - note: "hello", - }, - { - id: "grace", - age: 12, - name: "grace", - active: false, - joined: "2024-06-01T12:00:00Z", - note: "hi", - }, - { - id: "lin", - age: null, - name: "lin", - active: true, - joined: "2021-03-03T00:00:00Z", - note: null, - }, - { - id: "unknown", - age: 45, - name: null, - active: null, - joined: null, - note: null, - }, -]; - -interface TagRow { - id: string; - subjectId: string; - tag: string; - weight: number | null; -} - -/** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. - */ -const TAGS: readonly TagRow[] = [ - { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, - { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, - { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, - { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, - { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, -]; - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Resolves a reference key against one row, mapping a NULL column to `found: false`. - * - * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge SQLite has about the same row, so any disagreement between them is the compiler's, not the fixture's. - * - * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. - */ -function resolversFor(row: Readonly): Resolvers { - const known: Record = { - ...(row.age !== null && { - age: { kind: "number", value: row.age }, - }), - ...(row.name !== null && { name: { kind: "text", value: row.name } }), - ...(row.note !== null && { note: { kind: "text", value: row.note } }), - ...(row.active !== null && { - active: { kind: "boolean", value: row.active }, - }), - ...(row.joined !== null && { - joined: { kind: "instant", value: row.joined }, - }), - }; - - return { - resolveValue: async (key: JsonValue, context: unknown) => { - if (context !== undefined) { - if (typeof key !== "string" || !isPlainRecord(context)) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ - found: true, - value: { kind: "text", value }, - }); - } - return Promise.resolve({ found: false }); - } - const value = typeof key === "string" ? known[key] : undefined; - return Promise.resolve( - value === undefined ? { found: false } : { found: true, value }, - ); - }, - resolveLookup: () => { - throw new Error("no tree in this suite uses a lookup"); - }, - resolveCollection: async (collection: JsonValue) => { - if (collection !== "tags") return Promise.resolve([]); - return Promise.resolve( - TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ - tag: tagRow.tag, - weight: tagRow.weight, - })), - ); - }, - }; -} - -/** The threshold the coercion proofs compare against: greater than the text '9' sorts, and less than the number 9 is, so a coerced comparison and an honest one disagree about it. */ -const COERCION_THRESHOLD = 5; - -/** The lower of the two integers SQLite stores a boolean as, so `flag > FALSE_AS_INTEGER` is the ordering comparison trilean has no answer for. */ -const FALSE_AS_INTEGER = 0; - -/** - * Maps a compiled parameter onto something SQLite can bind. - * - * The one value kind that needs it is `boolean`: SQLite has no boolean type, and better-sqlite3 refuses a JS boolean outright ("SQLite3 can only bind numbers, strings, bigints, buffers, and null") rather than coercing it. That is a property of the driver and the engine, not of the compiled fragment -- `compilePredicateNode` hands back the tree's own literals unchanged in every dialect -- so the conversion belongs to the caller binding them, which is what this suite is standing in for. It is a loud failure rather than a silent one, which is why the compiler leaves it to the caller; README.md documents it alongside the `REGEXP` registration. - */ -function bindable(value: unknown): unknown { - return typeof value === "boolean" ? Number(value) : value; -} - -let db: Database.Database; - -beforeAll(() => { - db = new Database(":memory:"); - /** - * SQLite reserves `REGEXP` as syntax for a `regexp(pattern, value)` function it does not itself provide, so the dialect's `matches`/`notMatches` only run on a connection that has registered one. Two details of this registration are load-bearing rather than incidental, and README.md documents both: - * - * It returns `null` when either argument is NULL. SQLite does not propagate NULL through a user function on its own, and a function that answered 0 for a NULL value would make `NOT REGEXP` answer TRUE for a row whose value is unknown -- exactly the two-valued collapse this package exists to avoid. - * - * It returns 1/0 rather than a JS boolean, which better-sqlite3 rejects from a user function ("returned an invalid value") for the same reason it rejects one as a bound parameter. - */ - db.function("regexp", (pattern: unknown, text: unknown) => - typeof pattern !== "string" || typeof text !== "string" - ? null - : new RegExp(pattern).test(text) - ? 1 - : 0, - ); - - db.exec(SCHEMA); - db.exec(COERCION_SCHEMA); - db.exec(TAGS_SCHEMA); - - const insert = db.prepare( - "INSERT INTO subjects (id, age, name, active, joined, note) VALUES (?, ?, ?, ?, ?, ?)", - ); - for (const row of SUBJECTS) { - insert.run( - row.id, - row.age, - row.name, - row.active === null ? null : Number(row.active), - row.joined, - row.note, - ); - } - - // '9' and '10' straddle 5 differently as text than as numbers, and 1 and 0 are what SQLite stores a boolean as. Both pairs are chosen so a coerced comparison and an honest one disagree. - const insertCoercion = db.prepare( - "INSERT INTO coercion (label, numeric_text, flag) VALUES (?, ?, ?)", - ); - insertCoercion.run("nine", "9", 1); - insertCoercion.run("ten", "10", FALSE_AS_INTEGER); - - const insertTag = db.prepare( - `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES (?, ?, ?, ?)`, - ); - for (const tagRow of TAGS) { - insertTag.run(tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight); - } -}); - -afterAll(() => { - db.close(); -}); - -function selectMatching( - node: PredicateNode, - options: Readonly = sqliteSubjectOptions, -): string[] { - const compiled = compilePredicateNode(node, options); - const rows = db - .prepare( - `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, - ) - .all(...compiled.params.map(bindable)); - return rows.map((row) => row.id); -} - -async function evaluatorMatching(node: PredicateNode): Promise { - const matched: string[] = []; - for (const row of SUBJECTS) { - const evaluation = await evaluatePredicate( - node, - undefined, - resolversFor(row), - ); - if (evaluation.status === "definite" && evaluation.value) { - matched.push(row.id); - } - } - return matched.sort(); -} - -/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `sqliteSubjectOptions`; the quantifier suite passes `sqliteSubjectOptionsWithTags` instead. */ -async function agreeingRows( - node: PredicateNode, - options: Readonly = sqliteSubjectOptions, -): Promise { - const viaSql = selectMatching(node, options); - const viaEvaluator = await evaluatorMatching(node); - expect(viaSql).toEqual(viaEvaluator); - return viaSql; -} - -describe("comparisons against a column that can be NULL", () => { - const olderThan18: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }; - - it("excludes the row whose age is unknown", async () => { - await expect(agreeingRows(olderThan18)).resolves.toEqual([ - "ada", - "unknown", - ]); - }); - - it("still excludes it under negation, which two-valued logic could not do", async () => { - // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in SQLite exactly as `not(indeterminate)` is indeterminate in trilean. - await expect( - agreeingRows({ kind: "not", operand: olderThan18 }), - ).resolves.toEqual(["grace"]); - }); - - it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { - await expect( - agreeingRows({ - kind: "anyOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "lin" }, - }, - ], - }), - ).resolves.toEqual(["ada", "lin", "unknown"]); - }); - - it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { - // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. - await expect( - agreeingRows({ - kind: "not", - operand: { - kind: "allOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "nobody" }, - }, - ], - }, - }), - ).resolves.toEqual(["ada", "grace", "lin"]); - }); - - it("compares instants across a NULL, as the ISO-8601 text SQLite stores them as", async () => { - // SQLite has no timestamp type: an instant is stored and compared as text. Offset-bearing ISO-8601 in a common offset sorts chronologically as a string, which is what makes this agree with the evaluator's own instant comparison rather than merely happening to. - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "reference", key: "joined" }, - right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("compares booleans for equality across a NULL, as the integers SQLite stores them as", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: true }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); -}); - -describe("exists", () => { - const hasNote: PredicateNode = { - kind: "exists", - operand: { kind: "reference", key: "note" }, - }; - - it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { - const present = await agreeingRows(hasNote); - const absent = await agreeingRows({ kind: "not", operand: hasNote }); - expect(present).toEqual(["ada", "grace"]); - expect(absent).toEqual(["lin", "unknown"]); - expect([...present, ...absent].sort()).toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - }); -}); - -describe("textCompare", () => { - it("matches a pattern through the registered REGEXP function", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(a|g)" }, - }), - ).resolves.toEqual(["ada", "grace"]); - }); - - it("leaves a NULL operand unknown under a negated match", async () => { - // What a registered function has to get right, and the reason README.md spells the registration out rather than leaving it to the reader: `lin` is here because its name does not match, and `unknown` is absent because its name is not known. A regexp function that answered 0 for a NULL value instead of NULL would put `unknown` here too. - await expect( - agreeingRows({ - kind: "textCompare", - op: "notMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("fails loudly rather than answering wrongly when REGEXP is not registered", () => { - // The one thing the SQLite dialect asks of its caller, and the reason asking is acceptable: an unregistered REGEXP is a query error naming the missing function, not a fragment that quietly matches nothing. - const bare = new Database(":memory:"); - try { - bare.exec(SCHEMA); - const compiled = compilePredicateNode( - { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }, - sqliteSubjectOptions, - ); - expect(() => - bare - .prepare(`SELECT id FROM subjects WHERE ${compiled.sql}`) - .all(...compiled.params.map(bindable)), - ).toThrow(/no such function: REGEXP/i); - } finally { - 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); - }); -}); - -/** - * The equivalence claim `portableMatches`/`portableNotMatches` exist for, measured against real SQLite: compile a tree using a `trilean-regex` pattern, execute the translated `GLOB` wildcard against a real connection, and compare against `trilean-regex`'s own NFA matcher (via `evaluatePredicate`), not against `matches`/`notMatches`'s ECMAScript path. Unlike the `matches` suite above, no function registration is needed: `GLOB` is a core SQLite feature, which is the whole point of a portable grammar existing for this dialect at all. - * - * Every pattern below is deliberately within the reachable subset `portable-pattern.ts` documents (a literal run, `.`, a star of `.`, an edge anchor, a safe character class) -- a pattern outside it is refused by the guard before compilation, which `guard.test.ts`/`compile.test.ts` already cover as unit tests; this file measures only what a *pushed-down* fragment actually does once it reaches a real engine. - */ -describe("portableMatches/portableNotMatches (trilean-regex, translated to a GLOB wildcard)", () => { - it("matches a start-anchored literal prefix", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^gr" }, - }), - ).resolves.toEqual(["grace"]); - }); - - it("matches an end-anchored literal suffix", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "a$" }, - }), - ).resolves.toEqual(["ada"]); - }); - - it("matches '.' as exactly one wildcard character", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a.a$" }, - }), - ).resolves.toEqual(["ada"]); - }); - - it("matches a safe, non-negated character class", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^[ag]" }, - }), - ).resolves.toEqual(["ada", "grace"]); - }); - - it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableNotMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("fails to compile rather than answering wrongly when the pattern is outside GLOB's reachable subset", () => { - // No query is ever executed here -- unlike the REGEXP case above, this is a compile-time refusal (the guard's, run by compilePredicateNode itself), not a runtime one. Included in this file rather than only as a unit test to state plainly, next to the fragments that do compile, which shapes do not. - expect(() => - compilePredicateNode( - { - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada|grace" }, - }, - sqliteSubjectOptions, - ), - ).toThrow(/GLOB has no alternation operator/); - }); -}); - -describe("memberOf", () => { - it("matches a candidate list", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "ada" }, - { kind: "textLiteral", value: "nobody" }, - ], - }), - ).resolves.toEqual(["ada"]); - }); - - it("leaves NOT IN unknown for a NULL operand", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "ada" }], - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { - // The encoding PostgreSQL needs a `::boolean` on and SQLite does not, so this is where the missing annotation is shown not to matter. Executed rather than asserted as a string, because `(x IS NULL AND NULL)` is only the right encoding if SQLite really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. - const node: PredicateNode = { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual([]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - ["ada", "grace", "lin"], - ); - }); - - it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { - const node: PredicateNode = { - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - [], - ); - }); -}); - -describe("degenerate and adversarial fragments", () => { - it("executes a comparison between two literals, which needs no placeholder typed", async () => { - // The mirror of the PostgreSQL case: there, both placeholders must carry a cast or the server rejects the statement outright; here, two bare `?` are enough, which is why the SQLite dialect emits no cast at all. - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - }); - - it("executes an empty allOf and anyOf as their identities", async () => { - await expect( - agreeingRows({ kind: "allOf", operands: [] }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - await expect( - agreeingRows({ kind: "anyOf", operands: [] }), - ).resolves.toEqual([]); - }); - - it("treats an injection attempt as data and leaves the table standing", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { - kind: "textLiteral", - value: "ada'; DROP TABLE subjects; --", - }, - }), - ).resolves.toEqual([]); - - const surviving = db - .prepare<[], { count: number }>("SELECT count(*) AS count FROM subjects") - .get(); - expect(surviving?.count).toBe(SUBJECTS.length); - }); - - it("neutralises a hostile column name into one identifier the engine rejects", () => { - // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that SQLite reads it as a single inert identifier: this executes it, and the engine refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. - const hostile: SqlCompileOptions = { - dialect: "sqlite", - columnFor: () => ({ column: `name" = name OR "1` }), - }; - const compiled = compilePredicateNode( - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }, - hostile, - ); - expect(compiled.sql).toBe(`("name"" = name OR ""1" = ?)`); - - expect(() => - db - .prepare(`SELECT id FROM subjects WHERE ${compiled.sql}`) - .all(...compiled.params.map(bindable)), - ).toThrow(/no such column/i); - }); -}); - -describe("the divergences the guard's refusals exist to prevent", () => { - /** - * Each case here refuses a tree and then measures, against this connection, the wrong answer the refusal avoided. The refusals themselves are inherited unchanged from the PostgreSQL dialect, and that inheritance is exactly what needs evidence: it would be worth nothing if SQLite's affinity system happened to agree with trilean where PostgreSQL's coercion does not. - */ - - it("refuses NaN, and measures the driver substitution that refusal exists to prevent", async () => { - // A divergence in the opposite direction from PostgreSQL's, which is why the reason text is the dialect's own rather than a shared one. SQLite has no NaN: better-sqlite3 binds one as SQL NULL, so `NaN = NaN` is indeterminate there and matches nothing -- which happens to look like agreement -- while its negation matches nothing either, where trilean's `not(definite(false))` is definitely true and matches every row. The negation is the case that makes the divergence visible, so both are measured. - const equality: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: Number.NaN }, - right: { kind: "numberLiteral", value: Number.NaN }, - }; - expect(() => compilePredicateNode(equality, sqliteSubjectOptions)).toThrow( - /cannot compile 'numberLiteral'/, - ); - - const bound = db - .prepare<[number], { storedType: string }>( - "SELECT typeof(?) AS storedType", - ) - .get(Number.NaN); - expect(bound?.storedType).toBe("null"); - - const equalityRows = db - .prepare<[number, number], { id: string }>( - "SELECT id FROM subjects WHERE (? = ?) ORDER BY id", - ) - .all(Number.NaN, Number.NaN); - expect(equalityRows.map((row) => row.id)).toEqual([]); - await expect(evaluatorMatching(equality)).resolves.toEqual([]); - - const negation: PredicateNode = { kind: "not", operand: equality }; - const negatedRows = db - .prepare<[number, number], { id: string }>( - "SELECT id FROM subjects WHERE (NOT (? = ?)) ORDER BY id", - ) - .all(Number.NaN, Number.NaN); - expect(negatedRows.map((row) => row.id)).toEqual([]); - await expect(evaluatorMatching(negation)).resolves.toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - }); - - it("refuses an ordered text operand, and measures the lexicographic answer that refusal exists to prevent", () => { - // 9 and 10 are both greater than 5. Compared under the column's own TEXT affinity, which SQLite applies to the numeric side rather than the other way round, '9' > '5' and '10' > '5' disagree -- so the row that comes back is the wrong one, with no error and no warning. trilean returns wrong-type for the same comparison and directs the caller to `textCompare`. - const orderedText: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }; - expect(() => - compilePredicateNode(orderedText, sqliteSubjectOptions), - ).toThrow(/cannot compile 'compare'/); - - const coerced = db - .prepare<[number], { label: string }>( - "SELECT label FROM coercion WHERE numeric_text > ? ORDER BY label", - ) - .all(COERCION_THRESHOLD); - expect(coerced.map((row) => row.label)).toEqual(["nine"]); - }); - - it("refuses an ordered boolean, and measures the integer ordering that refusal exists to prevent", () => { - // SQLite has no boolean type, so `active > false` is an ordering over the integers 0 and 1 and answers definitely. trilean has no ordering for booleans at all. - const orderedBoolean: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }; - expect(() => - compilePredicateNode(orderedBoolean, sqliteSubjectOptions), - ).toThrow(/cannot compile 'compare'/); - - const ordered = db - .prepare<[number], { label: string }>( - "SELECT label FROM coercion WHERE flag > ? ORDER BY label", - ) - .all(FALSE_AS_INTEGER); - expect(ordered.map((row) => row.label)).toEqual(["nine"]); - }); - - it("refuses a cross-kind comparison, and measures the coercion that refusal exists to prevent", () => { - // No column and no affinity involved: SQLite still answers, ordering every text value above every numeric one by storage class rather than reporting a type error. trilean calls the same comparison wrong-type. - const crossKindTree: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "name" }, - right: { kind: "numberLiteral", value: COERCION_THRESHOLD }, - }; - expect(() => - compilePredicateNode(crossKindTree, sqliteSubjectOptions), - ).toThrow(/cannot compile 'compare'/); - - const crossKind = db - .prepare<[string, number], { answer: number }>("SELECT (? > ?) AS answer") - .get("abc", COERCION_THRESHOLD); - expect(crossKind?.answer).toBe(1); - }); -}); - -describe("some/every/fold over a correlated collection", () => { - const MATCH_THRESHOLD = 5; - const RANGE_LOW = 6; - const RANGE_HIGH = 8; - - const weightAboveThreshold: PredicateNode = { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, - }; - - it("some: true the moment one participating tag among several votes true", async () => { - // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - sqliteSubjectOptionsWithTags, - ), - ).resolves.toContain("grace"); - }); - - it("every: false the moment one participating tag among several votes false", async () => { - // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - sqliteSubjectOptionsWithTags, - ), - ).resolves.not.toContain("grace"); - }); - - it("filter narrows which tags participate before item is even evaluated", async () => { - // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. - const filtered: PredicateNode = { - kind: "every", - collection: "tags", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "tag" }, - right: { kind: "textLiteral", value: "senior" }, - }, - item: weightAboveThreshold, - }; - await expect( - agreeingRows(filtered, sqliteSubjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("some/every over an empty collection reduce to each connective's own identity", async () => { - // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - sqliteSubjectOptionsWithTags, - ), - ).resolves.not.toContain("ada"); - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - sqliteSubjectOptionsWithTags, - ), - ).resolves.toContain("ada"); - }); - - it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { - // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - sqliteSubjectOptionsWithTags, - ), - ).resolves.toContain("lin"); - }); - - it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { - // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. - const some: PredicateNode = { - kind: "some", - collection: "tags", - item: weightAboveThreshold, - }; - const every: PredicateNode = { - kind: "every", - collection: "tags", - item: weightAboveThreshold, - }; - const somePresent = await agreeingRows(some, sqliteSubjectOptionsWithTags); - const someAbsent = await agreeingRows( - { kind: "not", operand: some }, - sqliteSubjectOptionsWithTags, - ); - expect(somePresent).not.toContain("unknown"); - expect(someAbsent).not.toContain("unknown"); - - const everyPresent = await agreeingRows( - every, - sqliteSubjectOptionsWithTags, - ); - const everyAbsent = await agreeingRows( - { kind: "not", operand: every }, - sqliteSubjectOptionsWithTags, - ); - expect(everyPresent).not.toContain("unknown"); - expect(everyAbsent).not.toContain("unknown"); - }); - - it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { - // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. - const straddling: PredicateNode = { - kind: "some", - collection: "tags", - item: { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_LOW }, - }, - right: { - kind: "compare", - op: "lte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_HIGH }, - }, - }, - }; - await expect( - agreeingRows(straddling, sqliteSubjectOptionsWithTags), - ).resolves.not.toContain("grace"); - }); - - it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const minWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 3 }, - }; - await expect( - agreeingRows(maxWeight, sqliteSubjectOptionsWithTags), - ).resolves.toContain("grace"); - await expect( - agreeingRows(minWeight, sqliteSubjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const present = await agreeingRows(maxWeight, sqliteSubjectOptionsWithTags); - const absent = await agreeingRows( - { kind: "not", operand: maxWeight }, - sqliteSubjectOptionsWithTags, - ); - // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. - expect(present).not.toContain("ada"); - expect(present).not.toContain("unknown"); - expect(absent).not.toContain("ada"); - expect(absent).not.toContain("unknown"); - }); -}); - -describe("a tree deep enough to mix every supported kind", () => { - it("agrees with the evaluator row for row", async () => { - const node: PredicateNode = { - kind: "anyOf", - operands: [ - { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }, - right: { - kind: "not", - operand: { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "grace" }], - }, - }, - }, - { - kind: "allOf", - operands: [ - { kind: "exists", operand: { kind: "reference", key: "note" } }, - { - kind: "or", - left: { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "note" }, - right: { kind: "textLiteral", value: "^h" }, - }, - right: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }, - }, - ], - }, - ], - }; - - await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace"]); - }); -}); From 58185a682e23ffa10255693c327a76043dfa56dd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:56:32 +0100 Subject: [PATCH 11/12] test(trilean-sql): split postgres.test.ts into topic-scoped files under the 800-line cap test/integration/postgres.test.ts had grown to 829 lines, over the new max-lines cap. Splits it by tested concern into three files (predicate compilation, degenerate/adversarial fragments, and the some/every/fold correlated-collection suite), each comfortably under the cap. Extracts the shared container lifecycle -- schema, seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the agreeingRows comparison every case is built on -- into a new postgres-test-support.ts all three split files import from. Each split file still gets its own isolated vitest module instance, so each starts, seeds, and stops its own container via that shared beforeAll/afterAll exactly as the unsplit file did for itself. Kept a genuinely independent structural copy from the PGlite suite's own support module, per that suite's own stated design intent: a shared harness parameterised over both engines would make them agree by construction, which is the one thing this parity suite exists to avoid. --- .../test/integration/postgres-test-support.ts | 246 ++++++ .../integration/postgres.edge-cases.test.ts | 100 +++ .../integration/postgres.predicates.test.ts | 264 ++++++ .../integration/postgres.quantifiers.test.ts | 244 ++++++ .../test/integration/postgres.test.ts | 829 ------------------ 5 files changed, 854 insertions(+), 829 deletions(-) create mode 100644 packages/trilean-sql/test/integration/postgres-test-support.ts create mode 100644 packages/trilean-sql/test/integration/postgres.edge-cases.test.ts create mode 100644 packages/trilean-sql/test/integration/postgres.predicates.test.ts create mode 100644 packages/trilean-sql/test/integration/postgres.quantifiers.test.ts delete mode 100644 packages/trilean-sql/test/integration/postgres.test.ts diff --git a/packages/trilean-sql/test/integration/postgres-test-support.ts b/packages/trilean-sql/test/integration/postgres-test-support.ts new file mode 100644 index 0000000..c29c072 --- /dev/null +++ b/packages/trilean-sql/test/integration/postgres-test-support.ts @@ -0,0 +1,246 @@ +import { + PostgreSqlContainer, + type StartedPostgreSqlContainer, +} from "@testcontainers/postgresql"; +import pg from "pg"; +import type { + ComputedValue, + JsonValue, + PredicateNode, + Resolution, + Resolvers, +} from "trilean"; +import { evaluatePredicate } from "trilean"; +import { afterAll, beforeAll, expect } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { subjectOptions } from "../../src/test-support/columns"; + +/** + * The shared harness the split `postgres.*.test.ts` files below all import: the container lifecycle, the seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the `agreeingRows` comparison every case in those files is built on. + * + * Splitting the original single file by tested concern (predicates, edge cases, quantifiers) is purely a file-size matter -- vitest still gives each split file its own isolated module instance, so each one starts, seeds, and stops its own container via the `beforeAll`/`afterAll` registered here, exactly as the unsplit file did for itself. + */ + +const SCHEMA = ` + CREATE TABLE subjects ( + id text PRIMARY KEY, + age double precision, + name text, + active boolean, + joined timestamptz, + note text + ); +`; + +/** + * The one correlated child table the `some`/`every`/`fold` parity suite in `postgres.quantifiers.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id text PRIMARY KEY, + "subjectId" text NOT NULL, + tag text, + weight double precision + ); +`; + +interface SubjectRow { + id: string; + age: number | null; + name: string | null; + active: boolean | null; + joined: string | null; + note: string | null; +} + +/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ +export const SUBJECTS: readonly SubjectRow[] = [ + { + id: "ada", + age: 30, + name: "ada", + active: true, + joined: "2020-01-01T00:00:00Z", + note: "hello", + }, + { + id: "grace", + age: 12, + name: "grace", + active: false, + joined: "2024-06-01T12:00:00Z", + note: "hi", + }, + { + id: "lin", + age: null, + name: "lin", + active: true, + joined: "2021-03-03T00:00:00Z", + note: null, + }, + { + id: "unknown", + age: 45, + name: null, + active: null, + joined: null, + note: null, + }, +]; + +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `postgres.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Resolves a reference key against one row, mapping a NULL column to `found: false`. + * + * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. + */ +function resolversFor(row: Readonly): Resolvers { + const known: Record = { + ...(row.age !== null && { + age: { kind: "number", value: row.age }, + }), + ...(row.name !== null && { name: { kind: "text", value: row.name } }), + ...(row.note !== null && { note: { kind: "text", value: row.note } }), + ...(row.active !== null && { + active: { kind: "boolean", value: row.active }, + }), + ...(row.joined !== null && { + joined: { kind: "instant", value: row.joined }, + }), + }; + + return { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } + const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( + value === undefined ? { found: false } : { found: true, value }, + ); + }, + resolveLookup: () => { + throw new Error("no tree in this suite uses a lookup"); + }, + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); + }, + }; +} + +let container: StartedPostgreSqlContainer; +export let client: pg.Client; + +beforeAll(async () => { + container = await new PostgreSqlContainer("postgres:17-alpine").start(); + client = new pg.Client({ connectionString: container.getConnectionUri() }); + await client.connect(); + await client.query(SCHEMA); + await client.query(TAGS_SCHEMA); + for (const row of SUBJECTS) { + await client.query( + "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", + [row.id, row.age, row.name, row.active, row.joined, row.note], + ); + } + for (const tagRow of TAGS) { + await client.query( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, + [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], + ); + } +}); + +afterAll(async () => { + await client.end(); + await container.stop(); +}); + +async function selectMatching( + node: PredicateNode, + options: Readonly = subjectOptions, +): Promise { + const compiled = compilePredicateNode(node, options); + const result = await client.query<{ id: string }>( + `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, + compiled.params, + ); + return result.rows.map((row) => row.id); +} + +export async function evaluatorMatching( + node: PredicateNode, +): Promise { + const matched: string[] = []; + for (const row of SUBJECTS) { + const evaluation = await evaluatePredicate( + node, + undefined, + resolversFor(row), + ); + if (evaluation.status === "definite" && evaluation.value) { + matched.push(row.id); + } + } + return matched.sort(); +} + +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `subjectOptions`; a case exercising `matches`/`notMatches` passes `subjectOptionsWithPostgresRegexp`, since pushdown of those two is refused by default. */ +export async function agreeingRows( + node: PredicateNode, + options: Readonly = subjectOptions, +): Promise { + const [viaSql, viaEvaluator] = await Promise.all([ + selectMatching(node, options), + evaluatorMatching(node), + ]); + expect(viaSql).toEqual(viaEvaluator); + return viaSql; +} diff --git a/packages/trilean-sql/test/integration/postgres.edge-cases.test.ts b/packages/trilean-sql/test/integration/postgres.edge-cases.test.ts new file mode 100644 index 0000000..c135369 --- /dev/null +++ b/packages/trilean-sql/test/integration/postgres.edge-cases.test.ts @@ -0,0 +1,100 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { subjectOptions } from "../../src/test-support/columns"; + +import { + agreeingRows, + client, + evaluatorMatching, + SUBJECTS, +} from "./postgres-test-support"; + +describe("degenerate and adversarial fragments", () => { + it("executes a comparison between two literals, which needs both placeholders typed", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + }); + + it("executes an empty allOf and anyOf as their identities", async () => { + await expect( + agreeingRows({ kind: "allOf", operands: [] }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + await expect( + agreeingRows({ kind: "anyOf", operands: [] }), + ).resolves.toEqual([]); + }); + + it("refuses NaN, and measures the divergence that refusal exists to prevent", async () => { + // The refusal is in the guard, so this could have been a unit test -- but a unit test could only assert that NaN is refused, not that refusing it was right. What makes it right is a fact about PostgreSQL: it defines NaN as equal to itself, so `NaN = NaN` there is TRUE and would have selected the whole table, while trilean's `===` makes the same tree match nothing. Both halves are measured here. + const node: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: Number.NaN }, + right: { kind: "numberLiteral", value: Number.NaN }, + }; + expect(() => compilePredicateNode(node, subjectOptions)).toThrow( + /cannot compile 'numberLiteral'/, + ); + + const wouldHaveMatched = await client.query<{ id: string }>( + "SELECT id FROM subjects WHERE ($1::double precision = $2::double precision) ORDER BY id", + [Number.NaN, Number.NaN], + ); + expect(wouldHaveMatched.rows.map((row) => row.id)).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + await expect(evaluatorMatching(node)).resolves.toEqual([]); + }); + + it("treats an injection attempt as data and leaves the table standing", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { + kind: "textLiteral", + value: "ada'; DROP TABLE subjects; --", + }, + }), + ).resolves.toEqual([]); + + const surviving = await client.query<{ count: string }>( + "SELECT count(*)::text AS count FROM subjects", + ); + expect(surviving.rows[0]?.count).toBe(String(SUBJECTS.length)); + }); + + it("neutralises a hostile column name into one identifier the server rejects", async () => { + // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that PostgreSQL reads it as a single inert identifier: this executes it, and the server refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. + const hostile: SqlCompileOptions = { + dialect: "postgres", + columnFor: () => ({ column: `name" = name OR "1` }), + }; + const compiled = compilePredicateNode( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + hostile, + ); + expect(compiled.sql).toBe(`("name"" = name OR ""1" = $1::text)`); + + await expect( + client.query( + `SELECT id FROM subjects WHERE ${compiled.sql}`, + compiled.params, + ), + ).rejects.toThrow(/does not exist/i); + }); +}); diff --git a/packages/trilean-sql/test/integration/postgres.predicates.test.ts b/packages/trilean-sql/test/integration/postgres.predicates.test.ts new file mode 100644 index 0000000..6261f59 --- /dev/null +++ b/packages/trilean-sql/test/integration/postgres.predicates.test.ts @@ -0,0 +1,264 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import { + subjectOptions, + subjectOptionsWithPostgresRegexp, +} from "../../src/test-support/columns"; +import { agreeingRows, client, SUBJECTS } from "./postgres-test-support"; + +describe("comparisons against a column that can be NULL", () => { + const olderThan18: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }; + + it("excludes the row whose age is unknown", async () => { + await expect(agreeingRows(olderThan18)).resolves.toEqual([ + "ada", + "unknown", + ]); + }); + + it("still excludes it under negation, which two-valued logic could not do", async () => { + // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in PostgreSQL exactly as `not(indeterminate)` is indeterminate in trilean. + await expect( + agreeingRows({ kind: "not", operand: olderThan18 }), + ).resolves.toEqual(["grace"]); + }); + + it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { + await expect( + agreeingRows({ + kind: "anyOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "lin" }, + }, + ], + }), + ).resolves.toEqual(["ada", "lin", "unknown"]); + }); + + it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { + // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. + await expect( + agreeingRows({ + kind: "not", + operand: { + kind: "allOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "nobody" }, + }, + ], + }, + }), + ).resolves.toEqual(["ada", "grace", "lin"]); + }); + + it("compares instants across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("compares booleans for equality across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: true }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); +}); + +describe("exists", () => { + const hasNote: PredicateNode = { + kind: "exists", + operand: { kind: "reference", key: "note" }, + }; + + it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { + const present = await agreeingRows(hasNote); + const absent = await agreeingRows({ kind: "not", operand: hasNote }); + expect(present).toEqual(["ada", "grace"]); + expect(absent).toEqual(["lin", "unknown"]); + expect([...present, ...absent].sort()).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + }); +}); + +describe("textCompare", () => { + it("matches a pattern with PostgreSQL's own regular-expression operator, once postgresRegexpPushdown opts into it", async () => { + await expect( + agreeingRows( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }, + subjectOptionsWithPostgresRegexp, + ), + ).resolves.toEqual(["ada", "grace"]); + }); + + it("leaves a NULL operand unknown under a negated match, once postgresRegexpPushdown opts into it", async () => { + await expect( + agreeingRows( + { + kind: "textCompare", + op: "notMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + subjectOptionsWithPostgresRegexp, + ), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("refuses 'matches' by default, falling back to in-process evaluation", () => { + expect(() => + compilePredicateNode( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }, + subjectOptions, + ), + ).toThrow(/postgresRegexpPushdown/); + }); +}); + +/** + * The equivalence claim `portableMatches`/`portableNotMatches` exist for, measured the same way as the rest of this file: compile a tree using a `trilean-regex` pattern, execute the translated PostgreSQL syntax against the real server, and compare against trilean's own evaluator -- which for these two operators means `trilean-regex`'s own NFA matcher (see `compareText` in evaluator.ts), not native `RegExp`. Agreement here is evidence about `portable-pattern.ts`'s translation, not a re-run of the `matches`/`notMatches` suite above with different operator names. + */ +describe("portableMatches/portableNotMatches (trilean-regex, translated to PostgreSQL's own syntax)", () => { + it("matches a start-anchored literal prefix", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^gr" }, + }), + ).resolves.toEqual(["grace"]); + }); + + it("matches a bounded-repetition character class", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^[a-z]{3,4}$" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("matches an alternation", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(?:ada|lin)$" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableNotMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("PostgreSQL's own bare '.' already matches a newline under '~', exactly like trilean-regex's own '.', so this compiler translates it unchanged", async () => { + // Measured directly against the server rather than trusted from documentation prose alone (see portable-pattern.ts's own doc comment on renderPostgresPattern for why): PostgreSQL's prose reads, out of context, as though '.' excludes a newline by default, but under the plain '~'/'!~' operators this compiler emits, it does not. + const result = await client.query<{ result: boolean }>( + "SELECT ($1::text ~ $2::text) AS result", + ["a\nc", "a.c"], + ); + expect(result.rows[0]?.result).toBe(true); + }); +}); + +describe("memberOf", () => { + it("matches a candidate list", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "ada" }, + { kind: "textLiteral", value: "nobody" }, + ], + }), + ).resolves.toEqual(["ada"]); + }); + + it("leaves NOT IN unknown for a NULL operand", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "ada" }], + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { + // Executed rather than asserted as a string, because `(x IS NULL AND NULL::boolean)` is only the right encoding if PostgreSQL really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. + const node: PredicateNode = { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual([]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + ["ada", "grace", "lin"], + ); + }); + + it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { + const node: PredicateNode = { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + [], + ); + }); +}); diff --git a/packages/trilean-sql/test/integration/postgres.quantifiers.test.ts b/packages/trilean-sql/test/integration/postgres.quantifiers.test.ts new file mode 100644 index 0000000..ed9afed --- /dev/null +++ b/packages/trilean-sql/test/integration/postgres.quantifiers.test.ts @@ -0,0 +1,244 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { + subjectOptionsWithPostgresRegexp, + subjectOptionsWithTags, +} from "../../src/test-support/columns"; +import { agreeingRows } from "./postgres-test-support"; + +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, subjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + subjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows(every, subjectOptionsWithTags); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + subjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, subjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, subjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + subjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + +describe("a tree deep enough to mix every supported kind", () => { + it("agrees with the evaluator row for row", async () => { + const node: PredicateNode = { + kind: "anyOf", + operands: [ + { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }, + right: { + kind: "not", + operand: { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "grace" }], + }, + }, + }, + { + kind: "allOf", + operands: [ + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { + kind: "or", + left: { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "note" }, + right: { kind: "textLiteral", value: "^h" }, + }, + right: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + }, + ], + }, + ], + }; + + await expect( + agreeingRows(node, subjectOptionsWithPostgresRegexp), + ).resolves.toEqual(["ada", "grace"]); + }); +}); diff --git a/packages/trilean-sql/test/integration/postgres.test.ts b/packages/trilean-sql/test/integration/postgres.test.ts deleted file mode 100644 index 9055608..0000000 --- a/packages/trilean-sql/test/integration/postgres.test.ts +++ /dev/null @@ -1,829 +0,0 @@ -import { - PostgreSqlContainer, - type StartedPostgreSqlContainer, -} from "@testcontainers/postgresql"; -import pg from "pg"; -import type { - ComputedValue, - JsonValue, - PredicateNode, - Resolution, - Resolvers, -} from "trilean"; -import { evaluatePredicate } from "trilean"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { compilePredicateNode } from "../../src/compile"; -import type { SqlCompileOptions } from "../../src/options"; -import { - subjectOptions, - subjectOptionsWithPostgresRegexp, - subjectOptionsWithTags, -} from "../../src/test-support/columns"; - -/** - * The suite that turns this package's central claim from a design statement into a measured one. - * - * A compiled fragment's three-valued behaviour cannot be established by asserting its text: `("age" > $1)` is only indeterminate-preserving because of what PostgreSQL's planner does with a NULL `age`, and that is a fact about PostgreSQL, not about the string. So every case here compiles a tree, executes the fragment as a real `WHERE` clause against a real server, and compares the rows it returns against the rows trilean's own evaluator judges `definite(true)` for the same tree. Agreement on the rows *and* on their absence is the property under test; a divergence is a compiler bug regardless of what the SQL looks like. - */ - -const SCHEMA = ` - CREATE TABLE subjects ( - id text PRIMARY KEY, - age double precision, - name text, - active boolean, - joined timestamptz, - note text - ); -`; - -/** - * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. - */ -const TAGS_SCHEMA = ` - CREATE TABLE subject_tags ( - id text PRIMARY KEY, - "subjectId" text NOT NULL, - tag text, - weight double precision - ); -`; - -interface SubjectRow { - id: string; - age: number | null; - name: string | null; - active: boolean | null; - joined: string | null; - note: string | null; -} - -/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ -const SUBJECTS: readonly SubjectRow[] = [ - { - id: "ada", - age: 30, - name: "ada", - active: true, - joined: "2020-01-01T00:00:00Z", - note: "hello", - }, - { - id: "grace", - age: 12, - name: "grace", - active: false, - joined: "2024-06-01T12:00:00Z", - note: "hi", - }, - { - id: "lin", - age: null, - name: "lin", - active: true, - joined: "2021-03-03T00:00:00Z", - note: null, - }, - { - id: "unknown", - age: 45, - name: null, - active: null, - joined: null, - note: null, - }, -]; - -interface TagRow { - id: string; - subjectId: string; - tag: string; - weight: number | null; -} - -/** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. - */ -const TAGS: readonly TagRow[] = [ - { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, - { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, - { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, - { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, - { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, -]; - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Resolves a reference key against one row, mapping a NULL column to `found: false`. - * - * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. - * - * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. - */ -function resolversFor(row: Readonly): Resolvers { - const known: Record = { - ...(row.age !== null && { - age: { kind: "number", value: row.age }, - }), - ...(row.name !== null && { name: { kind: "text", value: row.name } }), - ...(row.note !== null && { note: { kind: "text", value: row.note } }), - ...(row.active !== null && { - active: { kind: "boolean", value: row.active }, - }), - ...(row.joined !== null && { - joined: { kind: "instant", value: row.joined }, - }), - }; - - return { - resolveValue: async (key: JsonValue, context: unknown) => { - if (context !== undefined) { - if (typeof key !== "string" || !isPlainRecord(context)) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ - found: true, - value: { kind: "text", value }, - }); - } - return Promise.resolve({ found: false }); - } - const value = typeof key === "string" ? known[key] : undefined; - return Promise.resolve( - value === undefined ? { found: false } : { found: true, value }, - ); - }, - resolveLookup: () => { - throw new Error("no tree in this suite uses a lookup"); - }, - resolveCollection: async (collection: JsonValue) => { - if (collection !== "tags") return Promise.resolve([]); - return Promise.resolve( - TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ - tag: tagRow.tag, - weight: tagRow.weight, - })), - ); - }, - }; -} - -let container: StartedPostgreSqlContainer; -let client: pg.Client; - -beforeAll(async () => { - container = await new PostgreSqlContainer("postgres:17-alpine").start(); - client = new pg.Client({ connectionString: container.getConnectionUri() }); - await client.connect(); - await client.query(SCHEMA); - await client.query(TAGS_SCHEMA); - for (const row of SUBJECTS) { - await client.query( - "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", - [row.id, row.age, row.name, row.active, row.joined, row.note], - ); - } - for (const tagRow of TAGS) { - await client.query( - `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, - [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], - ); - } -}); - -afterAll(async () => { - await client.end(); - await container.stop(); -}); - -async function selectMatching( - node: PredicateNode, - options: Readonly = subjectOptions, -): Promise { - const compiled = compilePredicateNode(node, options); - const result = await client.query<{ id: string }>( - `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, - compiled.params, - ); - return result.rows.map((row) => row.id); -} - -async function evaluatorMatching(node: PredicateNode): Promise { - const matched: string[] = []; - for (const row of SUBJECTS) { - const evaluation = await evaluatePredicate( - node, - undefined, - resolversFor(row), - ); - if (evaluation.status === "definite" && evaluation.value) { - matched.push(row.id); - } - } - return matched.sort(); -} - -/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `subjectOptions`; a case exercising `matches`/`notMatches` passes `subjectOptionsWithPostgresRegexp`, since pushdown of those two is refused by default. */ -async function agreeingRows( - node: PredicateNode, - options: Readonly = subjectOptions, -): Promise { - const [viaSql, viaEvaluator] = await Promise.all([ - selectMatching(node, options), - evaluatorMatching(node), - ]); - expect(viaSql).toEqual(viaEvaluator); - return viaSql; -} - -describe("comparisons against a column that can be NULL", () => { - const olderThan18: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }; - - it("excludes the row whose age is unknown", async () => { - await expect(agreeingRows(olderThan18)).resolves.toEqual([ - "ada", - "unknown", - ]); - }); - - it("still excludes it under negation, which two-valued logic could not do", async () => { - // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in PostgreSQL exactly as `not(indeterminate)` is indeterminate in trilean. - await expect( - agreeingRows({ kind: "not", operand: olderThan18 }), - ).resolves.toEqual(["grace"]); - }); - - it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { - await expect( - agreeingRows({ - kind: "anyOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "lin" }, - }, - ], - }), - ).resolves.toEqual(["ada", "lin", "unknown"]); - }); - - it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { - // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. - await expect( - agreeingRows({ - kind: "not", - operand: { - kind: "allOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "nobody" }, - }, - ], - }, - }), - ).resolves.toEqual(["ada", "grace", "lin"]); - }); - - it("compares instants across a NULL", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "reference", key: "joined" }, - right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("compares booleans for equality across a NULL", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: true }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); -}); - -describe("exists", () => { - const hasNote: PredicateNode = { - kind: "exists", - operand: { kind: "reference", key: "note" }, - }; - - it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { - const present = await agreeingRows(hasNote); - const absent = await agreeingRows({ kind: "not", operand: hasNote }); - expect(present).toEqual(["ada", "grace"]); - expect(absent).toEqual(["lin", "unknown"]); - expect([...present, ...absent].sort()).toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - }); -}); - -describe("textCompare", () => { - it("matches a pattern with PostgreSQL's own regular-expression operator, once postgresRegexpPushdown opts into it", async () => { - await expect( - agreeingRows( - { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(a|g)" }, - }, - subjectOptionsWithPostgresRegexp, - ), - ).resolves.toEqual(["ada", "grace"]); - }); - - it("leaves a NULL operand unknown under a negated match, once postgresRegexpPushdown opts into it", async () => { - await expect( - agreeingRows( - { - kind: "textCompare", - op: "notMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }, - subjectOptionsWithPostgresRegexp, - ), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("refuses 'matches' by default, falling back to in-process evaluation", () => { - expect(() => - compilePredicateNode( - { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(a|g)" }, - }, - subjectOptions, - ), - ).toThrow(/postgresRegexpPushdown/); - }); -}); - -/** - * The equivalence claim `portableMatches`/`portableNotMatches` exist for, measured the same way as the rest of this file: compile a tree using a `trilean-regex` pattern, execute the translated PostgreSQL syntax against the real server, and compare against trilean's own evaluator -- which for these two operators means `trilean-regex`'s own NFA matcher (see `compareText` in evaluator.ts), not native `RegExp`. Agreement here is evidence about `portable-pattern.ts`'s translation, not a re-run of the `matches`/`notMatches` suite above with different operator names. - */ -describe("portableMatches/portableNotMatches (trilean-regex, translated to PostgreSQL's own syntax)", () => { - it("matches a start-anchored literal prefix", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^gr" }, - }), - ).resolves.toEqual(["grace"]); - }); - - it("matches a bounded-repetition character class", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^[a-z]{3,4}$" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("matches an alternation", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(?:ada|lin)$" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableNotMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("PostgreSQL's own bare '.' already matches a newline under '~', exactly like trilean-regex's own '.', so this compiler translates it unchanged", async () => { - // Measured directly against the server rather than trusted from documentation prose alone (see portable-pattern.ts's own doc comment on renderPostgresPattern for why): PostgreSQL's prose reads, out of context, as though '.' excludes a newline by default, but under the plain '~'/'!~' operators this compiler emits, it does not. - const result = await client.query<{ result: boolean }>( - "SELECT ($1::text ~ $2::text) AS result", - ["a\nc", "a.c"], - ); - expect(result.rows[0]?.result).toBe(true); - }); -}); - -describe("memberOf", () => { - it("matches a candidate list", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "ada" }, - { kind: "textLiteral", value: "nobody" }, - ], - }), - ).resolves.toEqual(["ada"]); - }); - - it("leaves NOT IN unknown for a NULL operand", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "ada" }], - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { - // Executed rather than asserted as a string, because `(x IS NULL AND NULL::boolean)` is only the right encoding if PostgreSQL really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. - const node: PredicateNode = { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual([]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - ["ada", "grace", "lin"], - ); - }); - - it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { - const node: PredicateNode = { - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - [], - ); - }); -}); - -describe("degenerate and adversarial fragments", () => { - it("executes a comparison between two literals, which needs both placeholders typed", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - }); - - it("executes an empty allOf and anyOf as their identities", async () => { - await expect( - agreeingRows({ kind: "allOf", operands: [] }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - await expect( - agreeingRows({ kind: "anyOf", operands: [] }), - ).resolves.toEqual([]); - }); - - it("refuses NaN, and measures the divergence that refusal exists to prevent", async () => { - // The refusal is in the guard, so this could have been a unit test -- but a unit test could only assert that NaN is refused, not that refusing it was right. What makes it right is a fact about PostgreSQL: it defines NaN as equal to itself, so `NaN = NaN` there is TRUE and would have selected the whole table, while trilean's `===` makes the same tree match nothing. Both halves are measured here. - const node: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: Number.NaN }, - right: { kind: "numberLiteral", value: Number.NaN }, - }; - expect(() => compilePredicateNode(node, subjectOptions)).toThrow( - /cannot compile 'numberLiteral'/, - ); - - const wouldHaveMatched = await client.query<{ id: string }>( - "SELECT id FROM subjects WHERE ($1::double precision = $2::double precision) ORDER BY id", - [Number.NaN, Number.NaN], - ); - expect(wouldHaveMatched.rows.map((row) => row.id)).toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - await expect(evaluatorMatching(node)).resolves.toEqual([]); - }); - - it("treats an injection attempt as data and leaves the table standing", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { - kind: "textLiteral", - value: "ada'; DROP TABLE subjects; --", - }, - }), - ).resolves.toEqual([]); - - const surviving = await client.query<{ count: string }>( - "SELECT count(*)::text AS count FROM subjects", - ); - expect(surviving.rows[0]?.count).toBe(String(SUBJECTS.length)); - }); - - it("neutralises a hostile column name into one identifier the server rejects", async () => { - // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that PostgreSQL reads it as a single inert identifier: this executes it, and the server refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. - const hostile: SqlCompileOptions = { - dialect: "postgres", - columnFor: () => ({ column: `name" = name OR "1` }), - }; - const compiled = compilePredicateNode( - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }, - hostile, - ); - expect(compiled.sql).toBe(`("name"" = name OR ""1" = $1::text)`); - - await expect( - client.query( - `SELECT id FROM subjects WHERE ${compiled.sql}`, - compiled.params, - ), - ).rejects.toThrow(/does not exist/i); - }); -}); - -describe("some/every/fold over a correlated collection", () => { - const MATCH_THRESHOLD = 5; - const RANGE_LOW = 6; - const RANGE_HIGH = 8; - - const weightAboveThreshold: PredicateNode = { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, - }; - - it("some: true the moment one participating tag among several votes true", async () => { - // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("grace"); - }); - - it("every: false the moment one participating tag among several votes false", async () => { - // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.not.toContain("grace"); - }); - - it("filter narrows which tags participate before item is even evaluated", async () => { - // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. - const filtered: PredicateNode = { - kind: "every", - collection: "tags", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "tag" }, - right: { kind: "textLiteral", value: "senior" }, - }, - item: weightAboveThreshold, - }; - await expect( - agreeingRows(filtered, subjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("some/every over an empty collection reduce to each connective's own identity", async () => { - // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.not.toContain("ada"); - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("ada"); - }); - - it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { - // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("lin"); - }); - - it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { - // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. - const some: PredicateNode = { - kind: "some", - collection: "tags", - item: weightAboveThreshold, - }; - const every: PredicateNode = { - kind: "every", - collection: "tags", - item: weightAboveThreshold, - }; - const somePresent = await agreeingRows(some, subjectOptionsWithTags); - const someAbsent = await agreeingRows( - { kind: "not", operand: some }, - subjectOptionsWithTags, - ); - expect(somePresent).not.toContain("unknown"); - expect(someAbsent).not.toContain("unknown"); - - const everyPresent = await agreeingRows(every, subjectOptionsWithTags); - const everyAbsent = await agreeingRows( - { kind: "not", operand: every }, - subjectOptionsWithTags, - ); - expect(everyPresent).not.toContain("unknown"); - expect(everyAbsent).not.toContain("unknown"); - }); - - it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { - // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. - const straddling: PredicateNode = { - kind: "some", - collection: "tags", - item: { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_LOW }, - }, - right: { - kind: "compare", - op: "lte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_HIGH }, - }, - }, - }; - await expect( - agreeingRows(straddling, subjectOptionsWithTags), - ).resolves.not.toContain("grace"); - }); - - it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const minWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 3 }, - }; - await expect( - agreeingRows(maxWeight, subjectOptionsWithTags), - ).resolves.toContain("grace"); - await expect( - agreeingRows(minWeight, subjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const present = await agreeingRows(maxWeight, subjectOptionsWithTags); - const absent = await agreeingRows( - { kind: "not", operand: maxWeight }, - subjectOptionsWithTags, - ); - // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. - expect(present).not.toContain("ada"); - expect(present).not.toContain("unknown"); - expect(absent).not.toContain("ada"); - expect(absent).not.toContain("unknown"); - }); -}); - -describe("a tree deep enough to mix every supported kind", () => { - it("agrees with the evaluator row for row", async () => { - const node: PredicateNode = { - kind: "anyOf", - operands: [ - { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }, - right: { - kind: "not", - operand: { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "grace" }], - }, - }, - }, - { - kind: "allOf", - operands: [ - { kind: "exists", operand: { kind: "reference", key: "note" } }, - { - kind: "or", - left: { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "note" }, - right: { kind: "textLiteral", value: "^h" }, - }, - right: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }, - }, - ], - }, - ], - }; - - await expect( - agreeingRows(node, subjectOptionsWithPostgresRegexp), - ).resolves.toEqual(["ada", "grace"]); - }); -}); From 4952b3e08a38f7a42646368a732c2dd365897686 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:57:03 +0100 Subject: [PATCH 12/12] test(trilean-sql): split pglite.test.ts into topic-scoped files under the 800-line cap test/integration/pglite.test.ts had grown to 825 lines, over the new max-lines cap. Splits it by tested concern into three files (predicate compilation, degenerate/adversarial fragments, and the some/every/fold correlated-collection suite), each comfortably under the cap. Extracts the shared in-process database lifecycle -- schema, seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the agreeingRows comparison every case is built on -- into a new pglite-test-support.ts all three split files import from. Each split file still gets its own isolated vitest module instance, so each opens and closes its own PGlite instance via that shared beforeAll/afterAll exactly as the unsplit file did for itself. Kept a genuinely independent structural copy from the container-backed suite's own support module, per that suite's own stated design intent: a shared harness parameterised over both engines would make them agree by construction, which is the one thing this parity suite exists to avoid. --- .../test/integration/pglite-test-support.ts | 239 +++++ .../integration/pglite.edge-cases.test.ts | 99 +++ .../integration/pglite.predicates.test.ts | 263 ++++++ .../integration/pglite.quantifiers.test.ts | 244 ++++++ .../test/integration/pglite.test.ts | 825 ------------------ 5 files changed, 845 insertions(+), 825 deletions(-) create mode 100644 packages/trilean-sql/test/integration/pglite-test-support.ts create mode 100644 packages/trilean-sql/test/integration/pglite.edge-cases.test.ts create mode 100644 packages/trilean-sql/test/integration/pglite.predicates.test.ts create mode 100644 packages/trilean-sql/test/integration/pglite.quantifiers.test.ts delete mode 100644 packages/trilean-sql/test/integration/pglite.test.ts diff --git a/packages/trilean-sql/test/integration/pglite-test-support.ts b/packages/trilean-sql/test/integration/pglite-test-support.ts new file mode 100644 index 0000000..558b330 --- /dev/null +++ b/packages/trilean-sql/test/integration/pglite-test-support.ts @@ -0,0 +1,239 @@ +import { PGlite } from "@electric-sql/pglite"; +import type { + ComputedValue, + JsonValue, + PredicateNode, + Resolution, + Resolvers, +} from "trilean"; +import { evaluatePredicate } from "trilean"; +import { afterAll, beforeAll, expect } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { subjectOptions } from "../../src/test-support/columns"; + +/** + * The shared harness the split `pglite.*.test.ts` files below all import: the in-process database lifecycle, the seeded rows, the resolver bridging a row to the evaluator's own notion of "known", and the `agreeingRows` comparison every case in those files is built on. + * + * Splitting the original single file by tested concern (predicates, edge cases, quantifiers) is purely a file-size matter -- vitest still gives each split file its own isolated module instance, so each one opens, seeds, and closes its own PGlite instance via the `beforeAll`/`afterAll` registered here, exactly as the unsplit file did for itself. + */ + +const SCHEMA = ` + CREATE TABLE subjects ( + id text PRIMARY KEY, + age double precision, + name text, + active boolean, + joined timestamptz, + note text + ); +`; + +/** + * The one correlated child table the `some`/`every`/`fold` parity suite in `pglite.quantifiers.test.ts` needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. + */ +const TAGS_SCHEMA = ` + CREATE TABLE subject_tags ( + id text PRIMARY KEY, + "subjectId" text NOT NULL, + tag text, + weight double precision + ); +`; + +interface SubjectRow { + id: string; + age: number | null; + name: string | null; + active: boolean | null; + joined: string | null; + note: string | null; +} + +/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ +export const SUBJECTS: readonly SubjectRow[] = [ + { + id: "ada", + age: 30, + name: "ada", + active: true, + joined: "2020-01-01T00:00:00Z", + note: "hello", + }, + { + id: "grace", + age: 12, + name: "grace", + active: false, + joined: "2024-06-01T12:00:00Z", + note: "hi", + }, + { + id: "lin", + age: null, + name: "lin", + active: true, + joined: "2021-03-03T00:00:00Z", + note: null, + }, + { + id: "unknown", + age: 45, + name: null, + active: null, + joined: null, + note: null, + }, +]; + +interface TagRow { + id: string; + subjectId: string; + tag: string; + weight: number | null; +} + +/** + * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used in `pglite.quantifiers.test.ts` without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. + */ +const TAGS: readonly TagRow[] = [ + { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, + { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, + { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, + { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, + { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, +]; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Resolves a reference key against one row, mapping a NULL column to `found: false`. + * + * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. + * + * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. + */ +function resolversFor(row: Readonly): Resolvers { + const known: Record = { + ...(row.age !== null && { + age: { kind: "number", value: row.age }, + }), + ...(row.name !== null && { name: { kind: "text", value: row.name } }), + ...(row.note !== null && { note: { kind: "text", value: row.note } }), + ...(row.active !== null && { + active: { kind: "boolean", value: row.active }, + }), + ...(row.joined !== null && { + joined: { kind: "instant", value: row.joined }, + }), + }; + + return { + resolveValue: async (key: JsonValue, context: unknown) => { + if (context !== undefined) { + if (typeof key !== "string" || !isPlainRecord(context)) { + return Promise.resolve({ found: false }); + } + const value = context[key]; + if (typeof value === "number") { + return Promise.resolve({ + found: true, + value: { kind: "number", value }, + }); + } + if (typeof value === "string") { + return Promise.resolve({ + found: true, + value: { kind: "text", value }, + }); + } + return Promise.resolve({ found: false }); + } + const value = typeof key === "string" ? known[key] : undefined; + return Promise.resolve( + value === undefined ? { found: false } : { found: true, value }, + ); + }, + resolveLookup: () => { + throw new Error("no tree in this suite uses a lookup"); + }, + resolveCollection: async (collection: JsonValue) => { + if (collection !== "tags") return Promise.resolve([]); + return Promise.resolve( + TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ + tag: tagRow.tag, + weight: tagRow.weight, + })), + ); + }, + }; +} + +export let db: PGlite; + +beforeAll(async () => { + // No connection string, no port, no container: an in-memory database that exists for the lifetime of this process. + db = new PGlite(); + await db.exec(SCHEMA); + await db.exec(TAGS_SCHEMA); + for (const row of SUBJECTS) { + await db.query( + "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", + [row.id, row.age, row.name, row.active, row.joined, row.note], + ); + } + for (const tagRow of TAGS) { + await db.query( + `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, + [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], + ); + } +}); + +afterAll(async () => { + await db.close(); +}); + +async function selectMatching( + node: PredicateNode, + options: Readonly = subjectOptions, +): Promise { + const compiled = compilePredicateNode(node, options); + const result = await db.query<{ id: string }>( + `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, + compiled.params, + ); + return result.rows.map((row) => row.id); +} + +export async function evaluatorMatching( + node: PredicateNode, +): Promise { + const matched: string[] = []; + for (const row of SUBJECTS) { + const evaluation = await evaluatePredicate( + node, + undefined, + resolversFor(row), + ); + if (evaluation.status === "definite" && evaluation.value) { + matched.push(row.id); + } + } + return matched.sort(); +} + +/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `subjectOptions`; a case exercising `matches`/`notMatches` passes `subjectOptionsWithPostgresRegexp`, since pushdown of those two is refused by default. */ +export async function agreeingRows( + node: PredicateNode, + options: Readonly = subjectOptions, +): Promise { + const [viaSql, viaEvaluator] = await Promise.all([ + selectMatching(node, options), + evaluatorMatching(node), + ]); + expect(viaSql).toEqual(viaEvaluator); + return viaSql; +} diff --git a/packages/trilean-sql/test/integration/pglite.edge-cases.test.ts b/packages/trilean-sql/test/integration/pglite.edge-cases.test.ts new file mode 100644 index 0000000..a53ab1f --- /dev/null +++ b/packages/trilean-sql/test/integration/pglite.edge-cases.test.ts @@ -0,0 +1,99 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import type { SqlCompileOptions } from "../../src/options"; +import { subjectOptions } from "../../src/test-support/columns"; +import { + agreeingRows, + db, + evaluatorMatching, + SUBJECTS, +} from "./pglite-test-support"; + +describe("degenerate and adversarial fragments", () => { + it("executes a comparison between two literals, which needs both placeholders typed", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "numberLiteral", value: 1 }, + right: { kind: "numberLiteral", value: 2 }, + }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + }); + + it("executes an empty allOf and anyOf as their identities", async () => { + await expect( + agreeingRows({ kind: "allOf", operands: [] }), + ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); + await expect( + agreeingRows({ kind: "anyOf", operands: [] }), + ).resolves.toEqual([]); + }); + + it("refuses NaN, and measures the divergence that refusal exists to prevent", async () => { + // The refusal is in the guard, so this could have been a unit test -- but a unit test could only assert that NaN is refused, not that refusing it was right. What makes it right is a fact about PostgreSQL: it defines NaN as equal to itself, so `NaN = NaN` there is TRUE and would have selected the whole table, while trilean's `===` makes the same tree match nothing. Both halves are measured here, and the first of them is a fact about PGlite worth measuring separately from the server: a WASM build could in principle have shipped a different float comparison, and a driver that could not bind NaN at all -- as better-sqlite3 cannot -- would substitute NULL and produce the opposite row set rather than the same one. + const node: PredicateNode = { + kind: "compare", + op: "eq", + left: { kind: "numberLiteral", value: Number.NaN }, + right: { kind: "numberLiteral", value: Number.NaN }, + }; + expect(() => compilePredicateNode(node, subjectOptions)).toThrow( + /cannot compile 'numberLiteral'/, + ); + + const wouldHaveMatched = await db.query<{ id: string }>( + "SELECT id FROM subjects WHERE ($1::double precision = $2::double precision) ORDER BY id", + [Number.NaN, Number.NaN], + ); + expect(wouldHaveMatched.rows.map((row) => row.id)).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + await expect(evaluatorMatching(node)).resolves.toEqual([]); + }); + + it("treats an injection attempt as data and leaves the table standing", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { + kind: "textLiteral", + value: "ada'; DROP TABLE subjects; --", + }, + }), + ).resolves.toEqual([]); + + const surviving = await db.query<{ count: string }>( + "SELECT count(*)::text AS count FROM subjects", + ); + expect(surviving.rows[0]?.count).toBe(String(SUBJECTS.length)); + }); + + it("neutralises a hostile column name into one identifier the server rejects", async () => { + // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that PostgreSQL reads it as a single inert identifier: this executes it, and the server refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. + const hostile: SqlCompileOptions = { + dialect: "postgres", + columnFor: () => ({ column: `name" = name OR "1` }), + }; + const compiled = compilePredicateNode( + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "ada" }, + }, + hostile, + ); + expect(compiled.sql).toBe(`("name"" = name OR ""1" = $1::text)`); + + await expect( + db.query( + `SELECT id FROM subjects WHERE ${compiled.sql}`, + compiled.params, + ), + ).rejects.toThrow(/does not exist/i); + }); +}); diff --git a/packages/trilean-sql/test/integration/pglite.predicates.test.ts b/packages/trilean-sql/test/integration/pglite.predicates.test.ts new file mode 100644 index 0000000..da908d1 --- /dev/null +++ b/packages/trilean-sql/test/integration/pglite.predicates.test.ts @@ -0,0 +1,263 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { compilePredicateNode } from "../../src/compile"; +import { + subjectOptions, + subjectOptionsWithPostgresRegexp, +} from "../../src/test-support/columns"; +import { agreeingRows, db, SUBJECTS } from "./pglite-test-support"; + +describe("comparisons against a column that can be NULL", () => { + const olderThan18: PredicateNode = { + kind: "compare", + op: "gt", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }; + + it("excludes the row whose age is unknown", async () => { + await expect(agreeingRows(olderThan18)).resolves.toEqual([ + "ada", + "unknown", + ]); + }); + + it("still excludes it under negation, which two-valued logic could not do", async () => { + // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in PostgreSQL exactly as `not(indeterminate)` is indeterminate in trilean. + await expect( + agreeingRows({ kind: "not", operand: olderThan18 }), + ).resolves.toEqual(["grace"]); + }); + + it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { + await expect( + agreeingRows({ + kind: "anyOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "lin" }, + }, + ], + }), + ).resolves.toEqual(["ada", "lin", "unknown"]); + }); + + it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { + // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. + await expect( + agreeingRows({ + kind: "not", + operand: { + kind: "allOf", + operands: [ + olderThan18, + { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "nobody" }, + }, + ], + }, + }), + ).resolves.toEqual(["ada", "grace", "lin"]); + }); + + it("compares instants across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "lt", + left: { kind: "reference", key: "joined" }, + right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("compares booleans for equality across a NULL", async () => { + await expect( + agreeingRows({ + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: true }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); +}); + +describe("exists", () => { + const hasNote: PredicateNode = { + kind: "exists", + operand: { kind: "reference", key: "note" }, + }; + + it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { + const present = await agreeingRows(hasNote); + const absent = await agreeingRows({ kind: "not", operand: hasNote }); + expect(present).toEqual(["ada", "grace"]); + expect(absent).toEqual(["lin", "unknown"]); + expect([...present, ...absent].sort()).toEqual( + SUBJECTS.map((row) => row.id).sort(), + ); + }); +}); + +describe("textCompare", () => { + it("matches a pattern with PostgreSQL's own regular-expression operator, once postgresRegexpPushdown opts into it", async () => { + await expect( + agreeingRows( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }, + subjectOptionsWithPostgresRegexp, + ), + ).resolves.toEqual(["ada", "grace"]); + }); + + it("leaves a NULL operand unknown under a negated match, once postgresRegexpPushdown opts into it", async () => { + await expect( + agreeingRows( + { + kind: "textCompare", + op: "notMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }, + subjectOptionsWithPostgresRegexp, + ), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("refuses 'matches' by default, falling back to in-process evaluation", () => { + expect(() => + compilePredicateNode( + { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(a|g)" }, + }, + subjectOptions, + ), + ).toThrow(/postgresRegexpPushdown/); + }); +}); + +/** + * The same equivalence claim as `postgres.test.ts`'s own `portableMatches`/`portableNotMatches` suite, against PGlite instead of a server in a container -- see this file's own top-of-file comment for why keeping the two near-verbatim, rather than sharing a parameterised harness, is what actually establishes agreement rather than assuming it. + */ +describe("portableMatches/portableNotMatches (trilean-regex, translated to PostgreSQL's own syntax)", () => { + it("matches a start-anchored literal prefix", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^gr" }, + }), + ).resolves.toEqual(["grace"]); + }); + + it("matches a bounded-repetition character class", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^[a-z]{3,4}$" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("matches an alternation", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^(?:ada|lin)$" }, + }), + ).resolves.toEqual(["ada", "lin"]); + }); + + it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { + await expect( + agreeingRows({ + kind: "textCompare", + op: "portableNotMatches", + left: { kind: "reference", key: "name" }, + right: { kind: "textLiteral", value: "^a" }, + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("PGlite's own bare '.' already matches a newline under '~', exactly like trilean-regex's own '.', so this compiler translates it unchanged", async () => { + const result = await db.query<{ result: boolean }>( + "SELECT ($1::text ~ $2::text) AS result", + ["a\nc", "a.c"], + ); + expect(result.rows[0]?.result).toBe(true); + }); +}); + +describe("memberOf", () => { + it("matches a candidate list", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [ + { kind: "textLiteral", value: "ada" }, + { kind: "textLiteral", value: "nobody" }, + ], + }), + ).resolves.toEqual(["ada"]); + }); + + it("leaves NOT IN unknown for a NULL operand", async () => { + await expect( + agreeingRows({ + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "ada" }], + }), + ).resolves.toEqual(["grace", "lin"]); + }); + + it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { + // Executed rather than asserted as a string, because `(x IS NULL AND NULL::boolean)` is only the right encoding if PostgreSQL really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. + const node: PredicateNode = { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual([]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + ["ada", "grace", "lin"], + ); + }); + + it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { + const node: PredicateNode = { + kind: "memberOf", + op: "notIn", + operand: { kind: "reference", key: "name" }, + candidates: [], + }; + await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); + await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( + [], + ); + }); +}); diff --git a/packages/trilean-sql/test/integration/pglite.quantifiers.test.ts b/packages/trilean-sql/test/integration/pglite.quantifiers.test.ts new file mode 100644 index 0000000..f45ec43 --- /dev/null +++ b/packages/trilean-sql/test/integration/pglite.quantifiers.test.ts @@ -0,0 +1,244 @@ +import type { PredicateNode } from "trilean"; +import { describe, expect, it } from "vitest"; +import { + subjectOptionsWithPostgresRegexp, + subjectOptionsWithTags, +} from "../../src/test-support/columns"; +import { agreeingRows } from "./pglite-test-support"; + +describe("some/every/fold over a correlated collection", () => { + const MATCH_THRESHOLD = 5; + const RANGE_LOW = 6; + const RANGE_HIGH = 8; + + const weightAboveThreshold: PredicateNode = { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, + }; + + it("some: true the moment one participating tag among several votes true", async () => { + // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("grace"); + }); + + it("every: false the moment one participating tag among several votes false", async () => { + // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("grace"); + }); + + it("filter narrows which tags participate before item is even evaluated", async () => { + // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. + const filtered: PredicateNode = { + kind: "every", + collection: "tags", + filter: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: "tag" }, + right: { kind: "textLiteral", value: "senior" }, + }, + item: weightAboveThreshold, + }; + await expect( + agreeingRows(filtered, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("some/every over an empty collection reduce to each connective's own identity", async () => { + // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.not.toContain("ada"); + await expect( + agreeingRows( + { kind: "every", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("ada"); + }); + + it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { + // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. + await expect( + agreeingRows( + { kind: "some", collection: "tags", item: weightAboveThreshold }, + subjectOptionsWithTags, + ), + ).resolves.toContain("lin"); + }); + + it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { + // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. + const some: PredicateNode = { + kind: "some", + collection: "tags", + item: weightAboveThreshold, + }; + const every: PredicateNode = { + kind: "every", + collection: "tags", + item: weightAboveThreshold, + }; + const somePresent = await agreeingRows(some, subjectOptionsWithTags); + const someAbsent = await agreeingRows( + { kind: "not", operand: some }, + subjectOptionsWithTags, + ); + expect(somePresent).not.toContain("unknown"); + expect(someAbsent).not.toContain("unknown"); + + const everyPresent = await agreeingRows(every, subjectOptionsWithTags); + const everyAbsent = await agreeingRows( + { kind: "not", operand: every }, + subjectOptionsWithTags, + ); + expect(everyPresent).not.toContain("unknown"); + expect(everyAbsent).not.toContain("unknown"); + }); + + it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { + // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. + const straddling: PredicateNode = { + kind: "some", + collection: "tags", + item: { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_LOW }, + }, + right: { + kind: "compare", + op: "lte", + left: { kind: "reference", key: "weight" }, + right: { kind: "numberLiteral", value: RANGE_HIGH }, + }, + }, + }; + await expect( + agreeingRows(straddling, subjectOptionsWithTags), + ).resolves.not.toContain("grace"); + }); + + it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const minWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 3 }, + }; + await expect( + agreeingRows(maxWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + await expect( + agreeingRows(minWeight, subjectOptionsWithTags), + ).resolves.toContain("grace"); + }); + + it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { + const maxWeight: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "fold", + collection: "tags", + combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, + }, + right: { kind: "numberLiteral", value: 9 }, + }; + const present = await agreeingRows(maxWeight, subjectOptionsWithTags); + const absent = await agreeingRows( + { kind: "not", operand: maxWeight }, + subjectOptionsWithTags, + ); + // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. + expect(present).not.toContain("ada"); + expect(present).not.toContain("unknown"); + expect(absent).not.toContain("ada"); + expect(absent).not.toContain("unknown"); + }); +}); + +describe("a tree deep enough to mix every supported kind", () => { + it("agrees with the evaluator row for row", async () => { + const node: PredicateNode = { + kind: "anyOf", + operands: [ + { + kind: "and", + left: { + kind: "compare", + op: "gte", + left: { kind: "reference", key: "age" }, + right: { kind: "numberLiteral", value: 18 }, + }, + right: { + kind: "not", + operand: { + kind: "memberOf", + op: "in", + operand: { kind: "reference", key: "name" }, + candidates: [{ kind: "textLiteral", value: "grace" }], + }, + }, + }, + { + kind: "allOf", + operands: [ + { kind: "exists", operand: { kind: "reference", key: "note" } }, + { + kind: "or", + left: { + kind: "textCompare", + op: "matches", + left: { kind: "reference", key: "note" }, + right: { kind: "textLiteral", value: "^h" }, + }, + right: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "active" }, + right: { kind: "booleanLiteral", value: false }, + }, + }, + ], + }, + ], + }; + + await expect( + agreeingRows(node, subjectOptionsWithPostgresRegexp), + ).resolves.toEqual(["ada", "grace"]); + }); +}); diff --git a/packages/trilean-sql/test/integration/pglite.test.ts b/packages/trilean-sql/test/integration/pglite.test.ts deleted file mode 100644 index aa16b7b..0000000 --- a/packages/trilean-sql/test/integration/pglite.test.ts +++ /dev/null @@ -1,825 +0,0 @@ -import { PGlite } from "@electric-sql/pglite"; -import type { - ComputedValue, - JsonValue, - PredicateNode, - Resolution, - Resolvers, -} from "trilean"; -import { evaluatePredicate } from "trilean"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { compilePredicateNode } from "../../src/compile"; -import type { SqlCompileOptions } from "../../src/options"; -import { - subjectOptions, - subjectOptionsWithPostgresRegexp, - subjectOptionsWithTags, -} from "../../src/test-support/columns"; - -/** - * The same measured claim as `postgres.test.ts`, against PGlite instead of a server in a container. - * - * PGlite is PostgreSQL itself compiled to WebAssembly and run in this process, not a reimplementation of it or a dialect of its own, so the compiler needs no `dialect` value for it: every construct emitted under `dialect: "postgres"` -- `$N::type` casts, `~`/`!~`, double-quoted identifiers, `NULL::boolean` -- is parsed by the same parser and evaluated by the same planner. That is the claim this file exists to establish rather than assume, which is why it is a full parity suite rather than a smoke test: each case compiles a tree, executes the fragment as a real `WHERE` clause, and compares the rows against the ones trilean's own evaluator judges `definite(true)` for the same tree, exactly as the container-backed suite does. - * - * Keeping it a near-verbatim structural copy is deliberate. A shared harness parameterised over both engines would make the two suites agree by construction, and agreement by construction is the one thing this cannot establish -- the point is that two independently-driven executions of the same compiled SQL reach the same rows. The divergence between the files is therefore confined to how a connection is opened and a statement is run. - * - * It also needs no Docker daemon, so unlike the container-backed suite it runs anywhere Node does. - */ - -const SCHEMA = ` - CREATE TABLE subjects ( - id text PRIMARY KEY, - age double precision, - name text, - active boolean, - joined timestamptz, - note text - ); -`; - -/** - * The one correlated child table the `some`/`every`/`fold` parity suite below needs, matching `subjectOptionsWithTags`'s own `collectionFor` mapping (`src/test-support/columns.ts`): a subject's own tags, each carrying an optional `weight`. `"subjectId"` is quoted throughout -- schema, insert, and the mapping's own `join` string -- so its declared case survives PostgreSQL's default unquoted-identifier folding. - */ -const TAGS_SCHEMA = ` - CREATE TABLE subject_tags ( - id text PRIMARY KEY, - "subjectId" text NOT NULL, - tag text, - weight double precision - ); -`; - -interface SubjectRow { - id: string; - age: number | null; - name: string | null; - active: boolean | null; - joined: string | null; - note: string | null; -} - -/** `null` in a column means the same thing as a reference that resolves to nothing: the value is not known. Every row below carries at least one, because a table of fully-populated rows would exercise none of what this suite exists to check. */ -const SUBJECTS: readonly SubjectRow[] = [ - { - id: "ada", - age: 30, - name: "ada", - active: true, - joined: "2020-01-01T00:00:00Z", - note: "hello", - }, - { - id: "grace", - age: 12, - name: "grace", - active: false, - joined: "2024-06-01T12:00:00Z", - note: "hi", - }, - { - id: "lin", - age: null, - name: "lin", - active: true, - joined: "2021-03-03T00:00:00Z", - note: null, - }, - { - id: "unknown", - age: 45, - name: null, - active: null, - joined: null, - note: null, - }, -]; - -interface TagRow { - id: string; - subjectId: string; - tag: string; - weight: number | null; -} - -/** - * Every subject's own tags, seeded to exercise a distinct shape of participation each: `ada` has none (the empty-collection case); `grace`'s two weights (3, 9) straddle the `[RANGE_LOW, RANGE_HIGH]` window used below without either one falling inside it, and one alone fails `MATCH_THRESHOLD` while the other passes; `lin` pairs one clean, participating vote with one whose `weight` is unknown; `unknown` has a single tag whose `weight` is unknown, the sole-participant indeterminate case. - */ -const TAGS: readonly TagRow[] = [ - { id: "t1", subjectId: "grace", tag: "junior", weight: 3 }, - { id: "t2", subjectId: "grace", tag: "senior", weight: 9 }, - { id: "t3", subjectId: "lin", tag: "solo", weight: 9 }, - { id: "t4", subjectId: "lin", tag: "unsure", weight: null }, - { id: "t5", subjectId: "unknown", tag: "pending", weight: null }, -]; - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Resolves a reference key against one row, mapping a NULL column to `found: false`. - * - * That mapping is the correspondence the whole design rests on, and stating it in one place here is what makes the parity assertions below meaningful: the evaluator is being given exactly the knowledge PostgreSQL has about the same row, so any disagreement between them is the compiler's, not the fixture's. - * - * `resolveValue` also has to answer for a reference *inside* a `some`/`every`/`fold` item or filter, where `context` is the collection item itself (a plain `{ tag, weight }` object, mirroring how `evaluatePredicate` re-points its own `EvaluationContext` there) rather than the outer subject row -- `context !== undefined` is what tells the two apart, since the root evaluation is always called with `context: undefined`. - */ -function resolversFor(row: Readonly): Resolvers { - const known: Record = { - ...(row.age !== null && { - age: { kind: "number", value: row.age }, - }), - ...(row.name !== null && { name: { kind: "text", value: row.name } }), - ...(row.note !== null && { note: { kind: "text", value: row.note } }), - ...(row.active !== null && { - active: { kind: "boolean", value: row.active }, - }), - ...(row.joined !== null && { - joined: { kind: "instant", value: row.joined }, - }), - }; - - return { - resolveValue: async (key: JsonValue, context: unknown) => { - if (context !== undefined) { - if (typeof key !== "string" || !isPlainRecord(context)) { - return Promise.resolve({ found: false }); - } - const value = context[key]; - if (typeof value === "number") { - return Promise.resolve({ - found: true, - value: { kind: "number", value }, - }); - } - if (typeof value === "string") { - return Promise.resolve({ - found: true, - value: { kind: "text", value }, - }); - } - return Promise.resolve({ found: false }); - } - const value = typeof key === "string" ? known[key] : undefined; - return Promise.resolve( - value === undefined ? { found: false } : { found: true, value }, - ); - }, - resolveLookup: () => { - throw new Error("no tree in this suite uses a lookup"); - }, - resolveCollection: async (collection: JsonValue) => { - if (collection !== "tags") return Promise.resolve([]); - return Promise.resolve( - TAGS.filter((tagRow) => tagRow.subjectId === row.id).map((tagRow) => ({ - tag: tagRow.tag, - weight: tagRow.weight, - })), - ); - }, - }; -} - -let db: PGlite; - -beforeAll(async () => { - // No connection string, no port, no container: an in-memory database that exists for the lifetime of this process. - db = new PGlite(); - await db.exec(SCHEMA); - await db.exec(TAGS_SCHEMA); - for (const row of SUBJECTS) { - await db.query( - "INSERT INTO subjects (id, age, name, active, joined, note) VALUES ($1, $2, $3, $4, $5, $6)", - [row.id, row.age, row.name, row.active, row.joined, row.note], - ); - } - for (const tagRow of TAGS) { - await db.query( - `INSERT INTO subject_tags (id, "subjectId", tag, weight) VALUES ($1, $2, $3, $4)`, - [tagRow.id, tagRow.subjectId, tagRow.tag, tagRow.weight], - ); - } -}); - -afterAll(async () => { - await db.close(); -}); - -async function selectMatching( - node: PredicateNode, - options: Readonly = subjectOptions, -): Promise { - const compiled = compilePredicateNode(node, options); - const result = await db.query<{ id: string }>( - `SELECT id FROM subjects WHERE ${compiled.sql} ORDER BY id`, - compiled.params, - ); - return result.rows.map((row) => row.id); -} - -async function evaluatorMatching(node: PredicateNode): Promise { - const matched: string[] = []; - for (const row of SUBJECTS) { - const evaluation = await evaluatePredicate( - node, - undefined, - resolversFor(row), - ); - if (evaluation.status === "definite" && evaluation.value) { - matched.push(row.id); - } - } - return matched.sort(); -} - -/** Runs the tree both ways and asserts they agree, then hands back the row set so a case can also state what that set should be. Agreement alone would be satisfied by both being wrong in the same way, so every caller asserts the expected ids too. `options` defaults to `subjectOptions`; a case exercising `matches`/`notMatches` passes `subjectOptionsWithPostgresRegexp`, since pushdown of those two is refused by default. */ -async function agreeingRows( - node: PredicateNode, - options: Readonly = subjectOptions, -): Promise { - const [viaSql, viaEvaluator] = await Promise.all([ - selectMatching(node, options), - evaluatorMatching(node), - ]); - expect(viaSql).toEqual(viaEvaluator); - return viaSql; -} - -describe("comparisons against a column that can be NULL", () => { - const olderThan18: PredicateNode = { - kind: "compare", - op: "gt", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }; - - it("excludes the row whose age is unknown", async () => { - await expect(agreeingRows(olderThan18)).resolves.toEqual([ - "ada", - "unknown", - ]); - }); - - it("still excludes it under negation, which two-valued logic could not do", async () => { - // The load-bearing case. Under two-valued logic every row appears in exactly one of a predicate and its negation, so `lin` would have to turn up here. It does not, in either engine: NOT UNKNOWN is UNKNOWN in PostgreSQL exactly as `not(indeterminate)` is indeterminate in trilean. - await expect( - agreeingRows({ kind: "not", operand: olderThan18 }), - ).resolves.toEqual(["grace"]); - }); - - it("keeps a row whose unknown comparison is absorbed by a true disjunct", async () => { - await expect( - agreeingRows({ - kind: "anyOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "lin" }, - }, - ], - }), - ).resolves.toEqual(["ada", "lin", "unknown"]); - }); - - it("collapses an unknown conjunct absorbed by a false one, observably under negation", async () => { - // `unknown AND false` has to be FALSE rather than UNKNOWN, and the difference only shows through a NOT: a genuinely FALSE conjunction negates to TRUE and the row appears, whereas an UNKNOWN one would negate to UNKNOWN and it would not. `lin` appearing here is that absorption being exercised. - await expect( - agreeingRows({ - kind: "not", - operand: { - kind: "allOf", - operands: [ - olderThan18, - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "nobody" }, - }, - ], - }, - }), - ).resolves.toEqual(["ada", "grace", "lin"]); - }); - - it("compares instants across a NULL", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "reference", key: "joined" }, - right: { kind: "instantLiteral", value: "2022-01-01T00:00:00Z" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("compares booleans for equality across a NULL", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: true }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); -}); - -describe("exists", () => { - const hasNote: PredicateNode = { - kind: "exists", - operand: { kind: "reference", key: "note" }, - }; - - it("partitions the table, because it is the one predicate neither engine leaves unknown", async () => { - const present = await agreeingRows(hasNote); - const absent = await agreeingRows({ kind: "not", operand: hasNote }); - expect(present).toEqual(["ada", "grace"]); - expect(absent).toEqual(["lin", "unknown"]); - expect([...present, ...absent].sort()).toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - }); -}); - -describe("textCompare", () => { - it("matches a pattern with PostgreSQL's own regular-expression operator, once postgresRegexpPushdown opts into it", async () => { - await expect( - agreeingRows( - { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(a|g)" }, - }, - subjectOptionsWithPostgresRegexp, - ), - ).resolves.toEqual(["ada", "grace"]); - }); - - it("leaves a NULL operand unknown under a negated match, once postgresRegexpPushdown opts into it", async () => { - await expect( - agreeingRows( - { - kind: "textCompare", - op: "notMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }, - subjectOptionsWithPostgresRegexp, - ), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("refuses 'matches' by default, falling back to in-process evaluation", () => { - expect(() => - compilePredicateNode( - { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(a|g)" }, - }, - subjectOptions, - ), - ).toThrow(/postgresRegexpPushdown/); - }); -}); - -/** - * The same equivalence claim as `postgres.test.ts`'s own `portableMatches`/`portableNotMatches` suite, against PGlite instead of a server in a container -- see this file's own top-of-file comment for why keeping the two near-verbatim, rather than sharing a parameterised harness, is what actually establishes agreement rather than assuming it. - */ -describe("portableMatches/portableNotMatches (trilean-regex, translated to PostgreSQL's own syntax)", () => { - it("matches a start-anchored literal prefix", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^gr" }, - }), - ).resolves.toEqual(["grace"]); - }); - - it("matches a bounded-repetition character class", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^[a-z]{3,4}$" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("matches an alternation", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^(?:ada|lin)$" }, - }), - ).resolves.toEqual(["ada", "lin"]); - }); - - it("portableNotMatches is the negation, and still leaves an unresolved name unknown", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "portableNotMatches", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "^a" }, - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("PGlite's own bare '.' already matches a newline under '~', exactly like trilean-regex's own '.', so this compiler translates it unchanged", async () => { - const result = await db.query<{ result: boolean }>( - "SELECT ($1::text ~ $2::text) AS result", - ["a\nc", "a.c"], - ); - expect(result.rows[0]?.result).toBe(true); - }); -}); - -describe("memberOf", () => { - it("matches a candidate list", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [ - { kind: "textLiteral", value: "ada" }, - { kind: "textLiteral", value: "nobody" }, - ], - }), - ).resolves.toEqual(["ada"]); - }); - - it("leaves NOT IN unknown for a NULL operand", async () => { - await expect( - agreeingRows({ - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "ada" }], - }), - ).resolves.toEqual(["grace", "lin"]); - }); - - it("compiles an empty 'in' to something that is false, not unknown, for a known operand", async () => { - // Executed rather than asserted as a string, because `(x IS NULL AND NULL::boolean)` is only the right encoding if PostgreSQL really does evaluate it to FALSE for a known operand and NULL for an unknown one. Negating it is what separates those two outcomes: only the rows with a known name come back. - const node: PredicateNode = { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual([]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - ["ada", "grace", "lin"], - ); - }); - - it("compiles an empty 'notIn' to something that is true, not unknown, for a known operand", async () => { - const node: PredicateNode = { - kind: "memberOf", - op: "notIn", - operand: { kind: "reference", key: "name" }, - candidates: [], - }; - await expect(agreeingRows(node)).resolves.toEqual(["ada", "grace", "lin"]); - await expect(agreeingRows({ kind: "not", operand: node })).resolves.toEqual( - [], - ); - }); -}); - -describe("degenerate and adversarial fragments", () => { - it("executes a comparison between two literals, which needs both placeholders typed", async () => { - await expect( - agreeingRows({ - kind: "compare", - op: "lt", - left: { kind: "numberLiteral", value: 1 }, - right: { kind: "numberLiteral", value: 2 }, - }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - }); - - it("executes an empty allOf and anyOf as their identities", async () => { - await expect( - agreeingRows({ kind: "allOf", operands: [] }), - ).resolves.toEqual(["ada", "grace", "lin", "unknown"]); - await expect( - agreeingRows({ kind: "anyOf", operands: [] }), - ).resolves.toEqual([]); - }); - - it("refuses NaN, and measures the divergence that refusal exists to prevent", async () => { - // The refusal is in the guard, so this could have been a unit test -- but a unit test could only assert that NaN is refused, not that refusing it was right. What makes it right is a fact about PostgreSQL: it defines NaN as equal to itself, so `NaN = NaN` there is TRUE and would have selected the whole table, while trilean's `===` makes the same tree match nothing. Both halves are measured here, and the first of them is a fact about PGlite worth measuring separately from the server: a WASM build could in principle have shipped a different float comparison, and a driver that could not bind NaN at all -- as better-sqlite3 cannot -- would substitute NULL and produce the opposite row set rather than the same one. - const node: PredicateNode = { - kind: "compare", - op: "eq", - left: { kind: "numberLiteral", value: Number.NaN }, - right: { kind: "numberLiteral", value: Number.NaN }, - }; - expect(() => compilePredicateNode(node, subjectOptions)).toThrow( - /cannot compile 'numberLiteral'/, - ); - - const wouldHaveMatched = await db.query<{ id: string }>( - "SELECT id FROM subjects WHERE ($1::double precision = $2::double precision) ORDER BY id", - [Number.NaN, Number.NaN], - ); - expect(wouldHaveMatched.rows.map((row) => row.id)).toEqual( - SUBJECTS.map((row) => row.id).sort(), - ); - await expect(evaluatorMatching(node)).resolves.toEqual([]); - }); - - it("treats an injection attempt as data and leaves the table standing", async () => { - await expect( - agreeingRows({ - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { - kind: "textLiteral", - value: "ada'; DROP TABLE subjects; --", - }, - }), - ).resolves.toEqual([]); - - const surviving = await db.query<{ count: string }>( - "SELECT count(*)::text AS count FROM subjects", - ); - expect(surviving.rows[0]?.count).toBe(String(SUBJECTS.length)); - }); - - it("neutralises a hostile column name into one identifier the server rejects", async () => { - // A column name is the one caller-supplied string that has to reach the SQL text, so quoting is what makes it safe rather than parameterisation. Asserting the quoted string is not the same as establishing that PostgreSQL reads it as a single inert identifier: this executes it, and the server refusing it as a column that does not exist is the proof. The failure it rules out is the opposite outcome -- the injected `OR` taking effect and the fragment matching every row. - const hostile: SqlCompileOptions = { - dialect: "postgres", - columnFor: () => ({ column: `name" = name OR "1` }), - }; - const compiled = compilePredicateNode( - { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "name" }, - right: { kind: "textLiteral", value: "ada" }, - }, - hostile, - ); - expect(compiled.sql).toBe(`("name"" = name OR ""1" = $1::text)`); - - await expect( - db.query( - `SELECT id FROM subjects WHERE ${compiled.sql}`, - compiled.params, - ), - ).rejects.toThrow(/does not exist/i); - }); -}); - -describe("some/every/fold over a correlated collection", () => { - const MATCH_THRESHOLD = 5; - const RANGE_LOW = 6; - const RANGE_HIGH = 8; - - const weightAboveThreshold: PredicateNode = { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: MATCH_THRESHOLD }, - }; - - it("some: true the moment one participating tag among several votes true", async () => { - // grace's two tags are 3 (fails) and 9 (passes); a clean true vote wins outright. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("grace"); - }); - - it("every: false the moment one participating tag among several votes false", async () => { - // The same two tags fail `every`: junior's weight of 3 is a definite false vote regardless of senior's true one. - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.not.toContain("grace"); - }); - - it("filter narrows which tags participate before item is even evaluated", async () => { - // Filtered to only the 'senior' tag, junior's own failing weight never gets a vote at all -- grace passes `every` here even though it fails the unfiltered version above. - const filtered: PredicateNode = { - kind: "every", - collection: "tags", - filter: { - kind: "textCompare", - op: "equals", - left: { kind: "reference", key: "tag" }, - right: { kind: "textLiteral", value: "senior" }, - }, - item: weightAboveThreshold, - }; - await expect( - agreeingRows(filtered, subjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("some/every over an empty collection reduce to each connective's own identity", async () => { - // ada has no tags at all: the empty-participating-set case, matching some/every's own anyOf/allOf-style fold identities. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.not.toContain("ada"); - await expect( - agreeingRows( - { kind: "every", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("ada"); - }); - - it("some absorbs an indeterminate vote from a NULL-weighted tag alongside a clean true vote", async () => { - // lin's tags are 9 (passes) and an unknown weight (indeterminate); the clean true vote absorbs the indeterminate one under OR. - await expect( - agreeingRows( - { kind: "some", collection: "tags", item: weightAboveThreshold }, - subjectOptionsWithTags, - ), - ).resolves.toContain("lin"); - }); - - it("some/every are indeterminate when the sole participating tag's weight is unknown", async () => { - // unknown's one tag has no weight at all: neither engine can vote, so the row is absent from both some and its negation, and likewise for every. - const some: PredicateNode = { - kind: "some", - collection: "tags", - item: weightAboveThreshold, - }; - const every: PredicateNode = { - kind: "every", - collection: "tags", - item: weightAboveThreshold, - }; - const somePresent = await agreeingRows(some, subjectOptionsWithTags); - const someAbsent = await agreeingRows( - { kind: "not", operand: some }, - subjectOptionsWithTags, - ); - expect(somePresent).not.toContain("unknown"); - expect(someAbsent).not.toContain("unknown"); - - const everyPresent = await agreeingRows(every, subjectOptionsWithTags); - const everyAbsent = await agreeingRows( - { kind: "not", operand: every }, - subjectOptionsWithTags, - ); - expect(everyPresent).not.toContain("unknown"); - expect(everyAbsent).not.toContain("unknown"); - }); - - it("a per-row AND inside 'item' rules out the and-over-range hazard a naive split translation would fall into", async () => { - // grace's weights (3, 9) straddle [RANGE_LOW, RANGE_HIGH] without either single tag satisfying both bounds at once -- a compiler that pushed 'gte' and 'lte' down as two separate correlated checks, rather than one combined boolean per row, would wrongly answer true here. - const straddling: PredicateNode = { - kind: "some", - collection: "tags", - item: { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_LOW }, - }, - right: { - kind: "compare", - op: "lte", - left: { kind: "reference", key: "weight" }, - right: { kind: "numberLiteral", value: RANGE_HIGH }, - }, - }, - }; - await expect( - agreeingRows(straddling, subjectOptionsWithTags), - ).resolves.not.toContain("grace"); - }); - - it("fold('max'/'min') aggregates the projected weight across participating tags", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const minWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "min", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 3 }, - }; - await expect( - agreeingRows(maxWeight, subjectOptionsWithTags), - ).resolves.toContain("grace"); - await expect( - agreeingRows(minWeight, subjectOptionsWithTags), - ).resolves.toContain("grace"); - }); - - it("fold('max') is indeterminate over an empty collection and over one whose sole participant is NULL", async () => { - const maxWeight: PredicateNode = { - kind: "compare", - op: "eq", - left: { - kind: "fold", - collection: "tags", - combiner: { mode: "max", item: { kind: "reference", key: "weight" } }, - }, - right: { kind: "numberLiteral", value: 9 }, - }; - const present = await agreeingRows(maxWeight, subjectOptionsWithTags); - const absent = await agreeingRows( - { kind: "not", operand: maxWeight }, - subjectOptionsWithTags, - ); - // ada (no tags at all) and unknown (one tag, unknown weight) can never resolve definitely either way. - expect(present).not.toContain("ada"); - expect(present).not.toContain("unknown"); - expect(absent).not.toContain("ada"); - expect(absent).not.toContain("unknown"); - }); -}); - -describe("a tree deep enough to mix every supported kind", () => { - it("agrees with the evaluator row for row", async () => { - const node: PredicateNode = { - kind: "anyOf", - operands: [ - { - kind: "and", - left: { - kind: "compare", - op: "gte", - left: { kind: "reference", key: "age" }, - right: { kind: "numberLiteral", value: 18 }, - }, - right: { - kind: "not", - operand: { - kind: "memberOf", - op: "in", - operand: { kind: "reference", key: "name" }, - candidates: [{ kind: "textLiteral", value: "grace" }], - }, - }, - }, - { - kind: "allOf", - operands: [ - { kind: "exists", operand: { kind: "reference", key: "note" } }, - { - kind: "or", - left: { - kind: "textCompare", - op: "matches", - left: { kind: "reference", key: "note" }, - right: { kind: "textLiteral", value: "^h" }, - }, - right: { - kind: "compare", - op: "eq", - left: { kind: "reference", key: "active" }, - right: { kind: "booleanLiteral", value: false }, - }, - }, - ], - }, - ], - }; - - await expect( - agreeingRows(node, subjectOptionsWithPostgresRegexp), - ).resolves.toEqual(["ada", "grace"]); - }); -});