Skip to content

Commit b09820d

Browse files
committed
test(objectql,rest): a list at a scalar operator is pinned as the shape face's refusal at every engine and wire position (#21448)
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c22d18 commit b09820d

5 files changed

Lines changed: 153 additions & 5 deletions

‎packages/objectql/src/engine-boolean-comparand-declared-type-door.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,9 @@ describe('[#21333] the boolean-comparand arm at the engine collection point', ()
428428
['implicit Date', (v) => v, () => new Date(Date.UTC(2026, 0, 1)), 'date'],
429429
['a $nin member Date', (v) => ({ $nin: [v, true] }), () => new Date(Date.UTC(2026, 0, 1)), 'date'],
430430
['a $in member [true] (the card)', (v) => ({ $in: [false, v] }), () => [true], 'array'],
431-
['$gt [true]', (v) => ({ $gt: v }), () => [true], 'array'],
431+
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
432+
// shared comparand-shape face's refusal, one door before this arm, at every
433+
// position — pinned in its own block below.
432434
];
433435

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

507+
it('[#21448] $gt [true] is the shared comparand-shape face\'s at all three positions — in its words, no read', async () => {
508+
const faceSentence = (field: string, path: string) =>
509+
`Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([true]) at ${path}.`;
510+
reads.length = 0;
511+
const where = await refusalOf(engine.find(OBJECT, { where: { f_boolean: { $gt: [true] } } as FilterCondition }));
512+
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
513+
expect(where!.message).toMatch(/^find\('boolean_door_probe'\): Operator /);
514+
expect(where!.message).toContain(faceSentence('f_boolean', 'where.f_boolean.$gt'));
515+
const filtered = await refusalOf(engine.aggregate(OBJECT, {
516+
aggregations: [
517+
{ function: 'count', alias: 'all' },
518+
{ function: 'count', alias: 'bad', filter: { f_boolean: { $gt: [true] } } },
519+
],
520+
} as EngineAggregateOptions));
521+
expect({ code: filtered!.code, status: filtered!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
522+
expect(filtered!.message).toContain(faceSentence('f_boolean', 'aggregations[1].filter.f_boolean.$gt'));
523+
const having = await refusalOf(engine.aggregate(OBJECT, {
524+
groupBy: ['f_boolean'], aggregations: [{ function: 'count', alias: 'n' }], having: { f_boolean: { $gt: [true] } },
525+
} as EngineAggregateOptions));
526+
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
527+
expect(having!.message).toContain(faceSentence('f_boolean', 'having.f_boolean.$gt'));
528+
expect(reads).toHaveLength(0);
529+
// This arm's own walk still refuses the form when asked alone; no door
530+
// reaches it at a scalar operator any more.
531+
expect(() => narrowNumberComparands(OBJECT, 'find', engine.registry.getObject(OBJECT), { f_toggle: { $gt: [true] } }))
532+
.toThrow(/compares a declared toggle field/);
533+
});
534+
505535
it('[#21382] the controls at all three positions: true and 1 answer exactly what they answered before', async () => {
506536
for (const control of [true, 1]) {
507537
expect(await driverWhere({ f_boolean: control }), String(control)).toEqual(lowered({ f_boolean: true }));

‎packages/objectql/src/engine-number-comparand-declared-type-door.test.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
451451
['true', () => true, 'boolean'],
452452
['false', () => false, 'boolean'],
453453
['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'],
454-
['an array', () => [10], 'array'],
454+
// [#21448] `[10]` left this table: a list at a scalar operator is the
455+
// shared comparand-shape face's refusal, one door before this one, at every
456+
// position — pinned in its own block below.
455457
];
456458

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

505+
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 () => {
506+
const faceSentence = (op: string, field: string, path: string) =>
507+
`Operator "${op}" on field "${field}" requires a single comparable value, but received an array ([10]) at ${path}.`;
508+
reads.length = 0;
509+
const where = await refusalOf(engine.find(OBJECT, { where: { f_number: { $gt: [10] } } as FilterCondition }));
510+
expect({ code: where!.code, status: where!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
511+
expect(where!.message).toMatch(/^find\('number_door_probe'\): Operator /);
512+
expect(where!.message).toContain(faceSentence('$gt', 'f_number', 'where.f_number.$gt'));
513+
for (const op of ['$gt', '$lte'] as const) {
514+
const filtered = await refusalOf(engine.aggregate(OBJECT, {
515+
aggregations: [
516+
{ function: 'count', alias: 'all' },
517+
{ function: 'count', alias: 'bad', filter: { f_number: { [op]: [10] } } },
518+
],
519+
} as EngineAggregateOptions));
520+
expect({ code: filtered!.code, status: filtered!.status }, op).toEqual({ code: 'INVALID_FILTER', status: 400 });
521+
expect(filtered!.message, op).toMatch(/^aggregate\('number_door_probe'\): Operator /);
522+
expect(filtered!.message, op).toContain(faceSentence(op, 'f_number', `aggregations[1].filter.f_number.${op}`));
523+
}
524+
const having = await refusalOf(engine.aggregate(OBJECT, {
525+
groupBy: ['f_text'],
526+
aggregations: [{ function: 'count', alias: 'total' }],
527+
having: { total: { $gt: [10] } },
528+
} as EngineAggregateOptions));
529+
expect({ code: having!.code, status: having!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
530+
expect(having!.message).toContain(faceSentence('$gt', 'total', 'having.total.$gt'));
531+
expect(reads).toHaveLength(0);
532+
// This door's own walk still names the form when asked alone; no door
533+
// reaches that arm at a scalar operator any more.
534+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { $gt: [10] } }))
535+
.toMatchObject({ field: 'f_number', form: 'array' });
536+
});
537+
503538
it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => {
504539
reads.length = 0;
505540
await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } });

‎packages/rest/src/analytics-filter-refusal-envelope.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,16 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th
292292
member: 'selection.runtimeFilter.stage.$in.1',
293293
sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./,
294294
},
295+
{
296+
// [#21448] A list at a scalar operator, whatever the column type. Both
297+
// analytics faces bound its FIRST member on a text column (200, wrong
298+
// rows); the shared comparand-shape face now refuses it on query, and the
299+
// schema door asks that face, so it is refused here, located on the member.
300+
name: 'a list at a scalar operator',
301+
runtimeFilter: { stage: { $gt: ['a', 'z'] } },
302+
member: 'selection.runtimeFilter.stage.$gt',
303+
sentence: /^Operator "\$gt" on field "stage" requires a single comparable value, but received an array \(\["a","z"\]\)\. Write ONE value\./,
304+
},
295305
];
296306

