@@ -18,7 +18,7 @@ import type {
1818} from '@objectstack/spec/api' ;
1919import type { MetadataCacheRequest , MetadataCacheResponse , ServiceInfo , ApiRoutes , WellKnownCapabilities } from '@objectstack/spec/api' ;
2020import { readServiceSelfInfo } from '@objectstack/spec/api' ;
21- import { parseFilterAST , isFilterAST , VALID_AST_OPERATORS , REFERENCE_VALUE_TYPES , type DroppedFieldsEvent , type QueryAST } from '@objectstack/spec/data' ;
21+ import { parseFilterAST , isFilterAST , VALID_AST_OPERATORS , REFERENCE_VALUE_TYPES , RPC_QUERY_ALIAS_SLOTS , foldQueryAliasSlots , type QueryAliasConflict , type QueryAliasSlot , type DroppedFieldsEvent , type QueryAST } from '@objectstack/spec/data' ;
2222import { PLURAL_TO_SINGULAR , SINGULAR_TO_PLURAL } from '@objectstack/spec/shared' ;
2323import { applyConversionsToStoredItem } from '@objectstack/spec' ;
2424import { type FormView , isAggregatedViewContainer } from '@objectstack/spec/ui' ;
@@ -866,6 +866,49 @@ const ODATA_SPELLING: Readonly<Record<string, string>> = {
866866 filter : '$filter' , select : '$select' , expand : '$expand' ,
867867} ;
868868
869+ /**
870+ * [#3795] The spec's alias table ({@link RPC_QUERY_ALIAS_SLOTS}) extended with
871+ * the wire-only spellings no schema declares: `filters` (documented plural
872+ * alias of the `filter` transport param) and the OData `$filter` / `$expand`.
873+ * Every spelling of one QueryAST slot resolves through ONE fold — the four
874+ * slots that used to resolve backwards (canonical consulted last), each in its
875+ * own open-coded way, are the reason the table lives in the spec and not here.
876+ */
877+ const WIRE_QUERY_ALIAS_SLOTS : readonly QueryAliasSlot [ ] = ( ( ) => {
878+ const extra : Record < string , readonly string [ ] > = {
879+ where : [ 'filters' , '$filter' ] ,
880+ expand : [ '$expand' ] ,
881+ } ;
882+ return RPC_QUERY_ALIAS_SLOTS . map ( ( slot ) => ( {
883+ canonical : slot . canonical ,
884+ aliases : [ ...slot . aliases , ...( extra [ slot . canonical ] ?? [ ] ) ] ,
885+ } ) ) ;
886+ } ) ( ) ;
887+
888+ /**
889+ * [#4181 → #3795] Spellings of ONE slot carrying DIFFERENT values. Two values
890+ * for one slot cannot be reconciled — merging them would invent an intent the
891+ * caller never expressed, and picking one is the silent drop itself — so an
892+ * ambiguous request is refused. Redundant identical spellings pass. #4181
893+ * established this on the filter slot; the fold now applies it to all five.
894+ *
895+ * `spellingFor` maps each folded name back to the wire spelling the caller
896+ * actually wrote (`$orderby`, not `orderBy`) — the #4226 discipline.
897+ */
898+ function conflictingQueryParamsError (
899+ conflict : QueryAliasConflict ,
900+ spellingFor : ( name : string ) => string ,
901+ ) : Error {
902+ const names = conflict . spellings . map ( ( s ) => `'${ spellingFor ( s ) } '` ) . join ( ', ' ) ;
903+ const err : any = new Error (
904+ `Conflicting query parameters: ${ names } are spellings of the same parameter `
905+ + `(canonical '${ conflict . canonical } ') and were given different values. Send exactly one.` ,
906+ ) ;
907+ err . status = 400 ;
908+ err . code = 'INVALID_REQUEST' ;
909+ return err ;
910+ }
911+
869912/**
870913 * [#4181] A filter the normalizer cannot turn into a usable `FilterCondition`
871914 * by any route other than the array shapes {@link malformedFilterArrayError}
@@ -3589,35 +3632,39 @@ export class ObjectStackProtocolImplementation implements
35893632 delete options [ dollar ] ;
35903633 }
35913634
3592- // Numeric fields — normalize top → limit, skip → offset
3635+ // [#3795] One slot, one value. Every alias spelling of the five
3636+ // QueryAST slots resolves HERE, by the spec's own table, before the
3637+ // per-slot wire coercion below ever runs — so that coercion reads
3638+ // canonical keys only. An alias alone folds into its canonical key;
3639+ // redundant identical spellings collapse; different values for one
3640+ // slot are refused (the #4181 rule, generalized from the filter slot
3641+ // to all five — four of which used to resolve BACKWARDS here, each in
3642+ // its own way, disagreeing with the spec's documented precedence and
3643+ // with the runtime dispatcher's copy of the same fold).
3644+ //
3645+ // `arrivedAs` remembers which spelling carried each slot's value;
3646+ // composed with `wireSpelling` it names the parameter the caller
3647+ // actually wrote in every rejection below (#4226).
3648+ const spellingFor = ( name : string ) : string => wireSpelling [ name ] ?? name ;
3649+ const arrivedAs = foldQueryAliasSlots ( options , WIRE_QUERY_ALIAS_SLOTS , ( conflict ) => {
3650+ throw conflictingQueryParamsError ( conflict , spellingFor ) ;
3651+ } ) ;
3652+ const slotParam = ( canonical : string ) : string => spellingFor ( arrivedAs [ canonical ] ?? canonical ) ;
3653+
3654+ // Numeric fields — normalize top → limit ($top is the OData layer,
3655+ // outside the #3795 slot table), then coerce querystring strings.
35933656 if ( options . top != null ) {
35943657 options . limit = Number ( options . top ) ;
35953658 delete options . top ;
35963659 }
3597- if ( options . skip != null ) {
3598- options . offset = Number ( options . skip ) ;
3599- }
3600- // Deleted unconditionally, unlike `top` (a declared QueryAST key the
3601- // engine aliases itself): `skip` is wire-only, so a null/undefined one
3602- // left behind would reach the #4134 field gate below and be reported as
3603- // an unknown FIELD — a confusing rejection for a real parameter.
3604- delete options . skip ;
36053660 if ( options . limit != null ) options . limit = Number ( options . limit ) ;
36063661 if ( options . offset != null ) options . offset = Number ( options . offset ) ;
36073662
3608- // Select → fields: comma-separated string → array
3609- const projectionKey = options . select !== undefined ? ( wireSpelling . select ?? 'select' ) : 'fields' ;
3610- if ( typeof options . select === 'string' ) {
3611- options . fields = options . select . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ;
3612- } else if ( Array . isArray ( options . select ) ) {
3613- options . fields = options . select ;
3614- }
3615- if ( options . select !== undefined ) delete options . select ;
3616-
3617- // fields: comma-separated string → array. Clients may pass `?fields=name`
3618- // directly (not only via the `?select=` alias above) — a single-value
3619- // querystring param arrives as a bare string, which drivers' `.map()`
3663+ // Projection: comma-separated string → array. A single-value
3664+ // querystring param arrives as a bare string — `?fields=name` or the
3665+ // folded `?select=` / `$select` spellings — which drivers' `.map()`
36203666 // calls over `query.fields` would otherwise throw on.
3667+ const projectionKey = slotParam ( 'fields' ) ;
36213668 if ( typeof options . fields === 'string' ) {
36223669 options . fields = options . fields . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ;
36233670 } else if ( options . fields !== undefined && ! Array . isArray ( options . fields ) ) {
@@ -3628,18 +3675,16 @@ export class ObjectStackProtocolImplementation implements
36283675 // returned MORE than was asked for.
36293676 this . assertProjectionFieldsExist ( request . object , options . fields , projectionKey ) ;
36303677
3631- // Sort/orderBy → orderBy : every wire spelling → SortNode[].
3678+ // Sort: every wire shape → SortNode[].
36323679 //
36333680 // [#4226] `normalizeSortNodes` folds the two shapes that used to fall
36343681 // through this block untouched — `string[]` and `{field: direction}` —
36353682 // and refuses the ones it cannot read. Before it, "not a string and not
36363683 // an array" simply skipped the branch, leaving a value on `orderBy`
36373684 // that `SqlDriver`'s `Array.isArray` guard then declined to turn into
36383685 // an ORDER BY clause: no sort, no error, no way to tell.
3639- const usesOrderBy = options . orderBy !== undefined && options . orderBy !== null ;
3640- const sortValue = usesOrderBy ? options . orderBy : options . sort ;
3641- const sortKey = usesOrderBy ? ( wireSpelling . orderBy ?? 'orderBy' ) : 'sort' ;
3642- delete options . sort ;
3686+ const sortValue = options . orderBy ;
3687+ const sortKey = slotParam ( 'orderBy' ) ;
36433688 if ( sortValue === undefined || sortValue === null ) {
36443689 // Nothing to sort by — and an explicit `orderBy: null` must not ride
36453690 // to the engine as a value every driver quietly declines to read.
@@ -3656,41 +3701,15 @@ export class ObjectStackProtocolImplementation implements
36563701 else delete options . orderBy ;
36573702 }
36583703
3659- // Filter/filters/$filter → where: normalize all filter aliases.
3660- //
3661- // [#4181] These four names are FOUR SPELLINGS OF ONE SLOT (`filters` is
3662- // documented as a deprecated alias of `filter`), so `??` picking the
3663- // first non-null silently discarded the others: a body carrying both
3664- // `where` and a different `filter` ran the `filter` and dropped the
3665- // `where` with no signal. Two different values for one slot cannot be
3666- // reconciled — merging them would invent an intent the caller never
3667- // expressed, and picking one is the silent drop itself — so an
3668- // ambiguous request is refused. Redundant identical spellings are
3669- // harmless and pass.
3670- const filterAliases = ( [ 'filter' , 'filters' , '$filter' , 'where' ] as const )
3671- . filter ( ( k ) => options [ k ] !== undefined )
3672- . map ( ( k ) => ( { key : k , value : options [ k ] } ) ) ;
3673- if ( filterAliases . length > 1 ) {
3674- const distinct = new Set ( filterAliases . map ( ( a ) => JSON . stringify ( a . value ) ) ) ;
3675- if ( distinct . size > 1 ) {
3676- const err : any = new Error (
3677- `Conflicting filter parameters: ${ filterAliases . map ( ( a ) => `'${ a . key } '` ) . join ( ', ' ) } `
3678- + 'are aliases for the same filter and were given different values. Send exactly one.' ,
3679- ) ;
3680- err . status = 400 ;
3681- err . code = 'INVALID_REQUEST' ;
3682- throw err ;
3683- }
3684- }
3685-
3686- const filterValue = options . filter ?? options . filters ?? options . $filter ?? options . where ;
3687- const filterKey = filterAliases [ 0 ] ?. key ?? 'filter' ;
3688- delete options . filter ;
3689- delete options . filters ;
3690- delete options . $filter ;
3704+ // Filter: the folded slot value → a usable `FilterCondition` on
3705+ // `where`, or a rejection. The four spellings of this slot
3706+ // (`where`/`filter`/`filters`/`$filter`) already resolved through the
3707+ // #3795 fold above — #4181's one-slot-one-value rule, which this block
3708+ // pioneered before the fold generalized it.
3709+ const filterKey = slotParam ( 'where' ) ;
36913710
3692- if ( filterValue !== undefined ) {
3693- let parsedFilter = filterValue ;
3711+ if ( options . where !== undefined ) {
3712+ let parsedFilter = options . where ;
36943713 // A blank `?filter=` is ABSENT, not malformed — the same `length > 0`
36953714 // guard the export route applies before parsing. Deleting `where`
36963715 // here (rather than leaving `''` on it) is what lets every consumer
@@ -3755,31 +3774,24 @@ export class ObjectStackProtocolImplementation implements
37553774 }
37563775 }
37573776
3758- // Populate/expand/$expand → expand (Record<string, QueryAST>)
3759- const populateValue = options . populate ;
3760- const expandValue = options . $expand ?? options . expand ;
3777+ // Expand: the folded slot value → `Record<string, QueryAST>`. A comma
3778+ // list (string) and a name array both lower to `{name: {object: name}}`;
3779+ // the advanced `{rel: QueryAST}` map a caller may send directly on
3780+ // `POST /data/:object/query` passes through as-is. Lowering the ARRAY
3781+ // shape here (not just the string) also closes a pre-#3795 gap: a raw
3782+ // name array used to survive this block whole, so the #4226 gate read
3783+ // its INDICES as relation names and refused real requests with
3784+ // "Unknown field '0'".
3785+ const expandValue = options . expand ;
37613786 const expandNames : string [ ] = [ ] ;
3762- if ( typeof populateValue === 'string' ) {
3763- expandNames . push ( ...populateValue . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ) ;
3764- } else if ( Array . isArray ( populateValue ) ) {
3765- expandNames . push ( ...populateValue ) ;
3766- }
3767- if ( ! expandNames . length && expandValue ) {
3768- if ( typeof expandValue === 'string' ) {
3769- expandNames . push ( ...expandValue . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ) ;
3770- } else if ( Array . isArray ( expandValue ) ) {
3771- expandNames . push ( ...expandValue ) ;
3772- }
3787+ if ( typeof expandValue === 'string' ) {
3788+ expandNames . push ( ...expandValue . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ) ;
3789+ } else if ( Array . isArray ( expandValue ) ) {
3790+ expandNames . push ( ...expandValue ) ;
37733791 }
3774- delete options . populate ;
3775- delete options . $expand ;
3776- // Clean up non-object expand (e.g. string) BEFORE the Record conversion
3777- // below, so that populate-derived names can create the expand Record even
3778- // when a legacy string expand was also present.
3779- if ( typeof options . expand !== 'object' || options . expand === null ) {
3792+ if ( typeof options . expand !== 'object' || options . expand === null || Array . isArray ( options . expand ) ) {
37803793 delete options . expand ;
37813794 }
3782- // Only set expand if not already an object (advanced usage)
37833795 if ( expandNames . length > 0 && ! options . expand ) {
37843796 options . expand = { } as Record < string , any > ;
37853797 for ( const rel of expandNames ) {
0 commit comments