@@ -42,19 +42,35 @@ import type { QueryAST } from '@objectstack/spec/data';
4242 * one RLS scope answered correctly on a local SqlDriver and broke on Turso
4343 * remote — a local/remote divergence on a declared spec construct.
4444 *
45- * Two semantics are deliberate and pinned below, because the three in-tree
46- * implementations do not agree on them (filed as objectstack#5146):
45+ * Two semantics are deliberate and pinned below. One of them has since been
46+ * RULED and reversed; both are recorded here because the reversal is the point.
4747 *
48- * - **NULL rows follow the SQL family.** `NOT ("stage" = ?)` is UNKNOWN when
49- * `stage` is NULL, so that row is not returned — exactly what Knex's
50- * `whereNot` emits for local mode (`select … where (not (\`stage\` = ?))`,
51- * measured). `driver-memory`/`matchesFilterCondition` return it. Remote mode
52- * is pinned to the family it belongs to, so local and remote SQL agree.
53- * - **`$not: {}` is FALSE.** The inner filter compiles to no SQL, which under
54- * the #1073 invariant can only mean "vacuously TRUE", and `NOT TRUE` is
55- * FALSE. `driver-memory` and `matchesFilterCondition` agree; driver-sql
56- * returns every row there, but only because Knex drops an empty group — the
57- * widening direction of the very bug family #2704 closed.
48+ * - **NULL rows are RETURNED (#5146, landed on this face by #5903).** This
49+ * bullet used to say the opposite — "NULL rows follow the SQL family:
50+ * `NOT ("stage" = ?)` is UNKNOWN when `stage` is NULL, so that row is not
51+ * returned, exactly what Knex's `whereNot` emits for local mode;
52+ * `driver-memory`/`matchesFilterCondition` return it; remote mode is pinned to
53+ * the family it belongs to, so local and remote SQL agree." Every clause of
54+ * that was true when it was written and the CONCLUSION expired eleven months
55+ * later: objectstack#5146 ruled the two-valued answer canonical and PR #5296
56+ * landed it on `SqlDriver.applyFilterCondition`, so local mode — which
57+ * inherits it — started returning the NULL row while this independent
58+ * compiler did not. The sentence that justified the pin ("local and remote SQL
59+ * agree") became the reason to flip it. Measured on `origin/main` on
60+ * 2026-08-06 against the shared conformance fixture: LOCAL `['2','3','4']`,
61+ * REMOTE `['2']`.
62+ *
63+ * The predicate is made TOTAL leaf by leaf before the negation, so
64+ * `NOT (…)` is TRUE or FALSE for every row and never UNKNOWN. That is why the
65+ * compiled SQL below carries an `IS NOT NULL` conjunct that was not there
66+ * before, and why the polarity is per operator rather than a blanket
67+ * `OR col IS NULL`: `{ $not: { a: { $ne: 5 } } }` means "a is 5", which a
68+ * no-value row must NOT satisfy (pinned in section (g) below).
69+ * - **`$not: {}` is FALSE.** UNCHANGED. The inner filter compiles to no SQL,
70+ * which under the #1073 invariant can only mean "vacuously TRUE", and
71+ * `NOT TRUE` is FALSE. `driver-memory` and `matchesFilterCondition` agree;
72+ * driver-sql returns every row there, but only because Knex drops an empty
73+ * group — the widening direction of the very bug family #2704 closed.
5874 */
5975function transportWithCapturingClient ( ) {
6076 const calls : Array < { sql : string ; args : any [ ] } > = [ ] ;
@@ -98,7 +114,11 @@ describe('RemoteTransport $not (#1076)', () => {
98114 // Pre-fix: THREW `Filter on 'deal.$not' has an object comparand whose key
99115 // "stage" is not an operator`.
100116 const call = await compile ( { $not : { stage : 'won' } } ) ;
101- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ("stage" = ?)` ) ;
117+ // [#5903] The `IS NOT NULL` conjunct is #5146's leaf-totalising guard: a
118+ // row with no `stage` does not satisfy `stage = 'won'`, so it must satisfy
119+ // the negation. Without it `NOT (NULL = ?)` is UNKNOWN and the row
120+ // vanishes.
121+ expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))` ) ;
102122 expect ( call . args ) . toEqual ( [ 'won' ] ) ;
103123 expectBindsBalanced ( call ) ;
104124 } ) ;
@@ -123,7 +143,11 @@ describe('RemoteTransport $not (#1076)', () => {
123143 // `NOT (a AND b)`, never `NOT (a) AND b` — the inner object is one
124144 // condition and De Morgan is not the caller's intent.
125145 const call = await compile ( { $not : { stage : 'won' , amount : 10 } } ) ;
126- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ("stage" = ? AND "amount" = ?)` ) ;
146+ // Each leaf is guarded independently and the four conjuncts stay inside
147+ // the ONE negated group — `NOT (a AND b)`, never `NOT (a) AND b`.
148+ expect ( call . sql ) . toBe (
149+ `${ BARE_SCAN } WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?) AND ("amount" IS NOT NULL) AND ("amount" = ?)))` ,
150+ ) ;
127151 expect ( call . args ) . toEqual ( [ 'won' , 10 ] ) ;
128152 } ) ;
129153
@@ -202,27 +226,44 @@ describe('RemoteTransport $not (#1076)', () => {
202226 it ( 'compiles `$not: { $not: {…} }` as two nested negations' , async ( ) => {
203227 // Pre-fix: THREW `Unsupported filter operator "$not" on 'deal.$not'`.
204228 const call = await compile ( { $not : { $not : { stage : 'won' } } } ) ;
205- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT (NOT ("stage" = ?))` ) ;
229+ // The INNER `$not` is left un-rewritten by the outer one on purpose: its
230+ // own branch totalises its operand, and `NOT <total>` is itself total, so
231+ // recursing would stack a redundant guard on the same column. The guard
232+ // therefore appears exactly once, innermost.
233+ expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT (NOT ((("stage" IS NOT NULL) AND ("stage" = ?))))` ) ;
206234 expect ( call . args ) . toEqual ( [ 'won' ] ) ;
207235 expectBindsBalanced ( call ) ;
208236 } ) ;
209237
210238 it ( 'compiles `$not` over a nested `$or`' , async ( ) => {
211239 const call = await compile ( { $not : { $or : [ { stage : 'won' } , { stage : 'lost' } ] } } ) ;
212- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ((("stage" = ?) OR ("stage" = ?)))` ) ;
240+ // De Morgan is sound over two-valued leaves, which is the whole reason the
241+ // guard rides each LEAF rather than the `NOT`: hoisting it above this
242+ // `$or` would let a NULL `stage` satisfy the negation even when the other
243+ // disjunct is satisfied.
244+ expect ( call . sql ) . toBe (
245+ `${ BARE_SCAN } WHERE NOT ((((("stage" IS NOT NULL) AND ("stage" = ?))) OR ((("stage" IS NOT NULL) AND ("stage" = ?)))))` ,
246+ ) ;
213247 expect ( call . args ) . toEqual ( [ 'won' , 'lost' ] ) ;
214248 expectBindsBalanced ( call ) ;
215249 } ) ;
216250
217251 it ( 'compiles `$not` over a nested `$and`' , async ( ) => {
218252 const call = await compile ( { $not : { $and : [ { stage : 'won' } , { amount : 10 } ] } } ) ;
219- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ((("stage" = ?) AND ("amount" = ?)))` ) ;
253+ expect ( call . sql ) . toBe (
254+ `${ BARE_SCAN } WHERE NOT ((((("stage" IS NOT NULL) AND ("stage" = ?))) AND ((("amount" IS NOT NULL) AND ("amount" = ?)))))` ,
255+ ) ;
220256 expect ( call . args ) . toEqual ( [ 'won' , 10 ] ) ;
221257 } ) ;
222258
223259 it ( 'carries operator maps through the negation with their binds in order' , async ( ) => {
224260 const call = await compile ( { $not : { amount : { $gte : 10 , $lt : 100 } } } ) ;
225- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE NOT ("amount" >= ? AND "amount" < ?)` ) ;
261+ // ONE guard for the whole field constraint, not one per operator: the
262+ // constraint is the AND of its operators, so a NULL column satisfies it
263+ // only if it satisfies all of them — and it satisfies neither bound.
264+ expect ( call . sql ) . toBe (
265+ `${ BARE_SCAN } WHERE NOT ((("amount" IS NOT NULL) AND ("amount" >= ? AND "amount" < ?)))` ,
266+ ) ;
226267 expect ( call . args ) . toEqual ( [ 10 , 100 ] ) ;
227268 expectBindsBalanced ( call ) ;
228269 } ) ;
@@ -243,20 +284,29 @@ describe('RemoteTransport $not (#1076)', () => {
243284 // 'lost')`. Pre-fix the whole read THREW, so the scope was unusable on
244285 // Turso remote while working locally.
245286 const call = await compile ( { $and : [ { owner_id : 'u1' } , { $not : { stage : 'lost' } } ] } ) ;
246- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE (("owner_id" = ?) AND (NOT ("stage" = ?)))` ) ;
287+ expect ( call . sql ) . toBe (
288+ `${ BARE_SCAN } WHERE (("owner_id" = ?) AND (NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))))` ,
289+ ) ;
247290 expect ( call . args ) . toEqual ( [ 'u1' , 'lost' ] ) ;
248291 expectBindsBalanced ( call ) ;
249292 } ) ;
250293
251294 it ( 'compiles a `$not` inside an `$or` branch' , async ( ) => {
252295 const call = await compile ( { $or : [ { $not : { stage : 'lost' } } , { owner_id : 'u1' } ] } ) ;
253- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE ((NOT ("stage" = ?)) OR ("owner_id" = ?))` ) ;
296+ expect ( call . sql ) . toBe (
297+ `${ BARE_SCAN } WHERE ((NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))) OR ("owner_id" = ?))` ,
298+ ) ;
254299 expect ( call . args ) . toEqual ( [ 'lost' , 'u1' ] ) ;
255300 } ) ;
256301
257302 it ( 'keeps a `$not` sibling of plain field keys at the same level' , async ( ) => {
258303 const call = await compile ( { owner_id : 'u1' , $not : { stage : 'lost' } } ) ;
259- expect ( call . sql ) . toBe ( `${ BARE_SCAN } WHERE "owner_id" = ? AND NOT ("stage" = ?)` ) ;
304+ // The guard stays INSIDE the negated group. This is the assertion that
305+ // fails if it were ever spliced into the node's bare ` AND ` join, where
306+ // a stray `OR` would bind looser than the AND and widen the whole filter.
307+ expect ( call . sql ) . toBe (
308+ `${ BARE_SCAN } WHERE "owner_id" = ? AND NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))` ,
309+ ) ;
260310 expect ( call . args ) . toEqual ( [ 'u1' , 'lost' ] ) ;
261311 } ) ;
262312
@@ -351,9 +401,16 @@ describe('RemoteTransport $not (#1076)', () => {
351401 await t . count ( 'deal' , { where } as unknown as QueryAST ) ;
352402 await t . deleteMany ( 'deal' , { where } as any ) ;
353403 await t . updateMany ( 'deal' , { where } as any , { stage : 'lost' } ) ;
354- expect ( calls [ 0 ] . sql ) . toMatch ( / C O U N T \( \* \) .+ W H E R E N O T \( " s t a g e " = \? \) / i) ;
355- expect ( calls [ 1 ] . sql ) . toBe ( 'DELETE FROM "deal" WHERE NOT ("stage" = ?)' ) ;
356- expect ( calls [ 2 ] . sql ) . toMatch ( / ^ U P D A T E " d e a l " S E T .* W H E R E N O T \( " s t a g e " = \? \) $ / ) ;
404+ // The one negated predicate all three statements must carry, verbatim —
405+ // a WHERE that is right for `find` and wrong for `deleteMany` is how a
406+ // filter fix becomes a data-loss bug.
407+ const NEGATION = 'WHERE NOT ((("stage" IS NOT NULL) AND ("stage" = ?)))' ;
408+ expect ( calls [ 0 ] . sql ) . toMatch ( / C O U N T \( \* \) / i) ;
409+ expect ( calls [ 0 ] . sql ) . toContain ( NEGATION ) ;
410+ expect ( calls [ 1 ] . sql ) . toBe ( `DELETE FROM "deal" ${ NEGATION } ` ) ;
411+ expect ( calls [ 2 ] . sql ) . toMatch (
412+ / ^ U P D A T E " d e a l " S E T .* W H E R E N O T \( \( \( " s t a g e " I S N O T N U L L \) A N D \( " s t a g e " = \? \) \) \) $ / ,
413+ ) ;
357414 for ( const call of calls ) expectBindsBalanced ( call ) ;
358415 } ) ;
359416
@@ -425,21 +482,34 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
425482
426483 it ( '`$not: { stage: "won" }` returns the other rows, not a `no such column` error' , async ( ) => {
427484 // Pre-fix: threw before reaching SQLite; had it compiled, SQLite would have
428- // answered `no such column: $not`.
429- expect ( await ids ( { $not : { stage : 'won' } } ) ) . toEqual ( [ 'd_lost' , 'd_open' ] ) ;
485+ // answered `no such column: $not`. `d_null` joins the answer at #5903 — see
486+ // the next case.
487+ expect ( await ids ( { $not : { stage : 'won' } } ) ) . toEqual ( [ 'd_lost' , 'd_null' , 'd_open' ] ) ;
430488 } ) ;
431489
432- it ( 'drops the NULL-`stage` row — SQL three-valued logic, as `whereNot` does locally' , async ( ) => {
490+ it ( 'RETURNS the NULL-`stage` row — the #5146 ruling, on this face since #5903' , async ( ) => {
491+ // The direction of this case is REVERSED, deliberately. It read: "drops the
492+ // NULL-`stage` row — SQL three-valued logic, as `whereNot` does locally.
433493 // `d_null` is absent above and here. This is the SQL family's answer:
434494 // `NOT (NULL = 'won')` is UNKNOWN, not TRUE. `driver-memory` and
435495 // `matchesFilterCondition` would return it. Remote mode is pinned to local
436496 // SqlDriver so the two SQL transports cannot disagree; the cross-family
437- // divergence is objectstack#5146.
497+ // divergence is objectstack#5146."
498+ //
499+ // #5146 ruled that cross-family divergence — the JS answer is canonical, a
500+ // column with no value does not satisfy the negated condition — and PR
501+ // #5296 landed it on `SqlDriver`. The last clause of the old comment is
502+ // what makes this a flip rather than a regression: "the two SQL transports
503+ // cannot disagree" stopped being true the moment local inherited the fix,
504+ // and it is true again only with `d_null` on this side of the answer.
438505 const rows = await ids ( { $not : { stage : 'won' } } ) ;
439- expect ( rows ) . not . toContain ( 'd_null' ) ;
440- // …and the row IS reachable — it is excluded by the negation's semantics,
441- // not missing from the table .
506+ expect ( rows ) . toContain ( 'd_null' ) ;
507+ // …and the row is still identifiable as the no-value row, so this is not
508+ // passing because the seed lost its NULL .
442509 expect ( await ids ( { stage : null } ) ) . toEqual ( [ 'd_null' ] ) ;
510+ // The complement still excludes it: negation is not a licence to return
511+ // everything.
512+ expect ( await ids ( { $not : { stage : { $ne : 'won' } } } ) ) . toEqual ( [ 'd_won' ] ) ;
443513 } ) ;
444514
445515 it ( '`$not: {}` returns ZERO rows' , async ( ) => {
@@ -455,7 +525,12 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
455525 } ) ;
456526
457527 it ( 'negates a nested `$or` (De Morgan on real rows)' , async ( ) => {
458- expect ( await ids ( { $not : { $or : [ { stage : 'won' } , { stage : 'lost' } ] } } ) ) . toEqual ( [ 'd_open' ] ) ;
528+ // `d_null` is in the answer for the same reason it is above: it satisfies
529+ // neither disjunct, so it satisfies the negation.
530+ expect ( await ids ( { $not : { $or : [ { stage : 'won' } , { stage : 'lost' } ] } } ) ) . toEqual ( [
531+ 'd_null' ,
532+ 'd_open' ,
533+ ] ) ;
459534 } ) ;
460535
461536 it ( 'answers the RLS shape — owner AND NOT lost' , async ( ) => {
@@ -488,7 +563,19 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
488563 } ) ;
489564 // `d_lost` closed WITHIN 2025-01-15, so the negation excludes it; `d_won`
490565 // closed later, so the negation keeps it. Un-lowered, both come back.
491- expect ( await ids ( { $not : { closed_at : { $lte : '2025-01-15' } } } ) ) . toEqual ( [ 'd_won' ] ) ;
566+ //
567+ // [#5903] `d_open` and `d_null` never got a `closed_at`, and a row with no
568+ // value does not satisfy `closed_at <= …`, so the negation now returns
569+ // them. That is the SAME ruling as the case above applied to a range
570+ // operator — the guard is `requireValue` for every positive comparison —
571+ // and it is what LOCAL mode has answered since PR #5296. The lowering
572+ // assertion is unaffected: `d_lost` is still excluded, which is the only
573+ // way to tell a lowered upper bound from an un-lowered one.
574+ expect ( await ids ( { $not : { closed_at : { $lte : '2025-01-15' } } } ) ) . toEqual ( [
575+ 'd_null' ,
576+ 'd_open' ,
577+ 'd_won' ,
578+ ] ) ;
492579 } ) ;
493580
494581 it ( 'lowers `$between` inside `$not` instead of refusing it' , async ( ) => {
@@ -501,7 +588,11 @@ describe('TursoDriver remote — $not on real rows (#1076)', () => {
501588 } ) ;
502589
503590 it ( '`count` agrees with `find`' , async ( ) => {
504- expect ( await driver . count ( 'deal' , { object : 'deal' , where : { $not : { stage : 'won' } } } ) ) . toBe ( 2 ) ;
591+ // Three since #5903 — `d_null` is inside the negation now, and a count that
592+ // disagreed with the list under it is exactly the local/remote split this
593+ // issue closed, wearing a total instead of a row set.
594+ expect ( await driver . count ( 'deal' , { object : 'deal' , where : { $not : { stage : 'won' } } } ) ) . toBe ( 3 ) ;
595+ expect ( ( await ids ( { $not : { stage : 'won' } } ) ) . length ) . toBe ( 3 ) ;
505596 expect ( await driver . count ( 'deal' , { object : 'deal' , where : { $not : { } } } ) ) . toBe ( 0 ) ;
506597 } ) ;
507598
0 commit comments