Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/21448-list-at-scalar-operator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
---

A list at a scalar operator (`{ amount: { $gt: [10, 99] } }`) is refused at the shared comparand-shape face, whatever the column type, instead of being narrowed to its first member

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a LIST comparand at a scalar filter operator, at the shared comparand-shape face (assertListComparandShapes) and at the save door that asks it (FilterConditionSchema and the analytics carriers): no authorable key, spelling, export or type moves. FieldOperatorsSchema already refused a list at every one of these operator slots; the runtime face and the save door now refuse what that declaration refuses. No export is added or removed (the operator split moves to a module outside the data barrel), and no stored row is read or rewritten. Which one value the author meant by a refused list (its first member, a range, membership) is not something a ledger entry can decide, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand's shape and this diff adds none (not registered / already-registered); and the change is runtime behaviour with no published interface or type changed (not runtime-interface-only / type-surface-only). -->

**BREAKING**: this narrows what the shared filter faces accept. FROM: a list at a scalar operator passed the comparand-shape face, and each consumer answered it alone. The analytics lowering bound the list's first member (`{ note: { $gt: ['a', 'z'] } }` answered 200 as `$gt 'a'` on both analytics faces, and the engine-aggregate face did the same on a number column), `driver-sql` refused it in its own words, and `driver-memory` answered one at a text operator. TO: `INVALID_FILTER` / 400 at the face, before any read, on every door that runs it, with one sentence naming the operator, the field, the list and where. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, type or error code changes.

- **What changed.** `assertListComparandShapes` (`@objectstack/spec/data`) refuses a list under every scalar operator other than `$eq` / `$ne`, which keep their own refusals: `$gt`, `$gte`, `$lt`, `$lte`, the text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$like`, `$ilike`) and the flags (`$null`, `$exists`, `$empty`). This covers every depth, both filter spellings (object and `[field, op, value]`), and the empty list.
- The engine's `where`, per-aggregation `filter` and `having`, `parseFilterAST`, both analytics doors, the read-scope compiler and the RLS compiler all run the face, so all of them refuse it.
- The save door asks the same face. A dataset, measure, dashboard-widget or report filter that carries one is refused on save, located on the member. The HTTP routes that parse a filter in their body (`POST /api/v1/data/:object/query`, `/api/v1/analytics/query`, `/api/v1/analytics/dataset/query`) answer `VALIDATION_FAILED` / 400 there, as for every other face refusal.
- **What you may notice.** A filter that put a list under `$gt`, `$contains` or a flag now refuses instead of answering. Write one value; for "one of these values" use `$in` (authoring `in`), and for a range use `$between` (authoring `between`). A list at a flag reads in this sentence now, not the boolean-flag one.
- **Unchanged.** A list at `$in` / `$nin` / `$between`, `$in: []` / `$nin: []`, every single value (`null`, a `Date` and a `{ $field }` reference included), a list nested inside `$in`, and an operator outside the declared vocabulary, which keeps its own refusal.
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ const NON_BOOLEANS: ReadonlyArray<readonly [string, unknown, string]> = [
['"false" (truthy)', 'false', 'string ("false")'],
['0', 0, 'number (0)'],
['null', null, 'null (null)'],
// Not one of the card's five, but reaching the same gate: no earlier door
// refuses a list here, so it met the old `!!target` read like any other value.
['[true] (a list)', [true], 'array ([true])'],
// [#21448] `[true] (a list)` stood here, reaching this gate because no
// earlier door refused a list at a flag. The shared comparand-shape face
// does now, one door earlier, in its own words — pinned in the block below.
];

describe('[#20981] a non-boolean $exists / $null — refused before any driver read, on both positions', () => {
Expand Down Expand Up @@ -184,6 +184,25 @@ describe('[#20981] a non-boolean $exists / $null — refused before any driver r
}
}

it('[#21448] a LIST flag is the shared comparand-shape face\'s refusal, one door earlier, at both positions — no read', async () => {
for (const op of OPS) {
const sentence = `Operator "${op}" on field "name" requires a single comparable value, but received an array ([true])`;
const { engine, reads } = await makeEngine('rows', ROWS);
const filtered = await refusalOf(() => engine.aggregate(OBJECT, filterQuery({ name: { [op]: [true] } })));
expect({ code: filtered.code, status: filtered.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(filtered.message, op).toContain(`${sentence} at aggregations[1].filter.name.${op}.`);
expect(filtered.message, op).not.toContain('requires a boolean comparand');
expect(reads, `${op}: no row was read`).toEqual({ aggregate: 0, find: 0 });
for (const path of ['native', 'rows'] as const) {
const grouped = await makeEngine(path, ROWS);
const having = await refusalOf(() => grouped.engine.aggregate(OBJECT, havingQuery(path, { name: { [op]: [true] } })));
expect({ code: having.code, status: having.status }, `${op} ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(having.message, `${op} ${path}`).toContain(`${sentence} at having.name.${op}.`);
expect(grouped.reads, `${op} ${path}: no row was read`).toEqual({ aggregate: 0, find: 0 });
}
}
});

