7272 */
7373
7474import { StandardErrorCode } from '@objectstack/spec/api' ;
75- import { isVirtualSearchField } from '@objectstack/spec/data' ;
75+ import { isVirtualSearchField , classifyDottedFilterHead } from '@objectstack/spec/data' ;
7676
7777/**
7878 * The operators whose comparand `FieldOperatorsSchema` declares as a list, with
@@ -350,13 +350,20 @@ function assertFieldListComparands(
350350 * declaration the caller can fix, and refusing one would turn a policy into a
351351 * 400 nobody can act on.
352352 *
353- * SCOPE — the unmaterializable verdict ONLY. An UNKNOWN filter field is not
354- * judged here: that is the ingress gate's first verdict
355- * (`assertFilterFieldsExist`, #7534), the engine deliberately keeps its
356- * registry-less tolerance, and widening this door to it is a separate posture
357- * change on a second verdict, exactly as #7095 declined to inherit sort's
358- * `unknown` and `dotted` legs. DOTTED filter paths are likewise untouched —
359- * they have no verdict on this axis at either door.
353+ * SCOPE — the unmaterializable verdict and, since #8371, the DOTTED-head
354+ * verdict. An UNKNOWN filter field is still not judged here: that is the
355+ * ingress gate's first verdict (`assertFilterFieldsExist`, #7534), the engine
356+ * deliberately keeps its registry-less tolerance, and widening this door to it
357+ * is a separate posture change on a second verdict, exactly as #7095 declined
358+ * to inherit sort's `unknown` leg. The dotted verdict IS inherited (unlike
359+ * sort's) because it is author-reachable through the exact same forwarded
360+ * surfaces as the virtual one — a saved report's `query.filter` never passes
361+ * the ingress — and because the #8371 measurement showed the refused head
362+ * classes (relation / virtual / scalar) match zero rows on every backend, so
363+ * this door refuses nothing a driver could have served. A dotted key whose
364+ * head this door cannot classify — structured/JSON (the ruling's deliberate
365+ * carve-out: live on two of three backends), arrays, files, heads absent from
366+ * the field map — passes through unchanged.
360367 *
361368 * A registry-less host (`schema.fields` undefined) returns early, exactly as
362369 * the ingress gate returns early when `resolveQueryFields` cannot answer: a
@@ -377,7 +384,63 @@ export function assertFilterIsMaterializable(
377384) : void {
378385 const fields = ( schema as { fields ?: Record < string , unknown > } | undefined ) ?. fields ;
379386 if ( ! fields || typeof fields !== 'object' ) return ;
380- const named = collectFilterFieldNames ( where ) ;
387+ const { named, dotted } = collectFilterFieldNames ( where ) ;
388+ if ( named . length === 0 && dotted . length === 0 ) return ;
389+
390+ // [#8371] The DOTTED verdict, before the virtual one — mirroring the ingress
391+ // door's `unknown` > `dotted` > unmaterializable ladder, so a dotted path
392+ // whose head is a formula field gets the same answer at both doors (the
393+ // dotted one: it is wrong about the shape too, and the shape is what the
394+ // caller wrote). Judged by the ONE classification both doors share
395+ // (`classifyDottedFilterHead`, `@objectstack/spec/data`): relation, virtual
396+ // and plain-scalar heads matched ZERO rows on all three drivers in the
397+ // #8371 measurement, so this refusal takes nothing from anyone — it renames
398+ // a silent wrong answer into a loud one. A head the classifier answers
399+ // `null` for — structured/JSON (⛔ the ruling's deliberate carve-out, live
400+ // on memory and mongodb), arrays, files, or a head not in this field map —
401+ // passes through unchanged, the same fail-open direction the collector
402+ // documents.
403+ const judgedDotted = dotted . filter (
404+ ( f ) => classifyDottedFilterHead ( fields [ f . split ( '.' ) [ 0 ] ] as never ) !== null ,
405+ ) ;
406+ if ( judgedDotted . length > 0 ) {
407+ const first = judgedDotted [ 0 ] ;
408+ const head = first . split ( '.' ) [ 0 ] ;
409+ const headDef = fields [ head ] as { type ?: unknown } | undefined ;
410+ const headClass = classifyDottedFilterHead ( headDef as never ) ;
411+ const headType = String ( headDef ?. type ?? '' ) ;
412+ const body = headClass === 'relation'
413+ ? `filters on '${ first } ', which follows the relationship '${ head } ' into another object — `
414+ + `a filter reaches only columns of '${ object } ' itself, and '${ head } ' stores the related `
415+ + 'record\'s id, not an embedded document'
416+ : headClass === 'virtual'
417+ ? `filters on '${ first } ', a dotted path whose head '${ head } ' is a virtual ${ headType } `
418+ + `field on '${ object } ' — its value is computed on read, so no driver materialises a `
419+ + 'column for the path to reach into'
420+ : `filters on '${ first } ', a dotted path into '${ head } ', a ${ headType } field on `
421+ + `'${ object } ' that stores a single scalar value — there is nothing beneath it for a `
422+ + 'path to reach' ;
423+ const dottedErr = new Error (
424+ `ObjectQL.${ operation } ('${ object } ') ${ body } `
425+ + ( judgedDotted . length > 1 ? ` (also: ${ judgedDotted . slice ( 1 ) . join ( ', ' ) } )` : '' )
426+ + '. No backend serves the path, so the predicate can only match zero records: the query '
427+ + 'was refused instead of answered with an empty list.'
428+ // Deliberately the SAME remedy, in the same words, as the ingress
429+ // door's dotted refusal and both doors' #8296 virtual refusals. One
430+ // vocabulary across the doors.
431+ + ` Denormalise the value onto '${ object } ' (a stored field, written when the source`
432+ + ' changes) and filter that.' ,
433+ ) as Error & { code ?: string ; status ?: number ; field ?: string ; fields ?: string [ ] ; object ?: string } ;
434+ // Same identity argument as the virtual verdict below: the question is
435+ // about the NAME (its head's type), so `INVALID_FIELD`/400 — never a new
436+ // code, per the #8371 ruling's own words.
437+ dottedErr . status = 400 ;
438+ dottedErr . code = StandardErrorCode . enum . INVALID_FIELD ;
439+ dottedErr . field = first ;
440+ dottedErr . fields = judgedDotted ;
441+ dottedErr . object = object ;
442+ throw dottedErr ;
443+ }
381444 if ( named . length === 0 ) return ;
382445 // Judged by the same `@objectstack/spec/data` predicate the SEARCH axis and
383446 // the ingress door use, never a list minted here, so gate and drivers cannot
@@ -435,15 +498,21 @@ export function assertFilterIsMaterializable(
435498 * (`{$gte: 18}`) or a nested-relation condition (`{owner: {region: 'NA'}}`),
436499 * and the latter's keys belong to a DIFFERENT object whose field map this
437500 * gate has not resolved.
438- * - **A DOTTED key is dropped here**, not judged on its head: this door has one
439- * verdict and a dotted filter path has none on this axis (see the scope note
440- * above).
501+ * - **A DOTTED key is collected SEPARATELY** (#8371), never merged into
502+ * `named`: its verdict is its own (judged on the head's type by
503+ * `classifyDottedFilterHead`), and folding it into the undotted list would
504+ * hand `'owner.region'` to a virtual check that reads `fields['owner.region']`
505+ * — a lookup that can only miss.
441506 *
442507 * `depth` is a cheap backstop against a self-referential `where` — in-process
443508 * callers hand over live objects, and a gate that can hang the read path is
444509 * worse than the defect it closes.
445510 */
446- function collectFilterFieldNames ( where : unknown , out : string [ ] = [ ] , depth = 0 ) : string [ ] {
511+ function collectFilterFieldNames (
512+ where : unknown ,
513+ out : { named : string [ ] ; dotted : string [ ] } = { named : [ ] , dotted : [ ] } ,
514+ depth = 0 ,
515+ ) : { named : string [ ] ; dotted : string [ ] } {
447516 if ( depth > 32 ) return out ;
448517 if ( ! isFilterNode ( where ) ) return out ;
449518 for ( const [ key , value ] of Object . entries ( where ) ) {
@@ -456,8 +525,8 @@ function collectFilterFieldNames(where: unknown, out: string[] = [], depth = 0):
456525 }
457526 continue ;
458527 }
459- if ( key . includes ( '.' ) ) continue ;
460- out . push ( key ) ;
528+ if ( key . includes ( '.' ) ) { out . dotted . push ( key ) ; continue ; }
529+ out . named . push ( key ) ;
461530 }
462531 return out ;
463532}
0 commit comments