Skip to content

Building Queries

defineQuery builds a Query directly from typed input — no string round-trip, no parsing, no schema. It is the caller-side entry point: construct the query, hand it to the URL codec for transport, or to an adapter directly.

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

const query = defineQuery<User>({
    fields: ['id', 'name'],
    filters: { name: { $contains: 'jo' }, 'realm.id': [1, null] },
    relations: ['realm'],
    sort: '-created_at',
    pagination: { limit: 10 },
});

Supplying a record generic (defineQuery<User>) types every field path ('realm.name', …) with autocomplete; without one, plain strings are accepted.

No validation here

The build layer constructs the query verbatim. What a client may request is decided on the receiving side, where parsers validate against a Schema. Keeping the two concerns apart is deliberate: the caller doesn't need the schema, and the receiver never trusts the caller.

Filters

Four equivalent notations, freely mixable:

NotationExampleMeaning
scalar{ name: 'John' }equals
bare array{ 'realm.id': [1, null] }in list — null is a legal element; backends rewrite it to … OR IS NULL
operator object{ age: { $gte: 18, $lt: 65 } }explicit operators, combined with and
condition helpersor(gte('age', 18), eq('deleted_at', null))arbitrary condition trees

Multiple keys combine with and (a flat root-AND). Nested records ({ realm: { name: 'master' } }) and dot-paths ({ 'realm.name': 'master' }) are interchangeable. A bare RegExp value builds a regex condition.

Operator objects

One key per filter operator, prefixed with $:

$eq $ne $lt $lte $gt $gte $in $nin $startsWith $notStartsWith $endsWith $notEndsWith $contains $notContains $regex $mod $exists $elemMatch

Most take the field's value type ($eq/$ne also accept null, $in/$nin take arrays, the string operators take strings). The remaining value shapes:

typescript
defineQuery<User>({
    filters: {
        name: { $regex: /^jo/i },              // RegExp or pattern string
        age: { $mod: [4, 0] },                 // [divisor, remainder]
        email: { $exists: true },              // boolean
        items: {                               // match array elements;
            $elemMatch: { name: 'chess' },     // field paths are relative
        },                                     // to the element
    },
});

Operator keys that are present but undefined are skipped — conditional spreads like { $contains: search || undefined } simply contribute no condition. Unknown $ keys throw a BuildError (ErrorCode.OPERATOR_UNSUPPORTED) — input is never guessed at.

Reserved: $and / $or

Compound object keys belong to the MongoDB-style parser dialect and are deliberately not part of the build layer. Compound trees are written with the condition helpers instead: filters: or(...).

Condition helpers

Typed constructors for single conditions and compound trees — one per operator, mirroring the expression dialect one-to-one (eq('name', 'John') in code ≙ eq(name, 'John') on the wire):

typescript
import { and, eq, gte, inArray, or } from '@rapiq/core';

const query = defineQuery<User>({
    filters: and(
        eq('name', 'John'),
        or(gte('age', 18), eq('deleted_at', null)),
    ),
});

eq ne lt lte gt gte inArray nin startsWith notStartsWith endsWith notEndsWith contains notContains regex mod exists elemMatch — plus and / or compounds.

Three helpers deviate from the uniform (field, value) signature:

typescript
mod('age', 4, 0);                    // (field, divisor, remainder)
exists('email');                     // (field, value = true)
elemMatch('items', eq('name', 'x')); // (field, condition) — condition
                                     // field paths are element-relative

inArray

in is a reserved word in JavaScript, so the IN helper is named inArray. On the wire (expression dialect) the keyword stays in; nin is unaffected.

Like defineQuery, every helper accepts a record generic for typed field paths: eq<User>('realm.name', 'master').

Compound trees on the wire

The simple URL dialect can only express flat root-AND filter sets. Encoding a query with or(...) or nested groups throws a typed error instead of silently flattening — use the expression codec when compound trees must cross the URL boundary. Adapters consume compound trees natively. See Queries over the Wire.

Other parameters

ParameterInput forms
fieldsarray of keys with optional +/- prefix (['id', '+email', '-password']), per-relation record ({ realm: ['id'] }), or tuple [keys, record] — see Fields
sortkey with optional - prefix ('-created_at'), array of such keys, or record ({ created_at: 'DESC', realm: { name: 'ASC' } }) — see Sort
relationsdot-path names (['realm', 'items.user']) or record ({ realm: true, items: { user: true } }) — see Relations
pagination{ limit?, offset? } — see Pagination

Fragments

Each parameter has its own factory — defineFields, defineFilters, definePagination, defineRelations, defineSorts — returning a fragment that assigns directly into defineQuery input. Useful when query parts travel as data (props, composables, function arguments) before being assembled:

typescript
import { defineFilters, defineQuery } from '@rapiq/core';

const scope = defineFilters<User>({ realm_id: id });

const query = defineQuery<User>({
    filters: scope,          // fragments assign without casts
    pagination: { limit: 10 },
});

Fragments and raw input mix freely; already-built AST nodes pass through unchanged.

Next steps

Released under the MIT License.