// Where the flag sits must not change the verdict: the walk is row-independent,
// so a branch the per-row walk would short-circuit past is judged too.
const POSITIONS: ReadonlyArray<readonly [string, (flag: Record<string, unknown>) => Record<string, unknown>, string]> = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,9 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', ()
['implicit Date', (v) => v, () => new Date(Date.UTC(2026, 0, 1)), 'date'],
['a $nin member Date', (v) => ({ $nin: [v, true] }), () => new Date(Date.UTC(2026, 0, 1)), 'date'],
['a $in member [true] (the card)', (v) => ({ $in: [false, v] }), () => [true], 'array'],
['$gt [true]', (v) => ({ $gt: v }), () => [true], 'array'],
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
// shared comparand-shape face's refusal, one door before this arm, at every
// position — pinned in its own block below.
];

/** What the contract says is wrong, per non-string form — the clause after "which is not a boolean:". */
Expand Down Expand Up @@ -502,6 +504,34 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', ()
}
});

it('[#21448] $gt [true] is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => {
const faceSentence = (field: string, path: string) =>
`Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([true]) at ${path}.`;
reads.length = 0;
const where = await refusalOf(engine.find(OBJECT, { where: { f_boolean: { $gt: [true] } } as FilterCondition }));
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(where!.message).toMatch(/^find\('boolean_door_probe'\): Operator /);
expect(where!.message).toContain(faceSentence('f_boolean', 'where.f_boolean.$gt'));
const filtered = await refusalOf(engine.aggregate(OBJECT, {
aggregations: [
{ function: 'count', alias: 'all' },
{ function: 'count', alias: 'bad', filter: { f_boolean: { $gt: [true] } } },
],
} as EngineAggregateOptions));
expect({ code: filtered!.code, status: filtered!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(filtered!.message).toContain(faceSentence('f_boolean', 'aggregations[1].filter.f_boolean.$gt'));
const having = await refusalOf(engine.aggregate(OBJECT, {
groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: { $gt: [true] } },
} as EngineAggregateOptions));
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(having!.message).toContain(faceSentence('f_boolean', 'having.f_boolean.$gt'));
expect(reads).toHaveLength(0);
// This arm's own walk still refuses the form when asked alone; no door
// reaches it at a scalar operator any more.
expect(() => narrowNumberComparands(OBJECT, 'find', engine.registry.getObject(OBJECT), { f_toggle: { $gt: [true] } }))
.toThrow(/compares a declared toggle field/);
});

