Skip to content

Commit c24fc53

Browse files
committed
test(objectql): type the new #5869 call sites instead of erasing them to any
`check:query-options-erasure` went red: "test surface grew 267 -> 289". The 22 new engine call sites in engine-filter-array-lowering.test.ts all carried a bare `as any`, which the #4918 ratchet counts. Both remedies the gate names are used, split by what `tsc` actually says about each input rather than applied uniformly: - `as unknown as EngineQueryOptions` (via the `asFilterArrayQuery` helper, and the `EngineCountOptions` / `EngineAggregateOptions` twins on count/aggregate) for the FilterArray inputs. Those are off-contract BY DECLARATION -- `where` is a FilterCondition / Record<string, unknown> that an array is not assignable to, because FilterArray is INPUT-ONLY sugar the spec excludes (#5285). - The assertion simply DROPPED on the malformed-comparand cases (`{ stage: { $nin: 'won' } }`). Those type-check fine, because `where` is declared loosely on purpose -- which is precisely why the runtime gate this file pins has to exist. Erasing them would have hidden that they are type-legal. The 23 pre-existing sites in this file are untouched, as are the baseline JSON and eslint.config.mjs -- the ceiling is met by fixing the new sites, not by raising the number. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
1 parent 99eae83 commit c24fc53

1 file changed

Lines changed: 54 additions & 24 deletions

File tree

‎packages/objectql/src/engine-filter-array-lowering.test.ts‎

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,32 @@
2626
*/
2727

2828
import { describe, it, expect, beforeEach } from 'vitest';
29+
import type {
30+
EngineAggregateOptions,
31+
EngineCountOptions,
32+
EngineQueryOptions,
33+
} from '@objectstack/spec/data';
2934
import { ObjectQL } from './engine.js';
3035

36+
/**
37+
* [#4918] `FilterArray` on `where` is off-contract BY DECLARATION, and these
38+
* tests exist to drive it: `EngineQueryOptions.where` is a `FilterCondition` /
39+
* `Record< string, unknown >`, which an array is not assignable to, because
40+
* `FilterArray` is INPUT-ONLY authoring sugar the spec deliberately excludes
41+
* (#5285). So a test that hands the engine one has to say so, and
42+
* `as unknown as EngineQueryOptions` is how: it names the contract being
43+
* bypassed, keeps the rest of the call type-checked, and greps as an
44+
* intentional act — none of which a bare `as any` does.
45+
*
46+
* Deliberately NOT used for the malformed-COMPARAND cases below
47+
* (`{ stage: { $nin: 'won' } }`). Those are ordinary objects that `tsc`
48+
* accepts, because `where` is declared loosely on purpose — which is the whole
49+
* reason the runtime gate this file pins has to exist. Erasing them would hide
50+
* that they are type-legal, which is the point.
51+
*/
52+
const asFilterArrayQuery = (where: unknown): EngineQueryOptions =>
53+
({ where }) as unknown as EngineQueryOptions;
54+
3155
const deal = {
3256
name: 'deal',
3357
label: 'Deal',
@@ -320,15 +344,15 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
320344
['notin', [['stage', 'notin', 'won']]],
321345
['in', [['stage', 'in', 'won']]],
322346
])('refuses a scalar comparand on the collection operator %s', async (_op, where) => {
323-
await expect(engine.find('deal', { where } as any))
347+
await expect(engine.find('deal', asFilterArrayQuery(where)))
324348
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
325349
// Nothing ran: a refused filter must not reach the driver at all, or the
326350
// 400 would be describing a query that already returned rows.
327351
expect(reads).toHaveLength(0);
328352
});
329353

