Skip to content

Commit 178325b

Browse files
os-zhuangclaude
andauthored
fix(driver-memory): a no-value row satisfies $nin / $notContains in the reference matcher (#13356)
The reference matcher `driver-sql` was aligned TO for #5146 diverged from the platform's settled INCLUDE direction in 3 of 6 measured cells. Two independent causes, one per reading of "no value": 1. `checkCondition`'s pre-switch guard allowlisted `$ne` but not `$nin` / `$notContains`, so a MISSING key short-circuited to "no match" before those arms ran. 2. The `$notContains` arm rejected a `null` on its `typeof value !== 'string'` TYPE test rather than on the predicate — the half the guard cannot reach. Both now answer one named predicate, `noValueSatisfiesNegation`. The four in-code statements that spelled the #5499 freeze as LIVE and used it to excuse the divergence are settled together with the disposition rather than re-tensed: the freeze dissolved 2026-08-11, and the "driver-mongodb does this too" clause was never true for this operator family. `$exists` is untouched — it is the neighbouring cell with a different backend list. Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3f54efd commit 178325b

7 files changed

Lines changed: 367 additions & 67 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
fix(driver-memory): a no-value row satisfies `$nin` / `$notContains` in the reference matcher (#13166)
6+
7+
`memory-matcher`'s `match()` — the reference face `driver-sql` was aligned TO for
8+
#5146 — diverged from the platform's settled answer in 3 of 6 measured cells. The
9+
ruling is the INCLUDE direction: a row whose field has no value SATISFIES a
10+
negation-carrying operator (`$ne` / `$nin` / `$notContains`). That is #5146
11+
extended by #5298 option A, re-affirmed on 2026-08-10 after the reversal was
12+
priced and withdrawn.
13+
14+
"No value" has two readings and the divergence had two INDEPENDENT causes, one
15+
reachable from each:
16+
17+
- a MISSING key short-circuited to "no match" in `checkCondition`'s pre-switch
18+
guard, whose allowlist named `$ne` but not `$nin` / `$notContains`;
19+
- a `null` value failed the `$notContains` arm on its `typeof value !== 'string'`
20+
TYPE test rather than on the predicate — which the guard above cannot reach.
21+
22+
Both are fixed, and both now answer one named predicate rather than two spellings
23+
of one ruling.
24+
25+
**Grading — what does NOT change.** `InMemoryDriver.find()` is unaffected:
26+
`match()` is not part of this package's export surface, and the live mingo query
27+
path users actually reach already answered the include direction (measured on the
28+
card's fixture: `['2','3']` for all three operators, before and after). Nothing
29+
moved on the SQL side either — `driver-sql` (2241 tests) and `formula` (643) are
30+
untouched and green, because this change moves `driver-memory` TO the answer they
31+
already gave. The observable effect is on the reference face itself: the
32+
package's two filter faces now agree for these operators where they used to
33+
disagree.
34+
35+
`$exists` is deliberately NOT included — it is the neighbouring cell (#13195),
36+
with a different backend list, and remains a pinned divergence.

packages/drivers/driver-memory/src/memory-driver-document-not.test.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -208,28 +208,41 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
208208
*
209209
* `$exists` REFERENCE is correct. `$exists` means "has a value"
210210
* (#5298 ③ / #5369, PR #5962), so mingo's key-presence
211-
* reading is the divergent one.
212-
* `$nin` LIVE is correct. Negative operators MATCH no-value rows —
211+
* reading is the divergent one. STILL OPEN — #13195.
212+
* `$nin` LIVE was correct. Negative operators MATCH no-value rows —
213213
* #5146, extended by #5298, re-affirmed 2026-08-10 — so a
214214
* missing key satisfying `$nin` is the affirmed answer, and
215-
* the reference matcher's early-exit guard is the divergence.
216-
* `$notContains` LIVE is correct, for the same reason.
215+
* the reference matcher's early-exit guard was the
216+
* divergence. CLOSED by #13166: the guard now exempts the
217+
* negation-carrying operators, and the two faces agree.
218+
* `$notContains` LIVE was correct, for the same reason. CLOSED by #13166,
219+
* through a SECOND and independent cause — the arm's
220+
* `typeof value !== 'string'` test, which rejected a `null`
221+
* on its type rather than on the predicate.
217222
*
218223
* A ruling that morning (07:33Z) would have made the REFERENCE column the
219224
* target on all three rows. Cells 1 and 3 of it were WITHDRAWN the same day,
220225
* once the reversal's cross-backend cost had been measured, and the include
221-
* direction was re-affirmed — which leaves the split above.
226+
* direction was re-affirmed — which left the split above.
222227
*
223-
* ⛔ Nothing is flipped in either direction. The #5499 investment freeze was
224-
* the reason while it stood; it dissolved 2026-08-11 (head note of
225-
* `@objectstack/spec`'s `aggregation-conformance.ts`), so that excuse has
226-
* lapsed and the direction is now #13166's and #13195's to settle — ⛔ not
227-
* this pin's, and ⛔ not a sweep's. What the round trip confirmed is exactly
228-
* why this pin exists — this package answers with two different faces, so a
229-
* statement like "driver-memory already reads has-value" is true of the
230-
* reference matcher and FALSE of the live query path users actually reach.
228+
* ⚠️ Two of the three cells are no longer a divergence, and this block was
229+
* built for exactly that edit. It used to say "⛔ Nothing is flipped in either
230+
* direction. The #5499 investment freeze was the reason while it stood; it
231+
* dissolved 2026-08-11 …, so that excuse has lapsed and the direction is now
232+
* #13166's and #13195's to settle." #13166 settled its two: the `$nin` and
233+
* `$notContains` rows below now assert live and reference AGREEING, on the
234+
* affirmed include answer. They were not re-baselined onto whatever the
235+
* matcher began printing — the target was the live path's pre-existing
236+
* answer, named as correct in this very note before the fix existed.
237+
*
238+
* ⛔ The `$exists` row is untouched and stays a pinned divergence. It is a
239+
* different cell with a different backend list (`driver-mongodb` reads
240+
* key-presence too), and it belongs to #13195. What that row still shows is
241+
* why this pin exists — this package answers with two faces, so a statement
242+
* like "driver-memory already reads has-value" is true of the reference
243+
* matcher and FALSE of the live query path users actually reach.
231244
*/
232-
describe('[#5299] the settled cells, live vs reference — divergence pinned, disposition open (#13166/#13195)', () => {
245+
describe('[#5299] the settled cells, live vs reference — $nin / $notContains converged (#13166), $exists still open (#13195)', () => {
233246
const liveVsReference = async (where: unknown) => ({
234247
live: await idsFrom(nulled, where),
235248
reference: NULLED.filter((r) => match(r, where)).map((r) => r.id),
@@ -242,16 +255,22 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
242255
});
243256
});
244257

245-
it('$nin on an ABSENT field', async () => {
258+
it('$nin on an ABSENT field: the two faces now AGREE (#13166)', async () => {
259+
// Was `reference: ['2']` — the matcher's `value === undefined` guard
260+
// short-circuited before the `$nin` arm ran. The LIVE column is unchanged,
261+
// and it is the column this note already named correct.
246262
const live = await idsFrom(missing, { stage: { $nin: ['won'] } });
247263
const reference = MISSING.filter((r) => match(r, { stage: { $nin: ['won'] } })).map((r) => r.id);
248-
expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2'] });
264+
expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2', '3', '4'] });
249265
});
250266

251-
it('$notContains on a null field', async () => {
267+
it('$notContains on a null field: the two faces now AGREE (#13166)', async () => {
268+
// Was `reference: ['1', '3', '4']` — `typeof null !== 'string'` failed the
269+
// TYPE test, so the negation readmitted the null rows. The LIVE column is
270+
// unchanged here too.
252271
expect(await liveVsReference({ $not: { stage: { $notContains: 'w' } } })).toEqual({
253272
live: ['1'],
254-
reference: ['1', '3', '4'],
273+
reference: ['1'],
255274
});
256275
});
257276
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13166] A row with NO VALUE satisfies a negation-carrying operator — the
5+
* six cells, on this matcher, stated directly rather than through a `$not`.
6+
*
7+
* ## The ruling this file enforces
8+
*
9+
* `$ne` / `$nin` / `$notContains` MATCH a row whose field has no value. That is
10+
* the INCLUDE direction: #5146 made `$not` NULL-safe, #5298 option A extended it
11+
* to the non-negated negative operators, and the 2026-08-10 reversal that would
12+
* have taken SQL's three-valued answer as the common denominator was WITHDRAWN
13+
* the same day once its cross-backend cost had been measured. Ten other surfaces
14+
* already answer this way — `formula` (`matches-filter-not-null-safe.test.ts`),
15+
* the four SQL compilers via `nullSafeNegative` / `nullValueSatisfiesOperator`,
16+
* and `driver-mongodb`, which passes `$nin` through and compiles `$notContains`
17+
* to `{ $not: { $regex } }`, both of which match a missing or null field.
18+
*
19+
* ## Why the table has SIX cells and not three
20+
*
21+
* "No value" has two readings that this matcher reaches through DIFFERENT code,
22+
* so a three-cell table cannot see the difference between them:
23+
*
24+
* - `name: null` — the shape a SQL NULL round-trips into a record.
25+
* - the key absent — the shape a partial write leaves.
26+
*
27+
* Collapsing them is how the divergence stayed invisible: two INDEPENDENT causes
28+
* produced it, each reachable from only one of the two readings, so a fixture
29+
* carrying one reading measures at most one of them. Keep both columns.
30+
*
31+
* ⚠️ Do not collapse this into a single case, and do not add `$exists` to it.
32+
* `$exists` is the neighbouring cell (#13195): this package's live mingo path
33+
* and `driver-mongodb` still read it as key-presence rather than has-value, so
34+
* it is a different, still-open divergence with a different backend list.
35+
*
36+
* The complementary POSITIVE operators are asserted beside each negative one, so
37+
* a matcher that started answering "every row" to everything cannot pass this
38+
* file: the ruling is that a no-value row joins the negative answer, not that
39+
* predicates stop discriminating.
40+
*/
41+
42+
import { describe, it, expect } from 'vitest';
43+
44+
import { match } from './memory-matcher.js';
45+
46+
/** `name` present but null — how a SQL NULL round-trips into a record. */
47+
const NULLED: Array<Record<string, unknown>> = [
48+
{ id: '1', name: 'alpha-one' },
49+
{ id: '2', name: 'beta' },
50+
{ id: '3', name: null },
51+
];
52+
53+
/** The same rows with `name` ABSENT — the shape a partial write leaves. */
54+
const MISSING: Array<Record<string, unknown>> = [
55+
{ id: '1', name: 'alpha-one' },
56+
{ id: '2', name: 'beta' },
57+
{ id: '3' },
58+
];
59+
60+
const ids = (rows: Array<Record<string, unknown>>, filter: unknown): string[] =>
61+
rows.filter((r) => match(r, filter)).map((r) => String(r.id));
62+
63+
/** The ruling's answer: row 2 has a different value, row 3 has none. */
64+
const NO_VALUE_INCLUDED = ['2', '3'];
65+
66+
describe('[#13166] no-value rows and the negation-carrying operators', () => {
67+
describe('the six cells — three operators x two readings of "no value"', () => {
68+
const OPERATORS: Array<[name: string, filter: unknown]> = [
69+
['$ne', { name: { $ne: 'alpha-one' } }],
70+
['$nin', { name: { $nin: ['alpha-one'] } }],
71+
['$notContains', { name: { $notContains: 'one' } }],
72+
];
73+
74+
for (const [op, filter] of OPERATORS) {
75+
it(`${op}: a null value satisfies it`, () => {
76+
expect(ids(NULLED, filter)).toEqual(NO_VALUE_INCLUDED);
77+
});
78+
79+
it(`${op}: an absent key satisfies it`, () => {
80+
expect(ids(MISSING, filter)).toEqual(NO_VALUE_INCLUDED);
81+
});
82+
83+
it(`${op}: both readings of "no value" answer alike`, () => {
84+
expect(ids(MISSING, filter)).toEqual(ids(NULLED, filter));
85+
});
86+
}
87+
});
88+
89+
describe('the operators still discriminate — this is not "match everything"', () => {
90+
it('each negation excludes the row that DOES carry the comparand', () => {
91+
expect(ids(NULLED, { name: { $ne: 'alpha-one' } })).not.toContain('1');
92+
expect(ids(NULLED, { name: { $nin: ['alpha-one'] } })).not.toContain('1');
93+
expect(ids(NULLED, { name: { $notContains: 'one' } })).not.toContain('1');
94+
});
95+
96+
it('the positive twins keep answering the complement over the VALUED rows', () => {
97+
// A no-value row is in NEITHER answer for the positive operators: the
98+
// ruling moved the negative cells only.
99+
expect(ids(NULLED, { name: { $eq: 'alpha-one' } })).toEqual(['1']);
100+
expect(ids(MISSING, { name: { $in: ['alpha-one'] } })).toEqual(['1']);
101+
expect(ids(NULLED, { name: { $contains: 'one' } })).toEqual(['1']);
102+
expect(ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']);
103+
});
104+
105+
it('a present non-string value still fails $notContains on the type test', () => {
106+
// Out of scope for this card, and stated so a later reader does not read
107+
// the fix above as "any non-string satisfies the negation". Only the
108+
// no-value readings moved; a value that is there and is not a string
109+
// keeps the answer it had.
110+
expect(ids([{ id: '9', name: 42 }], { name: { $notContains: 'one' } })).toEqual([]);
111+
});
112+
});
113+
114+
describe('cross-operator agreement, the invariant that outlives the fixture', () => {
115+
it('$nin answers exactly what $ne answers — it is the list form of it', () => {
116+
// `formula`'s suite states the same identity over its own fixture. `$ne`
117+
// is the operator ENROLLED in `FILTER_LOGIC_CASES`, so this is the link
118+
// between the enrolled cell and the two that are not enrolled yet.
119+
for (const rows of [NULLED, MISSING]) {
120+
expect(ids(rows, { name: { $nin: ['alpha-one'] } }))
121+
.toEqual(ids(rows, { name: { $ne: 'alpha-one' } }));
122+
}
123+
});
124+
125+
it('$contains and $notContains partition the VALUED rows and both keep the no-value row out of the positive side', () => {
126+
for (const rows of [NULLED, MISSING]) {
127+
const inside = ids(rows, { name: { $contains: 'one' } });
128+
const outside = ids(rows, { name: { $notContains: 'one' } });
129+
expect(inside).toEqual(['1']);
130+
expect(outside).toEqual(NO_VALUE_INCLUDED);
131+
expect(inside.filter((id) => outside.includes(id))).toEqual([]);
132+
expect([...inside, ...outside].sort()).toEqual(['1', '2', '3']);
133+
}
134+
});
135+
});
136+
});

0 commit comments

Comments
 (0)