Skip to content

Commit ab146a9

Browse files
committed
test(spec): fulfil the $ne carrier-walk todo; changeset gains the $nin null-member rows
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfca00f commit ab146a9

2 files changed

Lines changed: 48 additions & 15 deletions

File tree

‎.changeset/20116-filter-save-door-face-parity.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ This also changes the `$ne` note of the equality-slot change earlier in this rel
4545
| `{ amount: { $gt: null } }` | `{ amount: { $eq: null } }` ("has no value") or `{ amount: { $ne: null } }` ("has a value") |
4646
| `{ stage: { $in: "won" } }` | `{ stage: { $in: ["won"] } }` or `{ stage: "won" }` |
4747
| `{ stage: { $in: ["won", null] } }` | `{ $or: [{ stage: { $in: ["won"] } }, { stage: { $null: true } }] }` |
48+
| `{ stage: { $nin: ["lost", null] } }`, meaning "has a value and is not `lost`" | `{ stage: { $nin: ["lost"], $null: false } }` |
49+
| `{ stage: { $in: [null, ""] } }`, a filter builder's "is empty" | `{ $or: [{ stage: { $null: true } }, { stage: "" }] }` |
50+
| `{ stage: { $nin: [null, ""] } }`, a filter builder's "is not empty" | `{ stage: { $null: false, $ne: "" } }` |
4851
| `{ amount: { $between: [null, 5] } }`, `{ amount: { $between: ["", 5] } }` | `{ amount: { $lte: 5 } }`, or the bound you meant |
4952
| `{ amount: { $between: [{ $field: "floor" }, 5] } }` | `{ amount: { $gte: { $field: "floor" }, $lte: 5 } }` |
5053
| `{ amount: { $between: 5 } }`, `{ amount: { $between: [1] } }` | `{ amount: { $between: [1, 5] } }` |

‎packages/spec/src/data/filter-ne-array-schema-door.test.ts‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ import { describe, expect, it } from 'vitest';
3636

3737
import { StandardErrorCode } from '../api/errors.zod';
3838
import { assertListComparandShapes } from './filter-comparand-shape';
39+
import { DatasetSchema } from '../ui/dataset.zod';
3940
import {
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(/^Operator "\$ne" on field "stage" requires a single comparable value/);
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

Comments
 (0)