330354
it('the refusal NAMES the operator, the field and the expected shape (#5346/#5348 wording)', async () => {
331-
const err = await engine.find('deal', { where: [['stage', 'not_in', 'won']] } as any)
355+
const err = await engine.find('deal', asFilterArrayQuery([['stage', 'not_in', 'won']]))
332356
.then(() => null, (e: any) => e);
333357

334358
expect(err).not.toBeNull();
@@ -367,7 +391,7 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
367391
[['stage', 'in', 'won']],
368392
[['amount', 'between', 5]],
369393
]) {
370-
const err = await engine.find('deal', { where } as any)
394+
const err = await engine.find('deal', asFilterArrayQuery(where))
371395
.then(() => null, (e: any) => e);
372396
expect(err.message.length, JSON.stringify(where)).toBeLessThan(CLIENT_MESSAGE_MAX);
373397
expect(err.message, JSON.stringify(where)).toMatch(/UNFILTERED result set/);
@@ -378,9 +402,12 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
378402
// The protocol/HTTP face runs its own `isFilterAST` → `parseFilterAST` and
379403
// hands the engine an already-lowered FilterCondition, so the array branch
380404
// above never sees a wire query. This is that shape, arriving as an object.
381-
await expect(engine.find('deal', { where: { stage: { $nin: 'won' } } } as any))
405+
// NOT erased: `where` is declared `Record< string, unknown >`, so `tsc`
406+
// accepts a malformed comparand. That it type-checks and still has to be
407+
// refused at runtime is exactly why this gate exists.
408+
await expect(engine.find('deal', { where: { stage: { $nin: 'won' } } }))
382409
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
383-
await expect(engine.find('deal', { where: { stage: { $in: 'won' } } } as any))
410+
await expect(engine.find('deal', { where: { stage: { $in: 'won' } } }))
384411
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
385412
});
386413

@@ -389,28 +416,31 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
389416
['a number', { amount: { $in: 10 } }],
390417
['an object', { stage: { $in: { a: 1 } } }],
391418
])('refuses a comparand that is %s — every non-list, not just strings', async (_l, where) => {
392-
await expect(engine.find('deal', { where } as any))
419+
await expect(engine.find('deal', { where }))
393420
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
394421
});
395422

396423
it('walks into $and / $or / $not — a nested scalar is refused with its own path', async () => {
397-
const err = await engine.find('deal', {
398-
where: ['and', ['amount', '>', 5], ['stage', 'not_in', 'won']],
399-
} as any).then(() => null, (e: any) => e);
424+
const err = await engine.find(
425+
'deal',
426+
asFilterArrayQuery(['and', ['amount', '>', 5], ['stage', 'not_in', 'won']]),
427+
).then(() => null, (e: any) => e);
400428
expect(err?.status).toBe(400);
401429
expect(err.message).toMatch(/where\.\$and\[1\]\.stage\.\$nin/);
402430

403-
await expect(engine.find('deal', { where: { $not: { stage: { $in: 'won' } } } } as any))
431+
await expect(engine.find('deal', { where: { $not: { stage: { $in: 'won' } } } }))
404432
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
405433
});
406434

407435
it('every engine entry point refuses it, not just find()', async () => {
408436
const where = [['stage', 'not_in', 'won']];
409-
await expect(engine.findOne('deal', { where } as any)).rejects.toMatchObject({ status: 400 });
410-
await expect(engine.count('deal', { where } as any)).rejects.toMatchObject({ status: 400 });
437+
await expect(engine.findOne('deal', asFilterArrayQuery(where)))
438+
.rejects.toMatchObject({ status: 400 });
439+
await expect(engine.count('deal', { where } as unknown as EngineCountOptions))
440+
.rejects.toMatchObject({ status: 400 });
411441
await expect(engine.aggregate('deal', {
412442
where, groupBy: ['stage'], aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
413-
} as any)).rejects.toMatchObject({ status: 400 });
443+
} as unknown as EngineAggregateOptions)).rejects.toMatchObject({ status: 400 });
414444
await expect(engine.update('deal', { amount: 1 }, { where, multi: true } as any))
415445
.rejects.toMatchObject({ status: 400 });
416446
await expect(engine.delete('deal', { where, multi: true } as any))
@@ -430,12 +460,12 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
430460
['a 1-tuple', [['amount', 'between', [1]]]],
431461
['a 3-tuple', [['amount', 'between', [1, 2, 3]]]],
432462
])('refuses a $between comparand that is %s', async (_l, where) => {
433-
await expect(engine.find('deal', { where } as any))
463+
await expect(engine.find('deal', asFilterArrayQuery(where)))
434464
.rejects.toMatchObject({ status: 400, code: 'INVALID_FILTER' });
435465
});
436466

