|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#14080] Ruling point 4's NEGATIVE pin, matcher side: a refused null |
| 5 | + * ORDERING comparand cannot reach this package's reference matcher. |
| 6 | + * |
| 7 | + * # What was ruled (2026-09-01, option A) |
| 8 | + * |
| 9 | + * #14080 measured, on this package's two faces and the card's numeric |
| 10 | + * fixture, that `{n: {$gt: null}}` / `{$gte: null}` / `{$lte: null}` answer |
| 11 | + * DIFFERENTLY: the live (mingo) path reads two absences as EQUAL, so |
| 12 | + * `$gte: null` admits the no-value row and `$gt: null` does not, while the |
| 13 | + * reference matcher compares through JS coercion, so `5 > null` is `5 > 0`. |
| 14 | + * It was the last null-comparand position the contract neither ruled on |
| 15 | + * (`$eq: null` / `$ne: null` ARE the null predicate, #5332) nor refused (the |
| 16 | + * 2026-08-31 ruling refused the `$in` / `$nin` members and the `$between` |
| 17 | + * bounds, #13357). The ruling REFUSES the shape at the contract's validation |
| 18 | + * entrance (`@objectstack/spec`, `assertListComparandShapes`, run inside |
| 19 | + * `parseFilterAST` and at the engine seam) instead of defining the semantics: |
| 20 | + * the divergence becomes constructively unreachable, ⛔ deliberately not |
| 21 | + * repaired (「⛔ 不单独修 matcher(死代码)」) and ⛔ no ordering-vs-null rule is |
| 22 | + * stated anywhere (「B(定义语义)排除」), so NOTHING in this file asserts what |
| 23 | + * either face would have answered. `memory-matcher-null-value-and-comparand.test.ts` |
| 24 | + * keeps those cells deliberately absent for the same reason. |
| 25 | + * |
| 26 | + * # What this file pins, and its honest boundary |
| 27 | + * |
| 28 | + * The same pipeline and the same boundary as |
| 29 | + * `memory-null-list-member-unreachable.test.ts`: a direct caller of this |
| 30 | + * driver compiles its filter with `parseFilterAST` and hands the result over, |
| 31 | + * and this file drives that pipeline end to end, pinning that for every |
| 32 | + * refused shape it ABORTS at the compile face, on BOTH readings of "no value", |
| 33 | + * before any row is consulted. The engine half (every verb, driver-call |
| 34 | + * witness) is pinned in `@objectstack/objectql`'s |
| 35 | + * `engine-filter-array-lowering.test.ts`; the wire/protocol face runs the same |
| 36 | + * `parseFilterAST`. `match()` and `InMemoryDriver.find()` remain plain library |
| 37 | + * functions — a caller that skips the compile face meets only this package's |
| 38 | + * own `assertFilterConditionShape`, which is deliberately NOT extended to the |
| 39 | + * null-ordering rule (⛔ 不做跨后端对齐工程). Same boundary as every #5869 |
| 40 | + * refusal since #9228; not widened here. |
| 41 | + */ |
| 42 | + |
| 43 | +import { describe, it, expect } from 'vitest'; |
| 44 | +import { parseFilterAST } from '@objectstack/spec/data'; |
| 45 | + |
| 46 | +import { match } from './memory-matcher.js'; |
| 47 | + |
| 48 | +type Refusal = Error & { code?: string; status?: number }; |
| 49 | + |
| 50 | +/** |
| 51 | + * The card's own NUMERIC fixture, in both readings of "no value" — numeric |
| 52 | + * because `null` coerces to `0` under a relational comparison, which is the |
| 53 | + * coercion that split the two faces; a string fixture hides it (#13553). |
| 54 | + */ |
| 55 | +const NULLED_ROWS: Array<Record<string, unknown>> = [ |
| 56 | + { id: '1', n: 5 }, |
| 57 | + { id: '2', n: 0 }, |
| 58 | + { id: '3', n: null }, |
| 59 | +]; |
| 60 | +const MISSING_ROWS: Array<Record<string, unknown>> = [ |
| 61 | + { id: '1', n: 5 }, |
| 62 | + { id: '2', n: 0 }, |
| 63 | + { id: '4' }, |
| 64 | +]; |
| 65 | + |
| 66 | +/** |
| 67 | + * The direct-caller pipeline: compile first, evaluate second. The refusal has |
| 68 | + * to land in step one — if compile returns, the matcher HAS been reached and |
| 69 | + * the pin below fails on the sentinel rather than on a missing throw. |
| 70 | + */ |
| 71 | +function compileThenMatch(rows: Array<Record<string, unknown>>, where: unknown): string[] { |
| 72 | + const condition = parseFilterAST(where); |
| 73 | + return rows.filter((row) => match(row, condition)).map((row) => String(row.id)); |
| 74 | +} |
| 75 | + |
| 76 | +const refusalOf = (run: () => unknown): Refusal => { |
| 77 | + try { |
| 78 | + run(); |
| 79 | + } catch (e) { |
| 80 | + return e as Refusal; |
| 81 | + } |
| 82 | + throw new Error('expected the compile face to refuse this filter, but it returned'); |
| 83 | +}; |
| 84 | + |
| 85 | +describe('[#14080] a refused null ordering comparand cannot reach the matcher (ruled 2026-09-01)', () => { |
| 86 | + it.each([ |
| 87 | + ['$gt: null', { n: { $gt: null } }], |
| 88 | + ['$gte: null', { n: { $gte: null } }], |
| 89 | + ['$lt: null', { n: { $lt: null } }], |
| 90 | + ['$lte: null', { n: { $lte: null } }], |
| 91 | + ['lowered array form, ">="', [['n', '>=', null]]], |
| 92 | + ['lowered array form, "before"', [['n', 'before', null]]], |
| 93 | + ])('%s aborts at the compile face on BOTH readings of "no value"', (_label, where) => { |
| 94 | + // Record-independent by construction — the compile face never sees a row — |
| 95 | + // so the two readings that split the faces (the card's table) cannot even |
| 96 | + // be posed. Driving both anyway is the point of the pin: neither fixture |
| 97 | + // gets an answer, so there is no divergence left to observe. |
| 98 | + for (const rows of [NULLED_ROWS, MISSING_ROWS]) { |
| 99 | + const err = refusalOf(() => compileThenMatch(rows, where)); |
| 100 | + expect(err.code, _label).toBe('INVALID_FILTER'); |
| 101 | + expect(err.status, _label).toBe(400); |
| 102 | + } |
| 103 | + }); |
| 104 | + |
| 105 | + it('the pipeline itself is real — a legal ordering comparand compiles and the matcher answers', () => { |
| 106 | + // Positive control: without it, the refusals above would also "pass" if |
| 107 | + // compileThenMatch were broken outright. `0` is the discriminator the |
| 108 | + // numeric fixture exists for — a VALUE, kept in, on every arm. |
| 109 | + expect(compileThenMatch(NULLED_ROWS, { n: { $gt: 0 } })).toEqual(['1']); |
| 110 | + expect(compileThenMatch(NULLED_ROWS, { n: { $gte: 0 } })).toEqual(['1', '2']); |
| 111 | + expect(compileThenMatch(MISSING_ROWS, { n: { $lt: 5 } })).toEqual(['2']); |
| 112 | + expect(compileThenMatch(MISSING_ROWS, [['n', '<=', 0]])).toEqual(['2']); |
| 113 | + }); |
| 114 | + |
| 115 | + it('the null PREDICATE still passes the same face — the refusal is ordering-shaped, not null-shaped', () => { |
| 116 | + // `$eq: null` IS the null predicate on both readings (#13494) and is the |
| 117 | + // spelling the refusal prescribes; the carve-out must not catch it. |
| 118 | + expect(compileThenMatch(NULLED_ROWS, { n: { $eq: null } })).toEqual(['3']); |
| 119 | + expect(compileThenMatch(MISSING_ROWS, { n: { $eq: null } })).toEqual(['4']); |
| 120 | + expect(compileThenMatch(NULLED_ROWS, { n: { $ne: null } })).toEqual(['1', '2']); |
| 121 | + }); |
| 122 | +}); |
0 commit comments