it('[#21382] the controls at all three positions: true and 1 answer exactly what they answered before', async () => {
for (const control of [true, 1]) {
expect(await driverWhere({ f_boolean: control }), String(control)).toEqual(lowered({ f_boolean: true }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
['true', () => true, 'boolean'],
['false', () => false, 'boolean'],
['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'],
['an array', () => [10], 'array'],
// [#21448] `[10]` left this table: a list at a scalar operator is the
// shared comparand-shape face's refusal, one door before this one, at every
// position — pinned in its own block below.
];

it('[#20502] refuses a boolean, a Date or an array in ONE aggregation\'s own filter, rooted at that position — no read', async () => {
Expand Down Expand Up @@ -500,6 +502,39 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
}
});

it('[#21448] an array at a scalar operator is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => {
const faceSentence = (op: string, field: string, path: string) =>
`Operator "${op}" on field "${field}" requires a single comparable value, but received an array ([10]) at ${path}.`;
reads.length = 0;
const where = await refusalOf(engine.find(OBJECT, { where: { f_number: { $gt: [10] } } as FilterCondition }));
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(where!.message).toMatch(/^find\('number_door_probe'\): Operator /);
expect(where!.message).toContain(faceSentence('$gt', 'f_number', 'where.f_number.$gt'));
for (const op of ['$gt', '$lte'] as const) {
const filtered = await refusalOf(engine.aggregate(OBJECT, {
aggregations: [
{ function: 'count', alias: 'all' },
{ function: 'count', alias: 'bad', filter: { f_number: { [op]: [10] } } },
],
} as EngineAggregateOptions));
expect({ code: filtered!.code, status: filtered!.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(filtered!.message, op).toMatch(/^aggregate\('number_door_probe'\): Operator /);
expect(filtered!.message, op).toContain(faceSentence(op, 'f_number', `aggregations[1].filter.f_number.${op}`));
}
const having = await refusalOf(engine.aggregate(OBJECT, {
groupBy: ['f_text'],
aggregations: [{ function: 'count', alias: 'total' }],
having: { total: { $gt: [10] } },
} as EngineAggregateOptions));
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(having!.message).toContain(faceSentence('$gt', 'total', 'having.total.$gt'));
expect(reads).toHaveLength(0);
// This door's own walk still names the form when asked alone; no door
// reaches that arm at a scalar operator any more.
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { $gt: [10] } }))
.toMatchObject({ field: 'f_number', form: 'array' });
});

it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => {
reads.length = 0;
await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } });
Expand Down
10 changes: 10 additions & 0 deletions packages/rest/src/analytics-filter-refusal-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,16 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th
member: 'selection.runtimeFilter.stage.$in.1',
sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./,
},
{
// [#21448] A list at a scalar operator, whatever the column type. Both
// analytics faces bound its FIRST member on a text column (200, wrong
// rows); the shared comparand-shape face now refuses it on query, and the
// schema door asks that face, so it is refused here, located on the member.
name: 'a list at a scalar operator',
runtimeFilter: { stage: { $gt: ['a', 'z'] } },
member: 'selection.runtimeFilter.stage.$gt',
sentence: /^Operator "\$gt" on field "stage" requires a single comparable value, but received an array \(\["a","z"\]\)\. Write ONE value\./,
},
];

for (const c of AT_THE_DOOR) {
Expand Down
38 changes: 37 additions & 1 deletion packages/rest/src/data-boolean-comparand-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ const REFUSED_OVER_REST: ReadonlyArray<readonly [string, unknown]> = [
['a $in member 2', { $in: [false, 2] }],
['a $nin member -1', { $nin: [-1] }],
['a $in member [true] (the card)', { $in: [false, [true]] }],
['$gt [true] (an array at a scalar slot)', { $gt: [true] }],
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
// shared comparand-shape face's refusal, one door before this one — pinned
// in its own block below. A list as a `$in` MEMBER is still this door's.
];

/**
Expand Down Expand Up @@ -245,6 +247,40 @@ for (const cell of CELLS) {
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
});

it('[#21448] $gt [true]: one 400 at every position, in the shared comparand-shape face\'s words — VALIDATION_FAILED at the wire, INVALID_FILTER in process — no read', async () => {
const before = reads.n;
const sentence = 'Operator "$gt" on field "done" requires a single comparable value, but received an array ([true])';
const where = { done: { $gt: [true] } } as FilterCondition;
// Over the wire the route parses its body first, and the schema door
// asks the face (#20116): VALIDATION_FAILED, located on the member, in
// the face's sentence less its location — before the engine runs.
for (const [body, member] of [
[{ where }, 'query.where.done.$gt'],
[perAggregation(where), 'query.aggregations.1.filter.done.$gt'],
[grouped('native', where), 'query.having.done.$gt'],
] as const) {
const res = await query(body as Record<string, unknown>);
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(res.body.code, member).toBe('VALIDATION_FAILED');
const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member);
expect(at, JSON.stringify(res.body.fields)).toHaveLength(1);
expect(at[0]!.message.startsWith(`${sentence}. Write ONE value.`), at[0]!.message).toBe(true);
}
// In process the engine's seam runs the face itself: INVALID_FILTER, located.
const err = await refusalOf(engine.find(OBJECT, { where }));
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(err?.message).toContain(`${sentence} at where.done.$gt.`);
const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation(where)));
expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(filtered?.message).toContain(`${sentence} at aggregations[1].filter.done.$gt.`);
for (const path of ['native', 'rows'] as const) {
const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, where)));
expect({ code: having?.code, status: having?.status }, `having ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
expect(having?.message, `having ${path}`).toContain(`${sentence} at having.done.$gt.`);
}
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
});

it('the controls: true, 1 and "true" answer the rows they name, at every position', async () => {
for (const [name, spec, ids] of CONTROLS) {
const res = await query({ where: { done: spec } });
Expand Down
Loading
Loading