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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/filter-null-semantics-ruled-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": patch
---

docs(spec): record the ruled EXCLUDE direction for negative operators over no-value rows (#5299)

`FILTER_LOGIC_CASES` is a published contract — its header tells third-party
driver authors it is "the single source of truth every filter backend is checked
against". Since 2026-08-10 that header has been telling them something the
maintainer has superseded, so this change makes the published document say what
is ruled as well as what is enforced.

**The ruling (#5299, 2026-08-10).** SQL's native three-valued logic is the common
denominator, and both JS evaluators align to it:

> **Negative operators never match no-value rows; the only ways to select "no
> value" are `$exists: false` / `$null: true`.**

Concretely: `$notContains` on a value-less field does not match, `$exists` means
"has a value" (`!= null`) and never key-presence, and `$nin` on a no-value field
does not match.

**No behaviour moves in this change, and the header now says why.** The ruled
direction reverses the one #5298 shipped and #5146 ruled before it — the same
direction the four enrolled `d`-column cases currently enforce, and the direction
every SQL compiler in the repo was deliberately bent to in #5298 (`nullSafeNegative`,
plus four independent copies of `nullValueSatisfiesOperator` answering `$nin` →
true and `$notContains` → true). A new "family 4" note in the header carries the
eleven-surface measurement of that gap, taken by adding the candidate rows to the
table and running every suite that drives it.

It also records the two things that block enrolment, both measured rather than
argued:

- the DEBT ledger in `scripts/check-driver-conformance.mjs` is per
(driver × case-set), not per case — there is no way to spell "this driver fails
one row", so a row added ahead of a backend is just a red gate;
- two of the five scored drivers (`driver-memory`, `driver-mongodb`) are inside
the #5499 investment freeze, and both answer the include direction on their live
query paths.

The pin tests in `@objectstack/formula` and `@objectstack/driver-memory` are
re-annotated to match, and the formula-side pins now assert the non-negated
`$notContains` / `$nin` row sets explicitly, so the cross-backend PR that lands
the ruled semantics has to move them deliberately. One stale claim is corrected
while doing it: `driver-memory`'s pin said `formula` reads `$exists` as
key-presence, which stopped being true in PR #5962.
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,23 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
* with the identical matcher-vs-formula divergence already filed as **#5299**,
* where this measurement is recorded. Pinned as measured so the fix that lands
* there has to move these lines deliberately.
*
* ⚠️ [#5299, ruled 2026-08-10] The ruling is in, and it says the REFERENCE
* column below is the target on all three rows: SQL three-valued logic is the
* common denominator, so **negative operators never match no-value rows; the
* only ways to select "no value" are `$exists: false` / `$null: true`.** The
* `live` column is therefore the side that is wrong on every row here —
* mingo's `$exists` is key-presence, and its `$nin` / `$notContains` match a
* value that is not there.
*
* ⛔ Still not flipped, and by decision rather than by difficulty: this package
* is inside the #5499 investment freeze. Note also what the ruling assumed and
* this file disproves — it says "driver-memory already reads has-value" and
* "driver-memory and SQL already agree", which is true of the reference
* matcher and FALSE of the live query path users actually reach. That is the
* reason this pin exists.
*/
describe('known two-face divergences on a value-less fieldpinned, see #5299', () => {
describe('[#5299] the ruled cells, live vs referencebehaviour frozen (#5499)', () => {
const liveVsReference = async (where: unknown) => ({
live: await idsFrom(nulled, where),
reference: NULLED.filter((r) => match(r, where)).map((r) => r.id),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,30 +128,68 @@ describe('[#5146] memory-matcher — $not over records with no value', () => {
});
});

// ── Where this matcher and `formula` disagree — pinned, not harmonised ─────

describe('known disagreements with formula.matchesFilterCondition (NOT ruled on by #5146)', () => {
// ── The three cells #5299 ruled on — behaviour FROZEN, annotation current ──

/**
* [#5299, ruled 2026-08-10] These three cells were filed as "known
* disagreements with `formula`, not ruled on by #5146". They are ruled now:
* SQL's native three-valued logic is the common denominator, so **negative
* operators never match no-value rows; the only ways to select "no value" are
* `$exists: false` / `$null: true`.**
*
* ⛔ Nothing below is flipped, and the reason is not inertia. This package is
* inside the #5499 investment freeze, and the ruling itself says
* `checkCondition`'s early-exit guard STAYS AS IT IS. What the ruling changed
* is the annotation: the section is no longer "a divergence nobody has ruled
* on", it is "a ruled target, with this matcher's distance from it measured".
*
* Re-measured on `60f0dd8`, because the old wording had gone stale in a way
* that mattered — it named `formula` as the key-presence reader on `$exists`,
* and `formula` stopped being that in PR #5962 (#5298 ③ / #5369). Where each
* cell actually stands:
*
* `$exists` CONVERGED, and this matcher was already right. Both
* evaluators read "has a value"; the formula-side assertion
* lives in `matches-filter-not-null-safe.test.ts`. Kept here
* because the two OTHER faces of this package — the live
* mingo query path and the analytics face — still read
* key-presence, so the package disagrees with itself and this
* test is the face that is correct.
* `$notContains` This matcher ALREADY answers the ruled semantics; every
* other surface in the repo (including all four SQL
* compilers, deliberately, via #5298's `nullSafeNegative`)
* answers the opposite. Here the gap is theirs, not ours.
* `$nin` HALF right: a missing key already does not match, a
* present-but-null value still does. The ruled answer is "no"
* for both. Frozen at this state.
*/
describe('[#5299] the ruled no-value cells — target recorded, behaviour frozen (#5499)', () => {
it('$nin: an ABSENT field is treated differently from a null one', () => {
// The early `value === undefined` guard in `checkCondition` exempts only
// `$exists` / `$ne` / `$null`, so an absent field fails `$nin` outright
// while a null field passes it. `formula` answers "not among" for both.
// Pinned as measured; the ruling belongs to the issue that records it.
// while a null field passes it. The ruling keeps this guard; the NULL half
// is the part still short of the ruled answer, and it is frozen.
expect(ids(NULLED, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']);
expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1', '3', '4']);
});

it('$notContains: a value-less field does NOT satisfy it here', () => {
// `typeof null !== 'string'` → false, so the negation matches. `formula`
// answers true for the same record, and `driver-sql` follows `formula`.
it('$notContains: a value-less field does NOT satisfy it here — the RULED answer', () => {
// `typeof null !== 'string'` → false, so the negation matches. This is
// what #5299 ruled canonical. `formula` and all four SQL compilers answer
// the opposite today; moving them is a cross-backend programme, not a
// change to this file.
expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1', '3', '4']);
});

it('$exists: a present-but-null field counts as NOT existing here', () => {
// `formula` reads `$exists` as "the key is present" (a null value exists);
// this matcher reads it as "has a value". Same answer for an absent field,
// different for a null one.
it('$exists: a present-but-null field counts as NOT existing here — CONVERGED', () => {
// Both readings of "no value" answer alike, and `formula` now agrees:
// "has a value", the strict mirror of `$null` (#5298 ③ / #5369, PR #5962).
// No longer a disagreement — the assertion is kept because this package's
// other two filter faces still read key-presence.
expect(ids(NULLED, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']);
expect(ids(MISSING, { $not: { stage: { $exists: true } } })).toEqual(['3', '4']);
expect(ids(NULLED, { stage: { $exists: true } })).toEqual(['1', '2']);
expect(ids(NULLED, { stage: { $exists: true } })).toEqual(ids(NULLED, { stage: { $null: false } }));
});
});
});
63 changes: 61 additions & 2 deletions packages/formula/src/matches-filter-not-null-safe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,12 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', (

it('$not of $notContains does NOT match them — the mirror case', () => {
// A value-less field satisfies `$notContains` here, so the negation
// rejects it. `driver-sql` follows this answer; `driver-memory` answers
// the opposite for a null-valued field, which is filed on its own.
// rejects it. `driver-sql` follows this answer; `driver-memory`'s
// REFERENCE matcher answers the opposite for a null-valued field.
//
// ⚠️ [#5299, ruled 2026-08-10] This is the SUPERSEDED direction — see the
// block at the bottom of this file for the ruled target and the measured
// reason nothing has moved yet.
expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']);
});

Expand Down Expand Up @@ -182,4 +186,59 @@ describe('[#5146] matchesFilterCondition — $not over records with no value', (
}
});
});

// ── The NON-negated negatives — pinned against a ruling that has not landed ─

/**
* [#5299, ruled 2026-08-10] The maintainer took SQL's native three-valued
* logic as the common denominator: **negative operators never match no-value
* rows; the only ways to select "no value" are `$exists: false` /
* `$null: true`.** Under that rule this evaluator answers `['2']` below.
*
* It answers `['2','3','4']`, and that is pinned here rather than fixed,
* because flipping it ALONE would re-open the exact hole PR #5962 closed. That
* PR converged `formula` (the RLS write-side `check`) and `read-scope-sql`
* (the read-side lowering) in ONE change precisely because they are
* security-coupled: one policy string must not admit two row sets. Every SQL
* face still emits `nullSafeNegative` for these two operators
* (`col IS NULL OR col NOT IN (…)`), so a formula-only flip would make an RLS
* `check` DENY a write on a null field that the read scope still RETURNS —
* #5962's defect with the sign reversed.
*
* So these assertions are load-bearing in both directions. They say what this
* evaluator does today, and they are the tripwire the cross-backend PR must
* step on: whoever lands the ruled semantics changes these lines DELIBERATELY,
* in the same PR that moves `driver-sql`, `read-scope-sql`, `filter-normalizer`
* and `driver-turso`'s remote transport — not one evaluator at a time.
*
* The full eleven-surface measurement and the enrolment blocker (the
* conformance ledger has no per-row DEBT, and two of the five scored drivers
* are inside the #5499 freeze) are recorded on family 4 in
* `@objectstack/spec`'s `filter-logic-conformance.ts` header.
*/
describe('[#5299] $notContains / $nin over a value-less field — the pre-ruling answer', () => {
it('$notContains MATCHES a value-less field — ruled target is that it must NOT', () => {
expect(matched({ stage: { $notContains: 'w' } })).toEqual(['2', '3', '4']);
});

it('$nin MATCHES a value-less field — ruled target is that it must NOT', () => {
expect(matched({ stage: { $nin: ['won'] } })).toEqual(['2', '3', '4']);
});

it('$ne answers identically — one family, and the reason a partial flip is incoherent', () => {
// `$nin` is the list form of `$ne`, and `$ne` is ENROLLED in
// `FILTER_LOGIC_CASES` asserting exactly this row set. Moving `$nin`
// without `$ne` splits this evaluator against itself and against the gate.
expect(matched({ stage: { $ne: 'won' } })).toEqual(['2', '3', '4']);
expect(matched({ stage: { $nin: ['won'] } })).toEqual(matched({ stage: { $ne: 'won' } }));
});

it('the ruled ESCAPE HATCH already works, in both directions', () => {
// Whatever happens to the three cells above, the rule's second half is
// already true here: "no value" is selectable, precisely, today.
expect(matched({ stage: { $exists: false } })).toEqual(['3', '4']);
expect(matched({ stage: { $null: true } })).toEqual(['3', '4']);
expect(matched({ stage: { $exists: true } })).toEqual(['1', '2']);
});
});
});
Loading
Loading