Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .agents/references/ucast.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand All @@ -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

Expand All @@ -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 |
Expand Down
53 changes: 36 additions & 17 deletions packages/codec-url/src/expression/encoder/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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).
Expand All @@ -47,38 +48,38 @@ 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.
throw AdapterError.featureUnsupported('filters:compound:empty');
}

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: {
Expand Down Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions packages/codec-url/test/unit/expression-roundtrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
Fields,
FilterCompoundOperator,
Filters,
ITSELF,
Pagination,
Query,
Relation,
Relations,
Sort,
Expand All @@ -21,6 +23,7 @@ import {
and,
contains,
defineQuery,
elemMatch,
endsWith,
eq,
exists,
Expand Down Expand Up @@ -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]),
Expand All @@ -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);
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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\'))',
);
});
});
});
2 changes: 2 additions & 0 deletions packages/codec-url/test/unit/simple-roundtrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Fields,
FilterCompoundOperator,
Filters,
ITSELF,
Pagination,
Relation,
Relations,
Expand Down Expand Up @@ -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);
});
Expand Down
90 changes: 86 additions & 4 deletions packages/core/src/build/parameter/filters/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +36,8 @@ export function defineFilters<
>(input: FiltersBuildInput<RECORD> | ICondition) : IFilters;
export function defineFilters(input: FiltersBuildInput<ObjectLiteral> | ICondition) : IFilters {
if (isParameterNode<Filter | Filters>(input)) {
assertConditionFields(input, false);

if (isFilters(input)) {
return input;
}
Expand All @@ -47,6 +55,7 @@ export function defineFilters(input: FiltersBuildInput<ObjectLiteral> | IConditi
function buildConditions(
input: unknown,
prefix?: string,
insideElemMatch = false,
) : Condition[] {
if (!isObject(input)) {
throw BuildError.inputInvalid();
Expand All @@ -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,
));
}

Expand All @@ -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)) {
Expand Down Expand Up @@ -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.
Expand All @@ -129,9 +158,23 @@ function buildOperatorCondition(
if (key === `$${FilterFieldOperator.ELEM_MATCH}`) {
let condition : Condition;
if (isParameterNode<Filter | Filters>(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 :
Expand Down Expand Up @@ -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<Filter | Filters>(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<Filter | Filters>(input.value)
) {
assertConditionFields(input.value, true);
}
}
Loading
Loading