297307
for (const c of AT_THE_DOOR) {

‎packages/rest/src/data-boolean-comparand-door.test.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ const REFUSED_OVER_REST: ReadonlyArray<readonly [string, unknown]> = [
9191
['a $in member 2', { $in: [false, 2] }],
9292
['a $nin member -1', { $nin: [-1] }],
9393
['a $in member [true] (the card)', { $in: [false, [true]] }],
94-
['$gt [true] (an array at a scalar slot)', { $gt: [true] }],
94+
// [#21448] `$gt [true]` left this table: a list at a scalar operator is the
95+
// shared comparand-shape face's refusal, one door before this one — pinned
96+
// in its own block below. A list as a `$in` MEMBER is still this door's.
9597
];
9698

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

250+
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 () => {
251+
const before = reads.n;
252+
const sentence = 'Operator "$gt" on field "done" requires a single comparable value, but received an array ([true])';
253+
const where = { done: { $gt: [true] } } as FilterCondition;
254+
// Over the wire the route parses its body first, and the schema door
255+
// asks the face (#20116): VALIDATION_FAILED, located on the member, in
256+
// the face's sentence less its location — before the engine runs.
257+
for (const [body, member] of [
258+
[{ where }, 'query.where.done.$gt'],
259+
[perAggregation(where), 'query.aggregations.1.filter.done.$gt'],
260+
[grouped('native', where), 'query.having.done.$gt'],
261+
] as const) {
262+
const res = await query(body as Record<string, unknown>);
263+
expect(res.status, JSON.stringify(res.body)).toBe(400);
264+
expect(res.body.code, member).toBe('VALIDATION_FAILED');
265+
const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member);
266+
expect(at, JSON.stringify(res.body.fields)).toHaveLength(1);
267+
expect(at[0]!.message.startsWith(`${sentence}. Write ONE value.`), at[0]!.message).toBe(true);
268+
}
269+
// In process the engine's seam runs the face itself: INVALID_FILTER, located.
270+
const err = await refusalOf(engine.find(OBJECT, { where }));
271+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
272+
expect(err?.message).toContain(`${sentence} at where.done.$gt.`);
273+
const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation(where)));
274+
expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
275+
expect(filtered?.message).toContain(`${sentence} at aggregations[1].filter.done.$gt.`);
276+
for (const path of ['native', 'rows'] as const) {
277+
const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, where)));
278+
expect({ code: having?.code, status: having?.status }, `having ${path}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
279+
expect(having?.message, `having ${path}`).toContain(`${sentence} at having.done.$gt.`);
280+
}
281+
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
282+
});
283+
248284
it('the controls: true, 1 and "true" answer the rows they name, at every position', async () => {
249285
for (const [name, spec, ids] of CONTROLS) {
250286
const res = await query({ where: { done: spec } });

‎packages/rest/src/data-number-comparand-door.test.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ const NON_STRING_OVER_REST: ReadonlyArray<readonly [string, unknown]> = [
120120
['$ne true', { $ne: true }],
121121
['a $in member true', { $in: [10, true] }],
122122
['a $between bound true', { $between: [true, 20] }],
123-
['$gt [1] (an array)', { $gt: [1] }],
123+
// [#21448] `$gt [1]` left this table: a list at a scalar operator is the
124+
// shared comparand-shape face's refusal, one door before this one — pinned
125+
// in its own block below. A list as a `$in` MEMBER is still this door's.
124126
['a $in member [1]', { $in: [[1], 10] }],
125127
];
126128

@@ -134,7 +136,7 @@ const NON_STRING_OVER_REST: ReadonlyArray<readonly [string, unknown]> = [
134136
const NON_STRING_IN_PROCESS: ReadonlyArray<readonly [string, () => unknown]> = [
135137
['true', () => true],
136138
['a Date', () => new Date(Date.UTC(2026, 0, 1))],
137-
['an array', () => [1]],
139+
// [#21448] `[1]` left this table for the shared comparand-shape face's block below.
138140
];
139141

140142
/** name · the constraint as a numeric string · the same as a number · `where` count. */
@@ -301,6 +303,41 @@ for (const cell of CELLS) {
301303
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
302304
});
303305

306+
it('[#21448] a list at a scalar operator: 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 () => {
307+
const before = reads.n;
308+
const sentence = (field: string) => `Operator "$gt" on field "${field}" requires a single comparable value, but received an array ([1])`;
309+
// Over the wire the route parses its body first, and the schema door
310+
// asks the face (#20116): VALIDATION_FAILED, located on the member, in
311+
// the face's sentence less its location — before the engine runs.
312+
for (const [body, member, field] of [
313+
[{ where: { amount: { $gt: [1] } } }, 'query.where.amount.$gt', 'amount'],
314+
[perAggregation({ amount: { $gt: [1] } } as FilterCondition), 'query.aggregations.1.filter.amount.$gt', 'amount'],
315+
[grouped('native', { total: { $gt: [1] } } as FilterCondition), 'query.having.total.$gt', 'total'],
316+
] as const) {
317+
const res = await query(body as Record<string, unknown>);
318+
expect(res.status, JSON.stringify(res.body)).toBe(400);
319+
expect(res.body.code, member).toBe('VALIDATION_FAILED');
320+
const at = (res.body.fields as Array<{ field: string; message: string }>).filter((f) => f.field === member);
321+
expect(at, JSON.stringify(res.body.fields)).toHaveLength(1);
322+
expect(at[0]!.message.startsWith(`${sentence(field)}. Write ONE value.`), at[0]!.message).toBe(true);
323+
}
324+
// In process the engine's seam runs the face itself: INVALID_FILTER, located.
325+
const found = await refusalOf(engine.find(OBJECT, { where: { amount: { $gt: [1] } } as FilterCondition }));
326+
expect({ code: found?.code, status: found?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
327+
expect(found?.message).toContain(`${sentence('amount')} at where.amount.$gt.`);
328+
const filtered = await refusalOf(engine.aggregate(OBJECT, perAggregation({ amount: { $gt: [1] } } as FilterCondition)));
329+
expect({ code: filtered?.code, status: filtered?.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
330+
expect(filtered?.message).toContain(`${sentence('amount')} at aggregations[1].filter.amount.$gt.`);
331+
for (const path of ['native', 'rows'] as const) {
332+
for (const column of ['total', 'top'] as const) {
333+
const having = await refusalOf(engine.aggregate(OBJECT, grouped(path, { [column]: { $gt: [1] } } as FilterCondition)));
334+
expect({ code: having?.code, status: having?.status }, `having ${path} ${column}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
335+
expect(having?.message, `having ${path} ${column}`).toContain(`${sentence(column)} at having.${column}.$gt.`);
336+
}
337+
}
338+
expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0);
339+
});
340+
304341
it('the control: a number is answered, and a numeric string counts what its number counts at every position', async () => {
305342
for (const [name, asString, asNumber, count] of NARROWED) {
306343
const s = await query({ where: { amount: asString } });

0 commit comments

Comments
 (0)