diff --git a/.agents/architecture.md b/.agents/architecture.md index 5f3b15007..f2ac62c3b 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -28,8 +28,8 @@ createURLCodec().encode (@rapiq/codec-url) The `Query` AST is an **intermediate representation (IR)**. Every package plays exactly one role around it: -1. **Define & interact** — client-side construction (plan 012): `defineQuery(QueryBuildInput)` + per-parameter `define*` fragment factories desugar typed input (scalars → `eq`, bare arrays → `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST — schema-free, no parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` → `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. `ICondition` is the open, structural filter extension contract: every live condition provides polymorphic immutable `seal()`, whose public return remains an interface. Visitor dispatch is intentionally not required; built-in `Filter` / `Filters` retain specialized visitors and are identified through that dispatch rather than member names, while custom kinds may define their own visitor contract. Helpers and injection accept `ICondition` directly; custom kinds remain opaque to built-in flatten/merge logic and require a consumer that understands them. Detached `{ operator, value }` data is not an `ICondition` until rehydrated. Queries compose immutably via `mergeQueries` (left-priority; fields/relations/sorts keyed by name, pagination per-property) and the `Filters` combinators: `merge()` = per-field replace over the displaceable leaves of a root-AND, total (issue #875 — a sealed condition, a nested group or a non-AND root is inert: never displaced, always and-ed in, so a merge only ever narrows); `and()`/`or()` = wrap & inject, sealing every injected condition (server scoping — displaceability rides on the node as `ICondition.sealed`, so no normalization pass can strip it; `flatten()` never hoists a sealed group, and `seal()` exposes the marker for validator residuals). The seal is server-side only: no wire dialect carries it. `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed — `defineQuery` replaces it. -2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840 — per-leaf policy residuals like `and(, seal())` stay attached to the leaf; the simple URL dialect then throws its usual typed `FEATURE_UNSUPPORTED` on encode, and a later `merge()` carries the group through as one inert conjunct — seal the SCOPE, not the group, so the marker survives a `flatten()` while the client's own leaf stays prunable by the relations gate, issue #877) or reject it, all without flattening compound structure. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. +1. **Define & interact**: client-side construction (plan 012). `defineQuery(QueryBuildInput)` plus per-parameter `define*` fragment factories desugar typed input (scalars to `eq`, bare arrays to `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST, with no schema or parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` to `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. `ICondition` is the open structural filter extension contract. A live condition is identified by `CONDITION_MARKER`, supplied by extending `Condition` or by a structural implementation carrying the marker with its `operator` and `value`; detached `{ operator, value }` data is not an `ICondition` until rehydrated. Visitor dispatch is intentionally not required; built-in `Filter` / `Filters` retain specialized visitors and are identified through that dispatch rather than member names, while custom kinds may define their own visitor contract. Helpers and injection accept `ICondition` directly; custom kinds remain opaque to built-in flatten/merge logic and require a consumer that understands them. Queries compose immutably via `mergeQueries`: fields, relations and sorts use keyed left priority; pagination uses per-property left priority; filters use monotonic ordered logical conjunction, so every predicate survives. `and()` / `or()` build explicit condition trees, and complete queries are never OR-composed. `preserve()` is a separate built-in pruning-policy wrapper, not a composition rule: it keeps a condition subtree atomic for relation pruning and carries custom conditions through its wrapper. `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed; `defineQuery` replaces it. +2. **Parse to IR**: parsers transform a dialect, such as simple object shapes or expression strings, into the IR and validate it against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept it, replace it with any `ICondition` (leaf or compound, issue #840). A per-leaf policy residual is `and(, preserve())`: preserve the residual, not the client-containing group, so only a residual that itself needs a rejected relation raises `SCHEMA_PRESERVED_CONDITION_PRUNED`. Preservation is independent from filter merge and condition identity. The simple URL dialect throws its usual typed `FEATURE_UNSUPPORTED` when encoding compounds. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. 3. **Consume the IR** — either interpret/walk it directly (`@rapiq/adapter-sql`, `@rapiq/adapter-typeorm` via visitors; `@rapiq/adapter-prisma`/`@rapiq/adapter-drizzle` serialize it into plain args/config objects; `@rapiq/adapter-memory` compiles it into plain functions to evaluate in-memory objects/arrays), or… 4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url` owns the complete HTTP URL wire format. The public `URLCodec` façade accepts a raw query string or a pre-parsed query object (Express `req.query`), maps wire names (`URLParameter`: `filter`, `page`, `include`, …) to canonical parameters and delegates to internal expression/simple strategies. Encoding writes stamped expression filters by default. Decoding dispatches stamped payloads and recognizes untagged expression strings or legacy simple bracket filters, so v2 follows a read-both/write-expression migration. App2 then works with the same IR. diff --git a/.agents/structure.md b/.agents/structure.md index b6aab827f..3aca3994f 100644 --- a/.agents/structure.md +++ b/.agents/structure.md @@ -52,12 +52,12 @@ Changes to `@rapiq/core` affect every other package. packages/core/src/ ├── parameter/ # Query AST node classes + visitor interfaces │ ├── fields/ # Fields/Field (include/exclude operators) -│ ├── filters/ # Filters (compound and/or) + Filter (field-op-value condition) +│ ├── filters/ # Filters (compound and/or) + Filter (field-op-value condition); CONDITION_MARKER/Condition identity and preserve() pruning wrapper │ │ └── helpers/ # typed condition helpers (eq, gte, inArray, and, or, …) │ ├── pagination/ # Pagination (limit/offset) │ ├── relations/ # Relations/Relation │ ├── sorts/ # Sorts/Sort (asc/desc) -│ ├── merge.ts # mergeQueries (left-priority IR merge; per-node merge/and/or methods live on the node classes) +│ ├── merge.ts # mergeQueries: keyed left priority for fields/relations/sorts, per-property pagination, monotonic filter conjunction │ └── module.ts # Query, IQueryVisitor (queries are built via defineQuery or parsed) ├── build/ # typed build layer: defineQuery + per-parameter define* factories │ └── parameter/ # Build*Input types + defineFields/defineFilters/… (schema-free, direct-to-AST) diff --git a/README.md b/README.md index 3016dbcb5..128ec361a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ which **relations**, which **page**, which **order**. rapiq turns them into one instead of ad-hoc string parsing. - 🧭 **Typed end to end**: every field path in `defineQuery` is checked against the record type; condition helpers (`eq`, `gte`, `and`, `or`, …) replace magic value strings. -- 🛡️ **The receiving side has the last word**: a `Schema` declares what a caller may request per parameter (allow-lists, defaults, mappings). Invalid input is dropped or rejected according to the parser dialect and schema policy, and injected conditions (`query.filters.and(...)`) can't be displaced by caller input. +- 🛡️ **The receiving side has the last word**: a `Schema` declares what a caller may request per parameter (allow-lists, defaults, mappings). Invalid input is dropped or rejected according to the parser dialect and schema policy, and server conditions (`query.filters.and(...)`) remain conjuncts alongside caller input. - 🔁 **Loss-free transport**: within each codec dialect, `decode(encode(query))` restores the same query; outside its subset, encoding fails loudly with a typed error instead of silently changing semantics. - 🔌 **Any backend**: the same AST executes everywhere: parameterized SQL fragments with presets for Postgres, MySQL, SQLite, MSSQL & Oracle, a TypeORM `SelectQueryBuilder`, a Prisma `findMany` args object, a drizzle relational-queries config, or compiled functions over in-memory data. - 📦 **Composable packages**: no monolith, install only what each side needs; `@rapiq/core` is the single shared foundation. diff --git a/packages/adapter-prisma/test/unit/acceptance.spec.ts b/packages/adapter-prisma/test/unit/acceptance.spec.ts index 82cddb7af..fd5b5b9b2 100644 --- a/packages/adapter-prisma/test/unit/acceptance.spec.ts +++ b/packages/adapter-prisma/test/unit/acceptance.spec.ts @@ -208,8 +208,8 @@ describe('acceptance: request query to prisma arguments', () => { const applyScoped = (input: string) => { const query = codec.decode(input, { schema: 'user' }); - // post-parse wrap & inject: a later replace-merge cannot - // displace the condition. + // post-parse injection retains the server condition as a + // conjunct alongside every client condition. const scoped = new Query({ ...query, filters: query!.filters.and(eq('realm_id', 1)), diff --git a/packages/adapter-typeorm/test/unit/acceptance.spec.ts b/packages/adapter-typeorm/test/unit/acceptance.spec.ts index 00b384c28..7f37f0e72 100644 --- a/packages/adapter-typeorm/test/unit/acceptance.spec.ts +++ b/packages/adapter-typeorm/test/unit/acceptance.spec.ts @@ -177,8 +177,8 @@ describe('acceptance: authup-style repository port (M2 gate)', () => { const applyScoped = (input: string) => { const query = codec.decode(input, { schema: 'user' }); - // plan 012 layer 3: post-parse wrap & inject — immutable, - // a later replace-merge cannot displace the condition. + // plan 012 layer 3: post-parse injection retains the server + // condition as a conjunct alongside every client condition. const scoped = new Query({ ...query, filters: query!.filters.and(eq('realm_id', masterRealmId)), diff --git a/packages/codec-url/test/unit/acceptance.spec.ts b/packages/codec-url/test/unit/acceptance.spec.ts index 2b7d3eda5..374f4f2ba 100644 --- a/packages/codec-url/test/unit/acceptance.spec.ts +++ b/packages/codec-url/test/unit/acceptance.spec.ts @@ -76,8 +76,8 @@ describe('gateway controller (hub archetype)', () => { it('should validate, pin a server condition and re-encode (flat forward)', () => { const query = codec.decode(requestQuery, { schema: 'log' })!; - // the gateway pins node_id with server priority: merge() is - // per-field replace, so a client-sent node_id could never win. + // the gateway pins node_id by conjunction: merge() retains the server + // constraint alongside any node_id constraint sent by the client. const forwarded = new Query({ fields: query.fields, filters: and(eq('node_id', 'node-1')).merge(query.filters), @@ -104,13 +104,11 @@ describe('gateway controller (hub archetype)', () => { expect(downstream.pagination).toEqual(new Pagination(50, 0)); }); - it('should carry undisplaceable scoping downstream', () => { + it('should carry conjunctive scoping downstream', () => { const query = codec.decode(requestQuery, { schema: 'log' })!; - // filters.and() injects a sealed condition — no later merge can - // displace it. The seal is a server-side composition marker - // rather than wire grammar, so what crosses the URL is the - // condition itself; the facade stamps the codec identity so the + // filters.and() adds an ordinary conjunct. What crosses the URL is + // the condition itself; the facade stamps the codec identity so the // downstream service knows how to decode it. const scoped = new Query({ fields: query.fields, @@ -131,9 +129,8 @@ describe('gateway controller (hub archetype)', () => { '&page[limit]=50&sort=-created_at', ); - // downstream: dispatch on the stamp, same AST comes out — minus - // the seal, which no wire dialect carries. The downstream service - // re-injects its own scoping instead of trusting the transport. + // downstream: dispatch on the stamp and the same AST comes out. The + // downstream service injects its own scoping instead of trusting the transport. const downstream = codec.decode(wire!)!; expect(downstream.filters).toEqual( diff --git a/packages/codec-url/test/unit/simple-roundtrip.spec.ts b/packages/codec-url/test/unit/simple-roundtrip.spec.ts index 5fc960159..9123b5635 100644 --- a/packages/codec-url/test/unit/simple-roundtrip.spec.ts +++ b/packages/codec-url/test/unit/simple-roundtrip.spec.ts @@ -7,6 +7,8 @@ import { AdapterError, + CONDITION_MARKER, + Condition, ErrorCode, Field, Fields, @@ -44,37 +46,27 @@ import { startsWith, } from '@rapiq/core'; import type { - ICondition, IFilter, IFilterVisitor, IQuery, } from '@rapiq/core'; import { SimpleURLDecoder, SimpleURLEncoder } from '../../src/simple'; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - - constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); } } function createStructuralFilter(field: string, value: unknown) : IFilter { const output : IFilter = { + [CONDITION_MARKER]: true, operator: 'eq', field, value, accept(visitor: IFilterVisitor) : R { return visitor.visitFilter(output); }, - seal() : IFilter { - return output; - }, }; return output; diff --git a/packages/core/README.md b/packages/core/README.md index 2666388b4..761e05298 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -33,7 +33,7 @@ Part of [**rapiq**](https://github.com/tada5hi/rapiq). Typed REST queries: *buil - 🧭 **Typed end to end**: every field path in `defineQuery` is checked against the record type; condition helpers (`eq`, `gte`, `and`, `or`, …) replace magic value strings. - 🌳 **One AST, many consumers**: the same `Query` node graph is walked into SQL, a TypeORM builder or in-memory predicates through the visitor pattern; core never changes when a backend is added. - 🛡️ **Schema = the receiving side's allow-list**: declare per-parameter `allowed` / `default` / `mapping`; parsers and codecs validate against it and drop or throw per policy. -- 🧩 **Composable & immutable**: `mergeQueries` (left priority) and the `Filters` combinators (`merge`, `and`, `or`) let a gateway scope a query without a client being able to displace injected conditions. +- 🧩 **Composable & immutable**: `mergeQueries` uses keyed left priority for fields, relations and sorts, per-property left priority for pagination, and ordered logical AND for filters. The `Filters` combinators (`merge`, `and`, `or`) let a gateway add scope without losing predicates. ## Installation @@ -59,7 +59,7 @@ const query = defineQuery({ }); ``` -Filters accept scalars (`{ name: 'John' }`), bare arrays (`in`, `null` is a legal element), `$`-operator objects (`{ age: { $gte: 18 } }`) and condition helpers (`eq`, `gte`, `inArray`, `and`, `or`, …). Queries compose immutably with `mergeQueries` (left priority) and the `Filters` combinators (`merge`, `and`, `or`). +Filters accept scalars (`{ name: 'John' }`), bare arrays (`in`, `null` is a legal element), `$`-operator objects (`{ age: { $gte: 18 } }`) and condition helpers (`eq`, `gte`, `inArray`, `and`, `or`, …). Queries compose immutably with `mergeQueries`: filters are ordered logical AND, while keyed parameters use left priority. Use `or(...)` inside a filter tree for alternatives. ### Declare what a caller may request diff --git a/packages/core/src/build/parameter/filters/index.ts b/packages/core/src/build/parameter/filters/index.ts index 73806ac38..d507df512 100644 --- a/packages/core/src/build/parameter/filters/index.ts +++ b/packages/core/src/build/parameter/filters/index.ts @@ -5,5 +5,6 @@ * view the LICENSE file that was distributed with this source code. */ +export * from './merge'; export * from './module'; export * from './types'; diff --git a/packages/core/src/build/parameter/filters/merge.ts b/packages/core/src/build/parameter/filters/merge.ts new file mode 100644 index 000000000..dd8780986 --- /dev/null +++ b/packages/core/src/build/parameter/filters/merge.ts @@ -0,0 +1,129 @@ +/* + * 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. + */ + +import { BuildError } from '../../../errors'; +import { isCondition } from '../../../parameter'; +import type { CONDITION_MARKER } from '../../../parameter'; +import type { ObjectLiteral } from '../../../types'; +import { isObject } from '../../../utils'; +import type { FiltersBuildInput } from './types'; +import { isNestedRecordValue } from './value'; + +/** + * Excludes a live condition from an input position at compile time. The + * generic-less arm resolves `FiltersBuildInput` to a bag of + * `any` values, which a condition object structurally satisfies, so without + * this the type-level half of the guard would hold only for callers who + * supply the record generic. + */ +type NotACondition = { [CONDITION_MARKER]?: never }; + +/** + * Per-field replace over filter build input: the first input to constrain a + * field wins it, and every field only one side constrains survives. This is + * the override-a-default operation, and it lives here rather than on the + * `Query` because a build input is plain data: it cannot carry a + * server-authored scope, so replacement cannot displace one. Composition of + * queries is conjunction and never drops a predicate ({@link mergeQueries}). + * + * Both notations {@link FiltersBuildInput} admits address the same fields — + * `{ 'realm.name': v }` and `{ realm: { name: v } }` — so inputs are reduced + * to their canonical dotted paths before being compared. Two inputs written + * in different notations therefore still replace each other, and a nested + * record is replaced key by key instead of wholesale the way an object + * spread would. + * + * ```typescript + * mergeFiltersInput( + * { realm: { name: 'b' } }, + * { 'realm.name': 'a', 'realm.id': 1 }, + * ); + * // { 'realm.name': 'b', 'realm.id': 1 } + * ``` + * + * Replacement is per field, not per operator: `{ age: { $gte: 18 } }` beating + * `{ age: { $lt: 65 } }` yields the lower bound alone. Keeping both is + * conjunction, which is what composing two queries does. + * + * A `$elemMatch` interior is a value, replaced whole. An `undefined` value + * claims no field, so a later input still supplies it. The result is the flat + * notation, itself valid input for {@link defineFilters} or another merge. + */ +export function mergeFiltersInput( + ...input: (FiltersBuildInput & NotACondition)[] +) : FiltersBuildInput; +export function mergeFiltersInput< + RECORD extends ObjectLiteral, +>(...input: (FiltersBuildInput & NotACondition)[]) : FiltersBuildInput; +export function mergeFiltersInput( + ...input: FiltersBuildInput[] +) : FiltersBuildInput { + const output : Record = {}; + + for (const current of input) { + collectPaths(current, '', output); + } + + return output as FiltersBuildInput; +} + +/** + * Reduce one input to canonical dotted paths, keeping the first value seen + * for each. First-occurrence priority is applied here rather than by the + * caller so it holds within a single input too: an input naming one field + * under both notations resolves the same way two inputs do. + */ +function collectPaths( + input: unknown, + prefix: string, + output: Record, +) : void { + // a live condition is not build input. Admitting one would make this a + // second composition path over server-authored trees, which is the + // primitive filter composition was made conjunctive to remove. + if (isCondition(input)) { + throw BuildError.inputInvalid(); + } + + if (!isObject(input)) { + throw BuildError.inputInvalid(); + } + + for (const key of Object.keys(input)) { + const value = input[key]; + + // skipped rather than recorded, so `{ name: undefined }` states no + // opinion instead of claiming the field and blanking it. Mirrors the + // build layer, which lowers such a key to no condition at all. + if (typeof value === 'undefined') { + continue; + } + + const field = prefix ? `${prefix}.${key}` : key; + + // a `$`-prefixed root key is not a field. It is passed through + // unresolved so defineFilters still reports it, rather than being + // reinterpreted as a path segment here. + if (!prefix && key.substring(0, 1) === '$') { + if (!Object.hasOwn(output, key)) { + output[key] = value; + } + + continue; + } + + if (isNestedRecordValue(field, value)) { + collectPaths(value, field, output); + + continue; + } + + if (!Object.hasOwn(output, field)) { + output[field] = value; + } + } +} diff --git a/packages/core/src/build/parameter/filters/module.ts b/packages/core/src/build/parameter/filters/module.ts index 1e766290e..9ac5cad45 100644 --- a/packages/core/src/build/parameter/filters/module.ts +++ b/packages/core/src/build/parameter/filters/module.ts @@ -20,6 +20,7 @@ import type { ObjectLiteral } from '../../../types'; import { isObject } from '../../../utils'; import { isParameterNode } from '../../utils'; import type { FiltersBuildInput } from './types'; +import { isNestedRecordValue } from './value'; const OPERATORS : Record = {}; for (const operator of Object.values(FilterFieldOperator)) { @@ -58,10 +59,10 @@ export function defineFilters(input: FiltersBuildInput | IConditi } /** - * Detect condition-shaped runtime data that lost its live behavior - * in a JSON/RPC/cache round trip. It has no `seal`, so it would - * otherwise be read as a record of field/value pairs and filter on - * columns literally named `operator` and `value`. + * Detect condition-shaped runtime data that lost its non-serializable brand + * in a JSON/RPC/cache round trip. Without the brand it would otherwise be + * read as a record of field/value pairs and filter on columns literally + * named `operator` and `value`. * * Deliberately narrow: only an object whose own keys are exactly the * condition properties, with a recognized operator, qualifies. A build @@ -79,7 +80,7 @@ function isDetachedCondition(input: unknown) : boolean { } for (const key of keys) { - if (key !== 'operator' && key !== 'value' && key !== 'sealed' && key !== 'field') { + if (key !== 'operator' && key !== 'value' && key !== 'preserved' && key !== 'field') { return false; } } @@ -188,8 +189,10 @@ function buildFieldConditions( } // nested record — relation traversal via dot-path prefixing; - // the element itself has no properties to traverse into. - if (field === ITSELF) { + // the element itself has no properties to traverse into. Asking the + // shared predicate rather than re-testing the marker here is what + // keeps lowering and path canonicalization from drifting apart. + if (!isNestedRecordValue(field, value)) { throw BuildError.keyValueInvalid(field); } diff --git a/packages/core/src/build/parameter/filters/value.ts b/packages/core/src/build/parameter/filters/value.ts new file mode 100644 index 000000000..fcd2cd1eb --- /dev/null +++ b/packages/core/src/build/parameter/filters/value.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +import { ITSELF, isCondition } from '../../../parameter'; +import { isObject } from '../../../utils'; +import { isParameterNode } from '../../utils'; + +/** + * Whether a field value opens a nested record to traverse into rather than + * describing the field itself. Everything else is a leaf: a scalar, `null`, + * a bare array (`in` sugar), a `RegExp`, a `Date`, or an operator object. + * + * Internal, and single-sourced deliberately. `buildFieldConditions` lowers a + * value with this rule and `mergeFiltersInput` canonicalizes key paths with + * it, so a value shape that moved between leaf and branch without both + * agreeing would silently yield a different set of paths than it yields + * conditions. It lives here rather than in either consumer so neither owns + * it, and it is kept out of the barrel because it is not public API. + */ +export function isNestedRecordValue(field: string, value: unknown) : boolean { + return ( + isObject(value) && + !Array.isArray(value) && + !(value instanceof RegExp) && + !(value instanceof Date) && + !isCondition(value) && + !isParameterNode(value) && + !Object.keys(value).some((key) => key.substring(0, 1) === '$') && + field !== ITSELF + ); +} diff --git a/packages/core/src/errors/code.ts b/packages/core/src/errors/code.ts index 9a7cfd97f..29a333ddd 100644 --- a/packages/core/src/errors/code.ts +++ b/packages/core/src/errors/code.ts @@ -42,7 +42,7 @@ export enum ErrorCode { SCHEMA_NAME_INVALID = 'schemaNameInvalid', - SCHEMA_SEALED_CONDITION_PRUNED = 'schemaSealedConditionPruned', + SCHEMA_PRESERVED_CONDITION_PRUNED = 'schemaPreservedConditionPruned', SCHEMA_UNRESOLVABLE = 'schemaUnresolvable', diff --git a/packages/core/src/errors/schema.ts b/packages/core/src/errors/schema.ts index e35b61cf7..0a21c2dc2 100644 --- a/packages/core/src/errors/schema.ts +++ b/packages/core/src/errors/schema.ts @@ -40,12 +40,12 @@ export class SchemaError extends BaseError { }); } - static sealedConditionPruned(relation: string, field: string) { + static preservedConditionPruned(relation: string, field: string) { return new this({ - message: `The relations validator rejected "${relation}", but the sealed filter condition on ` + - `"${field}" traverses it. A sealed condition must not be dropped, and a rejected relation ` + - 'must not be joined: align the relations validator with the filters validator that sealed it.', - code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED, + message: `The relations validator rejected "${relation}", but the preserved filter condition on ` + + `"${field}" traverses it. A preserved condition must not be dropped, and a rejected relation ` + + 'must not be joined: align the relations validator with the filters validator that preserved it.', + code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED, }); } diff --git a/packages/core/src/parameter/fields/collection/module.ts b/packages/core/src/parameter/fields/collection/module.ts index 34d525e20..78bf9f5f6 100644 --- a/packages/core/src/parameter/fields/collection/module.ts +++ b/packages/core/src/parameter/fields/collection/module.ts @@ -36,9 +36,9 @@ export class Fields implements IFields { * server-authored authorization decision, and dropping it silently * would widen disclosure. The surviving node keeping the identical * condition instance is fine; anything else refuses. Filters express - * the same protection differently: a sealed condition is carried - * through `Filters.merge()` instead of refusing it, because a filter - * can be and-ed in while a field is either gated or not. + * the same protection differently: a preserved condition stays atomic + * through `Filters.merge()` because a filter can be combined as a + * conjunct while a field is either gated or not. */ merge(other: IFields) : IFields { const output : IField[] = []; diff --git a/packages/core/src/parameter/filters/collection/module.ts b/packages/core/src/parameter/filters/collection/module.ts index d93efdaab..0b4c6d057 100644 --- a/packages/core/src/parameter/filters/collection/module.ts +++ b/packages/core/src/parameter/filters/collection/module.ts @@ -6,32 +6,25 @@ */ import { FilterCompoundOperator } from '../../../schema'; +import { Condition } from '../condition'; import type { ConditionOptions, ICondition } from '../condition'; -import { isFilter } from '../record'; import type { IFilters, IFiltersVisitor } from './types'; import { isFilters } from './check'; export class Filters< T extends ICondition = ICondition, -> implements IFilters { - readonly value: T[]; - - readonly operator: string; - - readonly sealed?: boolean; +> extends Condition implements IFilters { + readonly preserved?: boolean; constructor( operator: string, conditions: T[], options: ConditionOptions = {}, ) { - this.operator = operator; - this.value = conditions; + super(operator, conditions); - // only set when sealed, so an unsealed group stays - // structurally identical to what earlier versions produced. - if (options.sealed) { - this.sealed = true; + if (options.preserved) { + this.preserved = true; } } @@ -39,21 +32,13 @@ export class Filters< return visitor.visitFilters(this); } - seal() : IFilters { - if (this.sealed) { - return this; - } - - return new Filters(this.operator, this.value, { sealed: true }); - } - flatten(aggregatedResult?: T[]) : IFilters { // this.value.splice(0, this.value.length, ...next); return new Filters( this.operator, this.flattenInternal(this.value, this.operator, aggregatedResult), - { sealed: this.sealed }, + { preserved: this.preserved }, ); } @@ -65,14 +50,13 @@ export class Filters< const flatConditions: F[] = aggregatedResult || []; for (const currentNode of conditions) { - // merging same-operator children relies on associativity — - // NOT groups are not associative (not(not(x)) ≠ not(x)) — - // and a sealed group carries its protection in the node, so - // hoisting its children would strip that protection. + // Merging same-operator children relies on associativity. + // NOT groups are not associative (not(not(x)) is not not(x)). + // A preserved group stays atomic so pruning protection remains. if ( isFilters(currentNode, operator) && operator !== FilterCompoundOperator.NOT && - !currentNode.sealed + !currentNode.preserved ) { currentNode.flatten(flatConditions); } else { @@ -84,15 +68,9 @@ export class Filters< } /** - * Per-field replace (left/receiver priority): a conjunct of the other - * side is dropped when the receiver already constrains the same field. - * Only displaceable leaf conditions take part — a sealed condition and - * a nested group are inert: never displaced, never dropped, carried - * through as they are. A root that is not a displaceable AND counts as - * one such group, which makes the operation total. Every conjunct of - * the receiver survives into the result, so the outcome is never wider - * than the receiver; only the other side can lose conditions, which is - * what per-field replace is for. + * Ordered logical conjunction. Each condition from both sides survives + * in argument order. A non-preserved root AND contributes its flattened + * child conjuncts, while every other root remains one conjunct. */ merge(other: IFilters) : IFilters { if (this.value.length === 0) { @@ -103,37 +81,15 @@ export class Filters< return this; } - const left = toConjuncts(this); - - const seen = new Set(); - for (const condition of left) { - if (isFilter(condition)) { - seen.add(condition.field); - } - } - - const output : ICondition[] = [...left]; - for (const condition of toConjuncts(other)) { - if ( - isFilter(condition) && - !condition.sealed && - seen.has(condition.field) - ) { - continue; - } - - output.push(condition); - } - - return new Filters(FilterCompoundOperator.AND, output); + return new Filters(FilterCompoundOperator.AND, [ + ...toConjuncts(this), + ...toConjuncts(other), + ]); } /** - * Wrap & inject (immutable): the given conditions are sealed and - * combined with the receiver under an AND group. Sealed conditions - * survive both normalization ({@link Filters.flatten}) and later - * replace-merges ({@link Filters.merge}), so a scoping condition - * injected here cannot be displaced by anything composed afterwards. + * Wrap and append immutably: combine the given conditions with the + * receiver under an AND group while retaining their object identity. */ and(...conditions: ICondition[]) : IFilters { return this.wrap(FilterCompoundOperator.AND, conditions); @@ -151,53 +107,37 @@ export class Filters< return this; } - const injected = conditions.map(sealCondition); - // an empty receiver constrains nothing, so it must not become a // child: under OR it would widen the group to everything. if (this.value.length === 0) { - return new Filters(operator, injected); + return new Filters(operator, conditions); } // a receiver already carrying that operator contributes its - // conditions directly (associativity), which keeps the group flat - // and its own conditions displaceable by a later merge. + // conditions directly (associativity), which keeps the group flat. if ( this.operator === operator && operator !== FilterCompoundOperator.NOT && - !this.sealed + !this.preserved ) { - return new Filters(operator, [...this.value, ...injected]); + return new Filters(operator, [...this.value, ...conditions]); } - return new Filters(operator, [this, ...injected]); + return new Filters(operator, [this, ...conditions]); } } /** - * The `seal` helper, inlined for the conditions {@link Filters.and} / - * {@link Filters.or} inject: reaching for the helper here would make - * this module depend on one that depends back on it. - */ -function sealCondition(condition: ICondition) : ICondition { - if (typeof condition.seal === 'function') { - return condition.seal(); - } - - return condition; -} - -/** - * The conjuncts a {@link Filters.merge} operates on: the children of a - * displaceable root-AND, or the whole node — a group, a sealed tree, any - * other compound operator — as one inert conjunct. + * The conjuncts a {@link Filters.merge} operates on: the flattened + * children of a non-preserved root AND, or the whole node for a preserved + * tree or any other compound operator. */ function toConjuncts(input: IFilters) : ICondition[] { if ( input.operator === FilterCompoundOperator.AND && - !input.sealed + !input.preserved ) { - return input.value; + return input.flatten().value; } return [input]; diff --git a/packages/core/src/parameter/filters/collection/types.ts b/packages/core/src/parameter/filters/collection/types.ts index 24a924a8b..5cc4a4b16 100644 --- a/packages/core/src/parameter/filters/collection/types.ts +++ b/packages/core/src/parameter/filters/collection/types.ts @@ -18,24 +18,14 @@ export interface IFilters< readonly value : T[]; - /** - * Displaceability marker: a sealed group is never dropped by a - * later {@link IFilters.merge} and never collapsed into its parent - * by {@link IFilters.flatten}. - */ - readonly sealed?: boolean; - accept(visitor: IFiltersVisitor) : R; - /** - * A copy of this group carrying the {@link IFilters.sealed} marker - * (the receiver itself when it is already sealed). Reach for the - * `seal` helper to seal any {@link ICondition}. - */ - seal() : IFilters; - flatten(items?: T[]) : IFilters; + /** + * Combine both groups as an ordered logical AND. Empty groups are the + * identity; every condition from either non-empty group is retained. + */ merge(other: IFilters) : IFilters; and(...conditions: ICondition[]) : IFilters; diff --git a/packages/core/src/parameter/filters/condition.ts b/packages/core/src/parameter/filters/condition.ts index cca43a70b..6c9973611 100644 --- a/packages/core/src/parameter/filters/condition.ts +++ b/packages/core/src/parameter/filters/condition.ts @@ -7,42 +7,58 @@ import { isObject } from '../../utils'; +export const CONDITION_MARKER: unique symbol = Symbol.for('@rapiq/core/condition'); + export interface ICondition< T = unknown, > { - readonly operator: string; - - readonly value: T; + readonly [CONDITION_MARKER]: true; /** - * Displaceability marker: a sealed condition is never dropped by - * {@link IFilters.merge} and never collapsed into its parent group by - * {@link IFilters.flatten}. Set it through the `seal` helper or by - * injecting the condition with {@link IFilters.and} / {@link IFilters.or}. + * Relation-pruning protection marker. A preserved group stays atomic + * during normalization and pruning applies its contract to the subtree. */ - readonly sealed?: boolean; + readonly preserved?: boolean; + + readonly operator: string; - seal(): ICondition; + readonly value: T; } /** - * Identify a live structural condition through the behavior shared by every - * implementation. Visitor dispatch is deliberately not part of this check. + * Identify a live condition by its non-serializable marker. Visitor dispatch + * is deliberately not part of this check. */ export function isCondition(input: unknown) : input is ICondition { - return ( - isObject(input) && - typeof input.operator === 'string' && - 'value' in input && - typeof input.seal === 'function' - ); + if (!isObject(input)) { + return false; + } + + // the brand is asserted, not merely present: reading the value keeps a + // `[CONDITION_MARKER]: false` object out, which the interface now + // rejects at compile time too. + const marker : unknown = (input as { [CONDITION_MARKER]?: unknown })[CONDITION_MARKER]; + if (marker !== true) { + return false; + } + + if (typeof input.operator !== 'string') { + return false; + } + + if (!('value' in input)) { + return false; + } + + return typeof input.preserved === 'undefined' || + typeof input.preserved === 'boolean'; } /** - * Construction options shared by built-in condition node implementations. + * Construction options shared only by the built-in filter nodes. */ export type ConditionOptions = { - sealed?: boolean, + preserved?: boolean, }; /** @@ -52,24 +68,19 @@ export type ConditionOptions = { export abstract class Condition< T = unknown, > implements ICondition { + get [CONDITION_MARKER]() : true { + return true; + } + readonly operator: string; readonly value: T; - readonly sealed?: boolean; - - constructor( + protected constructor( operator: string, value: T, - options: ConditionOptions = {}, ) { this.operator = operator; this.value = value; - - if (options.sealed) { - this.sealed = true; - } } - - abstract seal(): ICondition; } diff --git a/packages/core/src/parameter/filters/index.ts b/packages/core/src/parameter/filters/index.ts index 8067a27e6..b923f071e 100644 --- a/packages/core/src/parameter/filters/index.ts +++ b/packages/core/src/parameter/filters/index.ts @@ -10,6 +10,6 @@ export * from './condition'; export * from './constants'; export * from './helpers'; export * from './plan'; +export * from './preserve'; export * from './record'; export * from './regex'; -export * from './seal'; diff --git a/packages/core/src/parameter/filters/preserve.ts b/packages/core/src/parameter/filters/preserve.ts new file mode 100644 index 000000000..7ea81c5d1 --- /dev/null +++ b/packages/core/src/parameter/filters/preserve.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +import { FilterCompoundOperator } from '../../schema'; +import type { ICondition } from './condition'; +import { Filters, isFilters } from './collection'; +import type { IFilters } from './collection'; +import { Filter, isFilter } from './record'; +import type { IFilter } from './record'; + +export function preserve(condition: IFilter): IFilter; +export function preserve(condition: IFilters): IFilters; +/** + * The catch-all admits every subtype too, so it cannot promise the + * {@link IFilters} wrapper a custom condition receives: an argument merely + * *typed* as {@link ICondition} may still be a leaf at runtime. Narrow the + * result with `isFilter` / `isFilters` when the kind matters. + */ +export function preserve(condition: ICondition): ICondition; +export function preserve(condition: ICondition): ICondition { + if (isFilter(condition)) { + return condition.preserved ? condition : + new Filter(condition.operator, condition.field, condition.value, { preserved: true }); + } + + if (isFilters(condition)) { + return condition.preserved ? condition : + new Filters(condition.operator, condition.value, { preserved: true }); + } + + return new Filters(FilterCompoundOperator.AND, [condition], { preserved: true }); +} diff --git a/packages/core/src/parameter/filters/record/module.ts b/packages/core/src/parameter/filters/record/module.ts index 75d1a9208..fd34098e3 100644 --- a/packages/core/src/parameter/filters/record/module.ts +++ b/packages/core/src/parameter/filters/record/module.ts @@ -6,20 +6,17 @@ */ import type { FilterFieldOperator } from '../../../schema'; +import { Condition } from '../condition'; import type { ConditionOptions } from '../condition'; import type { IFilter, IFilterVisitor } from './types'; export class Filter< OPERATOR extends string = `${FilterFieldOperator}`, VALUE = unknown, -> implements IFilter { - readonly operator: string; - - readonly value: VALUE; - +> extends Condition implements IFilter { readonly field: string; - readonly sealed?: boolean; + readonly preserved?: boolean; constructor( operator: string, @@ -27,31 +24,15 @@ export class Filter< value: VALUE, options: ConditionOptions = {}, ) { - this.operator = operator; + super(operator, value); this.field = field; - this.value = value; - // only set when sealed, so an unsealed condition stays - // structurally identical to what earlier versions produced. - if (options.sealed) { - this.sealed = true; + if (options.preserved) { + this.preserved = true; } } accept(visitor: IFilterVisitor) : R { return visitor.visitFilter(this); } - - seal() : IFilter { - if (this.sealed) { - return this; - } - - return new Filter( - this.operator, - this.field, - this.value, - { sealed: true }, - ); - } } diff --git a/packages/core/src/parameter/filters/record/types.ts b/packages/core/src/parameter/filters/record/types.ts index bf9909d3f..aed9d1ff7 100644 --- a/packages/core/src/parameter/filters/record/types.ts +++ b/packages/core/src/parameter/filters/record/types.ts @@ -29,19 +29,5 @@ export interface IFilter< readonly value: VALUE; - /** - * Displaceability marker: a sealed leaf is never dropped by a - * later `merge` and never hoisted out of its group by `flatten`. - */ - readonly sealed?: boolean; - accept(visitor: IFilterVisitor) : R; - - /** - * A copy of this leaf carrying the {@link IFilter.sealed} marker - * (the receiver itself when it is already sealed). Reach for the - * `seal` helper to seal any {@link ICondition}; both methods expose - * interface types rather than concrete implementations. - */ - seal() : IFilter; } diff --git a/packages/core/src/parameter/filters/seal.ts b/packages/core/src/parameter/filters/seal.ts deleted file mode 100644 index 53e16f950..000000000 --- a/packages/core/src/parameter/filters/seal.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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. - */ - -import type { ICondition } from './condition'; - -/** - * Mark a condition as non-displaceable: every live {@link ICondition} - * implementation owns its immutable sealed copy. A later `merge` never - * drops that copy and `flatten` never hoists it out of its group. This is - * what `Filters.and` / `Filters.or` apply to the conditions they inject; - * reach for it directly when a server-authored condition, such as a policy - * residual returned from a filters `validate` hook or a scoped default, - * has to survive composition by other code. - * - * The seal is a server-side composition marker, not part of any wire - * grammar: a sealed condition that is encoded and decoded again comes - * back displaceable. - * - * Detached runtime data without callable sealing behavior is left unchanged. - */ -export function seal(condition: T) : ReturnType; -export function seal(condition: ICondition) : ICondition { - if (typeof condition.seal === 'function') { - return condition.seal(); - } - - return condition; -} diff --git a/packages/core/src/parameter/merge.ts b/packages/core/src/parameter/merge.ts index f2f772adb..d366f6ff8 100644 --- a/packages/core/src/parameter/merge.ts +++ b/packages/core/src/parameter/merge.ts @@ -9,11 +9,10 @@ import { Query } from './module'; import type { IQuery } from './types'; /** - * Merge queries (the IR) with left priority — the first argument wins. - * Fields, relations and sorts are keyed by name (first occurrence sets - * value and position), pagination merges limit/offset independently and - * filters use {@link IFilters.merge} (per-field replace over displaceable - * root conditions, everything else and-ed in unchanged). + * Merge queries (the IR). Fields, relations and sorts have left priority: + * the first occurrence sets value and position. Pagination merges + * limit/offset independently. Filters use {@link IFilters.merge} as an + * ordered logical AND, retaining every condition. * * Immutable — inputs stay untouched, a new {@link Query} is returned. */ diff --git a/packages/core/src/parser/parameter/filters/validate.ts b/packages/core/src/parser/parameter/filters/validate.ts index 4a3fb303b..51b93debf 100644 --- a/packages/core/src/parser/parameter/filters/validate.ts +++ b/packages/core/src/parser/parameter/filters/validate.ts @@ -11,7 +11,7 @@ import { isCondition, isFilter, isFilters, - seal, + preserve, } from '../../../parameter'; import type { ICondition, @@ -88,7 +88,7 @@ export function applyFiltersSchemaValidation( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { sealed: input.sealed }); + leaf = new Filter(input.operator, input.field, interior, { preserved: input.preserved }); } } @@ -102,9 +102,9 @@ export function applyFiltersSchemaValidation( return undefined; } - // the replacement stands in for the leaf, so it inherits its - // protection: a validator must not silently unseal a condition. - return leaf.sealed ? seal(output) : output; + // The replacement stands in for the leaf, so it inherits + // relation-pruning preservation. + return leaf.preserved ? preserve(output) : output; } if (!isFilters(input)) { @@ -123,7 +123,7 @@ export function applyFiltersSchemaValidation( return undefined; } - return new Filters(input.operator, conditions, { sealed: input.sealed }); + return new Filters(input.operator, conditions, { preserved: input.preserved }); } /** @@ -162,7 +162,7 @@ export async function applyFiltersSchemaValidationAsync( } if (interior !== input.value) { - leaf = new Filter(input.operator, input.field, interior, { sealed: input.sealed }); + leaf = new Filter(input.operator, input.field, interior, { preserved: input.preserved }); } } @@ -171,7 +171,7 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - return leaf.sealed ? seal(output) : output; + return leaf.preserved ? preserve(output) : output; } if (!isFilters(input)) { @@ -190,5 +190,5 @@ export async function applyFiltersSchemaValidationAsync( return undefined; } - return new Filters(input.operator, conditions, { sealed: input.sealed }); + return new Filters(input.operator, conditions, { preserved: input.preserved }); } diff --git a/packages/core/src/parser/relation-prune.ts b/packages/core/src/parser/relation-prune.ts index 36aee90e5..be0ad073b 100644 --- a/packages/core/src/parser/relation-prune.ts +++ b/packages/core/src/parser/relation-prune.ts @@ -117,15 +117,15 @@ export function pruneRelationsByRelations(relations: IRelations, rejected: strin * running `prefix` reconstructs their absolute path before matching. Falls back * to the schema `default` when pruning empties the parameter. * - * A sealed condition is exempt from the drop, not from the gate: pruning - * anything out of a sealed subtree returns a query the sealed condition does - * not describe (wider under the `and(, )` shape a filters + * A preserved condition is exempt from the drop, not from the gate: pruning + * anything out of a preserved subtree returns a query the preserved condition + * does not describe (wider under the `and(, )` shape a filters * validator produces, narrower under an `or`), while keeping it would join a * relation the relations validator rejected. Neither outcome is correct, so the * contradiction between the two validators throws {@link SchemaError} - * (`SCHEMA_SEALED_CONDITION_PRUNED`) instead of resolving it silently. The - * decision is per node, not per operator: the seal says the condition survives - * composition intact, and pruning is not asked to reason about which shapes + * (`SCHEMA_PRESERVED_CONDITION_PRUNED`) instead of resolving it silently. The + * decision is per node, not per operator: preservation says the condition + * survives composition intact, and pruning is not asked to reason about which shapes * happen to fail open. */ export function pruneFiltersByRelations( @@ -150,13 +150,13 @@ export function pruneFiltersByRelations( // The default is the server's own baseline and is exempt from the gate: // it is re-applied here un-pruned, even when it names a rejected - // relation. A sealed default asserts the same must-survive contract as + // relation. A preserved default asserts the same must-survive contract as // a validator residual though, so it is checked: without this, the very // same default would throw or survive depending on whether the client // sent a filter of its own, which is what decides whether it was // materialized before this pass or after it. for (const condition of conditions) { - assertSealedSurvivesPruning(condition, rejected); + assertPreservedSurvivesPruning(condition, rejected); } } @@ -164,20 +164,20 @@ export function pruneFiltersByRelations( } /** - * Raise the sealed-condition contradiction for a condition that is kept rather + * Raise the preserved-condition contradiction for a condition that is kept rather * than pruned. The pruned copy is discarded: only the throw matters here. */ -function assertSealedSurvivesPruning(condition: ICondition, rejected: string[]) : void { +function assertPreservedSurvivesPruning(condition: ICondition, rejected: string[]) : void { pruneCondition(condition, rejected, '', false); } /** - * Drop the condition at `field`, unless it is protected: a drop inside a sealed + * Drop the condition at `field`, unless it is protected: a drop inside a preserved * subtree is the one case pruning must refuse rather than resolve. */ -function dropUnlessSealed(relation: string, field: string, sealed: boolean) : undefined { - if (sealed) { - throw SchemaError.sealedConditionPruned(relation, field); +function dropUnlessPreserved(relation: string, field: string, preserved: boolean) : undefined { + if (preserved) { + throw SchemaError.preservedConditionPruned(relation, field); } return undefined; @@ -187,39 +187,47 @@ function pruneCondition( node: ICondition, rejected: string[], prefix: string, - sealed: boolean, + preserved: boolean, ) : ICondition | undefined { - // the marker protects the whole subtree it heads: every condition below a - // seal is part of what the seal says must survive. - const sealed2 = sealed || !!node.sealed; + // The marker protects the whole subtree it heads. `preserve()` sets it on + // the built-in nodes and wraps anything else, but the marker itself is + // part of ICondition, so a custom condition setting it is honoured too. + const preserved2 = preserved || isConditionPreservedAtRoot(node); if (isFilter(node)) { const field = joinPath(prefix, node.field); const rejectedBy = matchRelationRejected(field, rejected); + // Dropping a leaf drops everything addressed below it, so the refusal + // is decided over the whole subtree rather than this node alone: an + // interior condition (`elemMatch`, or any operator built the same way) + // can carry the marker its parent does not. + if (typeof rejectedBy === 'string') { + return dropUnlessPreserved( + rejectedBy, + field, + preserved2 || isConditionPreserved(node), + ); + } + + // Descending is gated on the operator, not on the value shape: only + // `elemMatch` is known to address its interior relative to the element, + // which is what `field` becomes the prefix for. if ( node.operator === FilterFieldOperator.ELEM_MATCH && isCondition(node.value) ) { - if (typeof rejectedBy === 'string') { - return dropUnlessSealed(rejectedBy, field, sealed2); - } - - const interior = pruneCondition(node.value, rejected, field, sealed2); + const interior = pruneCondition(node.value, rejected, field, preserved2); if (!interior) { return undefined; } if (interior !== node.value) { - return new Filter(node.operator, node.field, interior, { sealed: node.sealed }); + return new Filter(node.operator, node.field, interior, { preserved: node.preserved }); } - - return node; } - return typeof rejectedBy === 'string' ? - dropUnlessSealed(rejectedBy, field, sealed2) : - node; + return node; } if (!isFilters(node)) { @@ -228,7 +236,7 @@ function pruneCondition( const conditions : ICondition[] = []; for (const child of node.value) { - const child2 = pruneCondition(child, rejected, prefix, sealed2); + const child2 = pruneCondition(child, rejected, prefix, preserved2); if (child2) { conditions.push(child2); } @@ -238,7 +246,30 @@ function pruneCondition( return undefined; } - return new Filters(node.operator, conditions, { sealed: node.sealed }); + return new Filters(node.operator, conditions, { preserved: node.preserved }); +} + +function isConditionPreservedAtRoot(node: ICondition) : boolean { + return node.preserved === true; +} + +function isConditionPreserved(node: ICondition) : boolean { + if (isConditionPreservedAtRoot(node)) { + return true; + } + + if (isFilters(node)) { + return node.value.some((child) => isConditionPreserved(child)); + } + + // A leaf value can be a condition in its own right, and it goes away with + // the leaf. The addressing convention does not matter here: the question + // is only whether anything below is marked. + if (isFilter(node) && isCondition(node.value)) { + return isConditionPreserved(node.value); + } + + return false; } function buildSortDefaults(schema: SortSchema) : Sorts { diff --git a/packages/core/test/unit/build/filters-merge.spec.ts b/packages/core/test/unit/build/filters-merge.spec.ts new file mode 100644 index 000000000..161c2fb3b --- /dev/null +++ b/packages/core/test/unit/build/filters-merge.spec.ts @@ -0,0 +1,139 @@ +/* + * 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. + */ + +import { + BuildError, + defineFilters, + eq, + mergeFiltersInput, +} from '../../../src'; +import type { IFilter } from '../../../src'; +import type { User } from '../../data'; + +const conditions = (input: unknown) => (defineFilters(input as never).value as IFilter[]) + .map((el) => [el.field, el.operator, el.value]); + +describe('src/build/parameter/filters/merge.ts', () => { + it('should let the first input win a field and keep the rest', () => { + const output = mergeFiltersInput( + { name: 'current' }, + { name: 'default', age: { $gte: 18 } }, + ); + + expect(conditions(output)).toEqual([ + ['name', 'eq', 'current'], + ['age', 'gte', 18], + ]); + }); + + it('should replace across both notations of the same field', () => { + // an object spread cannot see these are one field, and emits both. + const output = mergeFiltersInput( + { realm: { name: 'b' } }, + { 'realm.name': 'a', 'realm.id': 1 }, + ); + + expect(output).toEqual({ 'realm.name': 'b', 'realm.id': 1 }); + expect(conditions(output)).toEqual([ + ['realm.name', 'eq', 'b'], + ['realm.id', 'eq', 1], + ]); + }); + + it('should replace a nested record key by key rather than wholesale', () => { + // a shallow spread would drop `realm.id` with the branch it replaces. + const output = mergeFiltersInput( + { realm: { name: 'b' } }, + { realm: { name: 'a', id: 1 } }, + ); + + expect(output).toEqual({ 'realm.name': 'b', 'realm.id': 1 }); + }); + + it('should resolve both notations of one field within a single input', () => { + const output = mergeFiltersInput({ + 'realm.name': 'first', + realm: { name: 'second' }, + }); + + expect(output).toEqual({ 'realm.name': 'first' }); + }); + + it('should replace per field rather than per operator', () => { + const output = mergeFiltersInput( + { age: { $gte: 18 } }, + { age: { $lt: 65 } }, + ); + + // keeping both bounds is conjunction, which is mergeQueries' job. + expect(conditions(output)).toEqual([['age', 'gte', 18]]); + }); + + it('should treat an undefined value as no opinion', () => { + const output = mergeFiltersInput( + { name: undefined, age: 30 }, + { name: 'default' }, + ); + + expect(output).toEqual({ age: 30, name: 'default' }); + }); + + it('should treat a $elemMatch interior as one value', () => { + const output = mergeFiltersInput( + { items: { $elemMatch: { name: 'current' } } }, + { items: { $elemMatch: { name: 'default', id: '2' } } }, + ); + + expect(output).toEqual({ items: { $elemMatch: { name: 'current' } } }); + }); + + it('should keep every notation of a bare-array and regex leaf intact', () => { + const pattern = /^Jo/; + const output = mergeFiltersInput( + { id: ['1', '2', null] }, + { name: pattern }, + ); + + expect(conditions(output)).toEqual([ + ['id', 'in', ['1', '2', null]], + ['name', 'regex', pattern], + ]); + }); + + it('should not mutate its inputs', () => { + const left = { realm: { name: 'b' } }; + const right = { realm: { name: 'a', id: 1 } }; + + mergeFiltersInput(left, right); + + expect(left).toEqual({ realm: { name: 'b' } }); + expect(right).toEqual({ realm: { name: 'a', id: 1 } }); + }); + + it('should return an empty input without arguments', () => { + expect(mergeFiltersInput()).toEqual({}); + }); + + it('should refuse a live condition instead of replacing inside it', () => { + // The safety property, and it is a type error before it is a runtime + // one: replacement never sees a server-authored tree. + expect(() => mergeFiltersInput( + // @ts-expect-error a live condition is not build input + eq('realm_id', 'master'), + )).toThrow(BuildError); + }); + + it('should leave an invalid $-prefixed root key for defineFilters to report', () => { + // untyped call sites reach the permissive arm, where a `$` key is + // indistinguishable from a field name, so the report stays where it + // already was rather than being duplicated here. + const output = mergeFiltersInput({ $eq: 'x' }); + + expect(output).toEqual({ $eq: 'x' }); + expect(() => defineFilters(output)).toThrow(BuildError); + }); +}); diff --git a/packages/core/test/unit/build/module.spec.ts b/packages/core/test/unit/build/module.spec.ts index 505ca088e..8902f4700 100644 --- a/packages/core/test/unit/build/module.spec.ts +++ b/packages/core/test/unit/build/module.spec.ts @@ -8,7 +8,6 @@ import type { FieldsBuildInput, FiltersBuildInput, - ICondition, IFilter, QueryBuildInput, RelationsBuildInput, @@ -16,6 +15,7 @@ import type { } from '../../../src'; import { BuildError, + Condition, ErrorCode, FieldOperator, Filter, @@ -39,16 +39,9 @@ import type { Client, User } from '../../data'; const leafs = (filters: { value: unknown[] }) => filters.value as IFilter[]; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - - constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); } } diff --git a/packages/core/test/unit/parameter/condition-contract.spec.ts b/packages/core/test/unit/parameter/condition-contract.spec.ts index ca583ed76..a26364a0b 100644 --- a/packages/core/test/unit/parameter/condition-contract.spec.ts +++ b/packages/core/test/unit/parameter/condition-contract.spec.ts @@ -8,60 +8,36 @@ import { expectTypeOf } from 'vitest'; import type { ICondition, - IFilter, - IFilters, } from '../../../src'; import { + CONDITION_MARKER, + Condition, FilterCompoundOperator, Filters, and, elemMatch, eq, + isCondition, isFilter, isFilters, not, or, - seal, } from '../../../src'; type CustomValue = { scope: string, }; -interface ISealedCustomCondition extends ICondition { - readonly stage: 'sealed'; - readonly sealed: true; - seal(): ISealedCustomCondition; -} - -interface IUnsealedCustomCondition extends ICondition { - readonly stage: 'unsealed'; - seal(): ISealedCustomCondition; -} - -class CustomCondition implements ICondition { - readonly operator: string; - +class CustomCondition extends Condition { readonly field: string; - readonly sealed?: boolean; - constructor( - readonly value: CustomValue, - sealed?: boolean, + value: CustomValue, operator = 'custom', field = 'not-a-built-in-leaf', ) { - this.operator = operator; + super(operator, value); this.field = field; - - if (sealed) { - this.sealed = true; - } - } - - seal(): ICondition { - return this.sealed ? this : new CustomCondition(this.value, true); } flatten(): this { @@ -70,6 +46,31 @@ class CustomCondition implements ICondition { } describe('src/parameter/filters condition contract', () => { + it('should identify conditions through a non-serializable brand', () => { + const condition = new CustomCondition({ scope: 'tenant-a' }); + const detached = JSON.parse(JSON.stringify(condition)); + + expect(isCondition(condition)).toBe(true); + expect(isCondition(detached)).toBe(false); + expect(Object.hasOwn(condition, CONDITION_MARKER)).toBe(false); + expect(Object.getOwnPropertyDescriptor( + Condition.prototype, + CONDITION_MARKER, + )?.enumerable).toBe(false); + expect(isCondition({ [CONDITION_MARKER]: true })).toBe(false); + expect(isCondition({ + operator: 'custom', + value: {}, + })).toBe(false); + + // the brand is asserted, not merely present. + expect(isCondition({ + [CONDITION_MARKER]: false, + operator: 'custom', + value: {}, + })).toBe(false); + }); + it('should retain specialized visitor dispatch for built-in conditions', () => { const leaf = eq('name', 'Peter'); const group = and(eq('active', true)); @@ -81,8 +82,12 @@ describe('src/parameter/filters condition contract', () => { it('should not require visitor dispatch from a structural custom condition', () => { const condition = new CustomCondition({ scope: 'tenant-a' }); type HasAccept = 'accept' extends keyof ICondition ? true : false; + type HasSeal = 'seal' extends keyof ICondition ? true : false; + type HasSealed = 'sealed' extends keyof ICondition ? true : false; expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expect(condition.value.scope).toBe('tenant-a'); }); @@ -102,51 +107,17 @@ describe('src/parameter/filters condition contract', () => { expect(elemMatch('items', condition).value).toBe(condition); }); - it('should expose interfaces from seal contracts', () => { - const leaf = eq('name', 'Peter'); - const group = and(leaf); - - expectTypeOf(leaf.seal()).toEqualTypeOf(); - expectTypeOf(group.seal()).toEqualTypeOf(); - expectTypeOf(seal(leaf)).toEqualTypeOf(); - expectTypeOf(seal(group)).toEqualTypeOf(); - }); - - it('should seal custom conditions polymorphically behind the interface', () => { + it('should combine custom conditions without cloning or preserving them', () => { const condition = new CustomCondition({ scope: 'tenant-a' }); - const output = seal(condition); - - expectTypeOf(output).toEqualTypeOf>(); - expect(output).not.toBe(condition); - expect(output.sealed).toBe(true); - expect(seal(output)).toBe(output); - }); - - it('should derive the helper result from the declared seal interface', () => { - const assertReturnType = (condition: IUnsealedCustomCondition) => { - const output = seal(condition); - - expectTypeOf(output).toEqualTypeOf(); - }; - - expectTypeOf(assertReturnType).toBeFunction(); - }); - - it('should seal a custom condition injected into a built-in group', () => { - const condition = new CustomCondition({ scope: 'tenant-a' }); - const receiver = new Filters(FilterCompoundOperator.AND, [eq('name', 'Peter')]); - - const withAnd = receiver.and(condition); - const withOr = receiver.or(condition); + const receiver = and(eq('name', 'Peter')); - expect(withAnd.value[1]).toEqual(condition.seal()); - expect(withOr.value[1]).toEqual(condition.seal()); - expect(withAnd.value[1]).not.toBe(condition); - expect(withOr.value[1]).not.toBe(condition); + expect(receiver.and(condition).value.at(-1)).toBe(condition); + expect(receiver.or(condition).value.at(-1)).toBe(condition); + expect('preserved' in condition).toBe(false); }); it('should keep a custom condition opaque while flattening built-in groups', () => { - const condition = new CustomCondition({ scope: 'tenant-a' }, false, 'and'); + const condition = new CustomCondition({ scope: 'tenant-a' }, 'and'); const output = and(and(condition)).flatten(); expect(output.value).toHaveLength(1); @@ -156,7 +127,6 @@ describe('src/parameter/filters condition contract', () => { it('should carry a custom condition through a built-in merge unchanged', () => { const condition = new CustomCondition( { scope: 'tenant-a' }, - false, 'custom', 'name', ); @@ -167,22 +137,13 @@ describe('src/parameter/filters condition contract', () => { expect(output.value[1]).toBe(condition); }); - it('should keep detached runtime data unchanged when it has no seal method', () => { - const detached = { - operator: 'custom', - value: { scope: 'tenant-a' }, - }; - - expect(seal(detached as unknown as ICondition)).toBe(detached); - }); - it('should reject detached data at typed condition boundaries', () => { const detached = { operator: 'custom', value: { scope: 'tenant-a' }, }; - // @ts-expect-error detached data has no sealing behavior + // @ts-expect-error detached data has no condition brand const condition: ICondition = detached; // @ts-expect-error helpers accept live ICondition implementations and(detached); diff --git a/packages/core/test/unit/parameter/filters-non-node.spec.ts b/packages/core/test/unit/parameter/filters-non-node.spec.ts index 7133068c8..22dc8184d 100644 --- a/packages/core/test/unit/parameter/filters-non-node.spec.ts +++ b/packages/core/test/unit/parameter/filters-non-node.spec.ts @@ -9,6 +9,7 @@ import type { ICondition } from '../../../src'; import { AdapterError, BuildError, + Condition, ErrorCode, FilterCompoundOperator, Filters, @@ -31,16 +32,9 @@ const NON_NODE = { value: 'ACME', } as unknown as ICondition; -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - - constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); } } @@ -150,4 +144,12 @@ describe('src/parameter/filters (non-node conditions)', () => { )) .toThrow(BuildError); }); + + it('should refuse a detached preserved condition', () => { + const detached = JSON.parse(JSON.stringify( + new Filters(FilterCompoundOperator.AND, [eq('name', 'John')], { preserved: true }), + )); + + expect(() => defineFilters(detached)).toThrow(BuildError); + }); }); diff --git a/packages/core/test/unit/parameter/merge.spec.ts b/packages/core/test/unit/parameter/merge.spec.ts index 4ffa2963c..2b61ec325 100644 --- a/packages/core/test/unit/parameter/merge.spec.ts +++ b/packages/core/test/unit/parameter/merge.spec.ts @@ -5,8 +5,9 @@ * view the LICENSE file that was distributed with this source code. */ -import type { ICondition, IFilter } from '../../../src'; +import type { ICondition, IFilter, IFilters } from '../../../src'; import { + Condition, ErrorCode, Field, Fields, @@ -19,15 +20,24 @@ import { defineQuery, eq, gte, + isFilter, + isFilters, mergeQueries, or, - seal, + preserve, + pruneFiltersByRelations, } from '../../../src'; import type { User } from '../../data'; const conditions = (filters: { value: unknown[] }) => (filters.value as IFilter[]) .map((el) => [el.field, el.operator, el.value]); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); + } +} + describe('src/parameter/merge.ts', () => { it('should return an empty query without input', () => { const output = mergeQueries(); @@ -73,15 +83,29 @@ describe('src/parameter/merge.ts', () => { expect(output.pagination.offset).toBe(20); }); - it('should replace same-field filters with left priority (search over defaults)', () => { - const searchQ = defineQuery({ filters: { name: { $contains: 'Jo' } } }); - const defaultsQ = defineQuery({ filters: { name: 'John', age: { $gte: 18 } } }); - - const output = mergeQueries(searchQ, defaultsQ); + it('should retain same-field range conditions from both queries', () => { + const output = mergeQueries( + defineQuery({ filters: { age: { $gte: 18 } } }), + defineQuery({ filters: { age: { $lt: 65 } } }), + ); expect(conditions(output.filters)).toEqual([ - ['name', 'contains', 'Jo'], ['age', 'gte', 18], + ['age', 'lt', 65], + ]); + }); + + it('should retain contradictory same-field conditions in argument order', () => { + const a = defineQuery({ filters: { name: 'a' } }); + const b = defineQuery({ filters: { name: 'b' } }); + + expect(conditions(mergeQueries(a, b).filters)).toEqual([ + ['name', 'eq', 'a'], + ['name', 'eq', 'b'], + ]); + expect(conditions(mergeQueries(b, a).filters)).toEqual([ + ['name', 'eq', 'b'], + ['name', 'eq', 'a'], ]); }); @@ -112,8 +136,8 @@ describe('src/parameter/merge.ts', () => { const output = mergeQueries(flat, nested); - // per-field replace has no meaning inside a disjunction, so the - // group is carried through as one inert conjunct. + // the disjunction remains atomic, so its alternatives keep their + // meaning while the group is carried through as one conjunct. expect(output.filters.operator).toBe(FilterCompoundOperator.AND); expect(output.filters.value).toHaveLength(2); expect((output.filters.value[0] as IFilter).field).toBe('name'); @@ -145,42 +169,42 @@ describe('src/parameter/merge.ts', () => { }); describe('src/parameter/filters/collection/module.ts combinators', () => { - it('should inject sealed conditions via and() without mutating the receiver', () => { + it('should append the original condition via and() without mutating the receiver', () => { const query = defineQuery({ filters: { name: 'John' } }); + const condition = eq('realm.id', 'master'); - const scoped = query.filters.and(eq('realm.id', 'master')); + const scoped = query.filters.and(condition); // receiver untouched (immutable). expect(query.filters.value).toHaveLength(1); - // the receiver already carries the operator, so the group stays - // flat and only the injected condition is sealed. + // the receiver already carries the operator, so the group stays flat. expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(conditions(scoped)).toEqual([ ['name', 'eq', 'John'], ['realm.id', 'eq', 'master'], ]); - expect(scoped.value[0].sealed).toBeUndefined(); - expect(scoped.value[1].sealed).toBe(true); + expect(scoped.value[1]).toBe(condition); + expect((scoped.value[1] as IFilter).preserved).toBeUndefined(); }); it('should nest a receiver that carries another operator', () => { const receiver = or(eq('name', 'a'), eq('name', 'b')); - const scoped = receiver.and(eq('realm.id', 'master')); + const condition = eq('realm.id', 'master'); + const scoped = receiver.and(condition); expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(scoped.value).toHaveLength(2); expect(scoped.value[0]).toBe(receiver); - expect(scoped.value[1].sealed).toBe(true); + expect(scoped.value[1]).toBe(condition); }); - it('should not allow a later replace-merge to displace an injected condition', () => { + it('should retain a later condition alongside an injected condition', () => { const parsed = defineQuery({ filters: { name: 'John' } }); const scoped = parsed.filters.and(eq('realm.id', 'master')); - // the injected condition is sealed, so a same-field condition - // narrows the result instead of displacing the scope. + // conjunction keeps both same-field conditions. const hostile = new Filters(FilterCompoundOperator.AND, [ eq('realm.id', 'evil'), ]); @@ -191,11 +215,11 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { ['realm.id', 'eq', 'master'], ]); - // the other direction displaces the hostile condition outright: - // the receiver already constrains that field. + // argument order is preserved in the other direction too. expect(conditions(scoped.merge(hostile))).toEqual([ ['name', 'eq', 'John'], ['realm.id', 'eq', 'master'], + ['realm.id', 'eq', 'evil'], ]); }); @@ -204,8 +228,7 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { const scoped = defineQuery().filters.and(scope); const hostile = defineQuery({ filters: { 'realm.id': 'evil' } }); - // flatten() collapses same-operator nests; the seal rides on the - // node, so normalizing cannot turn the scope displaceable. + // flatten() collapses same-operator nests while retaining both leaves. const flat = scoped.flatten(); expect(conditions(hostile.filters.merge(flat))).toEqual([ @@ -217,22 +240,32 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { it('should keep an empty receiver out of the group', () => { const query = defineQuery(); - // an empty receiver constrains nothing — as an OR child it would + // an empty receiver constrains nothing; as an OR child it would // widen the group to everything. - const scoped = query.filters.and(eq('realm.id', 'master')); + const scope = eq('realm.id', 'master'); + const scoped = query.filters.and(scope); expect(scoped.operator).toBe(FilterCompoundOperator.AND); expect(conditions(scoped)).toEqual([['realm.id', 'eq', 'master']]); - expect(scoped.value[0].sealed).toBe(true); + expect(scoped.value).not.toContain(query.filters); + expect(scoped.value[0]).toBe(scope); + expect((scoped.value[0] as IFilter).preserved).toBeUndefined(); - const alternatives = query.filters.or(eq('name', 'a'), eq('name', 'b')); + const firstAlternative = eq('name', 'a'); + const secondAlternative = eq('name', 'b'); + const alternatives = query.filters.or(firstAlternative, secondAlternative); expect(alternatives.operator).toBe(FilterCompoundOperator.OR); expect(conditions(alternatives)).toEqual([ ['name', 'eq', 'a'], ['name', 'eq', 'b'], ]); + expect(alternatives.value).not.toContain(query.filters); + expect(alternatives.value[0]).toBe(firstAlternative); + expect((alternatives.value[0] as IFilter).preserved).toBeUndefined(); + expect(alternatives.value[1]).toBe(secondAlternative); + expect((alternatives.value[1] as IFilter).preserved).toBeUndefined(); }); - it('should not allow a replace-merge to displace a condition injected onto an empty receiver', () => { + it('should retain a condition injected onto an empty receiver during merge', () => { const scoped = new Query({ filters: defineQuery().filters.and(eq('realm.id', 'master')) }); const hostile = defineQuery({ filters: { 'realm.id': 'evil' } }); @@ -287,6 +320,25 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { expect((output.value[2] as IFilter).field).toBe('b'); }); + it('should preserve all leaves when merging nested and flattened root ANDs', () => { + const nested = and( + eq('name', 'John'), + and(gte('age', 18), eq('active', true)), + ); + const other = and(eq('realm.id', 'master')); + + const nestedOutput = nested.merge(other); + const flatOutput = nested.flatten().merge(other); + + expect(conditions(nestedOutput)).toEqual(conditions(flatOutput)); + expect(conditions(nestedOutput)).toEqual([ + ['name', 'eq', 'John'], + ['age', 'gte', 18], + ['active', 'eq', true], + ['realm.id', 'eq', 'master'], + ]); + }); + it('should treat a receiver that is not a root-AND as one conjunct', () => { const left = or(eq('a', 1), eq('b', 2)); const right = new Filters(FilterCompoundOperator.AND, [eq('a', 9)]); @@ -309,98 +361,67 @@ describe('src/parameter/filters/collection/module.ts combinators', () => { }); }); -describe('src/parameter/filters/helpers/module.ts seal', () => { - it('should seal a leaf condition immutably', () => { - const condition = eq('a', 1); - const sealed = seal(condition); - - expect(condition.sealed).toBeUndefined(); - expect(sealed.sealed).toBe(true); - expect(sealed.field).toBe('a'); - expect(sealed.operator).toBe('eq'); - expect(sealed.value).toBe(1); - - // idempotent — an already sealed condition is returned as is. - expect(seal(sealed)).toBe(sealed); - }); - - it('should delegate to the node, which knows how to copy itself', () => { - const leaf = eq('a', 1); - const group = or(eq('a', 1), eq('b', 2)); - - // the helper is a dispatcher — each node kind owns its own copy. - const sealedLeaf = leaf.seal(); - const sealedGroup = group.seal(); - - expect(sealedLeaf.sealed).toBe(true); - expect(sealedGroup.sealed).toBe(true); - - // idempotent: an already sealed node returns itself. - expect(sealedLeaf.seal()).toBe(sealedLeaf); - expect(sealedGroup.seal()).toBe(sealedGroup); - expect(seal(leaf)).toEqual(sealedLeaf); - }); - - it('should seal a group without touching its interior', () => { - const group = or(eq('a', 1), eq('b', 2)); - const sealed = seal(group); +describe('src/parameter/filters/preserve.ts', () => { + it('should preserve a built-in leaf immutably and idempotently', () => { + const condition = eq('realm_id', 'master'); + const output = preserve(condition); - expect(group.sealed).toBeUndefined(); - expect(sealed.sealed).toBe(true); - expect(sealed.value).toEqual(group.value); + expect(output).not.toBe(condition); + expect(output.preserved).toBe(true); + expect(preserve(output)).toBe(output); }); - it('should keep a sealed condition undisplaceable through a merge', () => { - const left = new Filters(FilterCompoundOperator.AND, [eq('realm_id', 'evil')]); - const right = new Filters(FilterCompoundOperator.AND, [seal(eq('realm_id', 'master'))]); + it('should return the same kind it received rather than always wrapping', () => { + // the catch-all overload admits every subtype, so it promises only + // ICondition back: a leaf stays a leaf and has no group operations. + const condition: ICondition = eq('realm_id', 'master'); + const output = preserve(condition); - expect(conditions(left.merge(right))).toEqual([ - ['realm_id', 'eq', 'evil'], - ['realm_id', 'eq', 'master'], - ]); + expect(isFilter(output)).toBe(true); + expect(isFilters(output)).toBe(false); + expect((output as unknown as IFilters).and).toBeUndefined(); }); - it('should not adopt the conditions of a sealed receiver', () => { - const receiver = seal(and(eq('a', 1))); + it('should preserve a custom condition through a built-in wrapper', () => { + const condition = new CustomCondition({ scope: 'tenant-a' }); + const output = preserve(condition); - // adopting them into a fresh (unsealed) group would strip the - // protection its children rely on, so it stays a child instead. - const output = receiver.and(eq('b', 2)); - - expect(output.value).toHaveLength(2); - expect(output.value[0]).toBe(receiver); - expect(output.value[1].sealed).toBe(true); + expect(isFilters(output)).toBe(true); + expect(output.preserved).toBe(true); + expect(output.value).toEqual([condition]); }); - it('should treat a sealed root as one inert conjunct', () => { - const sealedRoot = seal(and(eq('a', 1))); + it('should keep a preserved root group atomic through flatten and merge', () => { + const preservedRoot = preserve(and(eq('a', 1))); const other = new Filters(FilterCompoundOperator.AND, [eq('a', 9)]); - // decomposing it would expose its (unsealed) conditions to - // per-field replace. - const output = other.merge(sealedRoot); + expect(preservedRoot.flatten().preserved).toBe(true); + expect(and(eq('b', 2), preservedRoot).flatten().value[1]).toBe(preservedRoot); + const output = other.merge(preservedRoot); expect(output.value).toHaveLength(2); - expect(output.value[1]).toBe(sealedRoot); + expect(output.value[1]).toBe(preservedRoot); }); - it('should leave detached runtime data without a seal method untouched', () => { - const detached = { operator: 'and', value: [] }; + it('should keep a preserved AND receiver atomic when and() matches its operator', () => { + const receiver = preserve(and(eq('realm.id', 'SCOPE'))); - expect(seal(detached as unknown as ICondition)).toBe(detached); - }); + const output = receiver.and(eq('name', 'John')); - it('should not hoist a sealed group out of its parent', () => { - const inner = seal(and(eq('a', 1))); - const outer = and(eq('b', 2), inner); + expect(output.value[0]).toBe(receiver); + expect((output.value[0] as IFilters).preserved).toBe(true); + expect(() => pruneFiltersByRelations(output, ['realm'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); - const flat = outer.flatten(); + it('should keep a preserved OR receiver atomic when or() matches its operator', () => { + const receiver = preserve(or(eq('realm.id', 'SCOPE'), eq('id', 1))); - // hoisting would strip the protection its children rely on. - expect(flat.value).toHaveLength(2); - expect(flat.value[1]).toBe(inner); + const output = receiver.or(eq('name', 'John')); - // a sealed root keeps its marker across normalization. - expect(seal(and(and(eq('a', 1)))).flatten().sealed).toBe(true); + expect(output.value[0]).toBe(receiver); + expect((output.value[0] as IFilters).preserved).toBe(true); + expect(() => pruneFiltersByRelations(output, ['realm'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); diff --git a/packages/core/test/unit/parser/parameter/filters/validate.spec.ts b/packages/core/test/unit/parser/parameter/filters/validate.spec.ts index fbbaf0bb0..78b61922c 100644 --- a/packages/core/test/unit/parser/parameter/filters/validate.spec.ts +++ b/packages/core/test/unit/parser/parameter/filters/validate.spec.ts @@ -15,7 +15,7 @@ import { applyFiltersSchemaValidation, applyFiltersSchemaValidationAsync, defineFiltersSchema, - seal, + preserve, } from '../../../../../src'; import type { IFilter, Validator } from '../../../../../src'; @@ -284,10 +284,10 @@ describe('src/parser/parameter/filters/validate.ts', () => { .resolves.toBeUndefined(); }); - it('should keep a sealed condition sealed across validation', async () => { + it('should keep a preserved condition preserved across validation', async () => { const input = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter(FilterFieldOperator.EQUAL, 'name', 'admin')), - ], { sealed: true }); + preserve(new Filter(FilterFieldOperator.EQUAL, 'name', 'admin')), + ], { preserved: true }); const schema = defineFiltersSchema({ // a replacement stands in for the leaf it replaces. validate: (filter) => new Filter(filter.operator, filter.field, 'ADMIN'), @@ -295,17 +295,38 @@ describe('src/parser/parameter/filters/validate.ts', () => { const output = applyFiltersSchemaValidation(input, schema)!; const [leaf] = output.value as IFilter[]; - expect(output.sealed).toBe(true); - expect(leaf!.sealed).toBe(true); + expect(output.preserved).toBe(true); + expect(leaf!.preserved).toBe(true); expect(leaf!.value).toBe('ADMIN'); const outputAsync = (await applyFiltersSchemaValidationAsync(input, schema))!; const [asyncLeaf] = outputAsync.value as IFilter[]; - expect(outputAsync.sealed).toBe(true); - expect(asyncLeaf!.sealed).toBe(true); + expect(outputAsync.preserved).toBe(true); + expect(asyncLeaf!.preserved).toBe(true); expect(asyncLeaf!.value).toBe('ADMIN'); }); + it('should preserve an elemMatch leaf rebuilt after interior validation', async () => { + const input = preserve(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'), + )); + const schema = defineFiltersSchema({ + validate: (filter) => (filter.field === 'name' ? + new Filter(filter.operator, filter.field, 'ADMIN') : + filter), + }); + + const output = applyFiltersSchemaValidation(input, schema) as IFilter; + expect(output.preserved).toBe(true); + expect((output.value as IFilter).value).toBe('ADMIN'); + + const outputAsync = await applyFiltersSchemaValidationAsync(input, schema) as IFilter; + expect(outputAsync.preserved).toBe(true); + expect((outputAsync.value as IFilter).value).toBe('ADMIN'); + }); + it('should propagate asynchronous validator failures', async () => { const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); const schema = defineFiltersSchema({ diff --git a/packages/core/test/unit/parser/parameter/key-validation.spec.ts b/packages/core/test/unit/parser/parameter/key-validation.spec.ts index a92dcb49d..c6291d47c 100644 --- a/packages/core/test/unit/parser/parameter/key-validation.spec.ts +++ b/packages/core/test/unit/parser/parameter/key-validation.spec.ts @@ -6,6 +6,7 @@ */ import { + Condition, ErrorCode, FieldsParseError, Parameter, @@ -51,16 +52,9 @@ function fieldsOptions( }; } -class CustomCondition implements ICondition<{ scope: string }> { - readonly operator = 'custom'; - - constructor( - readonly value: { scope: string }, - readonly sealed?: boolean, - ) {} - - seal() : ICondition<{ scope: string }> { - return this.sealed ? this : new CustomCondition(this.value, true); +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); } } diff --git a/packages/core/test/unit/parser/relation-prune.spec.ts b/packages/core/test/unit/parser/relation-prune.spec.ts index 1c5932a7b..8e58ab47b 100644 --- a/packages/core/test/unit/parser/relation-prune.spec.ts +++ b/packages/core/test/unit/parser/relation-prune.spec.ts @@ -6,6 +6,7 @@ */ import { + Condition, ErrorCode, Field, Fields, @@ -21,14 +22,20 @@ import { defineFiltersSchema, defineSortSchema, isRelationRejected, + preserve, pruneFieldsByRelations, pruneFiltersByRelations, pruneRelationsByRelations, pruneSortsByRelations, - seal, } from '../../../src'; import type { IFilters } from '../../../src'; +class CustomCondition extends Condition<{ scope: string }> { + constructor(value: { scope: string }) { + super('custom', value); + } +} + function filterFields(input: IFilters) : string[] { const output : string[] = []; const walk = (node: any) => { @@ -200,32 +207,32 @@ describe('src/parser/relation-prune.ts', () => { }); }); - describe('pruneFiltersByRelations (sealed conditions)', () => { + describe('pruneFiltersByRelations (preserved conditions)', () => { const eq = (field: string) => new Filter(FilterFieldOperator.EQUAL, field, 'x'); - it('throws instead of dropping a sealed leaf', () => { + it('throws instead of dropping a preserved leaf', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('user.name')), + preserve(eq('user.name')), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('names the rejected relation and the sealed field', () => { + it('names the rejected relation and the preserved field', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('realm.id')), + preserve(eq('realm.id')), ]); expect(() => pruneFiltersByRelations(filters, ['realm'])) .toThrowError(/"realm".+"realm\.id"/); }); - it('throws instead of dropping a policy residual out of a sealed group', () => { + it('throws instead of dropping a policy residual out of a preserved group', () => { // the shape a filters validate hook produces: - // seal(and(, )) + // preserve(and(, )) const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filters(FilterCompoundOperator.AND, [ + preserve(new Filters(FilterCompoundOperator.AND, [ eq('name'), eq('realm.id'), ])), @@ -233,24 +240,24 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['realm'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws for a sealed condition nested below an unsealed group', () => { + it('throws for a preserved condition nested below an unpreserved group', () => { const filters = new Filters(FilterCompoundOperator.AND, [ new Filters(FilterCompoundOperator.OR, [ eq('id'), - seal(eq('user.name')), + preserve(eq('user.name')), ]), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws instead of dropping a sealed elemMatch', () => { + it('throws instead of dropping a preserved elemMatch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter( + preserve(new Filter( FilterFieldOperator.ELEM_MATCH, 'items', new Filter(FilterFieldOperator.EQUAL, 'id', 1), @@ -258,12 +265,51 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['items'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('throws instead of dropping an elemMatch with a preserved built-in descendant', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [preserve(eq('id'))]), + ), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws instead of pruning the interior of a sealed elemMatch', () => { + it('throws instead of dropping an elemMatch with a preserved custom-condition wrapper', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filter( + new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [ + preserve(new CustomCondition({ scope: 'tenant-a' })), + ]), + ), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('throws instead of dropping any leaf holding a preserved interior', () => { + // the drop takes the whole subtree with it, so the refusal cannot + // depend on which operator happens to address an interior. + const filters = new Filters(FilterCompoundOperator.AND, [ + new Filter('customOp', 'items', preserve(eq('id'))), + ]); + + expect(() => pruneFiltersByRelations(filters, ['items'])) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('throws instead of pruning the interior of a preserved elemMatch', () => { + const filters = new Filters(FilterCompoundOperator.AND, [ + preserve(new Filter( FilterFieldOperator.ELEM_MATCH, 'items', new Filters(FilterCompoundOperator.AND, [ @@ -274,44 +320,62 @@ describe('src/parser/relation-prune.ts', () => { ]); expect(() => pruneFiltersByRelations(filters, ['items.owner'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); // The two shapes below cannot fail open: dropping an OR arm narrows, - // and dropping the interior of a NOT removes a restriction the seal - // put there. Pruning still refuses, because the seal is a per-node - // marker and not a per-operator judgement call. - it('throws for a sealed OR arm, where a drop would narrow rather than widen', () => { + // and dropping the interior of a NOT removes a restriction that + // preserve() put there. Pruning still refuses, because preservation is + // a per-node marker and not a per-operator judgement call. + it('throws for a preserved OR arm, where a drop would narrow rather than widen', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])), + preserve(new Filters(FilterCompoundOperator.OR, [eq('id'), eq('user.name')])), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('throws for a sealed condition below a NOT', () => { + it('throws for a preserved condition below a NOT', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - new Filters(FilterCompoundOperator.NOT, [seal(eq('user.name'))]), + new Filters(FilterCompoundOperator.NOT, [preserve(eq('user.name'))]), ]); expect(() => pruneFiltersByRelations(filters, ['user'])) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); - it('keeps pruning around a sealed condition it does not touch', () => { + it('keeps pruning around a preserved condition it does not touch', () => { const filters = new Filters(FilterCompoundOperator.AND, [ - seal(eq('realm_id')), + preserve(eq('realm_id')), eq('user.name'), eq('id'), ]); const output = pruneFiltersByRelations(filters, ['user']); expect(filterFields(output)).toEqual(['realm_id', 'id']); - expect(output.value[0].sealed).toBe(true); + expect(output.value[0].preserved).toBe(true); }); - it('re-applies an UNSEALED default naming a rejected relation', () => { + it('preserves rebuilt groups and elemMatch leaves', () => { + const group = preserve(new Filters(FilterCompoundOperator.AND, [eq('realm_id')])); + const elemMatch = preserve(new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [eq('id')]), + )); + const filters = new Filters(FilterCompoundOperator.AND, [ + group, + elemMatch, + eq('user.name'), + ]); + + const output = pruneFiltersByRelations(filters, ['user']); + expect(output.value[0].preserved).toBe(true); + expect(output.value[1].preserved).toBe(true); + }); + + it('re-applies an unpreserved default naming a rejected relation', () => { // the server-authored baseline is exempt from the gate, which is // why the default fallback is not pruned. const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]); @@ -320,15 +384,29 @@ describe('src/parser/relation-prune.ts', () => { expect(filterFields(pruneFiltersByRelations(filters, ['user'], schema))).toEqual(['user.b']); }); - it('throws for a SEALED default naming a rejected relation', () => { + it('throws for a preserved default naming a rejected relation', () => { // otherwise the same default would throw when it is materialized // before this pass (client sent no filters) and survive when it is // materialized after it (client sent filters that all pruned away). const filters = new Filters(FilterCompoundOperator.AND, [eq('user.a')]); - const schema = defineFiltersSchema({ default: seal(eq('user.b')) }); + const schema = defineFiltersSchema({ default: preserve(eq('user.b')) }); expect(() => pruneFiltersByRelations(filters, ['user'], schema)) - .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); + }); + + it('throws for a default elemMatch with a preserved descendant after input pruning empties', () => { + const filters = new Filters(FilterCompoundOperator.AND, [eq('items.name')]); + const schema = defineFiltersSchema({ + default: new Filter( + FilterFieldOperator.ELEM_MATCH, + 'items', + new Filters(FilterCompoundOperator.AND, [preserve(eq('id'))]), + ), + }); + + expect(() => pruneFiltersByRelations(filters, ['items'], schema)) + .toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); }); diff --git a/packages/docs/guide/building-queries.md b/packages/docs/guide/building-queries.md index db2e26d2c..c9b6d664a 100644 --- a/packages/docs/guide/building-queries.md +++ b/packages/docs/guide/building-queries.md @@ -81,24 +81,16 @@ const query = defineQuery({ }); ``` -`eq` `ne` `lt` `lte` `gt` `gte` `inArray` `nin` `startsWith` `notStartsWith` `endsWith` `notEndsWith` `contains` `notContains` `regex` `mod` `size` `exists` `elemMatch`, plus the `and` / `or` / `not` compounds. `not(condition)` is the exact complement of its interior (see [Negation](/guide/filters#negation)); multiple arguments negate their conjunction. `seal(condition)` marks a condition undisplaceable by a later [`merge()`](/guide/merging-queries#seal-conditions-that-resist-replacement). +`eq` `ne` `lt` `lte` `gt` `gte` `inArray` `nin` `startsWith` `notStartsWith` `endsWith` `notEndsWith` `contains` `notContains` `regex` `mod` `size` `exists` `elemMatch`, plus the `and` / `or` / `not` compounds. `not(condition)` is the exact complement of its interior (see [Negation](/guide/filters#negation)); multiple arguments negate their conjunction. Use [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) only when a relation-pruning policy must retain a server-authored residual. #### Custom conditions ```typescript -import type { ICondition } from '@rapiq/core'; -import { and, eq } from '@rapiq/core'; +import { CONDITION_MARKER, Condition, and, eq } from '@rapiq/core'; -class GeoCondition implements ICondition<[number, number]> { - readonly operator = 'geo'; - - constructor( - readonly value: [number, number], - readonly sealed?: boolean, - ) {} - - seal(): ICondition<[number, number]> { - return this.sealed ? this : new GeoCondition(this.value, true); +class GeoCondition extends Condition<[number, number]> { + constructor(value: [number, number]) { + super('geo', value); } } @@ -108,7 +100,7 @@ const filters = and( ); ``` -`ICondition` is open and structural: a custom condition does not need to extend `Condition`, `IFilter`, or `IFilters`, and visitor dispatch is optional. Its `seal()` method returns the `ICondition` interface for an immutable protected copy, not the concrete implementation type. A custom condition may expose its own visitor contract when useful, and it needs a parser, adapter, or other consumer that understands its semantics. Built-in adapters intentionally reject unknown condition kinds. +`ICondition` is open and structural: extending `Condition` supplies the live-condition marker, but a custom class may instead implement `ICondition` with `readonly [CONDITION_MARKER] = true`. A condition must have the marker, an `operator`, and a `value`; detached serialized data is not a live condition. Visitor dispatch is optional. A custom condition may expose its own visitor contract when useful, and it needs a parser, adapter, or other consumer that understands its semantics. Built-in adapters intentionally reject unknown condition kinds. Use `isCondition(value)` to recognize the shared live contract. `isFilter(value)` and `isFilters(value)` deliberately identify only the built-in leaf and group kinds. @@ -160,6 +152,8 @@ const query = defineQuery({ Fragments and raw input mix freely; already-built AST nodes pass through unchanged. +To override a filter default on the same field, compose the *input* with [`mergeFiltersInput`](/guide/merging-queries#mergefiltersinput-per-field-replace-before-the-query) before it becomes a fragment. It replaces per field, where an object spread would emit both notations of one field and eat a nested sibling default. + ## Next steps - [Merging & Composition](/guide/merging-queries): combining queries from multiple sources. diff --git a/packages/docs/guide/concepts.md b/packages/docs/guide/concepts.md index 221069294..40646dc3b 100644 --- a/packages/docs/guide/concepts.md +++ b/packages/docs/guide/concepts.md @@ -108,7 +108,7 @@ import { mergeQueries } from '@rapiq/core'; const query = mergeQueries(userInput, componentDefaults, appDefaults); ``` -And the server can inject conditions a client can never displace: +And the server can add a condition that remains a conjunct alongside client input: ```typescript const scoped = new Query({ diff --git a/packages/docs/guide/errors.md b/packages/docs/guide/errors.md index f1459a219..13cb13952 100644 --- a/packages/docs/guide/errors.md +++ b/packages/docs/guide/errors.md @@ -37,7 +37,7 @@ BaseError { code: ErrorCode } `MergeError` with `FIELDS_CONDITION_DISCARDED`: a fields merge collision would drop a [row-scoped visibility gate](/guide/fields#row-scoped-fields). Keep the gated query as the receiver instead of merging it underneath an ungated one. -Filters have no such code: [`merge()`](/guide/merging-queries#merge-per-field-replace) is total. A condition it cannot replace soundly (a group, a sealed condition) is and-ed in, so composition narrows instead of failing. +Filters have no such code: [`merge()`](/guide/merging-queries#filters-monotonic-conjunction) is total. It composes every predicate with ordered logical AND, so composition narrows instead of failing. ### Parse time (client input) @@ -83,7 +83,7 @@ The URL encoders throw these too; a codec never silently changes what a query me | `SCHEMA_NAME_INVALID` | `registry.add()` with a schema that has no `name` | | `SCHEMA_UNRESOLVABLE` | `registry.getOrFail()` for a name that isn't registered | | `SCHEMA_KEY_VALIDATOR_CONFLICT` | a `fields`/`relations`/`sort` sub-schema declares both [`validate` and `validateMany`](/guide/schemas#batched-validation-with-validatemany); thrown while the schema is constructed, since there is no sensible precedence between them | -| `SCHEMA_SEALED_CONDITION_PRUNED` | the [relations gate](/guide/relations#validate-hooks) rejected a relation that a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition needs; the two validators contradict each other, see [scoping a filterable field](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) | +| `SCHEMA_PRESERVED_CONDITION_PRUNED` | the [relations gate](/guide/relations#validate-hooks) rejected a relation that a [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) filter condition needs; the two validators contradict each other, see [scoping a filterable field](/guide/recipes/authorization#scoping-server-conditions) | | `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` | `parse()` (or a synchronous codec method) encountered an async validator (a filter validator or a key validation hook); use the corresponding `Async` method | | `SCHEMA_ENTITY_MISMATCH` | `assertSchemaMatchesEntity` (`@rapiq/adapter-typeorm`) found schema keys unknown to the entity; thrown as `SchemaEntityMismatchError`, which carries the offending `schema`, `entity` and `keys`; see [validating schemas against entities](/packages/adapter-typeorm#validating-schemas-against-entities) | diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index 4148fb658..96ebf9a88 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -65,7 +65,7 @@ filter[id]=1,2,3 in list filter[email]=!null not null ``` -The v2 codec continues to decode this shape for existing clients. Multiple keys combine with **and**. Scalar coercion applies on decode: `'18'` → `18`, `'true'` → `true`, `'null'` → `null`. +The v2 codec continues to decode this shape for existing clients. Multiple keys combine with **and**. Scalar coercion applies on decode: `'18'` → `18`, `'true'` → `true`, `'null'` → `null`. The deprecated simple codec rejects duplicate same-field conditions and compound filters; use the default expression codec whenever a query needs either shape. ::: info Wire strings vs. code The `!`/`<`/`~` prefixes are the *wire* format. In code, use typed operator objects (`{ age: { $gte: 18 } }`) or condition helpers, never magic strings. @@ -207,11 +207,11 @@ defineSchema({ | `validate` | Sync or async per-filter hook: inspect a parsed `Filter`, replace it with any condition, or reject it. Receives the [parse context](/guide/schemas#validate-hooks-parse-context) as its second argument. | | `caseSensitive` | Fields whose equality comparisons stay exact instead of the [case-insensitive default](#case-sensitivity). | -`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `and(filter, seal(inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. +`validate` runs after key resolution, mapping and value coercion, and receives the caller-supplied [`context`](/guide/schemas#validate-hooks-parse-context) (e.g. the authenticated actor) as its second argument. Return the original filter to accept it, another condition to replace it, or `undefined` to reject that leaf; an inspect-only hook must still `return` the filter, otherwise every leaf is rejected. The replacement may be any condition, including a compound: an authorization decision like "you may filter on `realm_id`, but only within your realms" stays attached to the leaf that triggered it, `and(filter, preserve(inArray('realm_id', actor.realmIds)))`, instead of being injected separately after the parse. `$elemMatch` conditions are validated inside-out: every interior leaf passes the hook, then the `elemMatch` filter itself. The return value may also be a Promise of any of those results. On the server, [schema-aware encoding](/packages/codec-url) re-runs the schema-bound decoder, so a validator that is not idempotent (e.g. one that appends to the value) transforms a filter twice between a schema-aware `encode()` and the receiving `decode()`: keep validators idempotent. -Two consequences of a compound replacement mirror [`and()` injection](/guide/merging-queries#and-or-wrap-inject). A later replace-merge carries the group through as one conjunct instead of displacing the injected scoping, and [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement)ing it keeps that true across a normalization pass. And the legacy simple URL dialect cannot express compounds: a schema-aware `encode()` through that dialect throws a typed `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it fine. +All filter composition is ordered logical AND, so a later merge carries the complete replacement as a conjunct and never removes it. The deprecated simple URL dialect cannot express compounds: schema-aware encoding through it throws `FEATURE_UNSUPPORTED` for a query whose validator produced one; the default expression dialect encodes it. -Seal the **residual**, not the whole group. Both shapes survive a merge, because a group is never displaced as a unit either, but the seal is what keeps the residual protected once `flatten()` hoists it into the root, and keeping the client's own leaf outside it matters for [relation pruning](/guide/relations#validate-hooks): a sealed condition is exempt from the gate, so `seal(and(filter, ...))` also protects the client leaf it wraps, and a client filtering on a relation it may not traverse then hits `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead of having that filter dropped. With `and(filter, seal(...))` the client leaf prunes away normally and only a residual that itself names a rejected relation raises the error, which is the genuine contradiction. An unsealed replacement is displaceable throughout and gets pruned like any other condition. +Preserve the **residual**, not the whole group. `preserve(and(filter, ...))` also keeps the client leaf from being pruned by a rejected relation. With `and(filter, preserve(...))`, the client leaf prunes normally and only a residual that itself needs a rejected relation raises `SchemaError` (`ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED`). That is the genuine validator contradiction. `preserve()` exists for this relation-pruning policy, not filter composition. Use the synchronous `parse()` / `decode()` / schema-aware `encode()` methods when every validator is synchronous. Use their `Async` counterparts when a validator may be asynchronous: diff --git a/packages/docs/guide/merging-queries.md b/packages/docs/guide/merging-queries.md index 761569b46..0765f9f42 100644 --- a/packages/docs/guide/merging-queries.md +++ b/packages/docs/guide/merging-queries.md @@ -1,8 +1,8 @@ # Merging & Composition -Real queries rarely come from one place. A list view combines **user input** (a search box), **component state** (the current page), **props** (a parent-imposed scope) and **application defaults**, and the server may add conditions of its own. rapiq composes all of these on the `Query` itself, so every construction path (built, decoded, parsed by any dialect) combines the same way. +Real queries rarely come from one place. A list view combines user input, component state, parent scope and application defaults. rapiq composes these values on the `Query` itself, so built, decoded and parsed queries follow the same rules. -## `mergeQueries`: left priority +## `mergeQueries`: left priority where keys conflict ```typescript import { mergeQueries } from '@rapiq/core'; @@ -10,90 +10,142 @@ import { mergeQueries } from '@rapiq/core'; const query = mergeQueries(searchQuery, paginationQuery, propsQuery, defaultsQuery); ``` -The first argument wins conflicts. All operations are **immutable**: inputs stay untouched and new instances are returned, safe for reactivity systems and shared default fragments. +All operations are immutable. Inputs stay untouched and the result is a new query when composition changes it. Per-parameter semantics: | Parameter | Rule | |---|---| -| `fields` / `relations` / `sorts` | keyed by name, left-priority replace; order = first occurrence | +| `fields` / `relations` / `sorts` | keyed left priority | | `pagination` | per-property left priority (`limit` and `offset` merge independently) | -| `filters` | per-field replace via `Filters.merge` (see below) | +| `filters` | ordered logical AND; every predicate survives | -One guard applies to `fields`: a name collision that would discard a [row-scoped visibility gate](/guide/fields#row-scoped-fields) (`Field.condition`) throws a `MergeError` (`ErrorCode.FIELDS_CONDITION_DISCARDED`) instead of silently un-gating the column. A gate only ever arises server-side, from a schema hook; keep that parsed query as the receiver and its gates survive every collision. +For fields, a name collision that would discard a [row-scoped visibility gate](/guide/fields#row-scoped-fields) (`Field.condition`) throws `MergeError` (`ErrorCode.FIELDS_CONDITION_DISCARDED`) instead of silently un-gating the column. -## Filters: two explicit operations +## Filters: monotonic conjunction -Filters get two distinct operations, because a single "merge" would silently break one of the two use cases. +`Filters.merge()` and `mergeQueries()` combine filters as an ordered logical AND. No predicate is replaced, including conditions on the same field: -### `merge()`: per-field replace +```typescript +const clientQuery = defineQuery({ filters: { age: { $gte: 18 } } }); +const serverQuery = defineQuery({ filters: { age: { $lt: 65 } } }); + +mergeQueries(clientQuery, serverQuery).filters; +// and(age >= 18, age < 65) +``` -A condition on a field **replaces** the other side's conditions on the same field (it is *not* and-ed). This is the list-view case: user search input overrides a default on the same field, while unrelated defaults survive: +The receiver's non-preserved root `and` is flattened into its conjuncts. A preserved root, an `or(...)` or `not(...)` root remains one conjunct, so its meaning stays intact. An empty side passes the other side through unchanged. + +This monotonic rule is useful for server scope: a client condition on `realm_id` can only narrow the server's `realm_id` condition, never widen or remove it. + +Complete queries are never OR-composed. When a filter needs alternatives, express them inside the filter tree with `or(...)`: ```typescript -const searchQ = defineQuery({ filters: { name: { $contains: input } } }); -const defaultsQ = defineQuery({ filters: { name: 'John', age: { $gte: 18 } } }); +import { defineQuery, eq, gte, or } from '@rapiq/core'; -mergeQueries(searchQ, defaultsQ).filters; -// name contains (from searchQ), age >= 18 (from defaultsQ) +const query = defineQuery({ + filters: or(gte('age', 18), eq('status', 'admin')), +}); ``` -Replacement applies to the **displaceable root conditions** of both sides: the leaf conditions directly under a root `and`. Everything else is inert, carried through as it is and combined with `and`: +### `mergeFiltersInput`: per-field replace, before the query -| Conjunct | Behavior in a merge | -|---|---| -| root-level leaf condition | replaced by a receiver condition on the same field | -| [sealed](#seal-conditions-that-resist-replacement) condition | never replaced, always carried through | -| nested group (`or(...)`, `not(...)`, a whole non-`and` root) | never replaced, always carried through as one conjunct | +Overriding a default on the same field is a real need, and it belongs to the **input**, before a `Query` exists. `mergeFiltersInput` composes [filter build input](/guide/building-queries#filters) with per-field replace: the first input to constrain a field wins it, and every field only one side constrains survives. ```typescript -mergeQueries(flatQ, defineQuery({ filters: or(gte('age', 18), eq('email', null)) })).filters; -// and( , or(age >= 18, email = null) ) +import { defineQuery, mergeFiltersInput, mergeQueries } from '@rapiq/core'; + +const query = mergeQueries( + defineQuery({ + filters: mergeFiltersInput( + search ? { name: { $contains: search } } : {}, // user input wins + { name: 'John', age: { $gte: 18 } }, // component defaults + ), + }), + parentScope, +); +// name contains , age >= 18 ``` -Per-field replace has no sound reading inside a disjunction: dropping a condition there would change the group non-locally, keeping it would ignore receiver priority. So the group is and-ed in untouched, which makes `merge()` **total**: it never throws. Every conjunct of the receiver survives into the result, so a merge never returns anything wider than its receiver; only the other side can lose conditions, and only to same-field replacement. Widening stays the explicit operation, `or()` below. +Replacement is per **field**, not per operator: `{ age: { $gte: 18 } }` beating `{ age: { $lt: 65 } }` yields the lower bound alone. Keeping both is conjunction, which is what merging two queries does. An `undefined` value claims no field, so a later input still supplies it, and a `$elemMatch` interior is one value, replaced whole. -An empty side passes the other side through unchanged. +Because both [notations](/guide/building-queries#filters) address the same fields, inputs are reduced to canonical dotted paths first. That is the difference from an object spread, which sees only keys: -### `seal`: conditions that resist replacement +```typescript +// spread: two conditions on one field, and a lost sibling default +{ ...{ 'realm.name': 'a', 'realm.id': 1 }, ...{ realm: { name: 'b' } } } +// -> realm.name = 'a' AND realm.name = 'b' (realm.id survives by luck) +{ ...{ realm: { name: 'a', id: 1 } }, ...{ realm: { name: 'b' } } } +// -> realm.name = 'b' (realm.id silently gone) + +mergeFiltersInput({ realm: { name: 'b' } }, { realm: { name: 'a', id: 1 } }); +// -> { 'realm.name': 'b', 'realm.id': 1 } +``` -A **sealed** condition is never displaced by a merge and never collapsed into its parent group by `flatten()`. It is what `and()` / `or()` apply to everything they inject, and it is available on its own: +The result is the flat notation, itself valid input for `defineFilters` or another merge. -```typescript -import { eq, seal } from '@rapiq/core'; +::: tip Why replace is safe here and not on the query +`mergeFiltersInput` accepts build input only. Passing a condition (`eq(...)`, `and(...)`) is a type error and throws at runtime. A build input is plain data and can never carry a server-authored scope, so per-field replace cannot displace one. That is exactly what it could do on the IR, which is why [`Filters.merge`](#filters-monotonic-conjunction) is conjunction instead. +::: -const scope = seal(eq('realm_id', actor.realmId)); -``` +### Replacing a whole filters node -Sealing is immutable (a sealed copy is returned) and idempotent. It is a **server-side composition marker, not wire grammar**: a sealed condition that is encoded to a URL and decoded again comes back displaceable, which is why a receiving service re-injects its own scope instead of trusting the transport. +When the alternatives are already AST nodes, select between them before query composition. Select between the values **one control** can hold: -Every live `ICondition` owns this operation through `seal()`. The standalone helper and `Filters.and()` / `Filters.or()` call that method polymorphically, so custom structural conditions receive the same non-displaceability guarantee as built-in leaves and groups. +```typescript +// a status control: `defaultStatus` is what it shows until the user picks. +const defaultStatus = defineFilters({ status: 'active' }); +const currentStatus = status ? + defineFilters({ status }) : + undefined; + +const query = mergeQueries( + defineQuery({ filters: currentStatus ?? defaultStatus }), + parentScope, + defaults, +); +``` -`ICondition` describes a live AST object, not serialized `{ operator, value }` data: it requires an immutable `seal()` operation. After JSON, RPC, or cache transport, parse, decode, or rebuild the condition before composition. Runtime data admitted through JavaScript, `any`, or an explicit cast is left unchanged when no callable `seal` exists; a built-in adapter rejects it rather than silently dropping it. A live custom condition is also rejected unless the consumer understands that custom kind. +Putting `currentStatus` and `defaultStatus` in separate queries passed to `mergeQueries` would conjunct them. It does not replace the default. -The marker protects the whole subtree it heads, and it holds during parsing too: the [relations gate](/guide/relations#validate-hooks), which drops every key traversing a rejected relation, cannot silently prune a condition out of a sealed group. A rejected relation that a sealed condition needs throws `SchemaError` (`ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED`) instead. Subtree-wide protection is why a [policy residual](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace) seals the *scope* rather than the group around it: sealing the group would protect the client's own condition too. +::: warning Select between alternatives, never across them +Both branches of the selection must answer the same question. A baseline on an unrelated field is not an alternative to a status choice: fold `age >= 18` into `defaultStatus` and it disappears the moment the user touches the control. Keep such a baseline in its own query, where it merges in unconditionally, or express the override with `mergeFiltersInput` above, which is per field and has no such trap. +::: -### `and()` / `or()`: wrap & inject +### `and()` / `or()`: build a condition tree -Always defined, for combining condition trees. The injected conditions are sealed, so they are part of the tree rather than candidates for replacement. A receiver already carrying that operator contributes its own conditions to the group (which keeps them mergeable); anything else becomes a child of the new group. Calling `and()` / `or()` with no conditions returns the receiver unchanged. +`and()` and `or()` wrap condition trees immutably. Calling either method with no conditions returns the receiver unchanged. ```typescript import { Query, eq } from '@rapiq/core'; -// server-side, after parsing client input: const scoped = new Query({ ...query, filters: query.filters.and(eq('realm_id', actor.realmId)), }); ``` -The adapter output now contains the injected condition regardless of what the client sent, even when the client sent no filters at all. And because the injected condition is sealed, no later composition can drop it: a `merge()` carries it through and a client condition on the same field narrows within it, while `flatten()` and any other normalization leave the marker intact. That guarantee is unconditional, and it is a feature: injected security conditions cannot be merged away. See the [authorization recipe](/guide/recipes/authorization). +The scope is part of the resulting AND tree regardless of client input. A later query merge adds more conjuncts, so security scopes cannot be merged away. + +## Preservation is for relation pruning + +`preserve()` is not a merge or override tool. It marks a built-in condition subtree for the relation-pruning policy. If a relation validator rejects a relation that a preserved condition needs, parsing raises `SchemaError` (`ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED`) rather than silently changing the policy's meaning. + +Use it for a validator residual that must remain intact, and preserve the residual only: + +```typescript +import { and, inArray, preserve } from '@rapiq/core'; + +return and(filter, preserve(inArray('realm_id', actor.realmIds))); +``` + +Do not preserve the group containing the client filter. The client leaf must remain eligible for relation pruning; preserving only the server residual keeps the error reserved for a genuine contradiction in the residual itself. See [Relations](/guide/relations#validate-hooks) and [Authorization](/guide/recipes/authorization#scoping-server-conditions). ## Why not deep-merge input objects? -Generic object merging (smob, deepmerge, spread) on query *input* cannot express these semantics: same-field replace, null-preserving `in` arrays, injected conditions that resist displacement. Merge on the query instead: build fragments with [`defineQuery` / `define*`](/guide/building-queries#fragments), compose with `mergeQueries`, then encode or execute the result. +Generic object merging cannot express the AST rules: keyed left priority for fields, relations and sorts; per-property pagination; and ordered conjunction for filters. Build fragments with [`defineQuery` / `define*`](/guide/building-queries#fragments), compose them with `mergeQueries`, then encode or execute the result. ## Next steps -- [Recipes: Type-safe frontend queries](/guide/recipes/frontend): merging user input, props and defaults in a list view. -- [Recipes: Authorization & scoping](/guide/recipes/authorization): `and()` injection end-to-end. +- [Recipes: Type-safe frontend queries](/guide/recipes/frontend): UI state, scope and defaults. +- [Recipes: Authorization & scoping](/guide/recipes/authorization): server conditions and validator residuals. diff --git a/packages/docs/guide/recipes/authorization.md b/packages/docs/guide/recipes/authorization.md index 8536eb761..7a5a9a118 100644 --- a/packages/docs/guide/recipes/authorization.md +++ b/packages/docs/guide/recipes/authorization.md @@ -41,10 +41,10 @@ Hooks run against the **target schema** of each path segment (`include=client.re The `relations` hook is not scoped to `include=`. A dotted `filter[client.name]`, `fields[client]` or `sort=client.name` forces the same join, so the hook runs for those too, evaluated once per distinct relation across the query. An actor denied `client` cannot slip the join in through another parameter. ::: warning Authorization happens at the parse/decode boundary -The gate runs while **parsing untrusted input**: `parse()` / `decode()` and the per-parameter `decode*` helpers. It is a property of that boundary, not of the `Query` object itself: a query you assemble server-side with the schema-free [build layer](/guide/building-queries) (`defineQuery`, condition helpers) and hand straight to an adapter is **not** re-checked; the adapter joins what the query names. Feed client input through a parser/codec with a schema and `context`; keep server-authored queries (and schema `default`s) as the trusted baseline. See [Scoping](#scoping-inject-conditions-the-client-cannot-displace) for pinning server conditions the client cannot displace. +The gate runs while **parsing untrusted input**: `parse()` / `decode()` and the per-parameter `decode*` helpers. It is a property of that boundary, not of the `Query` object itself: a query you assemble server-side with the schema-free [build layer](/guide/building-queries) (`defineQuery`, condition helpers) and hand straight to an adapter is **not** re-checked; the adapter joins what the query names. Feed client input through a parser/codec with a schema and `context`; keep server-authored queries (and schema `default`s) as the trusted baseline. See [Scoping](#scoping-server-conditions) for adding server conditions. ::: -## Scoping: inject conditions the client cannot displace +## Scoping: server conditions After decoding the client's query, wrap its filters with your scope condition via `and()`: @@ -68,17 +68,17 @@ app.get('/users', async (req, res) => { }); ``` -Why `and()` and not a merge? `and()` injects the condition **sealed**, which marks it undisplaceable on the node itself: +Why `and()` and not a merge? Both produce conjunction, but `and()` makes the intent direct: - A client filter on `realm_id` narrows *within* the scope; it can never widen it. -- A later [`merge()`](/guide/merging-queries#merge-per-field-replace) carries the sealed condition through instead of replacing it, and `flatten()` (or any other normalization) leaves the marker in place. The guarantee is a property of the condition, not of the tree shape it happens to sit in, so it holds unconditionally: even when the client sent no filters at all. +- A later [`merge()`](/guide/merging-queries#filters-monotonic-conjunction) adds predicates by ordered logical AND. It cannot remove the scope, even if the client sent no filters at all. Belt and suspenders: also leave `realm_id` out of the schema's `filters.allowed` list, and clients can't even *mention* it. -When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `and(filter, seal(inArray('realm_id', actor.realmIds)))` and the policy residual stays attached to the leaf that triggered it, with the same displacement-proof effect on later merges. The [`seal`](/guide/merging-queries#seal-conditions-that-resist-replacement) is what makes the residual survive composition; without it the group is still never *replaced*, but a normalization pass may hoist its conditions into the root, where an unmarked residual becomes replaceable again. +When the scope belongs to one *filterable* field rather than the whole query ("you may filter on `realm_id`, but only within your realms"), the filters [`validate` hook](/guide/filters#schema-options) can express it in place: return `and(filter, preserve(inArray('realm_id', actor.realmIds)))`. The policy residual stays attached to the leaf that triggered it. `preserve()` is not needed for merge safety because every merge is conjunctive; it is needed only when the relations validator must reject a pruning contradiction. -::: tip Seal the residual, not the group -`seal(and(filter, ...))` protects the same residual, and additionally protects the client leaf it wraps, which is not something you want. A sealed condition is exempt from the relations gate above, so that shape turns "this actor may not traverse `realm`" into a thrown `SchemaError` the moment the client filters on `realm.name`, instead of simply dropping that filter. Sealing only the residual keeps the client's leaf prunable and reserves the error for the real contradiction: a residual that itself names a relation the gate rejects. Scoping through a local column (`realm_id`) never traverses a relation, so it never reaches the gate at all. +::: tip Preserve the residual, not the group +`preserve(and(filter, ...))` also protects the client leaf it wraps, which is not wanted. A preserved condition is exempt from relation pruning, so that shape turns "this actor may not traverse `realm`" into a thrown `SchemaError` when the client filters on `realm.name`, instead of dropping that client filter. Preserving only the residual keeps the client leaf prunable and reserves the error for a real contradiction: a residual that itself names a rejected relation. Scoping through a local column (`realm_id`) never traverses a relation, so it never reaches the gate. ::: ## Row-scoped column access @@ -188,5 +188,5 @@ compiled.apply(users); // apply the full query to loaded data ## Next steps -- [Merging & Composition](/guide/merging-queries): why injected trees resist merging. +- [Merging & Composition](/guide/merging-queries): conjunctive query composition and pruning preservation. - [@rapiq/adapter-memory](/packages/adapter-memory): the full in-memory semantics contract. diff --git a/packages/docs/guide/recipes/express-typeorm.md b/packages/docs/guide/recipes/express-typeorm.md index 59a982201..62ff39344 100644 --- a/packages/docs/guide/recipes/express-typeorm.md +++ b/packages/docs/guide/recipes/express-typeorm.md @@ -118,6 +118,6 @@ GET /users?filter[secret]=x 400: key not allowed ## Variations -- **Scope by the authenticated user**: inject filters the client cannot displace; see [Authorization & Scoping](/guide/recipes/authorization). +- **Scope by the authenticated user**: add a server filter conjunct; see [Authorization & Scoping](/guide/recipes/authorization). - **No TypeORM**: the next chapter, [Swapping the Backend: Prisma & Drizzle](/guide/recipes/prisma-drizzle), keeps the contract, codec and routes of this recipe and changes only the execute layer. For raw SQL, swap the adapter for [`@rapiq/adapter-sql`](/packages/adapter-sql) fragments; the decode half stays identical either way. - **Migrate old clients gradually**: the codec writes expressions but continues to recognize legacy bracket-filter input; see [Queries over the Wire](/guide/wire). diff --git a/packages/docs/guide/recipes/frontend.md b/packages/docs/guide/recipes/frontend.md index 560e1b6ff..3f505c792 100644 --- a/packages/docs/guide/recipes/frontend.md +++ b/packages/docs/guide/recipes/frontend.md @@ -2,7 +2,7 @@ *Chapter 2 of the [recipes storyline](/guide/recipes/): the client layer of the realm/user API, where queries are built, composed and encoded. Next: the server baseline in [REST API with Express & TypeORM](/guide/recipes/express-typeorm).* -A list component typically owes its query to three sources at once: **defaults** it ships with, a **scope** its parent imposes via props, and **user input** from search/sort/pagination controls. This recipe composes the three declaratively: typed against the record, merged by rank, encoded on demand. +A list component typically owes its query to three sources at once: **defaults** it ships with, a **scope** its parent imposes via props, and **user input** from search/sort/pagination controls. This recipe composes the three declaratively: typed against the record, merged with explicit parameter rules, encoded on demand. Works the same in Vue, React or plain TypeScript; rapiq has no framework dependency. @@ -31,9 +31,11 @@ import type { User } from 'my-api-types'; const codec = createURLCodec(); -// 1. component defaults: shipped with the list +// 1. component defaults: shipped with the list. A filter baseline belongs +// here, where it merges in on every request. const defaults = defineQuery({ fields: ['id', 'name', 'email'], + filters: { age: { $gte: 18 } }, sort: '-id', pagination: { limit: 25 }, }); @@ -47,11 +49,13 @@ function scopeFor(realmId: string) { // 3. user input: from the search box & pager function buildQuery(realmId: string, search: string, page: number) { const userInput = defineQuery({ - filters: { name: { $contains: search || undefined } }, + // an empty search box contributes no condition; a filled one is + // and-ed alongside the baseline instead of replacing it. + filters: search ? { name: { $contains: search } } : undefined, pagination: { offset: (page - 1) * 25 }, }); - // left wins: user input > scope > defaults + // keyed parameters use left priority; filters become an ordered AND return mergeQueries(userInput, defineQuery({ filters: scopeFor(realmId) }), defaults); } @@ -64,10 +68,12 @@ async function fetchUsers(realmId: string, search: string, page: number) { Three details doing quiet work here: -- `{ $contains: search || undefined }`: an `undefined` operator value contributes **no condition**, so the empty search box simply doesn't filter. No `if`-shuffling around the query object. -- The merge is **per-field** for filters: the user's `name` filter never disturbs the scope's `realm.id` condition, and both survive alongside the defaults' sort and fields. +- `search ? … : undefined` contributes **no condition** for an empty box, so there is no `if`-shuffling around the query object. +- Filters merge as an **ordered logical AND**: the user's `name` filter, the scope's `realm.id` condition and the `age` baseline all survive. Fields and sort retain keyed left priority. - Everything is **immutable**: merging never mutates its inputs, so `defaults` is safe as a module constant and fragments like the realm scope are safe to pass around as props. +Once filters are part of a query, composition intentionally retains every predicate. When user input has to *replace* a default on the same field rather than narrow it, compose the **input** with [`mergeFiltersInput`](/guide/merging-queries#mergefiltersinput-per-field-replace-before-the-query) before calling `defineQuery`. It replaces per field, so a baseline like `age >= 18` survives a search the user types. + ## Framework flavors ::: code-group diff --git a/packages/docs/guide/relations.md b/packages/docs/guide/relations.md index 516b7aad6..98195338e 100644 --- a/packages/docs/guide/relations.md +++ b/packages/docs/guide/relations.md @@ -92,9 +92,9 @@ Two rules make the gate hard to bypass: - **Traversal counts.** The hook also gates relations reached through dotted [filter](/guide/filters), [field](/guide/fields) and [sort](/guide/sort) keys (`filter[items.id]`, `fields[items]`, `sort=items.name`), which the backends would otherwise auto-join. It runs **once per distinct relation** across the whole query, so a join has a single authorization point regardless of which parameter forced it. - **Rejection prunes deep.** Rejecting a relation drops every deeper relation reached through it and every dependent key in every parameter, at parse time; the pruned branch never enters the AST. Under [`throwOnFailure`](/guide/schemas#failure-behavior-drop-vs-throw) the rejection throws a `RelationsParseError` (`ErrorCode.KEY_VALIDATE_REJECTED`) instead. -One thing pruning never drops: a [sealed](/guide/merging-queries#seal-conditions-that-resist-replacement) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) sealed a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a *wider* one for the usual `and(, )` residual), keeping it joins a relation the gate refused. Parsing therefore throws a `SchemaError` with `ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED` rather than picking one. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-inject-conditions-the-client-cannot-displace). +One thing pruning never drops: a [`preserve()`](/guide/merging-queries#preservation-is-for-relation-pruning) filter condition. If a filters [`validate` hook](/guide/filters#schema-options) preserves a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a *wider* one for the usual `and(, )` residual), keeping it joins a relation the gate refused. Parsing therefore throws a `SchemaError` with `ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED` rather than picking one. That is a server misconfiguration, not client input: see [Authorization](/guide/recipes/authorization#scoping-server-conditions). -A schema `default` filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Sealing a default opts it back in, since a seal asserts the same must-survive contract as a residual and raises the same error. +A schema `default` filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Preserving a default opts it back into the contradiction check, since it asserts the same must-survive contract as a residual and raises the same error. ## Interaction with other parameters diff --git a/packages/docs/packages/codec-url.md b/packages/docs/packages/codec-url.md index 4bbc051cc..45e09ba26 100644 --- a/packages/docs/packages/codec-url.md +++ b/packages/docs/packages/codec-url.md @@ -74,7 +74,7 @@ codec.encode(query, { codec: URL_SIMPLE_CODEC }); // codec=url-simple&filter[age]=>=18 ``` -The simple dialect supports flat root-AND filters only. Nested groups, repeated conditions on one field, and values that collide with its marker grammar throw during encoding. +The deprecated simple dialect supports flat root-AND filters only. Duplicate same-field conditions, compound filters, and values that collide with its marker grammar throw during encoding. ## Shared URL parameters diff --git a/packages/docs/packages/core.md b/packages/docs/packages/core.md index b06b3b503..aa8bbd9f9 100644 --- a/packages/docs/packages/core.md +++ b/packages/docs/packages/core.md @@ -11,9 +11,9 @@ npm install @rapiq/core | Area | Exports (selection) | Guide | |---|---|---| | **Query AST** | `Query`, `Fields`/`Field`, `Filters`/`Filter`, `Relations`/`Relation`, `Sorts`/`Sort`, `Pagination`, operator constants (`FilterFieldOperator`, `FilterCompoundOperator`, `FieldOperator`, `SortDirection`, `Parameter`), visitor interfaces (`IQueryVisitor`, `IFiltersVisitor`, …), `hasFieldConditions` | [The Query AST](/guide/query-ast) | -| **Build layer** | `defineQuery`, `defineFields`, `defineFilters`, `definePagination`, `defineRelations`, `defineSorts` | [Building Queries](/guide/building-queries) | -| **Condition helpers** | `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `inArray`, `nin`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, `contains`, `notContains`, `regex`, `mod`, `size`, `exists`, `elemMatch`, `and`, `or`, `not`, `seal` | [Condition helpers](/guide/building-queries#condition-helpers) | -| **Composition** | `mergeQueries`, `seal` (or `.seal()` on a node), `Filters.merge` / `.and` / `.or` | [Merging & Composition](/guide/merging-queries) | +| **Build layer** | `defineQuery`, `defineFields`, `defineFilters`, `definePagination`, `defineRelations`, `defineSorts`, `mergeFiltersInput` | [Building Queries](/guide/building-queries) | +| **Condition helpers** | `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `inArray`, `nin`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, `contains`, `notContains`, `regex`, `mod`, `size`, `exists`, `elemMatch`, `and`, `or`, `not`, `preserve` | [Condition helpers](/guide/building-queries#condition-helpers) | +| **Composition** | `mergeQueries`, `Filters.merge` / `.and` / `.or`; `mergeFiltersInput` for per-field replace on build input; `preserve` for relation-pruning policy | [Merging & Composition](/guide/merging-queries) | | **Plan layer** | `planCondition`, `interpretPlan`, `distributeNegation`, `IPlanInterpreter`, `FILTER_OPERATOR_SEMANTICS`, plan types (`ConditionPlan`, `ComparePlan`, `CompoundPlan`, …) | [The visitor pattern](/guide/query-ast#the-visitor-pattern) | | **Schema system** | `defineSchema`, `Schema`, `SchemaRegistry`, per-parameter `define*Schema` factories, `ResolutionScope`, `Schema.describe()` | [Schemas & Validation](/guide/schemas) | | **Parser base** | `BaseParser`, per-parameter parse-option types | [Custom parsers](/guide/query-ast#writing-a-custom-parser-resolutionscope) | diff --git a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts index f2dbcfea4..4d68747f0 100644 --- a/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts +++ b/packages/parser-simple/test/unit/parser/relations-traversal.spec.ts @@ -13,7 +13,7 @@ import { and, defineSchema, eq, - seal, + preserve, } from '@rapiq/core'; import type { ICondition, @@ -323,7 +323,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { * wider result set than the policy allows, keeping it would join a rejected * relation. The contradiction is a server misconfiguration, so it throws. */ - describe('sealed policy residuals vs. relation pruning (#877)', () => { + describe('preserved policy residuals vs. relation pruning (#877)', () => { function buildScopedRegistry( residual: (filter: IFilter) => ICondition, relationsValidate: (name: string) => boolean, @@ -349,11 +349,11 @@ describe('relations.validate for traversed relation paths (#815)', () => { return registry; } - const sealedResidual = (filter: IFilter) => seal(and(filter, eq('realm.id', 'SCOPE'))); + const preservedResidual = (filter: IFilter) => preserve(and(filter, eq('realm.id', 'SCOPE'))); const input = { filters: { name: 'John', 'realm.name': 'master' } }; it('keeps the residual when the relations hook permits the relation', () => { - const parser = new SimpleParser(buildScopedRegistry(sealedResidual, () => true)); + const parser = new SimpleParser(buildScopedRegistry(preservedResidual, () => true)); const query = parser.parse(input, { schema: 'user', context: actor }); expect(filterFields(query.filters)).toEqual(['name', 'realm.id', 'realm.name']); @@ -361,7 +361,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { it('throws instead of pruning the residual when the relation is rejected', () => { const parser = new SimpleParser(buildScopedRegistry( - sealedResidual, + preservedResidual, (name) => name !== 'realm', )); @@ -370,39 +370,39 @@ describe('relations.validate for traversed relation paths (#815)', () => { parser.parse(input, { schema: 'user', context: actor }); } catch (e) { expect(e).toBeInstanceOf(SchemaError); - expect((e as SchemaError).code).toEqual(ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED); + expect((e as SchemaError).code).toEqual(ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED); } }); it('throws on the async path too', async () => { const parser = new SimpleParser(buildScopedRegistry( - sealedResidual, + preservedResidual, (name) => name !== 'realm', )); await expect(parser.parseAsync(input, { schema: 'user', context: actor })) - .rejects.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + .rejects.toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); it('throws for a standalone filters parse', () => { - const registry = buildScopedRegistry(sealedResidual, (name) => name !== 'realm'); + const registry = buildScopedRegistry(preservedResidual, (name) => name !== 'realm'); expect(() => new SimpleFiltersParser(registry).parse( input.filters, { schema: 'user', context: actor }, - )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); /** - * The shape the docs recommend: seal the residual, leave the client's - * own leaf outside it. Both shapes resist a merge, but this one keeps - * the client leaf prunable, so the gate stays a drop for the client and - * the error stays reserved for a residual that itself names a rejected - * relation. + * The shape the docs recommend: preserve the residual, leave the client's + * own leaf outside it. Conjunctive composition retains both conditions, + * while this shape keeps the client leaf prunable, so the gate stays a + * drop for the client and the error stays reserved for a residual that + * itself names a rejected relation. */ - describe('seal the residual, not the group', () => { - const localResidual = (filter: IFilter) => and(filter, seal(eq('realm_id', 'SCOPE'))); - const relationResidual = (filter: IFilter) => and(filter, seal(eq('realm.id', 'SCOPE'))); + describe('preserve the residual, not the group', () => { + const localResidual = (filter: IFilter) => and(filter, preserve(eq('realm_id', 'SCOPE'))); + const relationResidual = (filter: IFilter) => and(filter, preserve(eq('realm.id', 'SCOPE'))); function buildLocalRegistry(residual: (filter: IFilter) => ICondition) : SchemaRegistry { const registry = new SchemaRegistry(); @@ -428,7 +428,7 @@ describe('relations.validate for traversed relation paths (#815)', () => { expect(filterFields(query.filters)).toEqual(['realm_id']); }); - it('keeps the residual sealed, so a later merge cannot displace it', () => { + it('keeps the residual preserved through normalization', () => { const parser = new SimpleParser(buildLocalRegistry(localResidual)); const query = parser.parse({ filters: { name: 'John' } }, { schema: 'user', context: actor }); @@ -436,9 +436,9 @@ describe('relations.validate for traversed relation paths (#815)', () => { const residual = group.value[1] as IFilter; expect(residual.field).toBe('realm_id'); - expect(residual.sealed).toBe(true); + expect(residual.preserved).toBe(true); // and it stays marked once normalization hoists it into the root - expect(query.filters.flatten().value.some((c) => (c as IFilter).sealed)).toBe(true); + expect(query.filters.flatten().value.some((c) => (c as IFilter).preserved)).toBe(true); }); it('still throws when the residual itself names the rejected relation', () => { @@ -447,12 +447,12 @@ describe('relations.validate for traversed relation paths (#815)', () => { expect(() => parser.parse( { filters: { name: 'John', 'realm.name': 'master' } }, { schema: 'user', context: actor }, - )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_SEALED_CONDITION_PRUNED })); + )).toThrowError(expect.objectContaining({ code: ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED })); }); }); - it('prunes an UNSEALED residual, which is what the seal marks', () => { - // without seal() the residual is an ordinary displaceable condition: + it('prunes an unpreserved residual, which is what preserve marks', () => { + // without preserve() the residual is an ordinary condition: // pruning drops it like any other client-owned leaf. const parser = new SimpleParser(buildScopedRegistry( (filter) => and(filter, eq('realm.id', 'SCOPE')),