@@ -14,6 +14,17 @@ import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyD
1414// `AggregationNodeSchema.function` actually admits.
1515import { AggregationFunction } from '@objectstack/spec/data' ;
1616import { STRUCTURED_JSON_TYPES , FILE_REFERENCE_TYPES , MULTI_OPTION_TYPES , NUMERIC_VALUE_TYPES } from '@objectstack/spec/data' ;
17+ // [#5659] The Filter Protocol's boolean identity reduction — `$and: []` is TRUE,
18+ // `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One
19+ // implementation for all four consumers, proven against the same
20+ // `FILTER_LOGIC_CASES` table this driver's conformance suite runs; this file
21+ // supplies only its own refusals. See `reduceFilterNode` below.
22+ import {
23+ reduceFilterVerdict ,
24+ reduceFilterKeyVerdict ,
25+ type FilterVerdict as SharedFilterVerdict ,
26+ type FilterVerdictHooks ,
27+ } from '@objectstack/spec/data' ;
1728// `defaultValue` runtime tokens (#4560). The DDL below asks the SPEC — not a
1829// list of its own — which `defaultValue`s are instructions rather than literals,
1930// so the engine and this driver can never disagree about what may become a
@@ -1000,8 +1011,12 @@ function safeShapePreview(value: unknown): string {
10001011 * - `'true'` — matches every row; the compiler emits NO clause for it.
10011012 * - `'false'` — matches no row; the compiler emits the dialect FALSE constant.
10021013 * - `'clause'` — carries at least one real predicate; compile it normally.
1014+ *
1015+ * [#5659] The vocabulary is `@objectstack/spec`'s now, because the REDUCTION
1016+ * that produces it is — see {@link reduceFilterNode}. Kept as a local alias so
1017+ * every use site below still reads `FilterVerdict`.
10031018 */
1004- type FilterVerdict = 'true' | 'false' | 'clause' ;
1019+ type FilterVerdict = SharedFilterVerdict ;
10051020
10061021/**
10071022 * [#5134] Is `value` a Filter Protocol NODE — the shape `FilterConditionSchema`
@@ -1359,52 +1374,59 @@ function assertFilterNodeList(value: unknown, key: string, path: string): assert
13591374 * "empty because the author wrote nothing" from "empty because something failed
13601375 * to compile". A structural verdict has no such blind spot, and it lets the
13611376 * emitter guarantee that every group it opens receives at least one clause.
1377+ *
1378+ * ## [#5659] The algebra is `@objectstack/spec`'s; the REFUSALS are this driver's
1379+ *
1380+ * Everything above describes a ruling (#5322/#5134) that four consumers had to
1381+ * agree on and implemented four times — here, in `driver-mongodb`, in
1382+ * `driver-memory`'s matcher, and nearly a fifth time inside `@objectstack/lint`,
1383+ * which declined to hand-write it and filed #5659 instead. The reduction now
1384+ * lives once, in {@link reduceFilterVerdict}, proven against the same
1385+ * `FILTER_LOGIC_CASES` table this driver's conformance suite runs.
1386+ *
1387+ * What stays here is what is genuinely this driver's: WHICH shapes it refuses
1388+ * and with which message. They are handed to the shared walk as
1389+ * {@link SQL_FILTER_VERDICT_HOOKS} and are invoked from exactly the positions
1390+ * they were invoked from before, so no wording, code or status moved — the
1391+ * conformance case-set is green on both sides of the change.
13621392 */
13631393function reduceFilterNode ( node : Record < string , unknown > , path : string ) : FilterVerdict {
1364- let sawFalse = false ;
1365- let sawClause = false ;
1366- for ( const [ key , value ] of Object . entries ( node ) ) {
1367- const verdict = reduceFilterKey ( key , value , path ) ;
1368- if ( verdict === 'false' ) sawFalse = true ;
1369- else if ( verdict === 'clause' ) sawClause = true ;
1370- }
1371- // AND over the node's keys: FALSE dominates, then a real predicate, else TRUE.
1372- return sawFalse ? 'false' : sawClause ? 'clause' : 'true' ;
1394+ return reduceFilterVerdict ( node , { ...SQL_FILTER_VERDICT_HOOKS , path } ) ;
13731395}
13741396
13751397/** [#5134] The verdict of ONE key of a filter node. */
13761398function reduceFilterKey ( key : string , value : unknown , path : string ) : FilterVerdict {
1377- const here = path ? `${ path } .${ key } ` : key ;
1378-
1379- if ( key === '$and' || key === '$or' ) {
1380- assertFilterNodeList ( value , key , here ) ;
1381- let sawTrue = false ;
1382- let sawFalse = false ;
1383- let sawClause = false ;
1384- value . forEach ( ( element , index ) => {
1385- const elementPath = `${ here } [${ index } ]` ;
1386- assertFilterNode ( element , elementPath ) ;
1387- const verdict = reduceFilterNode ( element , elementPath ) ;
1388- if ( verdict === 'true' ) sawTrue = true ;
1389- else if ( verdict === 'false' ) sawFalse = true ;
1390- else sawClause = true ;
1391- } ) ;
1392- // `$and: []` → no FALSE, no clause → TRUE (the AND identity).
1393- if ( key === '$and' ) return sawFalse ? 'false' : sawClause ? 'clause' : 'true' ;
1394- // `$or: []` → no TRUE, no clause → FALSE (the OR identity). This is the
1395- // half the old compile got backwards: it answered the whole table.
1396- return sawTrue ? 'true' : sawClause ? 'clause' : 'false' ;
1397- }
1399+ return reduceFilterKeyVerdict ( key , value , { ...SQL_FILTER_VERDICT_HOOKS , path } ) ;
1400+ }
13981401
1399- if ( key === '$not' ) {
1400- assertFilterNode ( value , here ) ;
1401- const inner = reduceFilterNode ( value , here ) ;
1402- // NOT TRUE ≡ FALSE — so `{ $not: {} }` matches nothing.
1403- return inner === 'true' ? 'false' : inner === 'false' ? 'true' : 'clause' ;
1404- }
1402+ /**
1403+ * [#5659] This driver's half of the reduction: the shape refusals, at the
1404+ * positions the shared walk visits them.
1405+ *
1406+ * `assertFilterNodeList` / `assertFilterNode` are wrapped in arrows rather than
1407+ * passed by reference because they are TypeScript assertion functions, whose
1408+ * narrowing is meaningless — and whose declaration requirements are a nuisance
1409+ * — through a property reference. Nothing else about the call changes.
1410+ */
1411+ const SQL_FILTER_VERDICT_HOOKS : FilterVerdictHooks = {
1412+ assertNodeList : ( value , key , path ) => assertFilterNodeList ( value , key , path ) ,
1413+ assertNode : ( value , path ) => assertFilterNode ( value , path ) ,
1414+ classifyKey : ( key , value , here ) => classifyFilterKey ( key , value , here ) ,
1415+ } ;
14051416
1417+ /**
1418+ * [#5134] The verdict of ONE **non-combinator** key — and this driver's gate on
1419+ * everything a field constraint may not be.
1420+ *
1421+ * `here` is the already-joined path of the key, exactly as the reduction hands
1422+ * it over; the three combinator arms this used to open with are the shared
1423+ * walk's now, and the refusals below are unchanged from when they sat under
1424+ * them.
1425+ */
1426+ function classifyFilterKey ( key : string , value : unknown , here : string ) : FilterVerdict {
14061427 // [#5348] Everything still `$`-prefixed at this point is an UNDECLARED
1407- // combinator — the three declared ones each returned above. Refused here and
1428+ // combinator — the shared walk resolved the three declared ones before this
1429+ // key ever reached the hook (#5659). Refused here and
14081430 // not in the emitter for exactly the reason the two lines below are here, and
14091431 // the reason #5327 gave for `{ field: {} }`: this walk is exhaustive and does
14101432 // not short-circuit, while the emitter is skipped wholesale by a boolean
0 commit comments