2626 */
2727
2828import { describe , it , expect , beforeEach } from 'vitest' ;
29+ import type {
30+ EngineAggregateOptions ,
31+ EngineCountOptions ,
32+ EngineQueryOptions ,
33+ } from '@objectstack/spec/data' ;
2934import { 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+
3155const 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 ( / U N F I L T E R E D r e s u l t s e t / ) ;
@@ -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 ( / w h e r e \. \$ a n d \[ 1 \] \. s t a g e \. \$ n i n / ) ;
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