Skip to content

trilean-sql: compile some/every/fold(max|min) over a correlated table - #48

Merged
Mearman merged 12 commits into
mainfrom
feat/trilean-sql-quantifiers
Sep 15, 2026
Merged

Mearman merged 12 commits into
mainfrom
feat/trilean-sql-quantifiers

Conversation

@Mearman

@Mearman Mearman commented Sep 15, 2026

Copy link
Copy Markdown
Member

Closes #47.

Adds a collectionFor option (alongside the existing columnFor) that maps a some/every/fold node's collection key onto a correlated table -- { table, join, columnFor }, where join is caller-authored raw SQL relating one row of the table to the outer row (a plain FK, a composite key, or an FK plus a literal discriminator for the EAV case the linked issue is actually about) and the collection-level columnFor resolves references inside that collection's own item/filter.

some/every compile to a correlated subquery that aggregates each participating row's own vote with MAX/CASE, built to match the evaluator's OR/AND-fold absorption exactly rather than a plain EXISTS/NOT EXISTS -- a naive two-valued EXISTS translation would collapse SQL's NULL handling into boolean logic and disagree with evaluatePredicate the moment a row's filter or item is itself indeterminate. fold(max|min) compiles the same correlated subquery aggregated with MAX/MIN, going NULL the instant any participating row is indeterminate. fold(reduce) stays refused -- it threads an arbitrary combine expression through the collection in whatever order the caller's own resolver returns items, which has no general SQL translation.

One thing not in the original issue write-up: fold(max|min) also refuses a projected item that's statically known to be text or boolean. trilean's own compareValues refuses to order either past a single item, but SQL's MAX/MIN will happily order text lexicographically or booleans as 0/1 the moment two or more rows participate -- and since a collection's real row count is only known at query time, there's no way to prove in advance that only one row will ever show up, so it's refused unconditionally rather than only when a fixed pair of operands proves the mismatch (the same thing compare already does for a boolean gt/lt).

Every case is measured against a real connection, not just asserted as a string: added a shared subject_tags fixture across all three integration suites (Postgres via testcontainers, PGlite, better-sqlite3) seeded to cover an empty collection, a subject where every tag passes a threshold, one where only some do, one pairing a clean vote with a tag whose weight is unknown, and one whose sole tag has an unknown weight -- then run the compiled SQL and evaluatePredicate side by side and check they agree row for row, including the and-over-range case a naive per-column split (rather than one combined boolean per row) would get wrong.

prepublishOnly is green (lint, typecheck, unit + integration across all three dialects, build, publint, attw).

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-15T15:33:11.293921Z c8d6677 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…rences

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.
…onFor 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.
…ity 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.
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.
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.
…nslation

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.
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.
…r 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.
…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.
… 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.
…er 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.
… 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.
@Mearman
Mearman force-pushed the feat/trilean-sql-quantifiers branch from c8d6677 to 4952b3e Compare September 15, 2026 15:57
@Mearman
Mearman merged commit a08804e into main Sep 15, 2026
14 checks passed
@Mearman
Mearman deleted the feat/trilean-sql-quantifiers branch September 15, 2026 16:00
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in trilean-sql@2.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trilean-sql: some/every (and fold) have no SQL compilation -- no way to express a correlated/EAV collection reference

1 participant