@@ -36,9 +36,11 @@ import { describe, expect, it } from 'vitest';
3636
3737import { StandardErrorCode } from '../api/errors.zod' ;
3838import { assertListComparandShapes } from './filter-comparand-shape' ;
39+ import { DatasetSchema } from '../ui/dataset.zod' ;
3940import {
4041 EqualityOperatorSchema ,
4142 FieldOperatorsSchema ,
43+ FilterConditionSchema ,
4244 NormalizedFilterSchema ,
4345 parseFilterAST ,
4446} from './filter.zod' ;
@@ -235,21 +237,49 @@ describe('#19886 §4 — what stays accepted, at BOTH doors', () => {
235237} ) ;
236238
237239// ---------------------------------------------------------------------------
238- // §5 The boundary this change does NOT move
240+ // §5 The stored-filter carrier walk — refused on save since #20116
239241// ---------------------------------------------------------------------------
240242
241- describe ( '#19886 §5 — the stored-filter carrier walk' , ( ) => {
242- // Why the operator slot's refusal does not reach a stored carrier:
243- // `FilterConditionSchema` parses a field entry as `unknown` and judges it
244- // with its own walk (`checkFilterConditionComparands`), which judges `$eq`
245- // and not `$ne`. The `$eq` side of that walk is pinned in
246- // `filter-equality-array-schema-door.test.ts`, including that its equality
247- // arm raises nothing for `$ne`.
248- it . todo (
249- 'FilterConditionSchema (every stored filter carrier: dataset, widget, report, rollup …) refusing '
250- + '$ne: [...] on SAVE. Ruling A names the face and FieldOperatorsSchema.$ne, not the carrier walk, '
251- + 'so the carrier still saves the shape and the face refuses it at query time. Reported to the '
252- + 'seat for its own decision; ⛔ not pinned green here, because a green pin would read as a '
253- + 'ruling nobody made' ,
254- ) ;
243+ describe ( '#19886 §5 — the stored-filter carrier walk refuses $ne: [...] too (#20116)' , ( ) => {
244+ // This section held an `it.todo`: ruling A named the face and
245+ // `FieldOperatorsSchema.$ne`, not `FilterConditionSchema`'s carrier walk, so
246+ // every stored carrier still saved the shape and the face refused it at query
247+ // time. #20116's `$ne` member (route A: the equality arm's reach, the one
248+ // sentence) routes every slot that walk reaches through the face itself, so
249+ // the todo is fulfilled here. The full operator × comparand table, carriers
250+ // and nested relations included, is `filter-save-door-face-parity.test.ts`.
251+ it ( 'FilterConditionSchema refuses it at the slot, in the face\'s sentence less its location' , ( ) => {
252+ const where = { stage : { $ne : [ 'won' , 'lost' ] } } ;
253+ const face = faceRefusal ( where ) ;
254+ expect ( face . code ) . toBe ( StandardErrorCode . enum . INVALID_FILTER ) ;
255+ expect ( face . status ) . toBe ( 400 ) ;
256+ const location = ' at where.stage.$ne.' ;
257+ expect ( face . message . split ( location ) ) . toHaveLength ( 2 ) ;
258+ const issue = issueAt ( FilterConditionSchema . safeParse ( where ) , 'stage.$ne' ) ;
259+ expect ( issue . code ) . toBe ( 'custom' ) ;
260+ expect ( issue . message ) . toBe ( face . message . replace ( location , '.' ) ) ;
261+ expect ( issue . message ) . toMatch ( / ^ O p e r a t o r " \$ n e " o n f i e l d " s t a g e " r e q u i r e s a s i n g l e c o m p a r a b l e v a l u e / ) ;
262+ expect ( issue . message ) . toContain ( REMEDY ) ;
263+ expect ( issue . message ) . toMatch ( NOT_APPLIED ) ;
264+ } ) ;
265+
266+ it ( 'a stored carrier refuses it at its own path — a dataset filter' , ( ) => {
267+ const parsed = DatasetSchema . safeParse ( {
268+ name : 'deals_ds' ,
269+ label : 'Deals' ,
270+ object : 'deal' ,
271+ dimensions : [ { name : 'stage' , field : 'stage' , type : 'string' } ] ,
272+ measures : [ { name : 'deal_count' , aggregate : 'count' } ] ,
273+ filter : { $or : [ { stage : { $ne : [ ] } } ] } ,
274+ } ) ;
275+ expect ( issueAt ( parsed , 'filter.$or.0.stage.$ne' ) . message ) . toContain ( REMEDY ) ;
276+ } ) ;
277+
278+ it ( 'CONTROL — the null predicate and a scalar still save' , ( ) => {
279+ for ( const where of [ { stage : { $ne : null } } , { stage : { $ne : 'lost' } } ] ) {
280+ const result = FilterConditionSchema . safeParse ( where ) ;
281+ expect ( result . success , JSON . stringify ( result . error ?. issues ) ) . toBe ( true ) ;
282+ expect ( result . data ) . toEqual ( where ) ;
283+ }
284+ } ) ;
255285} ) ;
0 commit comments