|
4 | 4 | * HAVING evaluator (#4286 step 3) — semantics over AGGREGATED rows. |
5 | 5 | * |
6 | 6 | * The namespace is the aggregated row's own columns (aggregation aliases + |
7 | | - * groupBy projections); operator semantics mirror the Filter Protocol's |
8 | | - * memory evaluation, EXCEPT that an unknown operator throws — ignoring one |
9 | | - * would silently return unfiltered aggregates, the exact silently-inert |
10 | | - * failure (#4286, ADR-0078) enforcement exists to end. |
| 7 | + * groupBy projections); operator semantics follow the Filter Protocol, with two |
| 8 | + * deliberate divergences from driver-memory's matcher: an unknown operator |
| 9 | + * throws — ignoring one would silently return unfiltered aggregates, the exact |
| 10 | + * silently-inert failure (#4286, ADR-0078) enforcement exists to end — and the |
| 11 | + * negation-carrying operators are NULL-safe per #5298 (see the grid at the |
| 12 | + * bottom of this file, #5905). |
11 | 13 | */ |
12 | 14 |
|
13 | 15 | import { describe, it, expect } from 'vitest'; |
@@ -78,3 +80,113 @@ describe('matchesHaving — the unknown-operator refusal', () => { |
78 | 80 | expect(matchesHaving({ k: 'Alpha' }, { k: { $regex: '^alp', $options: 'i' } })).toBe(true); |
79 | 81 | }); |
80 | 82 | }); |
| 83 | + |
| 84 | +/** |
| 85 | + * [#5905] The no-value grid for the negation-carrying operators. |
| 86 | + * |
| 87 | + * #5298 ruled (option A, 2026-08-06) that "the column has no value" SATISFIES a |
| 88 | + * test for "not this value", and PR #5962 landed it on driver-sql, formula, |
| 89 | + * service-analytics and the `FILTER_LOGIC_*` conformance table. HAVING is the |
| 90 | + * fifth evaluation face of the same vocabulary and was not in that PR's |
| 91 | + * inventory, so it stayed the lone holdout — and no conformance table would |
| 92 | + * have caught it, because `FILTER_LOGIC_CASES` does not drive the HAVING path |
| 93 | + * (verified: `packages/objectql` imports it nowhere). This grid IS that |
| 94 | + * coverage. |
| 95 | + * |
| 96 | + * Two no-value shapes, deliberately separated, because on this face they did |
| 97 | + * NOT arrive at the old answer by the same route: |
| 98 | + * |
| 99 | + * - NULLED — the key is present with `null`. The early-exit guard tests |
| 100 | + * `=== undefined`, so it never fired here; `$nin` was already NULL-safe and |
| 101 | + * `$notContains` was not (`typeof null !== 'string'` ⇒ judged false). |
| 102 | + * - MISSING — the key is absent, so the aggregated row reads `undefined`. The |
| 103 | + * early-exit guard fired first and answered false for BOTH operators, before |
| 104 | + * either arm was reached. |
| 105 | + * |
| 106 | + * The positive-operator rows are the control: `$in` / `$contains` must keep |
| 107 | + * REJECTING both no-value shapes. Widening the exemption list too far would |
| 108 | + * turn them green, which is the failure this pair is here to catch. |
| 109 | + */ |
| 110 | +describe('no-value rows and the negation-carrying operators (#5905 / #5298 option A)', () => { |
| 111 | + const NULLED = { customer_id: 'nulled', tag: null, total: 100 }; |
| 112 | + const MISSING = { customer_id: 'missing', total: 100 }; |
| 113 | + const VALUED_OUT = { customer_id: 'valued_out', tag: 'gamma', total: 100 }; |
| 114 | + const VALUED_IN = { customer_id: 'valued_in', tag: 'alpha', total: 100 }; |
| 115 | + const GRID = [NULLED, MISSING, VALUED_OUT, VALUED_IN]; |
| 116 | + |
| 117 | + describe('$nin', () => { |
| 118 | + it('a NULLED column satisfies $nin', () => { |
| 119 | + expect(matchesHaving(NULLED, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true); |
| 120 | + }); |
| 121 | + |
| 122 | + it('a MISSING column satisfies $nin', () => { |
| 123 | + expect(matchesHaving(MISSING, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true); |
| 124 | + }); |
| 125 | + |
| 126 | + it('a present value OUTSIDE the list still satisfies $nin (unchanged)', () => { |
| 127 | + expect(matchesHaving(VALUED_OUT, { tag: { $nin: ['alpha', 'beta'] } })).toBe(true); |
| 128 | + }); |
| 129 | + |
| 130 | + it('a present value INSIDE the list still fails $nin (unchanged)', () => { |
| 131 | + expect(matchesHaving(VALUED_IN, { tag: { $nin: ['alpha', 'beta'] } })).toBe(false); |
| 132 | + }); |
| 133 | + |
| 134 | + it('applyHaving keeps both no-value rows and drops only the listed value', () => { |
| 135 | + expect(applyHaving(GRID, { tag: { $nin: ['alpha', 'beta'] } }).map((r) => r.customer_id)) |
| 136 | + .toEqual(['nulled', 'missing', 'valued_out']); |
| 137 | + }); |
| 138 | + }); |
| 139 | + |
| 140 | + describe('$notContains', () => { |
| 141 | + it('a NULLED column satisfies $notContains', () => { |
| 142 | + expect(matchesHaving(NULLED, { tag: { $notContains: 'lph' } })).toBe(true); |
| 143 | + }); |
| 144 | + |
| 145 | + it('a MISSING column satisfies $notContains', () => { |
| 146 | + expect(matchesHaving(MISSING, { tag: { $notContains: 'lph' } })).toBe(true); |
| 147 | + }); |
| 148 | + |
| 149 | + it('a present value WITHOUT the substring still satisfies $notContains (unchanged)', () => { |
| 150 | + expect(matchesHaving(VALUED_OUT, { tag: { $notContains: 'lph' } })).toBe(true); |
| 151 | + }); |
| 152 | + |
| 153 | + it('a present value WITH the substring still fails $notContains (unchanged)', () => { |
| 154 | + expect(matchesHaving(VALUED_IN, { tag: { $notContains: 'lph' } })).toBe(false); |
| 155 | + }); |
| 156 | + |
| 157 | + it('applyHaving keeps both no-value rows and drops only the containing value', () => { |
| 158 | + expect(applyHaving(GRID, { tag: { $notContains: 'lph' } }).map((r) => r.customer_id)) |
| 159 | + .toEqual(['nulled', 'missing', 'valued_out']); |
| 160 | + }); |
| 161 | + }); |
| 162 | + |
| 163 | + describe('the control: positive operators still reject a no-value column', () => { |
| 164 | + it('$in rejects NULLED and MISSING', () => { |
| 165 | + expect(matchesHaving(NULLED, { tag: { $in: ['alpha', 'beta'] } })).toBe(false); |
| 166 | + expect(matchesHaving(MISSING, { tag: { $in: ['alpha', 'beta'] } })).toBe(false); |
| 167 | + }); |
| 168 | + |
| 169 | + it('$contains rejects NULLED and MISSING', () => { |
| 170 | + expect(matchesHaving(NULLED, { tag: { $contains: 'lph' } })).toBe(false); |
| 171 | + expect(matchesHaving(MISSING, { tag: { $contains: 'lph' } })).toBe(false); |
| 172 | + }); |
| 173 | + |
| 174 | + it('$ne — already exempt before #5905 — is unchanged for both shapes', () => { |
| 175 | + expect(matchesHaving(NULLED, { tag: { $ne: 'alpha' } })).toBe(true); |
| 176 | + expect(matchesHaving(MISSING, { tag: { $ne: 'alpha' } })).toBe(true); |
| 177 | + expect(matchesHaving(VALUED_IN, { tag: { $ne: 'alpha' } })).toBe(false); |
| 178 | + }); |
| 179 | + }); |
| 180 | + |
| 181 | + /** |
| 182 | + * The NULL-safety lives at the LEAF, so `$not` inverts it rather than |
| 183 | + * inheriting it — the same design driver-sql writes down for its own |
| 184 | + * `$not` rewrite (a nested negation totalises its own operand). A no-value |
| 185 | + * row satisfies `$nin`, therefore it does NOT satisfy `$not: { $nin }`. |
| 186 | + */ |
| 187 | + it('$not inverts the leaf answer instead of re-applying the guard', () => { |
| 188 | + expect(matchesHaving(MISSING, { $not: { tag: { $nin: ['alpha'] } } })).toBe(false); |
| 189 | + expect(matchesHaving(NULLED, { $not: { tag: { $notContains: 'lph' } } })).toBe(false); |
| 190 | + expect(matchesHaving(VALUED_IN, { $not: { tag: { $nin: ['alpha'] } } })).toBe(true); |
| 191 | + }); |
| 192 | +}); |
0 commit comments