diff --git a/.agents/references/ucast.md b/.agents/references/ucast.md index fdcd5ed94..efdae3b89 100644 --- a/.agents/references/ucast.md +++ b/.agents/references/ucast.md @@ -15,7 +15,7 @@ package is the reference implementation for rapiq's mongo parser dialect | `DocumentCondition` (e.g. `$where`) | — | rapiq has no document-level conditions (and `$where` is a code-execution foot-gun; deliberately unsupported) | | `NULL_CONDITION` sentinel (dropped by `pushIfNonNullCondition`) | — | rapiq parsers just don't emit a node (e.g. `$options` is consumed by its `$regex` sibling) | | `optimizedCompoundCondition()` (`core/src/utils.ts`) — flattens same-operator nesting, unwraps single-child compounds | `Filters.flatten()` | rapiq's flatten does not unwrap single-child compounds | -| `ITSELF` field sentinel (elemMatch on scalar arrays) | — | rapiq `elemMatch` values are conditions with fields relative to the array element | +| `ITSELF` field sentinel (elemMatch on scalar arrays) | `ITSELF = '$this'` (`core/src/parameter/filters/constants.ts`, plan 016) | ucast uses a symbol; rapiq uses a reserved token string so the marker survives codec round-trips (expression wire form `elemMatch(scores,gt($this,'5'))`) | ## Parsing (`@ucast/core` ObjectQueryParser + `@ucast/mongo`) @@ -38,10 +38,14 @@ package is the reference implementation for rapiq's mongo parser dialect - `$regex` + sibling `$options`: string is compiled to `new RegExp(value, $options)`; `$options` itself parses to `NULL_CONDITION` (consumed by the `$regex` instruction via `context.query`). - `$elemMatch`: `hasOperators(value)` ? parse as field operators on `ITSELF` : parse as nested - query; result nested inside a `FieldCondition`. + query; result nested inside a `FieldCondition`. rapiq mirrors the split since plan 016 + (`MongoFiltersParser.buildElemMatch`: any `MONGO_FIELD_OPERATORS` key → element-level form on + `ITSELF`). - Value validation throws plain `Error` (`ensureIs*` helpers); rapiq uses typed `FiltersParseError` + `ErrorCode` members instead. -- Operators with no rapiq AST equivalent: `$size`, `$all`, `$where`, `$type`. +- `$all`: ucast keeps it as its own condition; rapiq desugars it to an AND of independently + scoped `elemMatch(f, eq(ITSELF, v))` conditions (plan 016 Q4, no dedicated operator). +- Operators with no rapiq AST equivalent: `$size`, `$where`, `$type`. ## Interpretation @@ -58,7 +62,7 @@ explicitly rejected its interpreter-registry shape in favor of the core visitor |---|---|---| | `createJsInterpreter(operators, {get, compare})` → `interpret(condition, object)` | `FiltersVisitor`/`FiltersCompiler` (`parameter/filters/`), `compileFilters(condition)` → `Predicate` | rapiq compiles once to a reusable closure; extension = subclass `FiltersCompiler`, not an options bag | | interpreter registry (record of functions, unregistered op throws plain `Error`) | per-operator `visitFilterX` methods; `visitFilter` fallback throws `AdapterError.operatorUnsupported` | closed operator set over `FilterFieldOperator` | -| `getObjectField`/`getValueByPath` (dot paths; array parent → map+**flatten**; numeric segments index arrays; `ITSELF` sentinel) | join-row binding (`parameter/filters/binding.ts`): dotted prefixes = relation paths, ∃-DFS binds one element per path (SQL LEFT-join parity); leaf lookup is own-property only | ucast quantifies each leaf independently; rapiq binds same-element across the tree (`elemMatch` = prefix composition, like `@rapiq/sql`) — settled by maintainer, plan 014 Q4. No numeric-index segments, no ITSELF | +| `getObjectField`/`getValueByPath` (dot paths; array parent → map+**flatten**; numeric segments index arrays; `ITSELF` sentinel) | join-row binding (`parameter/filters/binding.ts`): dotted prefixes = relation paths, ∃-DFS binds one element per path (SQL LEFT-join parity); leaf lookup is own-property only; ITSELF leaves read the bound element (real array elements only, plan 016 Q5) | ucast quantifies each leaf independently; rapiq binds same-element across dotted paths (plan 014 Q4) but every `elemMatch` opens its own quantifier scope (plan 016 Q3, mongo parity). No numeric-index segments | | `eq` (whole-array equality ∨ membership; RegExp value acts as pattern; null matches missing own-prop) | `buildEqualTest` (`parameter/filters/compiler.ts`): strict equality after null-unification, Date by `getTime`, array leaf → membership only | no whole-array equality, no RegExp-as-eq-value; `undefined` ≡ `null` (ucast: explicit `undefined` does NOT match null) | | `ne`/`nin` = `!eq`/`!within` (complement) | same complement law (plan 014 Q3) | agreement — this is where rapiq deviates from SQL 3VL instead | | `exists` = `hasOwn` (undefined-valued key exists) | `exists` = is-not-null | SQL parity beats mongo presence semantics | diff --git a/packages/codec-url/src/expression/encoder/filters.ts b/packages/codec-url/src/expression/encoder/filters.ts index ac7ca422b..11bf77b7d 100644 --- a/packages/codec-url/src/expression/encoder/filters.ts +++ b/packages/codec-url/src/expression/encoder/filters.ts @@ -8,9 +8,10 @@ import type { ICondition, IFilters } from '@rapiq/core'; import { AdapterError, - Filter, FilterFieldOperator, - Filters, + ITSELF, + isFilter, + isFilters, } from '@rapiq/core'; import { FILTER_EXPRESSION_KEYWORDS, @@ -32,8 +33,8 @@ const FIELD_SEGMENT = new RegExp(`^(?:${FILTER_FIELD_SEGMENT_PATTERN})$`); /** * Serialize a filters tree to its expression-dialect wire form, * e.g. and(eq(name,'John'),or(gte(age,'18'),eq(email,null))). - * Nested compounds are first-class in this dialect; only operators - * without a grammar production (REGEX, MOD, EXISTS, ELEM_MATCH) + * Nested compounds and elemMatch are first-class in this dialect; + * only operators without a grammar production (REGEX, MOD, EXISTS) * and non-wire-safe names/values are rejected. * * Returns null for an empty root (nothing to emit). @@ -47,15 +48,15 @@ export function serializeFiltersExpression(input: IFilters) : string | null { // wraps bare conditions back into a root AND. if ( input.value.length === 1 && - input.value[0] instanceof Filter + isFilter(input.value[0]) ) { - return serializeCondition(input.value[0]); + return serializeCondition(input.value[0], false); } - return serializeCompound(input); + return serializeCompound(input, false); } -function serializeCompound(input: IFilters) : string { +function serializeCompound(input: IFilters, insideElemMatch: boolean) : string { if (input.value.length === 0) { // an empty compound has no grammar production — and() is a // syntax error on decode. @@ -63,22 +64,22 @@ function serializeCompound(input: IFilters) : string { } const children = input.value.map( - (child) => serializeCondition(child), + (child) => serializeCondition(child, insideElemMatch), ); return `${input.operator}(${children.join(',')})`; } -function serializeCondition(node: ICondition) : string { - if (node instanceof Filters) { - return serializeCompound(node); +function serializeCondition(node: ICondition, insideElemMatch: boolean) : string { + if (isFilters(node)) { + return serializeCompound(node, insideElemMatch); } - if (!(node instanceof Filter)) { + if (!isFilter(node)) { throw AdapterError.featureUnsupported('filters:condition'); } - const field = serializeField(node.field); + const field = serializeField(node.field, insideElemMatch); switch (node.operator) { case FilterFieldOperator.EQUAL: { @@ -123,15 +124,33 @@ function serializeCondition(node: ICondition) : string { case FilterFieldOperator.NOT_ENDS_WITH: { return `not(endsWith(${field},${serializeMatchText(node.value)}))`; } + case FilterFieldOperator.ELEM_MATCH: { + const interior = node.value as ICondition; + if (!isFilter(interior) && !isFilters(interior)) { + throw AdapterError.featureUnsupported('filters:elemMatch:value'); + } + + return `elemMatch(${field},${serializeCondition(interior, true)})`; + } default: { - // REGEX, MOD, EXISTS, ELEM_MATCH, ... have no expression - // grammar production. + // REGEX, MOD, EXISTS, ... have no expression grammar + // production. throw AdapterError.operatorUnsupported(node.operator); } } } -function serializeField(input: string) : string { +function serializeField(input: string, insideElemMatch: boolean) : string { + // the ITSELF marker has a dedicated token — legal only inside an + // elemMatch interior, where it references the array element. + if (input === ITSELF) { + if (!insideElemMatch) { + throw AdapterError.featureUnsupported(`filters:field:${input}`); + } + + return input; + } + const segments = input.split('.'); for (const segment of segments) { diff --git a/packages/codec-url/test/unit/expression-roundtrip.spec.ts b/packages/codec-url/test/unit/expression-roundtrip.spec.ts index f3801aeec..253759179 100644 --- a/packages/codec-url/test/unit/expression-roundtrip.spec.ts +++ b/packages/codec-url/test/unit/expression-roundtrip.spec.ts @@ -12,7 +12,9 @@ import { Fields, FilterCompoundOperator, Filters, + ITSELF, Pagination, + Query, Relation, Relations, Sort, @@ -21,6 +23,7 @@ import { and, contains, defineQuery, + elemMatch, endsWith, eq, exists, @@ -98,6 +101,9 @@ describe('round-trip', () => { ['notContains', notContains('name', 'oh')], ['contains with comma', contains('name', 'a,b')], ['relation path condition', eq('items.name', 'a')], + ['elemMatch document form', elemMatch('items', eq('name', 'chess'))], + ['elemMatch ITSELF leaf', elemMatch('scores', gt(ITSELF, 5))], + ['nested elemMatch on the element itself', elemMatch('matrix', elemMatch(ITSELF, gt(ITSELF, 5)))], ])('should round-trip %s', (_, filter) => { expect(roundTripFilter(filter)).toEqual( new Filters(FilterCompoundOperator.AND, [filter]), @@ -124,6 +130,17 @@ describe('round-trip', () => { 'same-field branches', and(gte('age', 18), lt('age', 65)), ], + [ + 'elemMatch compound interior', + and(elemMatch('items', and(eq('id', 1), eq('active', true)))), + ], + [ + '$all desugar (independent element matches)', + and( + elemMatch('tags', eq(ITSELF, 'a')), + elemMatch('tags', eq(ITSELF, 'b')), + ), + ], ])('should round-trip %s', (_, filters) => { expect(roundTripFilter(filters)).toEqual(filters); }); @@ -176,11 +193,25 @@ describe('round-trip', () => { ['value-mutating numeric text (0xFF would decode to 255)', eq('code', '0xFF')], ['NaN value (would decode to the string NaN)', eq('age', Number.NaN)], ['keyword field segment', eq('null', 'x')], + ['elemMatch keyword field segment', eq('elemMatch', 'x')], ['non-tokenizable field segment', eq('a b', 'x')], ['empty nested compound', and(eq('name', 'x'), or())], ])('should throw for %s', (_, filter) => { expectTypedFailure(filter, ErrorCode.FEATURE_UNSUPPORTED); }); + + it('should throw for the ITSELF marker outside an elemMatch interior', () => { + // hand-built — defineQuery already rejects this shape. + const query = new Query({ filters: new Filters(FilterCompoundOperator.AND, [eq(ITSELF, 'x')]) }); + + try { + encoder.encode(query); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toBe(ErrorCode.FEATURE_UNSUPPORTED); + } + }); }); describe('full query', () => { @@ -230,5 +261,15 @@ describe('round-trip', () => { 'filter=and(eq(name,\'John\'),or(gte(age,\'18\'),eq(email,null)))&page[limit]=20', ); }); + + it('should emit the documented elemMatch wire format', () => { + const query = defineQuery({ filters: elemMatch('scores', gt(ITSELF, 5)) }); + + const encoded = encoder.encode(query); + + expect(decodeURIComponent(encoded!)).toEqual( + 'filter=elemMatch(scores,gt($this,\'5\'))', + ); + }); }); }); diff --git a/packages/codec-url/test/unit/simple-roundtrip.spec.ts b/packages/codec-url/test/unit/simple-roundtrip.spec.ts index 25010f4bd..3d6590a4f 100644 --- a/packages/codec-url/test/unit/simple-roundtrip.spec.ts +++ b/packages/codec-url/test/unit/simple-roundtrip.spec.ts @@ -12,6 +12,7 @@ import { Fields, FilterCompoundOperator, Filters, + ITSELF, Pagination, Relation, Relations, @@ -154,6 +155,7 @@ describe('round-trip', () => { ['mod', mod('age', [2, 0])], ['exists', exists('email', true)], ['elemMatch', elemMatch('items', eq('name', 'a'))], + ['elemMatch with ITSELF interior', elemMatch('scores', gt(ITSELF, 5))], ])('should throw for the %s operator (no wire syntax)', (_, filter) => { expectTypedFailure(filter, ErrorCode.OPERATOR_UNSUPPORTED); }); diff --git a/packages/core/src/build/parameter/filters/module.ts b/packages/core/src/build/parameter/filters/module.ts index ad9e7b53d..a58383159 100644 --- a/packages/core/src/build/parameter/filters/module.ts +++ b/packages/core/src/build/parameter/filters/module.ts @@ -7,7 +7,13 @@ import { BuildError } from '../../../errors'; import type { Condition, ICondition, IFilters } from '../../../parameter'; -import { Filter, Filters, isFilters } from '../../../parameter'; +import { + Filter, + Filters, + ITSELF, + isFilter, + isFilters, +} from '../../../parameter'; import { FilterCompoundOperator, FilterFieldOperator } from '../../../schema'; import type { ObjectLiteral } from '../../../types'; import { isObject } from '../../../utils'; @@ -30,6 +36,8 @@ export function defineFilters< >(input: FiltersBuildInput | ICondition) : IFilters; export function defineFilters(input: FiltersBuildInput | ICondition) : IFilters { if (isParameterNode(input)) { + assertConditionFields(input, false); + if (isFilters(input)) { return input; } @@ -47,6 +55,7 @@ export function defineFilters(input: FiltersBuildInput | IConditi function buildConditions( input: unknown, prefix?: string, + insideElemMatch = false, ) : Condition[] { if (!isObject(input)) { throw BuildError.inputInvalid(); @@ -61,9 +70,17 @@ function buildConditions( continue; } + // a $-prefixed key is never a field name — with one exception: + // the ITSELF marker addresses the array element inside an + // elemMatch interior. + if (key.startsWith('$') && !(key === ITSELF && insideElemMatch && !prefix)) { + throw BuildError.keyInvalid(key); + } + output.push(...buildFieldConditions( prefix ? `${prefix}.${key}` : key, value, + insideElemMatch, )); } @@ -73,7 +90,14 @@ function buildConditions( function buildFieldConditions( field: string, value: unknown, + insideElemMatch = false, ) : Condition[] { + // the ITSELF marker is only legal as a complete field — + // it addresses the element itself and has no dotted form. + if (field !== ITSELF && field.split('.').includes(ITSELF)) { + throw BuildError.keyInvalid(field); + } + // bare array = IN sugar; null is a legal element, // backend adapters own the `OR IS NULL` rewrite. if (Array.isArray(value)) { @@ -113,8 +137,13 @@ function buildFieldConditions( return output; } - // nested record — relation traversal via dot-path prefixing. - return buildConditions(value, field); + // nested record — relation traversal via dot-path prefixing; + // the element itself has no properties to traverse into. + if (field === ITSELF) { + throw BuildError.keyValueInvalid(field); + } + + return buildConditions(value, field, insideElemMatch); } // scalar (incl. null) = EQUAL sugar. @@ -129,9 +158,23 @@ function buildOperatorCondition( if (key === `$${FilterFieldOperator.ELEM_MATCH}`) { let condition : Condition; if (isParameterNode(value)) { + assertConditionFields(value, true); + condition = value; } else { - const conditions = buildConditions(value); + let conditions : Condition[]; + if ( + isObject(value) && + Object.keys(value).every((child) => child.startsWith('$') && child !== ITSELF) + ) { + // element-level operator object (mongo's + // { $elemMatch: { $gt: 5 } }) — the operators apply + // to the element itself. + conditions = buildFieldConditions(ITSELF, value, true); + } else { + conditions = buildConditions(value, undefined, true); + } + const [first] = conditions; condition = first && conditions.length === 1 ? first : @@ -159,3 +202,42 @@ function buildOperatorCondition( return new Filter(operator, field, value); } + +/** + * Verify the ITSELF marker contract on a pre-built condition tree: + * the marker is only legal as the complete field of a condition + * inside an elemMatch interior. + */ +function assertConditionFields( + input: ICondition, + insideElemMatch: boolean, +) : void { + if (isFilters(input)) { + for (const child of input.value) { + if (isParameterNode(child)) { + assertConditionFields(child, insideElemMatch); + } + } + + return; + } + + if (!isFilter(input)) { + return; + } + + if (input.field === ITSELF) { + if (!insideElemMatch) { + throw BuildError.keyInvalid(input.field); + } + } else if (input.field.split('.').includes(ITSELF)) { + throw BuildError.keyInvalid(input.field); + } + + if ( + input.operator === FilterFieldOperator.ELEM_MATCH && + isParameterNode(input.value) + ) { + assertConditionFields(input.value, true); + } +} diff --git a/packages/core/src/parameter/filters/constants.ts b/packages/core/src/parameter/filters/constants.ts new file mode 100644 index 000000000..24975f785 --- /dev/null +++ b/packages/core/src/parameter/filters/constants.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +/** + * Reserved self-reference marker (spelled `$this` on the wire). + * + * Legal only as the complete field of a condition inside an + * elemMatch interior. A leaf condition uses it to address the bound + * array element itself instead of one of its properties; a nested + * elemMatch may take it as its own field for arrays of arrays: + * + * ```ts + * elemMatch('scores', gt(ITSELF, 5)) // some score > 5 + * elemMatch('matrix', elemMatch(ITSELF, gt(ITSELF, 5))) // some row has a value > 5 + * ``` + * + * Anywhere else the marker is a typed error — build layer and parsers + * reject it, backend adapters without element semantics + * (`@rapiq/sql`, `@rapiq/typeorm`) throw `featureUnsupported`. + */ +export const ITSELF = '$this'; diff --git a/packages/core/src/parameter/filters/index.ts b/packages/core/src/parameter/filters/index.ts index 03eb0c011..605c667b7 100644 --- a/packages/core/src/parameter/filters/index.ts +++ b/packages/core/src/parameter/filters/index.ts @@ -7,6 +7,7 @@ export * from './collection'; export * from './condition'; +export * from './constants'; export * from './helpers'; export * from './record'; export * from './regex'; diff --git a/packages/core/test/unit/build/module.spec.ts b/packages/core/test/unit/build/module.spec.ts index 30a26e76a..b26f9f69d 100644 --- a/packages/core/test/unit/build/module.spec.ts +++ b/packages/core/test/unit/build/module.spec.ts @@ -14,6 +14,7 @@ import { FilterCompoundOperator, FilterFieldOperator, Filters, + ITSELF, SortDirection, defineFields, defineFilters, @@ -21,6 +22,7 @@ import { defineQuery, defineRelations, defineSorts, + elemMatch, eq, gte, or, @@ -125,6 +127,62 @@ describe('src/build/parameter/filters/*.ts', () => { expect((leafs(fromHelper)[0] as IFilter).value).toBe(condition); }); + it('should desugar an element-level $elemMatch operator object onto ITSELF', () => { + const output = defineFilters({ scores: { $elemMatch: { $gt: 5 } } }); + + const leaf = leafs(output)[0] as IFilter; + expect(leaf.operator).toBe(FilterFieldOperator.ELEM_MATCH); + expect(leaf.field).toBe('scores'); + expect(leaf.value).toMatchObject({ + operator: FilterFieldOperator.GREATER_THAN, + field: ITSELF, + value: 5, + }); + + // several element-level operators form an implicit AND interior. + const range = defineFilters({ scores: { $elemMatch: { $gt: 5, $lt: 10 } } }); + const interior = (leafs(range)[0] as IFilter).value as Filters; + expect(interior.operator).toBe(FilterCompoundOperator.AND); + expect(interior.value).toHaveLength(2); + expect(interior.value[0]).toMatchObject({ field: ITSELF, value: 5 }); + expect(interior.value[1]).toMatchObject({ field: ITSELF, value: 10 }); + }); + + it('should accept the ITSELF marker inside an elemMatch interior', () => { + const condition = elemMatch('tags', eq(ITSELF, 'a')); + + const output = defineFilters(condition); + expect(leafs(output)[0]).toBe(condition); + + // explicit ITSELF key in the interior document form. + const fromInput = defineFilters({ tags: { $elemMatch: { $this: 'a' } } }); + expect((leafs(fromInput)[0] as IFilter).value).toMatchObject({ + operator: FilterFieldOperator.EQUAL, + field: ITSELF, + value: 'a', + }); + }); + + it('should throw a typed error on the ITSELF marker outside an elemMatch interior', () => { + const inputs = [ + () => defineFilters(eq(ITSELF, 5)), + () => defineFilters({ $this: 5 } as never), + () => defineFilters({ 'items.$this': 5 } as never), + () => defineFilters(eq(`items.${ITSELF}`, 5)), + () => defineFilters(elemMatch(ITSELF, eq('name', 'x'))), + ]; + + for (const input of inputs) { + try { + input(); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(BuildError); + expect((e as BuildError).code).toBe(ErrorCode.KEY_INVALID); + } + } + }); + it('should pass a compound helper value through unchanged', () => { const compound = or(gte('age', 18), eq('email', null)); diff --git a/packages/docs/guide/building-queries.md b/packages/docs/guide/building-queries.md index 8f9f82464..5487a4427 100644 --- a/packages/docs/guide/building-queries.md +++ b/packages/docs/guide/building-queries.md @@ -50,6 +50,9 @@ defineQuery({ items: { // match array elements; $elemMatch: { name: 'chess' }, // field paths are relative }, // to the element + scores: { // element-level operators + $elemMatch: { $gt: 5 }, // apply to the element + }, // itself (ITSELF marker) }, }); ``` @@ -84,6 +87,8 @@ mod('age', 4, 0); // (field, divisor, remainder) exists('email'); // (field, value = true) elemMatch('items', eq('name', 'x')); // (field, condition) — condition // field paths are element-relative +elemMatch('scores', gt(ITSELF, 5)); // ITSELF addresses the element + // itself (scalar arrays) ``` ::: info `inArray` diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index 35557d084..8180b805d 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -33,7 +33,9 @@ Every dialect maps onto the same operator set (`FilterFieldOperator`): | `EXISTS` | is not null | `$exists` | `exists` | — | | `ELEM_MATCH` | array element match | `$elemMatch` | `elemMatch` | — | -The last four have no representation in the URL dialects — they work in code, via the [MongoDB-style parser](/packages/parser-mongo), and in every [adapter](/guide/executing-queries). +`REGEX`, `MOD` and `EXISTS` have no representation in the URL dialects — they work in code, via the [MongoDB-style parser](/packages/parser-mongo), and in every [adapter](/guide/executing-queries). `ELEM_MATCH` travels in the [expression dialect](/packages/parser-expression) only. + +Inside an `elemMatch` interior, the reserved `ITSELF` marker (wire spelling `$this`) may take the field position of a condition to address the array element itself: `elemMatch('scores', gt(ITSELF, 5))` matches when some score is greater than five. `@rapiq/memory` evaluates it element-wise; the SQL adapters throw a typed `featureUnsupported` (a joined relation row is not a scalar column). Anywhere outside an `elemMatch` interior the marker is a typed error. ## On the wire (expression dialect) diff --git a/packages/docs/guide/wire.md b/packages/docs/guide/wire.md index 494916cef..6ff869bf4 100644 --- a/packages/docs/guide/wire.md +++ b/packages/docs/guide/wire.md @@ -81,7 +81,8 @@ Every wire dialect expresses a subset of the query AST. Inside that subset, `dec | `or(...)`, nested groups | ✗ throws | ✓ | | Several conditions on one field | ✗ throws | ✓ | | Commas / simple operator markers in values | ✗ throws | ✓ (quoted) | -| `regex` / `mod` / `exists` / `elemMatch` | ✗ throws | ✗ throws | +| `elemMatch` (incl. the `ITSELF` element marker, wire spelling `$this`) | ✗ throws | ✓ | +| `regex` / `mod` / `exists` | ✗ throws | ✗ throws | ## Schema-aware transport diff --git a/packages/docs/packages/codec-url.md b/packages/docs/packages/codec-url.md index d3945c555..8a1d6ee0c 100644 --- a/packages/docs/packages/codec-url.md +++ b/packages/docs/packages/codec-url.md @@ -47,9 +47,13 @@ The default filter wire shape is one function-call expression: codec=url-expression&filter=and(gte(age,'18'),or(eq(status,'active'),eq(status,'pending'))) ``` -It carries flat filters, repeated fields and nested `and`/`or` trees. Values are quoted, so commas and simple-dialect operator markers retain their literal meaning. +It carries flat filters, repeated fields, nested `and`/`or` trees and `elemMatch` conditions. Values are quoted, so commas and simple-dialect operator markers retain their literal meaning. Inside an `elemMatch` interior the element itself is addressed by the reserved `$this` marker (core's `ITSELF` constant): -Operators without an expression grammar production—`regex`, `mod`, `exists` and `elemMatch`—throw a typed unsupported error during encoding rather than changing semantics. +```text +filter=elemMatch(scores,gt($this,'5')) +``` + +Operators without an expression grammar production—`regex`, `mod` and `exists`—throw a typed unsupported error during encoding rather than changing semantics. ## Legacy simple dialect diff --git a/packages/docs/packages/memory.md b/packages/docs/packages/memory.md index 4401a01b5..4d3df3c1e 100644 --- a/packages/docs/packages/memory.md +++ b/packages/docs/packages/memory.md @@ -95,17 +95,37 @@ const user = { // no single item is both id=1 and active -> no match (sql join parity) compileFilters(and(eq('items.id', 1), eq('items.active', true)))(user); // false -// same-element matching, stated explicitly — identical result in SQL and memory +// same-element matching, stated explicitly compileFilters(elemMatch('items', and(eq('id', 1), eq('active', true))))(user); // false ``` -`elemMatch` is field-prefix composition (exactly like the SQL adapter), so an `elemMatch` and a dotted condition on the same path share their binding. Where SQL has no opinion — a *leaf* value that is an array, e.g. `tags: ['a', 'b']` — Mongo element semantics apply: `eq('tags', 'a')` is membership, `in` is intersection. +Every `elemMatch` node opens its **own quantifier scope**: its interior conditions share one element, but two `elemMatch` nodes on the same field — or an `elemMatch` beside a dotted condition — bind independently (Mongo `$elemMatch` semantics). Where SQL has no opinion — a *leaf* value that is an array, e.g. `tags: ['a', 'b']` — Mongo element semantics apply: `eq('tags', 'a')` is membership, `in` is intersection. + +### ITSELF (element-level conditions) + +Inside an `elemMatch` interior, the `ITSELF` marker addresses the array element itself — the shape the mongo parser produces for element-level `$elemMatch` and `$all`: + +```typescript +import { ITSELF, elemMatch, gt, eq, and } from '@rapiq/core'; + +// { scores: { $elemMatch: { $gt: 5 } } } +compileFilters(elemMatch('scores', gt(ITSELF, 5)))({ scores: [3, 7] }); // true + +// { tags: { $all: ['a', 'b'] } } — one independent element match per value +compileFilters(and( + elemMatch('tags', eq(ITSELF, 'a')), + elemMatch('tags', eq(ITSELF, 'b')), +))({ tags: ['a', 'b'] }); // true +``` + +`ITSELF` conditions only match **real array elements** — a missing field, a scalar, a to-one object or an empty array never matches (no NULL-row fallback, mirroring Mongo's `$elemMatch`). Outside an `elemMatch` interior the marker is a typed error. ### Divergences | Case | @rapiq/memory | Baseline | |---|---|---| | Per-leaf array quantification | same-element binding | Mongo/ucast quantify each dotted condition independently | +| Multiple `elemMatch` on one field | independent element bindings | SQL adapter: one join alias, same row | | `exists` | is-not-null | Mongo: property presence | | `contains` family | case-insensitive | Mongo/ucast: case-sensitive | diff --git a/packages/docs/packages/parser-expression.md b/packages/docs/packages/parser-expression.md index e3a53447e..b42b31563 100644 --- a/packages/docs/packages/parser-expression.md +++ b/packages/docs/packages/parser-expression.md @@ -15,6 +15,8 @@ and(eq(name, 'John'), gte(age, '18')) or(in(status, 'active', 'pending'), gt(age, '65')) not(eq(name, 'foo')) contains(user.name, 'Bob') +elemMatch(items, and(eq(id, '1'), eq(active, 'true'))) +elemMatch(scores, gt($this, '5')) ``` | Function | Meaning | AST operator | @@ -26,14 +28,16 @@ contains(user.name, 'Bob') | `contains(field, value)` | substring | `CONTAINS` | | `startsWith(field, value)` | prefix | `STARTS_WITH` | | `endsWith(field, value)` | suffix | `ENDS_WITH` | +| `elemMatch(field, expr)` | array element match | `ELEM_MATCH` | | `and(expr, …)` / `or(expr, …)` | compound | `Filters` node | -| `not(expr, …)` | negation | flips operators (`eq` → `NOT_EQUAL`, `contains` → `NOT_CONTAINS`, …), `and` ↔ `or` | +| `not(expr, …)` | negation | flips operators (`eq` → `NOT_EQUAL`, `contains` → `NOT_CONTAINS`, …), `and` ↔ `or`; `elemMatch` has no complement and throws | Rules: - **Values are always single-quoted** — `gte(age, '18')`, not `gte(age, 18)`. Quoted numerals are coerced to numbers, `'true'`/`'false'` to booleans, `'null'` to `null`. Escape a quote by doubling it (`'it''s'`). - Quoted values are never comma-split — `eq(name, 'a,b')` is the literal string `'a,b'`; lists are separate arguments. - Field names allow `[A-Za-z0-9_-]` with dots for relation paths (`user.name`). +- Inside an `elemMatch` interior, field paths are relative to the array element; the reserved `$this` marker (core's `ITSELF` constant) addresses the element itself. Outside an interior, `$this` is an error. The language mirrors the [condition helpers](/guide/building-queries#condition-helpers) one-to-one: `eq('name', 'John')` in code ≙ `eq(name, 'John')` on the wire. diff --git a/packages/docs/packages/parser-mongo.md b/packages/docs/packages/parser-mongo.md index ef12bb300..c05fb734f 100644 --- a/packages/docs/packages/parser-mongo.md +++ b/packages/docs/packages/parser-mongo.md @@ -18,6 +18,8 @@ Filters are plain objects with typed values — numbers stay numbers, there is n { $or: [{ name: 'John' }, { age: { $lt: 18 } }] } // compound { 'realm.name': 'master' } // dotted relation path { items: { $elemMatch: { id: { $gt: 5 } } } } // array-element match +{ scores: { $elemMatch: { $gt: 5 } } } // element-level match (the element itself) +{ tags: { $all: ['a', 'b'] } } // every listed value has a matching element ``` Multiple document entries combine with an implicit AND. `$and` / `$or` / `$nor` take a non-empty array of sub-documents; `$nor` and `$not` desugar via De Morgan operator negation (`$eq` ↔ `$ne`, `$lt` ↔ `$gte`, AND ↔ OR, …). @@ -39,7 +41,8 @@ Multiple document entries combine with an implicit AND. `$and` / `$or` / `$nor` | `$options` | — (modifier) | — | flag string; only beside a string-valued `$regex` | | `$mod` | `MOD` | — (throws) | `[divisor, remainder]`, divisor ≠ 0 | | `$exists` | `EXISTS` | boolean flag flipped | boolean | -| `$elemMatch` | `ELEM_MATCH` | — (throws) | nested filter document, fields relative to the array element | +| `$elemMatch` | `ELEM_MATCH` | — (throws) | nested filter document (fields relative to the array element) or element-level operator object (operators apply to the element itself, via the `ITSELF` marker) | +| `$all` | AND of `ELEM_MATCH` | — (throws) | non-empty array of scalar/`null`/`Date`; desugars to one independently scoped element match per value | | `$not` | negation | — (no nesting) | object of field-level operators | † **rapiq extension — not valid MongoDB.** These cover the substring matches MongoDB only reaches through `$regex`, mapping 1:1 to the AST operators every rapiq dialect shares. @@ -48,8 +51,8 @@ Multiple document entries combine with an implicit AND. `$and` / `$or` / `$nor` - Nested plain objects expand to dotted key paths — `{ realm: { name: 'x' } }` ≡ `{ 'realm.name': 'x' }` — instead of MongoDB's exact-embedded-document match. - A bare array value means `$in` instead of MongoDB's exact-array match. - Negation is algebraic (De Morgan operator flipping) — it does not replicate MongoDB's missing-field semantics, where `$not` also matches documents lacking the field. -- `$elemMatch` supports the nested-document form only; element-level operators (`{ $elemMatch: { $gt: 5 } }`) throw. -- `$where`, `$size`, `$all`, `$type` and the other evaluation/geo/bitwise operators are unsupported and throw. +- `$all` never falls back to plain equality on non-array fields — MongoDB's `{ tags: { $all: ['a'] } }` matches `tags: 'a'`, the rapiq desugar does not (element matches require a real array). +- `$where`, `$size`, `$type` and the other evaluation/geo/bitwise operators are unsupported and throw. - MongoDB's `x` regex flag has no JavaScript equivalent — `$options: 'x'` is rejected. - An empty sub-document `{}` inside a compound (MongoDB's match-all branch) is a grammar error. ::: @@ -96,4 +99,4 @@ Failures fall into two classes: Absent input, `{}` and an all-dropped document are not failures — the schema's `filters.default` applies, like with the other parsers. -Error codes: malformed documents carry `ErrorCode.SYNTAX_INVALID`, invalid operator values `KEY_VALUE_INVALID`, non-object top-level input `INPUT_INVALID`, known-but-unsupported MongoDB operators (`$where`, `$size`, …) `OPERATOR_UNSUPPORTED`, the element-level `$elemMatch` form `FEATURE_UNSUPPORTED`. See [Error Handling](/guide/errors). +Error codes: malformed documents carry `ErrorCode.SYNTAX_INVALID`, invalid operator values `KEY_VALUE_INVALID`, non-object top-level input `INPUT_INVALID`, known-but-unsupported MongoDB operators (`$where`, `$size`, …) and non-negatable operators under `$not`/`$nor` (`$regex`, `$mod`, `$elemMatch`, `$all`) `OPERATOR_UNSUPPORTED`. See [Error Handling](/guide/errors). diff --git a/packages/docs/packages/sql.md b/packages/docs/packages/sql.md index b50939366..e50a75095 100644 --- a/packages/docs/packages/sql.md +++ b/packages/docs/packages/sql.md @@ -131,3 +131,7 @@ adapter.execute(query, { visitor: { caseSensitive: ['id'] } }); On folding dialects, give hot string filter columns an expression index (`CREATE INDEX ... ON "user" (lower(name))`) — or opt them out. Folding only happens for string filter values. Backends with column metadata can exempt whole columns by overriding `isCaseFoldable(field)` on the filters adapter (default: `true`) — the [TypeORM adapter](/packages/typeorm) uses it to fold only string-typed columns. + +### ITSELF (element-level conditions) + +The [`ITSELF` marker](/guide/filters#operators) — an `elemMatch` interior condition on the array element itself, produced e.g. by the mongo parser's element-level `$elemMatch` and `$all` — has no SQL rendering: `elemMatch` maps to a relation join, and a joined row is not a scalar column. Both `@rapiq/sql` and `@rapiq/typeorm` throw a typed `AdapterError` (`ErrorCode.FEATURE_UNSUPPORTED`). Dialect-level JSON-array support (`json_each` / `unnest`) may lift this later; evaluate such filters with [`@rapiq/memory`](/packages/memory) in the meantime. diff --git a/packages/memory/src/parameter/filters/binding.ts b/packages/memory/src/parameter/filters/binding.ts index 5ea10f1c8..06d8fe326 100644 --- a/packages/memory/src/parameter/filters/binding.ts +++ b/packages/memory/src/parameter/filters/binding.ts @@ -5,20 +5,39 @@ * view the LICENSE file that was distributed with this source code. */ +import { ITSELF } from '@rapiq/core'; import { resolveProperty } from '../../helpers'; import type { Predicate } from '../../types'; +import { BINDING_ELEMENT_FLAG, BINDING_SCOPE_SEPARATOR } from './constants'; import type { BindingContext, ConditionEval } from './types'; const EMPTY_CONTEXT : BindingContext = new Map(); /** - * The join-row candidates a relation path segment contributes: - * every element of an array, the object itself, or a single - * NULL row when the value is absent or the array is empty. + * The source value a binding-path segment reads off its parent + * binding. elemMatch segments carry a scope discriminator that is + * stripped before property resolution; an ITSELF segment (an + * elemMatch on the element itself) re-reads the parent binding. */ -function bindingCandidates(parent: unknown, segment: string) : unknown[] { - const raw = resolveProperty(parent, segment); +function bindingSource(parent: unknown, segment: string) : unknown { + const separatorIndex = segment.indexOf(BINDING_SCOPE_SEPARATOR); + const name = separatorIndex === -1 ? + segment : + segment.slice(0, separatorIndex); + if (name === ITSELF) { + return parent; + } + + return resolveProperty(parent, name); +} + +/** + * The join-row candidates a binding path contributes: every element + * of an array, the object itself, or a single NULL row when the + * value is absent or the array is empty. + */ +function bindingCandidates(raw: unknown) : unknown[] { if (Array.isArray(raw)) { return raw.length > 0 ? raw : [null]; } @@ -28,7 +47,7 @@ function bindingCandidates(parent: unknown, segment: string) : unknown[] { /** * Quantify a compiled condition tree over all assignments of - * elements to relation paths (LEFT-join row semantics): the input + * elements to binding paths (LEFT-join row semantics): the input * matches if some assignment satisfies the whole tree. */ export function createBoundPredicate( @@ -60,7 +79,13 @@ export function createBoundPredicate( path : path.slice(separatorIndex + 1); - const candidates = bindingCandidates(parent, segment); + const raw = bindingSource(parent, segment); + + // ITSELF leaves only match real array elements — never a + // to-one object, a scalar or the NULL row. + ctx.set(`${path}${BINDING_ELEMENT_FLAG}`, Array.isArray(raw) && raw.length > 0); + + const candidates = bindingCandidates(raw); for (const candidate of candidates) { ctx.set(path, candidate); @@ -70,6 +95,7 @@ export function createBoundPredicate( } ctx.delete(path); + ctx.delete(`${path}${BINDING_ELEMENT_FLAG}`); return false; }; diff --git a/packages/memory/src/parameter/filters/compiler.ts b/packages/memory/src/parameter/filters/compiler.ts index 7a7ebe87b..cc5b1fc58 100644 --- a/packages/memory/src/parameter/filters/compiler.ts +++ b/packages/memory/src/parameter/filters/compiler.ts @@ -16,6 +16,7 @@ import { AdapterError, FilterCompoundOperator, FilterRegexFlag, + ITSELF, createFilterRegex, isFilter, isFilters, @@ -27,6 +28,7 @@ import { resolveProperty, toText, } from '../../helpers'; +import { BINDING_ELEMENT_FLAG, BINDING_SCOPE_SEPARATOR } from './constants'; import type { FilterCompileResult, FiltersVisitorOptions, ValueTest } from './types'; /** @@ -55,11 +57,17 @@ export class FiltersCompiler implements IFiltersVisitor, protected fieldPrefix : string; + protected bindingPrefix : string; + + protected scopeSequence : number; + protected caseSensitiveFields : Set; constructor(options: FiltersVisitorOptions = {}) { this.paths = new Set(); this.fieldPrefix = ''; + this.bindingPrefix = ''; + this.scopeSequence = 0; this.caseSensitiveFields = new Set(options.caseSensitive || []); } @@ -134,14 +142,28 @@ export class FiltersCompiler implements IFiltersVisitor, throw AdapterError.featureUnsupported('filters:elemMatch:value'); } - const oldPrefix = this.fieldPrefix; + // an elemMatch on the element itself (arrays of arrays) is + // only meaningful inside another elemMatch scope. + if (expr.field === ITSELF && !this.bindingPrefix) { + throw AdapterError.featureUnsupported('filters:itself'); + } - this.fieldPrefix = `${oldPrefix}${expr.field}.`; + const oldFieldPrefix = this.fieldPrefix; + const oldBindingPrefix = this.bindingPrefix; + + // every elemMatch opens its own quantifier scope: the + // discriminated segment gives this interior an element binding + // of its own, so two elemMatches on one field quantify + // independently (e.g. one per $all value). + this.scopeSequence += 1; + this.fieldPrefix = `${oldFieldPrefix}${expr.field}.`; + this.bindingPrefix = `${oldBindingPrefix}${expr.field}${BINDING_SCOPE_SEPARATOR}${this.scopeSequence}.`; try { return expr.value.accept(this); } finally { - this.fieldPrefix = oldPrefix; + this.fieldPrefix = oldFieldPrefix; + this.bindingPrefix = oldBindingPrefix; } } @@ -222,7 +244,23 @@ export class FiltersCompiler implements IFiltersVisitor, // ----------------------------------------------------------- protected leaf(field: string, test: ValueTest) : FilterCompileResult { - const key = `${this.fieldPrefix}${field}`; + if (field === ITSELF) { + // the marker addresses the element bound by the enclosing + // elemMatch scope; outside one it has no referent. + if (!this.bindingPrefix) { + throw AdapterError.featureUnsupported('filters:itself'); + } + + const path = this.bindingPrefix.slice(0, -1); + const flag = `${path}${BINDING_ELEMENT_FLAG}`; + + this.registerPath(path); + + return (ctx) => ctx.get(flag) === true && + test(normalizeValue(ctx.get(path))); + } + + const key = `${this.bindingPrefix}${field}`; const separatorIndex = key.lastIndexOf('.'); if (separatorIndex === -1) { diff --git a/packages/memory/src/parameter/filters/constants.ts b/packages/memory/src/parameter/filters/constants.ts new file mode 100644 index 000000000..89fbcf9f4 --- /dev/null +++ b/packages/memory/src/parameter/filters/constants.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +/** + * Separates the property name of a binding-path segment from the + * elemMatch scope discriminator (e.g. items + separator + 1). Every + * elemMatch node opens its own quantifier scope, so two elemMatches + * on one field bind independent elements; the NUL byte keeps + * discriminated segments out of the real property namespace. + */ +export const BINDING_SCOPE_SEPARATOR = '\u0000'; + +/** + * Context-key suffix flagging whether the value bound to a path is a + * real array element (as opposed to a to-one object, a scalar or the + * NULL row of a missing/empty source). ITSELF leaves only match real + * elements - mongo's $elemMatch never matches non-arrays. + */ +export const BINDING_ELEMENT_FLAG = '\u0000*'; diff --git a/packages/memory/test/unit/filters/binding.spec.ts b/packages/memory/test/unit/filters/binding.spec.ts index 8b70cc2a7..f507dabf6 100644 --- a/packages/memory/test/unit/filters/binding.spec.ts +++ b/packages/memory/test/unit/filters/binding.spec.ts @@ -10,6 +10,7 @@ import { ErrorCode, Filter, FilterFieldOperator, + ITSELF, and, elemMatch, eq, @@ -162,16 +163,18 @@ describe('filters: join-row binding', () => { expect(compileFilters(elemMatch('realm', eq('name', 'other')))(input)).toBeFalsy(); }); - it('should collapse two elemMatches on one field to the same element', () => { - // both reference the same join path — sql parity fallout, - // settled in plan 014. + it('should quantify two elemMatches on one field independently', () => { + // every elemMatch opens its own quantifier scope (plan 016, + // revising the plan 014 parity fallout): different elements + // may satisfy different elemMatches. const predicate = compileFilters(and( elemMatch('items', eq('id', 1)), elemMatch('items', eq('active', true)), )); - expect(predicate(user)).toBeFalsy(); + expect(predicate(user)).toBeTruthy(); expect(predicate({ items: [{ id: 1, active: true }] })).toBeTruthy(); + expect(predicate({ items: [{ id: 1, active: false }] })).toBeFalsy(); }); it('should reject a non-condition value', () => { @@ -186,16 +189,106 @@ describe('filters: join-row binding', () => { } }); - it('should share the binding with dotted siblings on the same path', () => { - // elemMatch is prefix rewriting: both conditions reference - // the items join and therefore the same element (sql parity). + it('should bind independently of dotted siblings on the same path', () => { + // the elemMatch scope is its own quantifier — the dotted + // sibling keeps its implicit join-row binding (plan 016). const predicate = compileFilters(and( elemMatch('items', eq('id', 1)), eq('items.active', true), )); - expect(predicate(user)).toBeFalsy(); + expect(predicate(user)).toBeTruthy(); expect(predicate({ items: [{ id: 1, active: true }] })).toBeTruthy(); + expect(predicate({ items: [{ id: 1, active: false }] })).toBeFalsy(); + }); + }); + + describe('ITSELF', () => { + it('should evaluate a leaf against the element itself', () => { + const input = { scores: [3, 7, 9] }; + + expect(compileFilters(elemMatch('scores', new Filter( + FilterFieldOperator.GREATER_THAN, + ITSELF, + 8, + )))(input)).toBeTruthy(); + + expect(compileFilters(elemMatch('scores', new Filter( + FilterFieldOperator.GREATER_THAN, + ITSELF, + 10, + )))(input)).toBeFalsy(); + }); + + it('should bind interior ITSELF conditions to the same element', () => { + // mongo semantics: one element must satisfy the whole interior. + const predicate = compileFilters(elemMatch('scores', and( + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + new Filter(FilterFieldOperator.LESS_THAN, ITSELF, 8), + ))); + + expect(predicate({ scores: [3, 7] })).toBeTruthy(); + expect(predicate({ scores: [3, 9] })).toBeFalsy(); + }); + + it('should quantify per $all value independently', () => { + // the parser desugar of { tags: { $all: ['a', 'b'] } }. + const predicate = compileFilters(and( + elemMatch('tags', eq(ITSELF, 'a')), + elemMatch('tags', eq(ITSELF, 'b')), + )); + + expect(predicate({ tags: ['a', 'b', 'c'] })).toBeTruthy(); + expect(predicate({ tags: ['a', 'c'] })).toBeFalsy(); + expect(predicate({ tags: [] })).toBeFalsy(); + }); + + it('should never match missing, scalar or empty sources', () => { + const predicate = compileFilters(elemMatch('scores', eq(ITSELF, 5))); + + expect(predicate({ scores: [5] })).toBeTruthy(); + expect(predicate({ scores: [] })).toBeFalsy(); + expect(predicate({ scores: 5 })).toBeFalsy(); + expect(predicate({ scores: null })).toBeFalsy(); + expect(predicate({})).toBeFalsy(); + + // not even a null test matches the empty-array NULL row. + expect(compileFilters(elemMatch('scores', eq(ITSELF, null)))({ scores: [] })).toBeFalsy(); + expect(compileFilters(elemMatch('scores', eq(ITSELF, null)))({ scores: [null] })).toBeTruthy(); + }); + + it('should evaluate nested element-level elemMatch on arrays of arrays', () => { + // { matrix: { $elemMatch: { $elemMatch: { $gt: 5 } } } } + const predicate = compileFilters(elemMatch('matrix', elemMatch(ITSELF, new Filter( + FilterFieldOperator.GREATER_THAN, + ITSELF, + 5, + )))); + + expect(predicate({ matrix: [[1, 2], [3, 9]] })).toBeTruthy(); + expect(predicate({ matrix: [[1, 2], [3, 4]] })).toBeFalsy(); + expect(predicate({ matrix: [1, 9] })).toBeFalsy(); + }); + + it('should compare string elements case-insensitively by default', () => { + expect(compileFilters(elemMatch('tags', eq(ITSELF, 'Chess')))({ tags: ['chess'] })).toBeTruthy(); + }); + + it('should throw typed outside an elemMatch interior', () => { + const inputs = [ + () => compileFilters(eq(ITSELF, 5)), + () => compileFilters(elemMatch(ITSELF, eq('id', 1))), + ]; + + for (const input of inputs) { + try { + input(); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toEqual(ErrorCode.FEATURE_UNSUPPORTED); + } + } }); }); }); diff --git a/packages/parser-expression/src/parameter/filters/constants.ts b/packages/parser-expression/src/parameter/filters/constants.ts index 62558e754..668db099b 100644 --- a/packages/parser-expression/src/parameter/filters/constants.ts +++ b/packages/parser-expression/src/parameter/filters/constants.ts @@ -21,8 +21,10 @@ export enum FilterTokenType { ENDS_WITH = 'endsWith', IN = 'in', NIN = 'nin', + ELEM_MATCH = 'elemMatch', FIELD = 'FIELD', + ITSELF = 'ITSELF', ESCAPED_TEXT = 'ESCAPED_TEXT', NULL = 'NULL', LPAREN = 'LPAREN', @@ -52,6 +54,7 @@ export const FILTER_EXPRESSION_KEYWORDS = { endsWith: FilterTokenType.ENDS_WITH, in: FilterTokenType.IN, nin: FilterTokenType.NIN, + elemMatch: FilterTokenType.ELEM_MATCH, null: FilterTokenType.NULL, } as const satisfies Record; diff --git a/packages/parser-expression/src/parameter/filters/module.ts b/packages/parser-expression/src/parameter/filters/module.ts index 922f9cb8b..b37a7a4dd 100644 --- a/packages/parser-expression/src/parameter/filters/module.ts +++ b/packages/parser-expression/src/parameter/filters/module.ts @@ -15,13 +15,16 @@ import type { import { BaseParser, DEFAULT_ID, + ErrorCode, Filter, FilterCompoundOperator, FilterFieldOperator, Filters, FiltersParseError, + ITSELF, MAX_TRAVERSAL_DEPTH, Parameter, + ParseError, ResolutionScope, applyFiltersSchemaValidation, applyFiltersSchemaValidationAsync, @@ -55,6 +58,12 @@ export class ExpressionFiltersParser extends BaseParser< private pos = 0; + /** + * How many elemMatch interiors the parser is currently inside — + * the ITSELF marker is only legal at depth > 0. + */ + private elemMatchDepth = 0; + // --------------------------------------------------------- parse( @@ -132,6 +141,7 @@ export class ExpressionFiltersParser extends BaseParser< } this.pos = 0; + this.elemMatchDepth = 0; this.tokens = this.tokenize(input); // expressions are precise — invalid keys always throw, @@ -196,7 +206,8 @@ export class ExpressionFiltersParser extends BaseParser< // keywords are classified from whole identifiers (lookup below) — // matching them in the regex would split identifiers that merely // start with a keyword (e.g. "order" -> "or" + "der"). - const regex = new RegExp(`\\s+|\\(|\\)|,|\\.|'(?:''|[^'])*'|${FILTER_FIELD_SEGMENT_PATTERN}`, 'g'); + // $-words are reserved markers, never field segments. + const regex = new RegExp(`\\s+|\\(|\\)|,|\\.|'(?:''|[^'])*'|\\$[A-Za-z0-9_]*|${FILTER_FIELD_SEGMENT_PATTERN}`, 'g'); let match: RegExpExecArray | null; let cursor = 0; @@ -216,6 +227,15 @@ export class ExpressionFiltersParser extends BaseParser< case ',': tokens.push({ type: FilterTokenType.COMMA }); break; case '.': tokens.push({ type: FilterTokenType.DOT }); break; default: + if (value.startsWith('$')) { + if (value !== ITSELF) { + throw FiltersParseError.syntaxInvalid(`The marker ${value} is unknown.`); + } + + tokens.push({ type: FilterTokenType.ITSELF }); + break; + } + // own-property check: exotic field names inherited from // Object.prototype (toString, constructor, ...) stay fields. if (Object.prototype.hasOwnProperty.call(FILTER_EXPRESSION_KEYWORDS, value)) { @@ -268,6 +288,8 @@ export class ExpressionFiltersParser extends BaseParser< case FilterTokenType.IN: case FilterTokenType.NIN: return this.parseInExpression(scope, negation); + case FilterTokenType.ELEM_MATCH: + return this.parseElemMatchExpression(scope, negation, depth); default: throw FiltersParseError.syntaxInvalid(`Unexpected token in filter expression: ${token.type}`); } @@ -498,6 +520,123 @@ export class ExpressionFiltersParser extends BaseParser< ); } + /** + * elemMatch(field, expr): field paths inside the interior are + * relative to the array element; the ITSELF marker addresses the + * element itself. There is no complement — a negated elemMatch + * would silently widen and always throws. + */ + private parseElemMatchExpression( + scope?: FiltersScope, + negation: boolean = false, + depth: number = 0, + ): Filter { + if (negation) { + throw FiltersParseError.operatorUnsupported('elemMatch'); + } + + this.consume(FilterTokenType.ELEM_MATCH); + this.consume(FilterTokenType.LPAREN); + + const target = this.parseElemMatchTarget(scope); + + this.consume(FilterTokenType.COMMA); + + this.elemMatchDepth += 1; + + let condition : Filters | Filter; + try { + condition = this.parseFilterExpression(target.scope, false, depth + 1); + } finally { + this.elemMatchDepth -= 1; + } + + this.consume(FilterTokenType.RPAREN); + + return new Filter(FilterFieldOperator.ELEM_MATCH, target.field, condition); + } + + /** + * The field an elemMatch binds plus the interior resolution scope: + * the related schema when resolvable, otherwise an unbound scope + * inheriting the current policy (e.g. a JSON array column). + */ + private parseElemMatchTarget( + scope?: FiltersScope, + ): { field: string, scope?: FiltersScope } { + if (this.peek().type === FilterTokenType.ITSELF) { + // an elemMatch on the element itself (arrays of arrays); + // the chain parser enforces the interior-only contract. + const field = this.parseExpressionFieldChain(scope); + + return { + field, + scope: scope ? this.buildUnboundScope(scope) : undefined, + }; + } + + const token = this.consume(FilterTokenType.FIELD); + + const parts = [token.value!]; + while (this.peek().type === FilterTokenType.DOT) { + this.consume(FilterTokenType.DOT); + parts.push(this.consume(FilterTokenType.FIELD).value!); + } + + if (!scope) { + return { field: parts.join('.') }; + } + + // a leading segment matching the schema name addresses the + // schema itself (mirrors parseExpressionFieldChain). + if ( + parts.length > 1 && + (parts[0] === DEFAULT_ID || parts[0] === scope.schema.name) + ) { + parts.shift(); + } + + const resolved = scope.resolveKey(parts.join('.')); + + /* istanbul ignore next -- the scope always throws */ + if (!resolved.success) { + throw FiltersParseError.keyInvalid(resolved.input); + } + + const field = [...resolved.path, resolved.name].join('.'); + + let interior : FiltersScope | undefined; + try { + const verdict = resolved.scope.descend(resolved.name); + if (verdict instanceof ResolutionScope) { + interior = verdict as FiltersScope; + } + } catch (e) { + // elemMatch on a non-relation field is legal — a missing + // related schema (thrown as keyPathInvalid, the scope is + // always throwing) falls back to the unbound scope; every + // other failure (e.g. relations gating) propagates. + if ( + !(e instanceof ParseError) || + e.code !== ErrorCode.KEY_PATH_INVALID + ) { + throw e; + } + } + + return { + field, + scope: interior ?? this.buildUnboundScope(scope), + }; + } + + private buildUnboundScope(current: FiltersScope) : FiltersScope { + return ResolutionScope.for(this.registry, Parameter.FILTERS, undefined, { + throwOnFailure: true, + strict: current.strict, + }) as FiltersScope; + } + private parseExpressionValue(): Scalar { const token = this.consume(); @@ -514,6 +653,18 @@ export class ExpressionFiltersParser extends BaseParser< private parseExpressionFieldChain( scope?: FiltersScope, ): string { + if (this.peek().type === FilterTokenType.ITSELF) { + this.consume(FilterTokenType.ITSELF); + + // the marker addresses the element bound by the enclosing + // elemMatch interior and never resolves against the schema. + if (this.elemMatchDepth === 0) { + throw FiltersParseError.keyInvalid(ITSELF); + } + + return ITSELF; + } + const token = this.consume(FilterTokenType.FIELD); if (token.type !== FilterTokenType.FIELD) { throw FiltersParseError.syntaxInvalid(`Unexpected token in field chain: ${token.type}`); diff --git a/packages/parser-expression/test/unit/parser/filters.spec.ts b/packages/parser-expression/test/unit/parser/filters.spec.ts index 622615bfc..35ca5cd27 100644 --- a/packages/parser-expression/test/unit/parser/filters.spec.ts +++ b/packages/parser-expression/test/unit/parser/filters.spec.ts @@ -12,9 +12,12 @@ import { FilterFieldOperator, Filters, FiltersParseError, + ITSELF, Relation, Relations, + SchemaRegistry, defineFiltersSchema, + defineSchema, } from '@rapiq/core'; import { registry } from '../../data/schema'; import { ExpressionFiltersParser, ExpressionParser } from '../../../src'; @@ -466,6 +469,146 @@ describe('filters/expr-parser', () => { }); }); + describe('elemMatch', () => { + it('should parse the nested document form', () => { + const output = parser.parseExact('elemMatch(items,eq(name,\'chess\'))'); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filter(FilterFieldOperator.EQUAL, 'name', 'chess'), + )); + }); + + it('should parse a compound interior', () => { + const output = parser.parseExact('elemMatch(items,and(eq(id,\'1\'),eq(active,\'true\')))'); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'id', 1), + new Filter(FilterFieldOperator.EQUAL, 'active', true), + ]), + )); + }); + + it('should parse the ITSELF marker inside the interior', () => { + const output = parser.parseExact('elemMatch(scores,gt($this,\'5\'))'); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'scores', + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + )); + }); + + it('should parse a nested elemMatch on the element itself', () => { + const output = parser.parseExact('elemMatch(matrix,elemMatch($this,gt($this,\'5\')))'); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'matrix', + new Filter( + FilterFieldOperator.ELEM_MATCH, + ITSELF, + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + ), + )); + }); + + it('should throw on a negated elemMatch', () => { + const error = FiltersParseError.operatorUnsupported('elemMatch'); + + expect(() => parser.parseExact('not(elemMatch(items,eq(id,\'1\')))')).toThrow(error); + }); + + it('should throw on the ITSELF marker outside an elemMatch interior', () => { + const error = FiltersParseError.keyInvalid(ITSELF); + + expect(() => parser.parseExact('eq($this,\'5\')')).toThrow(error); + expect(() => parser.parseExact('elemMatch($this,eq(id,\'1\'))')).toThrow(error); + }); + + it('should throw on an unknown marker', () => { + expect(() => parser.parseExact('eq($foo,\'5\')')).toThrow(FiltersParseError); + expect(() => parser.parseExact('eq($,\'5\')')).toThrow(FiltersParseError); + }); + + it('should throw on a dotted ITSELF chain', () => { + expect(() => parser.parseExact('elemMatch(items,eq($this.name,\'x\'))')).toThrow(FiltersParseError); + }); + + describe('schema-constrained', () => { + let elemRegistry : SchemaRegistry; + let constrained : ExpressionFiltersParser; + + beforeAll(() => { + elemRegistry = new SchemaRegistry(); + elemRegistry.add(defineSchema({ + name: 'user', + filters: { allowed: ['id', 'name', 'items', 'meta'] }, + relations: { allowed: ['items'] }, + schemaMapping: { items: 'item' }, + })); + elemRegistry.add(defineSchema({ + name: 'item', + filters: { allowed: ['id'] }, + })); + + constrained = new ExpressionFiltersParser(elemRegistry); + }); + + it('should validate interior keys against the related schema', () => { + const output = constrained.parseExact('elemMatch(items,eq(id,\'1\'))', { schema: 'user' }); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filter(FilterFieldOperator.EQUAL, 'id', 1), + )); + + const error = FiltersParseError.keyNotPermitted('name'); + expect(() => constrained.parseExact('elemMatch(items,eq(name,\'x\'))', { schema: 'user' })).toThrow(error); + }); + + it('should throw on a non allowed elemMatch field', () => { + const error = FiltersParseError.keyNotPermitted('secret'); + + expect(() => constrained.parseExact('elemMatch(secret,eq(id,\'1\'))', { schema: 'user' })).toThrow(error); + }); + + it('should fall back to an unbound interior scope on a schemaless field', () => { + const output = constrained.parseExact('elemMatch(meta,eq(value,\'5\'))', { schema: 'user' }); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'meta', + new Filter(FilterFieldOperator.EQUAL, 'value', 5), + )); + }); + + it('should honor the relations context', () => { + const error = FiltersParseError.keyPathNotPermitted('items'); + + expect(() => constrained.parseExact('elemMatch(items,eq(id,\'1\'))', { + schema: 'user', + relations: new Relations([new Relation('realm')]), + })).toThrow(error); + }); + + it('should accept the ITSELF marker under a schema', () => { + const output = constrained.parseExact('elemMatch(meta,gt($this,\'5\'))', { schema: 'user' }); + + expect(output).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'meta', + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + )); + }); + }); + }); + describe('parse (strict mode)', () => { it('should throw for any key when parsing schemaless with the strict option', () => { const error = FiltersParseError.keyNotPermitted('name'); diff --git a/packages/parser-mongo/src/parameter/filters/constants.ts b/packages/parser-mongo/src/parameter/filters/constants.ts index 57a2b2524..71b9ba50f 100644 --- a/packages/parser-mongo/src/parameter/filters/constants.ts +++ b/packages/parser-mongo/src/parameter/filters/constants.ts @@ -53,6 +53,7 @@ export const MONGO_FIELD_OPERATORS : readonly string[] = [ '$mod', '$exists', '$elemMatch', + '$all', '$not', ]; @@ -62,7 +63,6 @@ export const MONGO_FIELD_OPERATORS : readonly string[] = [ */ export const MONGO_UNSUPPORTED_OPERATORS : readonly string[] = [ '$size', - '$all', '$type', '$where', '$text', diff --git a/packages/parser-mongo/src/parameter/filters/module.ts b/packages/parser-mongo/src/parameter/filters/module.ts index 7202e47c8..d313ca207 100644 --- a/packages/parser-mongo/src/parameter/filters/module.ts +++ b/packages/parser-mongo/src/parameter/filters/module.ts @@ -19,6 +19,7 @@ import { FilterFieldOperator, Filters, FiltersParseError, + ITSELF, KeyResolutionErrorCode, MAX_TRAVERSAL_DEPTH, Parameter, @@ -598,6 +599,17 @@ export class MongoFiltersParser extends BaseParser< break; } + case '$all': { + // no complement twin — negated $all can not be + // expressed without silently widening. + if (negated) { + throw FiltersParseError.operatorUnsupported('$all'); + } + + this.validateInValue(key, input[operator]); + + break; + } case '$not': { if (negated) { throw FiltersParseError.syntaxInvalid( @@ -755,6 +767,19 @@ export class MongoFiltersParser extends BaseParser< break; } + case '$all': { + // for each listed value some element must equal it — + // an AND of independently scoped element matches. + for (const element of input[operator]) { + output.push(new Filter( + FilterFieldOperator.ELEM_MATCH, + resolved.field, + new Filter(FilterFieldOperator.EQUAL, ITSELF, element), + )); + } + + break; + } case '$not': { const conditions = this.buildOperatorObject( input[operator], @@ -783,53 +808,82 @@ export class MongoFiltersParser extends BaseParser< } /** - * Build an $elemMatch condition: the value re-enters document - * context, fields relative to the array element. The interior scope - * is the related schema when resolvable, otherwise an unbound scope - * inheriting the current policy (e.g. a JSON array column). + * Build an $elemMatch condition. A field-operator interior + * (`{ $gt: 5 }`) is the element-level form — the operators apply to + * the array element itself, referenced by the ITSELF marker. Any + * other value re-enters document context, fields relative to the + * array element. The document-form interior scope is the related + * schema when resolvable, otherwise an unbound scope inheriting the + * current policy (e.g. a JSON array column). */ protected buildElemMatch( input: Record, resolved: FieldResolution, depth: number, ) : ICondition | undefined { - // the element-level operator form (matching the scalar element - // itself) has no self-reference marker in the AST. + if (depth > MAX_DEPTH) { + throw FiltersParseError.syntaxInvalid('The maximum nesting depth was exceeded.'); + } + const keys = Object.keys(input); - for (const key of keys) { - if (MONGO_FIELD_OPERATORS.includes(key)) { - throw FiltersParseError.featureUnsupported('$elemMatch (element-level operators)'); + if (keys.some((key) => MONGO_FIELD_OPERATORS.includes(key))) { + // grammar of the interior is validated like any other + // operator object (unknown operators, plain keys and + // misplaced compounds throw). + const regex = this.validateOperatorObject(resolved.field, input, false); + + const conditions = this.buildOperatorObject( + input, + { + field: ITSELF, + name: ITSELF, + scope: this.buildUnboundScope(resolved.scope), + }, + false, + depth + 1, + regex, + ); + + const condition = this.combineDocument(conditions, false); + + /* istanbul ignore next -- validation rejected empty interiors */ + if (!condition) { + return undefined; } + + return new Filter(FilterFieldOperator.ELEM_MATCH, resolved.field, condition); } let child : FiltersScope | undefined; - try { - const verdict = resolved.scope.descend(resolved.name); - if (verdict instanceof ResolutionScope) { - child = verdict; - } else if (verdict.code !== KeyResolutionErrorCode.SCHEMA_UNRESOLVABLE) { - // relations gating (pathNotPermitted) is a schema-policy - // failure for the entry — drop it. - return undefined; - } - } catch (e) { - // $elemMatch on a non-relation field is legal — a missing - // related schema (thrown as keyPathInvalid under - // throwOnFailure) falls back to the unbound scope; - // every other failure propagates. - if ( - !(e instanceof ParseError) || - e.code !== ErrorCode.KEY_PATH_INVALID - ) { - throw e; + if (resolved.name === ITSELF) { + // the element itself is never schema-resolvable. + child = this.buildUnboundScope(resolved.scope); + } else { + try { + const verdict = resolved.scope.descend(resolved.name); + if (verdict instanceof ResolutionScope) { + child = verdict; + } else if (verdict.code !== KeyResolutionErrorCode.SCHEMA_UNRESOLVABLE) { + // relations gating (pathNotPermitted) is a schema-policy + // failure for the entry — drop it. + return undefined; + } + } catch (e) { + // $elemMatch on a non-relation field is legal — a missing + // related schema (thrown as keyPathInvalid under + // throwOnFailure) falls back to the unbound scope; + // every other failure propagates. + if ( + !(e instanceof ParseError) || + e.code !== ErrorCode.KEY_PATH_INVALID + ) { + throw e; + } } } if (!child) { - child = ResolutionScope.for(this.registry, Parameter.FILTERS, undefined, { - throwOnFailure: resolved.scope.throwOnFailure, - strict: resolved.scope.strict, - }) as FiltersScope; + child = this.buildUnboundScope(resolved.scope); } const conditions = this.parseDocument(input, child, false, depth + 1); @@ -842,6 +896,13 @@ export class MongoFiltersParser extends BaseParser< return new Filter(FilterFieldOperator.ELEM_MATCH, resolved.field, condition); } + + protected buildUnboundScope(current: FiltersScope) : FiltersScope { + return ResolutionScope.for(this.registry, Parameter.FILTERS, undefined, { + throwOnFailure: current.throwOnFailure, + strict: current.strict, + }) as FiltersScope; + } } // --------------------------------------------------------- diff --git a/packages/parser-mongo/src/parameter/filters/types.ts b/packages/parser-mongo/src/parameter/filters/types.ts index de1cbf7a8..04e5be769 100644 --- a/packages/parser-mongo/src/parameter/filters/types.ts +++ b/packages/parser-mongo/src/parameter/filters/types.ts @@ -44,7 +44,8 @@ export type MongoFieldQueryOperators = { $mod?: [number, number], $exists?: boolean, $elemMatch?: ObjectLiteral, - $not?: Omit, '$not' | '$regex' | '$options' | '$mod' | '$elemMatch'>, + $all?: (V | null)[], + $not?: Omit, '$not' | '$regex' | '$options' | '$mod' | '$elemMatch' | '$all'>, }; /** diff --git a/packages/parser-mongo/test/unit/parser/filters.spec.ts b/packages/parser-mongo/test/unit/parser/filters.spec.ts index 5b57c6148..86375142d 100644 --- a/packages/parser-mongo/test/unit/parser/filters.spec.ts +++ b/packages/parser-mongo/test/unit/parser/filters.spec.ts @@ -13,6 +13,7 @@ import { FilterFieldOperator, Filters, FiltersParseError, + ITSELF, Relation, Relations, SchemaRegistry, @@ -1018,11 +1019,73 @@ describe('filters/mongo-parser', () => { ])); }); - it('should throw on the element operator form', () => { - const error = FiltersParseError.featureUnsupported('$elemMatch (element-level operators)'); + it('should parse the element operator form onto the ITSELF marker', () => { + expect(parseFlat({ scores: { $elemMatch: { $gte: 5 } } })).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'scores', + new Filter(FilterFieldOperator.GREATER_THAN_EQUAL, ITSELF, 5), + )); + + // several element-level operators form an implicit AND interior. + expect(parseFlat({ scores: { $elemMatch: { $gt: 5, $lt: 10 } } })).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'scores', + new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + new Filter(FilterFieldOperator.LESS_THAN, ITSELF, 10), + ]), + )); + }); + + it('should negate element operators locally under an interior $not', () => { + expect(parseFlat({ scores: { $elemMatch: { $not: { $eq: 5 } } } })).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'scores', + new Filter(FilterFieldOperator.NOT_EQUAL, ITSELF, 5), + )); + }); - expect(() => parser.parse({ items: { $elemMatch: { $gte: 5 } } })).toThrow(error); - expect(() => parser.parse({ items: { $elemMatch: { $not: { $eq: 5 } } } })).toThrow(error); + it('should validate the element operator interior as grammar', () => { + expectParseError( + () => parser.parse({ scores: { $elemMatch: { $gte: 5, name: 'x' } } }), + ErrorCode.SYNTAX_INVALID, + ); + expectParseError( + () => parser.parse({ scores: { $elemMatch: { $gte: null } } }), + ErrorCode.KEY_VALUE_INVALID, + ); + expectParseError( + () => parser.parse({ scores: { $elemMatch: { $size: 2 } } }), + ErrorCode.OPERATOR_UNSUPPORTED, + ); + }); + + it('should keep the element operator interior unbound under a schema', () => { + const output = constrained.parse( + { meta: { $elemMatch: { $gte: 5 } } }, + { schema: 'user', throwOnFailure: true }, + ); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'meta', + new Filter(FilterFieldOperator.GREATER_THAN_EQUAL, ITSELF, 5), + ), + ])); + }); + + it('should parse a nested element-level elemMatch onto the element itself', () => { + // array of arrays: some inner array contains an element > 5. + expect(parseFlat({ matrix: { $elemMatch: { $elemMatch: { $gt: 5 } } } })).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'matrix', + new Filter( + FilterFieldOperator.ELEM_MATCH, + ITSELF, + new Filter(FilterFieldOperator.GREATER_THAN, ITSELF, 5), + ), + )); }); it('should always throw on an empty $elemMatch object', () => { @@ -1113,6 +1176,99 @@ describe('filters/mongo-parser', () => { }); }); + describe('$all', () => { + it('should desugar to an AND of independently scoped element matches', () => { + const output = parser.parse({ tags: { $all: ['a', 'b'] } }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, 'a'), + ), + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, 'b'), + ), + ])); + }); + + it('should desugar a single value to one element match', () => { + expect(parseFlat({ tags: { $all: ['a'] } })).toEqual(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, 'a'), + )); + }); + + it('should accept null and Date elements like $in', () => { + const date = new Date('2026-01-01T00:00:00.000Z'); + + const output = parser.parse({ tags: { $all: [null, date] } }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, null), + ), + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, date), + ), + ])); + }); + + it('should combine with sibling operators through the enclosing document', () => { + const output = parser.parse({ tags: { $all: ['a'], $exists: true } }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'tags', + new Filter(FilterFieldOperator.EQUAL, ITSELF, 'a'), + ), + new Filter(FilterFieldOperator.EXISTS, 'tags', true), + ])); + }); + + it('should validate the value like $in', () => { + expectParseError(() => parser.parse({ tags: { $all: [] } }), ErrorCode.KEY_VALUE_INVALID); + expectParseError(() => parser.parse({ tags: { $all: 'a' } }), ErrorCode.KEY_VALUE_INVALID); + expectParseError(() => parser.parse({ tags: { $all: [{ a: 1 }] } }), ErrorCode.KEY_VALUE_INVALID); + expectParseError(() => parser.parse({ tags: { $all: [/x/] } }), ErrorCode.KEY_VALUE_INVALID); + }); + + it('should throw on a negated $all', () => { + expectParseError( + () => parser.parse({ tags: { $not: { $all: ['a'] } } }), + ErrorCode.OPERATOR_UNSUPPORTED, + ); + expectParseError( + () => parser.parse({ $nor: [{ tags: { $all: ['a'] } }] }), + ErrorCode.OPERATOR_UNSUPPORTED, + ); + }); + + it('should follow the schema policy for the field key', () => { + const schema = defineFiltersSchema({ allowed: ['id'] }); + + const dropped = parser.parse({ tags: { $all: ['a'] } }, { schema }); + expect(dropped).toEqual(new Filters(FilterCompoundOperator.AND, [])); + + expectParseError( + () => parser.parse({ tags: { $all: ['a'] } }, { schema, throwOnFailure: true }), + ErrorCode.KEY_NOT_ALLOWED, + ); + }); + + it('should throw at document level', () => { + expectParseError(() => parser.parse({ $all: ['a'] }), ErrorCode.SYNTAX_INVALID); + }); + }); + describe('parseTyped', () => { it('should delegate to parse', () => { const output = parser.parseTyped({ diff --git a/packages/sql/src/adapter/filters/base.ts b/packages/sql/src/adapter/filters/base.ts index 829309eee..fe3abe5d1 100644 --- a/packages/sql/src/adapter/filters/base.ts +++ b/packages/sql/src/adapter/filters/base.ts @@ -5,6 +5,7 @@ * view the LICENSE file that was distributed with this source code. */ +import { AdapterError, ITSELF } from '@rapiq/core'; import { ParamPlaceholderIndexer, parseField } from '../../helpers'; import type { IRelationsAdapter } from '../relations'; import type { IFiltersAdapter } from './types'; @@ -134,6 +135,13 @@ export abstract class FiltersBaseAdapter< inputNormalized = input; } + // the ITSELF marker references an array element itself — + // a joined relation row is not a scalar column, so SQL has + // no rendering for it (dialect JSON support may follow). + if (inputNormalized.split('.').includes(ITSELF)) { + throw AdapterError.featureUnsupported('filters:itself'); + } + const output = parseField(inputNormalized, this.rootAlias(), (path) => this.relations.buildAlias(path)); if (output.relation) { this.relations.add(output.relation); diff --git a/packages/sql/test/unit/interpreters/elem-match.spec.ts b/packages/sql/test/unit/interpreters/elem-match.spec.ts index a51caa8f4..3101a4ea3 100644 --- a/packages/sql/test/unit/interpreters/elem-match.spec.ts +++ b/packages/sql/test/unit/interpreters/elem-match.spec.ts @@ -5,7 +5,13 @@ * view the LICENSE file that was distributed with this source code. */ -import { Filter, Filters } from '@rapiq/core'; +import { + AdapterError, + ErrorCode, + Filter, + Filters, + ITSELF, +} from '@rapiq/core'; import type { FiltersContainerOptions } from '../../../src'; import { FiltersAdapter, @@ -67,6 +73,24 @@ describe('elemMatch', () => { expect(relationsAdapter.getPaths()).toStrictEqual(['items', 'items.parts']); }); + it('throws typed on an ITSELF leaf', () => { + // a joined relation row is not a scalar column — SQL has no + // rendering for the element itself. + const condition = new Filter( + 'elemMatch', + 'tags', + new Filter('eq', ITSELF, 'a'), + ); + + try { + condition.accept(visitor); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toEqual(ErrorCode.FEATURE_UNSUPPORTED); + } + }); + it('generates query from a compound condition based on relation', () => { const condition = new Filter( 'elemMatch', diff --git a/packages/typeorm/test/unit/filters.spec.ts b/packages/typeorm/test/unit/filters.spec.ts index 9db7885a3..a4b034700 100644 --- a/packages/typeorm/test/unit/filters.spec.ts +++ b/packages/typeorm/test/unit/filters.spec.ts @@ -6,10 +6,13 @@ */ import { + AdapterError, + ErrorCode, Filter, FilterCompoundOperator, FilterFieldOperator, Filters, + ITSELF, Query, } from '@rapiq/core'; import type { DataSource } from 'typeorm'; @@ -326,6 +329,24 @@ describe('src/filters', () => { expect(data.length).toEqual(1); }); + it('should throw typed on an ITSELF leaf', () => { + // a joined relation row is not a scalar column — no rendering + // for the element itself (mirrors @rapiq/sql). + const condition = new Filter( + FilterFieldOperator.ELEM_MATCH, + 'role', + new Filter(FilterFieldOperator.EQUAL, ITSELF, 'admin'), + ); + + try { + createQueryBuilder(condition); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AdapterError); + expect((e as AdapterError).code).toEqual(ErrorCode.FEATURE_UNSUPPORTED); + } + }); + it('should work with deep relation', async () => { const condition = new Filter( FilterFieldOperator.EQUAL,