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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RECORD>(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(<leaf>, seal(<scope>))` 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<RECORD>(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(<leaf>, preserve(<scope>))`: 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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.

Expand Down
4 changes: 2 additions & 2 deletions .agents/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document NOT in the filters tree.

.agents/architecture.md and packages/core/README.md describe not as a supported filter operation. This entry still says compound and/or, so the package summary is incomplete. Change it to compound and/or/not or mark the list as non-exhaustive.

As per coding guidelines, keep this file and all corresponding files in .agents updated as the project evolves.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/structure.md at line 55, Update the filters tree entry in
structure.md to include NOT alongside compound AND/OR filters, using “compound
and/or/not” or an explicitly non-exhaustive description. Keep the summary
consistent with the documented filter operations in architecture.md and
packages/core/README.md.

Source: Coding guidelines

│ │ └── 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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>` 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.
Expand Down
4 changes: 2 additions & 2 deletions packages/adapter-prisma/test/unit/acceptance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions packages/adapter-typeorm/test/unit/acceptance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
17 changes: 7 additions & 10 deletions packages/codec-url/test/unit/acceptance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Log>('node_id', 'node-1')).merge(query.filters),
Expand All @@ -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,
Expand All @@ -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(
Expand Down
20 changes: 6 additions & 14 deletions packages/codec-url/test/unit/simple-roundtrip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import {
AdapterError,
CONDITION_MARKER,
Condition,
ErrorCode,
Field,
Fields,
Expand Down Expand Up @@ -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<R>(visitor: IFilterVisitor<R>) : R {
return visitor.visitFilter(output);
},
seal() : IFilter {
return output;
},
};

return output;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>` 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

Expand All @@ -59,7 +59,7 @@ const query = defineQuery<User>({
});
```

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

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/build/parameter/filters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
* view the LICENSE file that was distributed with this source code.
*/

export * from './merge';
export * from './module';
export * from './types';
Loading
Loading