Skip to content

Commit a2dce86

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20476-define-stack-conversions-reach-doors
2 parents 04c0733 + b057434 commit a2dce86

8 files changed

Lines changed: 544 additions & 47 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
fix(spec)!: a boolean, a `Date` or an array compared against a number field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, the same as a non-numeric string
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter COMPARAND value at the engine's query door, decided by the published number-comparand verdict: no authorable key, spelling, export or stored shape moves (the verdict's function, its case table and its words keep their names; three exports are added), and no stored row is read or rewritten. What is refused is a boolean, a Date or an array compared against a declared numeric field or a numeric aggregated column; which number the caller meant is not something a ledger entry can decide (reading true as 1 is exactly the silent coercion this refuses). The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a filter comparand (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what a filter may compare a number field with. `numberComparandDoorVerdict`, the published verdict the engine's number-comparand door consumes, judged strings only; it now also answers `door-refusal` for a boolean, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The string rule is unchanged, and so are both packages' root exports except three additions to `@objectstack/spec/data`: `NON_NUMERIC_VALUE_FORMS` and the types `NonNumericValueForm` and `NonNumericComparandForm` (the refusal's `form` and the refusal site's `value` widen to carry a non-string).
13+
14+
FROM `true` / `false`, a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `number`, `currency`, `percent`, `rating`, `slider`, `progress` or `summary` field (or a `count` / `sum` / `avg`, or a numeric `min` / `max` / groupBy column in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send the number the filter means (`12`, `-3.5`, `1e3`), or compare a `Date` with a date or datetime field.
15+
16+
Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query`, three rows (5, 12, 30) of a `number` field:
17+
18+
| position | comparand | before: memory · SQLite · PostgreSQL 16 | now, on all three |
19+
|:--|:--|:--|:--|
20+
| `where` | `$gt true` | no rows · every row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
21+
| `where` | `$ne true` | every row · every row · 500 | `INVALID_FILTER` / 400 |
22+
| `where` | `$between [true, 20]` | no rows · two rows · 500 | `INVALID_FILTER` / 400 |
23+
| `where` | `$gt` a `Date` (in-process callers) | no rows · no rows · 500 | `INVALID_FILTER` / 400 |
24+
| `where` | `$gt [1]` | a driver's own 400, in each driver's words | `INVALID_FILTER` / 400, in one set of words |
25+
| `where` | a `$in` member `[1]` | no rows · a driver 400 · a driver 400 | `INVALID_FILTER` / 400 |
26+
| per-aggregation `filter` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | count 3 (count 0), on all three | `INVALID_FILTER` / 400 |
27+
| `having` on `sum(amount)` | `$gt true` / `$gt [1]` (`$gt` a `Date`) | every group (no group), on all three | `INVALID_FILTER` / 400 |
28+
| all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same |
29+
30+
**Who is affected.** A caller that compares a number field with a boolean or an array through any door that reaches the engine (a `where`, `$filter` or `filter` body of `POST /data/:object/query`, or an in-process engine call), or with a `Date` in-process (a flow, a hook, server code). No example app, platform object or other in-repo producer compares a number field that way.
31+
32+
**Unchanged.** A number, a `bigint` (narrowed or refused by the comparand-type door, as before) and `null` (the null test) are answered as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. An array at an equality slot (implicit, `$eq`, `$ne`) keeps the comparand-shape door's refusal. A boolean or a `Date` compared against a boolean, date or datetime field is not this door's subject. Driver-direct callers that never pass through the engine keep each driver's native binding.

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

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
4545
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
4646
import {
4747
NON_NUMERIC_STRING_FORMS,
48+
NON_NUMERIC_VALUE_FORMS,
4849
NUMBER_COMPARAND_DOOR_CASES,
50+
normalizeFilterComparandTypes,
4951
NUMBER_COMPARAND_DOOR_FIXTURE,
5052
NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT,
5153
NUMBER_COMPARAND_DOOR_LIST_OPERATORS,
@@ -157,8 +159,9 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
157159
// The untyped formula is the table's only deferred row, and it is judged one door earlier.
158160
expect(DEFERRED).toHaveLength(0);
159161
expect(STAGED.map((c) => c.verdict)).toEqual(['passes']);
160-
// Every refused form the grammar names is driven, not just the card's "abc".
161-
expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set(NON_NUMERIC_STRING_FORMS));
162+
// Every refused form the grammar names is driven, not just the card's "abc" —
163+
// and [#20502] every non-string form (a boolean, a Date, an array) beside them.
164+
expect(new Set(REFUSALS.map((c) => c.form))).toEqual(new Set([...NON_NUMERIC_STRING_FORMS, ...NON_NUMERIC_VALUE_FORMS]));
162165
// Every judged position is driven both ways.
163166
const positions = (cs: readonly NumberComparandDoorCase[]) =>
164167
new Set(cs.filter((c) => c.key === 'f_number').map((c) => c.position.replace(/\[\d\]$/, '')));
@@ -383,6 +386,110 @@ describe('[#20351] the number-comparand declared-type door at the engine collect
383386
expect(await groups({ id: { $ne: 'abc' } })).toEqual(['r1', 'r2', 'r3']);
384387
});
385388

389+
// ── [#20502] a boolean, a Date, an array: one refusal at every position ──
390+
391+
/** The non-string comparands the widened verdict refuses — each a value the card measured three ways. */
392+
const NON_STRING: ReadonlyArray<readonly [string, () => unknown, string]> = [
393+
['true', () => true, 'boolean'],
394+
['false', () => false, 'boolean'],
395+
['a Date', () => new Date(Date.UTC(2026, 0, 1)), 'date'],
396+
['an array', () => [10], 'array'],
397+
];
398+
399+
it('[#20502] refuses a boolean, a Date or an array in ONE aggregation\'s own filter, rooted at that position — no read', async () => {
400+
for (const [name, value, form] of NON_STRING) {
401+
for (const op of ['$gt', '$lte'] as const) {
402+
reads.length = 0;
403+
const err = await refusalOf(engine.aggregate(OBJECT, {
404+
aggregations: [
405+
{ function: 'count', alias: 'all' },
406+
{ function: 'count', alias: 'bad', filter: { f_number: { [op]: value() } } },
407+
],
408+
} as EngineAggregateOptions));
409+
expect(err, `${name} ${op}`).not.toBeNull();
410+
expect({ code: err!.code, status: err!.status }, `${name} ${op}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
411+
expect(err!.message, `${name} ${op}`).toContain(`aggregations[1].filter.f_number.${op}`);
412+
expect(err!.message, `${name} ${op}`).toMatch(/^aggregate\('number_door_probe'\): filter on 'f_number' compares a declared number field/);
413+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), { f_number: { [op]: value() } }), name)
414+
.toMatchObject({ field: 'f_number', form });
415+
expect(reads, `${name} ${op}`).toHaveLength(0);
416+
}
417+
}
418+
});
419+
420+
it('[#20502] refuses a boolean, a Date or an array against a NUMERIC `having` column — count, sum and a numeric min alike', async () => {
421+
for (const [name, value] of NON_STRING) {
422+
for (const [fn, field] of [['count', undefined], ['sum', 'f_number'], ['min', 'f_currency']] as const) {
423+
reads.length = 0;
424+
const err = await refusalOf(engine.aggregate(OBJECT, {
425+
groupBy: ['f_text'],
426+
aggregations: [{ function: fn, ...(field ? { field } : {}), alias: 'total' }],
427+
having: { total: { $gt: value() } },
428+
} as EngineAggregateOptions));
429+
expect(err, `${name} ${fn}`).not.toBeNull();
430+
expect({ code: err!.code, status: err!.status }, `${name} ${fn}`).toEqual({ code: 'INVALID_FILTER', status: 400 });
431+
expect(err!.message, `${name} ${fn}`).toContain('having.total.$gt');
432+
expect(err!.message, `${name} ${fn}`).toContain("filter on 'total' compares a declared number field");
433+
expect(reads, `${name} ${fn}`).toHaveLength(0);
434+
}
435+
}
436+
});
437+
438+
it('[#20502] the numeric control at all three positions: a number reaches the driver and the evaluator as written', async () => {
439+
reads.length = 0;
440+
await engine.find(OBJECT, { where: { f_number: { $gt: 10 } } });
441+
expect(reads[0]?.ast?.where).toEqual({ f_number: { $gt: 10 } });
442+
const counted = await engine.aggregate(OBJECT, {
443+
aggregations: [{ function: 'count', alias: 'all' }, { function: 'count', alias: 'm', filter: { f_number: { $gt: 10 } } }],
444+
} as EngineAggregateOptions);
445+
expect(Number((counted[0] as Record<string, unknown>).m)).toBe(2);
446+
const groups = (await engine.aggregate(OBJECT, {
447+
groupBy: ['id'],
448+
aggregations: [{ function: 'sum', field: 'f_number', alias: 'total' }],
449+
having: { total: { $gt: 10 } },
450+
} as EngineAggregateOptions)).map((r) => (r as Record<string, unknown>).id).sort();
451+
expect(groups).toEqual(['r2', 'r3']);
452+
});
453+
454+
it('[#20502] the same mistake as FilterArray sugar and inside $and / $or / $not — one answer per mistake', async () => {
455+
const sugar = await refusalOf(
456+
engine.find(OBJECT, { where: [['f_number', '>', true]] } as unknown as EngineQueryOptions),
457+
);
458+
expect({ code: sugar!.code, status: sugar!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
459+
expect(sugar!.message).toContain("filter on 'f_number' compares a declared number field against true at where.f_number.$gt");
460+
for (const where of [
461+
{ $and: [{ f_text: 'a' }, { f_number: { $gt: true } }] },
462+
{ $or: [{ f_text: 'a' }, { f_currency: { $in: [1, new Date(0)] } }] },
463+
{ $not: { f_percent: { $between: [[1], 10] } } },
464+
]) {
465+
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
466+
expect({ code: err?.code, status: err?.status }, JSON.stringify(where)).toEqual({ code: 'INVALID_FILTER', status: 400 });
467+
}
468+
expect(reads).toHaveLength(0);
469+
expect(engine.judgeFilter(OBJECT, { f_number: { $gt: new Date(0) } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 });
470+
});
471+
472+
it('[#20502] a value OUTSIDE the accepted comparand types is the comparand-TYPE door\'s refusal, in that door\'s words, on both spellings', async () => {
473+
const context = `find('${OBJECT}')`;
474+
for (const [name, value] of [['a plain object', { a: 1 }], ['undefined', undefined], ['a Map', new Map()]] as const) {
475+
const where = { f_number: { $gt: value } };
476+
// The verdict passes it, so THIS door has no second opinion …
477+
expect(findNonNumericComparand(engine.registry.getObject(OBJECT), where), name).toBeNull();
478+
// … and the engine answers exactly what the comparand-type door answers.
479+
let expected: Error | undefined;
480+
try { normalizeFilterComparandTypes(where, context); } catch (e) { expected = e as Error; }
481+
expect(expected, name).toBeDefined();
482+
const err = await refusalOf(engine.find(OBJECT, { where: where as FilterCondition }));
483+
expect({ code: err?.code, status: err?.status }, name).toEqual({ code: 'INVALID_FILTER', status: 400 });
484+
expect(err!.message, name).toBe(expected!.message);
485+
}
486+
const sugar = await refusalOf(engine.find(OBJECT, { where: [['f_number', '>', { a: 1 }]] } as unknown as EngineQueryOptions));
487+
let expected: Error | undefined;
488+
try { normalizeFilterComparandTypes({ f_number: { $gt: { a: 1 } } }, context); } catch (e) { expected = e as Error; }
489+
expect(sugar!.message).toBe(expected!.message);
490+
expect(reads).toHaveLength(0);
491+
});
492+
386493
it('GUARD the having walk narrows copy-on-write and judges only columns classed numeric', () => {
387494
const classes = new Map([['total', 'numeric' as const], ['label', 'text' as const], ['unknown', undefined]]);
388495
const having = { total: { $in: ['1', 2] }, label: { $eq: 'abc' }, unknown: { $eq: 'abc' } };

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
* filter collection point: the fifth gate on the seam that already carries
66
* the #5869 comparand-shape gate, the #8296 unmaterializable-field gate, the
77
* #15661 text-operator declared-type gate and the #8690 temporal-comparand
8-
* gate. It answers a fifth question about the same predicate: *is this string
9-
* a number the column can be compared with.*
8+
* gate. It answers a fifth question about the same predicate: *is this
9+
* comparand a number the column can be compared with.* [#20502] widened the
10+
* question from strings to every comparand, in the spec's verdict alone: a
11+
* boolean, a `Date` and an array are refused beside a non-numeric string,
12+
* and this file changed only to carry a refused value that is not a string.
1013
*
1114
* ## The direction this implements (triage, recorded on #20336)
1215
*
@@ -45,9 +48,12 @@
4548
*
4649
* ## The door's two answers
4750
*
48-
* - **Refuse** a string the grammar does not read as a number: `INVALID_FILTER`
49-
* / 400, the existing filter envelope, in the contract's words, before any
50-
* driver is resolved. A `{placeholder}` is refused too, unresolved: every
51+
* - **Refuse** a string the grammar does not read as a number, and a boolean,
52+
* a `Date` or an array (#20502): `INVALID_FILTER` / 400, the existing filter
53+
* envelope, in the contract's words, before any driver is resolved. A value
54+
* outside the comparand-type door's accepted set (`undefined`, a plain
55+
* object, a `Map`) passes the verdict and is refused by that door, one call
56+
* later, in its own words. A `{placeholder}` is refused too, unresolved: every
5157
* filter token resolves to an id or a date, never a number (the contract
5258
* argues it), and this door runs before `resolveWhereTokens`, as its
5359
* temporal neighbour records it must.
@@ -178,7 +184,11 @@ function fieldMetaOf(def: unknown): NumberComparandDoorFieldMeta | null {
178184
return typeof returnType === 'string' ? { type, returnType } : { type };
179185
}
180186

181-
/** One comparand at a judged position: the spec's verdict, routed. */
187+
/**
188+
* One comparand at a judged position: the spec's verdict, routed. Whatever
189+
* the comparand is — a string, a boolean, a `Date`, an array (#20502) — the
190+
* verdict alone decides; this function only turns its answer into an outcome.
191+
*/
182192
function judgeComparand(
183193
meta: NumberComparandDoorFieldMeta,
184194
field: string,
@@ -195,8 +205,7 @@ function judgeComparand(
195205
declaredType: meta.type,
196206
...(meta.returnType === undefined ? {} : { returnType: meta.returnType }),
197207
path,
198-
// `door-refusal` is answered for a string comparand only.
199-
value: comparand as string,
208+
value: comparand,
200209
form: verdict.form,
201210
},
202211
};
@@ -292,7 +301,7 @@ function declaredMetaOf(schema: unknown): MetaOf | null {
292301
}
293302

294303
/**
295-
* Walk one `FilterCondition` and return the FIRST string a declared numeric
304+
* Walk one `FilterCondition` and return the FIRST comparand a declared numeric
296305
* field cannot be compared with, or `null`.
297306
*
298307
* Exported for the same reason the sibling walks are: a consumer that needs to
@@ -314,7 +323,7 @@ function refuse(context: string, refusal: NonNumericComparand): never {
314323
}
315324

316325
/**
317-
* Refuse every string a declared numeric field cannot be compared with, and
326+
* Refuse every comparand a declared numeric field cannot be compared with, and
318327
* narrow every numeric string to its number — `INVALID_FILTER` / 400, this
319328
* package's existing filter envelope, in the contract's words. No code is
320329
* minted.

0 commit comments

Comments
 (0)