feat(driver-sql): compile $field to column-to-column comparison (#5222) - #7582
Conversation
`FieldReferenceSchema` (`{ $field: 'col' }`) is declared in the spec and really
is produced — `compileCelToFilter` emits it for a field-to-field comparison in a
CEL permission/RLS rule — but its only implementation was the in-memory
evaluator. #5041 measured that and installed a loud refusal (INVALID_FILTER/400,
replacing a bare TypeError and a silent zero-row answer inside $in lists),
deliberately leaving the capability to this issue. Until now one permission rule
had two behaviours, chosen by whether the query reached a database.
The six scalar comparison operators now compile the reference to a real column
reference. The refusal gate is NARROWED, never removed — dot paths, undeclared
columns, the tenant-isolation column (both sides, because = commutes),
cross-class comparisons, list members and the string family all keep the
ADR-0112 envelope.
Every emitted predicate is written TOTAL across NULLs, so it agrees with the
two-valued in-memory evaluator rather than with three-valued SQL: both columns
NULL satisfies $eq, and $not over a cross-field leaf is its exact complement.
A cross-path conformance suite proves that row for row on both SQL drivers.
Closes #5222
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #5222
The second half of #5041.
FieldReferenceSchema({ $field: 'col' }) is declared in the spec and genuinely PRODUCED —compileCelToFilteremits it whenever a CEL permission/RLS rule compares one field to another — but its only implementation was the in-memory evaluator. #5041 measured that, replaced a bareTypeError(and, inside$inlists, a silent zero-row answer) with a loudINVALID_FILTER/ 400, and deliberately left the capability itself to this issue. Until now one permission rule had two behaviours, chosen by whether the query reached a database.What compiles now
The six scalar comparison operators —
$eq/$ne/$gt/$gte/$lt/$lte, including the array-triple authorings that lower to them — compile the reference to a real column reference (knex??identifier binding):Nothing that worked before changes. The refusal gate was narrowed, never removed.
The refused arm, and why each entry is there
All keep the ADR-0112 envelope (
INVALID_FILTER/ 400):account.owner_id)=commute — a ban one swap away is not a ban.multiple: true/ JSON columns, andformulafields (virtual — no column at all).$in/$nin/$betweenlist membersresolveValuereturns an array unchanged), so there is no correct semantics for SQL to be equivalent TO.%-matches-every-row bypass.{ field: { $field: 'other' } }parseFilterAST(['a', '=', { $field: 'b' }])lowers to. The memory evaluator answersfalsefor it rather than reading it as an equality, so compiling it would open a divergence in the change that closes one. The message now names$eqinstead of falling through to a generic operator list.Equivalence is proven, not asserted
A cross-path conformance suite runs each supported shape through
matchesFilterConditionand through SQL push-down against the same seeded rows, holding both to the same declared id list — a third, independent statement of the semantics, so a case fails loudly when both paths drift together.The NULL rows are the point. Three-valued SQL against a two-valued JS matcher is the one place these paths can genuinely diverge, so the fixture carries every NULL arrangement two columns can be in: target NULL, referent NULL, and BOTH NULL. Every emitted predicate is therefore written TOTAL —
{ a: { $eq: { $field: 'b' } } }matches a row where both columns are NULL (a plaina = bdrops it), and$notover any cross-field leaf is its exact complement, so the #5146 negation rewrite needs no guard.Coverage: both SQL drivers (
driver-sqlite-wasminherits the compiler but executes through its own sql.js dialect, which binds the identifier list itself), across the full driver axis — SQLite always, live Postgres and MySQL when provisioned.Reverse verification
Predicted in writing before running, then measured:
not a declared fieldA = Bfor$eq$eq, self-reference,$not $eq,$or, De Morgan red; memory assertion stays greenThe last one corrected the change. Measuring it showed the divergence is directional and the corpus had tested the wrong direction:
{ stage: { $gt: { $field: 'amount' } } }(text target, numeric referent) returns four rows on SQLite and none in memory — SQLite orders by storage class, so every TEXT sorts above every INTEGER, while JS coerces'won' > 10to a NaN comparison. The mirrored spelling happens to agree. The divergent direction is now the corpus's headline case, and the code comment states which cell was measured divergent instead of claiming all of them are.Notes for review
packages/spec/src/data. That directory is the (driver × case-set) matrixcheck-driver-conformance.mjsscores, and every case-set there obliges every driver to import it or carry a ledger entry — promoting it would enroldriver-memory,driver-mongodbanddriver-tursoREMOTE as DEBT for a capability this issue's ruling scoped to SQL push-down. It is exported from@objectstack/driver-sqlinstead, which is also what letsdriver-sqlite-wasmrun the identical corpus.@objectstack/formulais added as a devDependency of both driver packages — the cross-path suite needs the reference evaluator. No runtime dependency added.service-analytics'sread-scope-sql/filter-normalizerare independent emitters with their own mirrored comparand gate and still refuse$field. Out of scope here (this issue's rulings are about the driver), but it means a CEL rule lowered through the analytics faces still gets a 400 — reported to the PM for its own card.Generated by Claude Code