437467
it('the $between refusal keeps the platform-wide wording and names the field', async () => {
438-
const err = await engine.find('deal', { where: [['amount', 'between', 5]] } as any)
468+
const err = await engine.find('deal', asFilterArrayQuery([['amount', 'between', 5]]))
439469
.then(() => null, (e: any) => e);
440470
// Verbatim leading sentence from driver-sql / driver-memory: one condition,
441471
// one wording, wherever the caller meets it.
@@ -448,30 +478,30 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
448478
// ── what must KEEP working: the declared list shapes ───────────────────
449479

450480
it('a proper list comparand still reaches the driver untouched', async () => {
451-
await engine.find('deal', { where: [['stage', 'in', ['won', 'lost']]] } as any);
481+
await engine.find('deal', asFilterArrayQuery([['stage', 'in', ['won', 'lost']]]));
452482
expect(lastWhere()).toEqual({ stage: { $in: ['won', 'lost'] } });
453483

454-
await engine.find('deal', { where: [['stage', 'not_in', ['lost']]] } as any);
484+
await engine.find('deal', asFilterArrayQuery([['stage', 'not_in', ['lost']]]));
455485
expect(lastWhere()).toEqual({ stage: { $nin: ['lost'] } });
456486

457-
await engine.find('deal', { where: [['amount', 'between', [5, 25]]] } as any);
487+
await engine.find('deal', asFilterArrayQuery([['amount', 'between', [5, 25]]]));
458488
expect(lastWhere()).toEqual({ amount: { $between: [5, 25] } });
459489
});
460490

461491
it('an EMPTY list is a declared predicate, not a malformed one', async () => {
462492
// `$in: []` matches nothing and `$nin: []` matches everything — both
463493
// drivers say so in as many words. Arity is not this gate's business.
464-
await engine.find('deal', { where: { stage: { $in: [] } } } as any);
494+
await engine.find('deal', { where: { stage: { $in: [] } } });
465495
expect(lastWhere()).toEqual({ stage: { $in: [] } });
466-
await engine.find('deal', { where: { stage: { $nin: [] } } } as any);
496+
await engine.find('deal', { where: { stage: { $nin: [] } } });
467497
expect(lastWhere()).toEqual({ stage: { $nin: [] } });
468498
});
469499

470500
it('the gate does not re-judge list MEMBERS — that is #5234, on another face', async () => {
471501
// A `$field` reference and a plain object are both legitimate members here;
472502
// this gate asks only whether the comparand is a list at all.
473503
const where = { stage: { $in: [{ $field: 'other' }, 'won'] } };
474-
await engine.find('deal', { where } as any);
504+
await engine.find('deal', { where });
475505
expect(lastWhere()).toEqual(where);
476506
});
477507

@@ -480,18 +510,18 @@ describe('Door 2 lowers FilterArray to FilterCondition before the driver (#5158)
480510
// stored document whose own key happens to be `$in` — a stricter contract
481511
// than any backend applies.
482512
const where = { stage: { $eq: { $in: 'not-an-operator-here' } } };
483-
await engine.find('deal', { where } as any);
513+
await engine.find('deal', { where });
484514
expect(lastWhere()).toEqual(where);
485515
});
486516

487517
it('a scalar on a NON-collection operator is untouched', async () => {
488-
await engine.find('deal', { where: [['stage', '!=', 'won']] } as any);
518+
await engine.find('deal', asFilterArrayQuery([['stage', '!=', 'won']]));
489519
expect(lastWhere()).toEqual({ stage: { $ne: 'won' } });
490520
// String bounds on a range comparison stay legal — `FieldOperatorsSchema`
491521
// declares `$gt` as number|Date|FieldReference, but ISO strings are what the
492522
// showcase apps send and every backend accepts. This gate enforces the
493523
// three list declarations, not the whole schema.
494-
await engine.find('deal', { where: [['stage', '>', '2026-01-01']] } as any);
524+
await engine.find('deal', asFilterArrayQuery([['stage', '>', '2026-01-01']]));
495525
expect(lastWhere()).toEqual({ stage: { $gt: '2026-01-01' } });
496526
});
497527

0 commit comments

Comments
 (0)