Skip to content

Core Concepts

rapiq has four moving parts — Query, Schema, Parser, Adapter. Everything else in the package family is a variation of one of them. Understanding these four once makes every other page in these docs a detail.

Everything meets in the middle:

produce
defineQuerytyped input, condition helpers
URL codecsdecode query strings
Parserssimple · expression · mongo
Schemaallow-list — constrains parsing
Querythe shared AST
consume
@rapiq/typeormSelectQueryBuilder
@rapiq/sqlparameterized fragments
@rapiq/memorycompiled functions
URL codecsencode for transport

Query — the shared language

A Query is a small, typed object tree (an AST) with one collection per parameter:

typescript
class Query {
    readonly fields: IFields;         // which fields to return
    readonly filters: IFilters;       // and/or tree of conditions
    readonly relations: IRelations;   // which related resources to load
    readonly sorts: ISorts;           // order keys + direction
    readonly pagination: IPagination; // { limit, offset }
}

It is the only thing the packages share — parsers produce it, codecs transport it, schemas constrain it, adapters consume it. That single meeting point is what makes the pieces swappable: the TypeORM adapter neither knows nor cares whether a query arrived as a URL, an expression string, a MongoDB-style document or was built in code.

There are three ways to obtain one:

You have…UseWhere
Typed input in codedefineQuery + condition helperscaller side
Raw wire inputa parser or URL decoderreceiver side
Special needshand-assembled AST nodesrarely

One query, four notations

All of these produce the same Query:

typescript
defineQuery<User>({
    filters: { age: { $gte: 18 }, name: { $contains: 'jo' } },
});
txt
filter[age]=>=18&filter[name]=~jo~
txt
and(gte(age, '18'), contains(name, 'jo'))
typescript
{ filters: { age: { $gte: 18 }, name: { $contains: 'jo' } } }

Dialects are input syntax only; semantics live in the AST.

Schema — the server's contract

A Schema<RECORD> declares what a client may request, per parameter — allowed keys, defaults, alias mappings, a pagination cap:

typescript
const userSchema = defineSchema<User>({
    name: 'user',
    fields: { allowed: ['id', 'name', 'email'], default: ['id', 'name'] },
    filters: { allowed: ['id', 'name', 'age'] },
    relations: { allowed: ['realm'] },
    sort: { allowed: ['id', 'age'], default: { id: 'DESC' } },
    pagination: { maxLimit: 50 },
    schemaMapping: { realm: 'realm' },
});

Parsers consult the schema while parsing: disallowed input is dropped by default, or throws when throwOnFailure is set. A SchemaRegistry stores schemas by name so relation paths (realm.name) validate against the related record's schema. Details in Schemas & Validation.

Parser — from input to Query

Parsers turn raw, untrusted input into a validated Query. Each speaks one input dialect:

  • SimpleParser — plain objects/arrays, URL-query-like ({ age: '>=18' })
  • ExpressionParser — expression strings (and(eq(name, 'John'), gte(age, '18')))
  • MongoParser — MongoDB-style documents ({ age: { $gte: 18 } })

Parsers are transport-agnostic: they read canonical parameter keys (fields, filters, …) and don't know about URLs. Codecs wrap them for a concrete transport — URLDecoder maps URL wire names (filter, page, include, …) onto a parser, and URLEncoder does the reverse trip. One codec pair per wire dialect.

Adapter — from Query to results

Adapters execute a Query against a backend:

  • @rapiq/typeorm — mutates a TypeORM SelectQueryBuilder
  • @rapiq/sql — renders parameterized SQL fragments for any driver
  • @rapiq/memory — compiles the query into plain functions over in-memory data

They all consume the AST through the visitor pattern: every node has accept(visitor), and a backend implements visitor interfaces for the nodes it cares about. New backends never require changes to core — see The Query AST if you want to build one.

Composition — queries are values

Because queries are plain immutable-ish values, they compose after construction, regardless of where each part came from:

typescript
import { mergeQueries } from '@rapiq/core';

const query = mergeQueries(userInput, componentDefaults, appDefaults);

And the server can inject conditions a client can never displace:

typescript
const scoped = new Query({
    ...query,
    filters: query.filters.and(eq('realm_id', actor.realmId)),
});

Both patterns are covered in Merging & Composition.

Where each package sits

StagePackages
Build@rapiq/core (defineQuery, helpers)
Transport@rapiq/codec-url-simple, @rapiq/codec-url-expression, @rapiq/codec-url
Parse & validate@rapiq/parser-simple, @rapiq/parser-expression, @rapiq/parser-mongo + Schema from core
Execute@rapiq/typeorm, @rapiq/sql, @rapiq/memory

Next steps

Follow the pipeline in order:

  1. Building Queries
  2. Schemas & Validation
  3. Queries over the Wire
  4. Executing Queries

Released under the MIT License.