@rapiq/adapter-memory
Evaluates a parsed Query against in-memory data: plain JavaScript objects and arrays. It is the in-memory sibling of the SQL and TypeORM adapters: the same visitor-pattern surface, but instead of SQL fragments or a mutated query builder, the visitors compile the AST into plain functions.
npm install @rapiq/core @rapiq/adapter-memoryTypical uses: authorization guards that must agree with what the database query returns (see the authorization recipe), in-memory filtering of already-loaded collections, mock backends in tests, and applying a query to data that never lived in a database.
Usage
Apply a whole query to a collection:
import { applyQuery } from '@rapiq/adapter-memory';
const { data, total, pagination } = applyQuery(query, users);data: filtered → sorted → paginated → projected records.total: the number of matches before pagination (for the responsemetablock, mirroring the TypeORM adapter's pagination echo).pagination: the applied{ limit, offset }.
Or compile once and reuse:
import { compileQuery } from '@rapiq/adapter-memory';
const compiled = compileQuery<User>(query);
compiled.matches(user); // evaluate the filters against a single input -> boolean
compiled.apply(users); // apply the whole query to a collectionApplying a single parameter
Every parameter compiles independently into a plain function:
import {
compileFields,
compileFilters,
compilePagination,
compileSorts,
} from '@rapiq/adapter-memory';
import { and, eq, gte } from '@rapiq/core';
const predicate = compileFilters(and(eq('name', 'Peter'), gte('age', 18)));
predicate({ name: 'Peter', age: 28 }); // true
const comparator = compileSorts(query.sorts); // (a, b) => number
const projector = compileFields(query.fields); // (input) => projected
const slicer = compilePagination(query.pagination); // (data) => pageUnder the hood each compile* helper wraps a visitor class (FiltersVisitor, SortsVisitor, FieldsVisitor, PaginationVisitor, QueryVisitor); subclass those for custom behavior. Compilation validates the AST (unknown operators throw); the compiled functions themselves never throw.
compileFilters accepts the open ICondition type so custom consumers can share the AST contract. The bundled memory compiler lowers built-in Filter / Filters semantics only and throws CONDITION_DETACHED for an unknown custom kind. Conditions held abstractly (builder output, a schema's filters.default, a lowered authorization residual) pass straight through when they use those built-in semantics, with no narrowing cast required.
Filter semantics
The package aims for SQL parity: the same query should select the same records in memory as @rapiq/adapter-sql/@rapiq/adapter-typeorm select from the database.
Null & missing values
undefined, missing properties and null are one absent value (SQL has a single NULL).
- Positive operators (
eq,lt,lte,gt,gte,in,mod,size,contains,startsWith,endsWith,regex) never match absent values, excepteq(field, null),inwith anullelement, andexists(field, false). - Negated operators are exact complements:
ne,nin,notContains,notStartsWithandnotEndsWithdo match absent values.ne('name', 'Peter')matches a record without a name. existsmeans has a non-null value (SQLIS NOT NULL), not Mongo's "property present".- Type mismatches evaluate to
false, never to an error.
Date values
A record value that is a Date reads a string operand as the instant it denotes, so gte('created_at', '2026-08-23T00:00:00.000Z') selects the window it describes rather than nothing. No metadata is needed: a Date never crosses the wire, so the pair identifies itself. Two strings stay strings (a column may well hold ISO text), and a number stays incomparable against a Date, since this backend infers temporality from the value alone. See date values for the fleet-wide contract.
String matching
contains, startsWith, endsWith (and their negations) are case-insensitive by default and treat the filter value as a literal: the compiler escapes it into an anchored regular expression, so metacharacters match themselves. Numbers are matched by their decimal string form; other value types never match. (@rapiq/adapter-sql renders the same operators as an escaped LIKE pattern rather than a regex; the selected records are the same, the spelling is not.)
String equality (eq / ne / in / nin) is case-insensitive by default too. Opt fields out via the caseSensitive option, mirroring a schema's filters.caseSensitive list:
applyQuery(query, users, { caseSensitive: ['id'] });
compileFilters(eq('id', 'aBc'), { caseSensitive: ['id'] });caseSensitive: true keeps every comparison exact: for evaluating arbitrary condition trees whose field keys aren't known upfront, e.g. caller-supplied authorization policies:
compileFilters(condition, { caseSensitive: true });Both forms govern the equality family and the anchored operators: an opted-out field compares with plain string equality and compiles its pattern without the i flag, so contains('name', 'ETER') matches PETER but not Peter. caseSensitive: false equals the default. The regex operator is never affected: its case handling comes from the pattern's own i flag.
Regex patterns run as-is
The regex operator compiles the query's pattern with JavaScript's backtracking RegExp engine and evaluates it against every record: a crafted pattern (nested quantifiers) over long field values can burn CPU (ReDoS). Compilation only rejects invalid syntax. The URL dialects cannot carry a regex, but the mongo dialect accepts $regex; when queries originate from untrusted input, gate the operator with the schema's filters.validate hook. See the regex trust model.
Join-row binding
Dotted paths emulate the SQL adapter's joins: all conditions on one relation path bind to the same array element, and the record matches if some assignment of elements satisfies the whole filter tree. An empty or absent array contributes one all-null row, like a LEFT JOIN.
const user = {
items: [
{ id: 1, active: false },
{ id: 2, active: true },
],
};
// no single item is both id=1 and active -> no match (sql join parity)
compileFilters(and(eq('items.id', 1), eq('items.active', true)))(user); // false
// same-element matching, stated explicitly
compileFilters(elemMatch('items', and(eq('id', 1), eq('active', true))))(user); // falseEvery elemMatch node opens its own quantifier scope: its interior conditions share one element, but two elemMatch nodes on the same field (or an elemMatch beside a dotted condition) bind independently (Mongo $elemMatch semantics). Where SQL has no opinion (a leaf value that is an array, e.g. tags: ['a', 'b']), Mongo element semantics apply: eq('tags', 'a') is membership, in is intersection.
ITSELF (element-level conditions)
Inside an elemMatch interior, the ITSELF marker addresses the array element itself: the shape the mongo parser produces for element-level $elemMatch and $all:
import { ITSELF, elemMatch, gt, eq, and } from '@rapiq/core';
// { scores: { $elemMatch: { $gt: 5 } } }
compileFilters(elemMatch('scores', gt(ITSELF, 5)))({ scores: [3, 7] }); // true
// { tags: { $all: ['a', 'b'] } }: one independent element match per value
compileFilters(and(
elemMatch('tags', eq(ITSELF, 'a')),
elemMatch('tags', eq(ITSELF, 'b')),
))({ tags: ['a', 'b'] }); // trueITSELF conditions only match real array elements: a missing field, a scalar, a to-one object or an empty array never matches (no NULL-row fallback, mirroring Mongo's $elemMatch). Outside an elemMatch interior the marker is a typed error.
Divergences
| Case | @rapiq/adapter-memory | Baseline |
|---|---|---|
| Per-leaf array quantification | same-element binding | Mongo/ucast quantify each dotted condition independently |
Multiple elemMatch on one field | independent element bindings | SQL adapter: one join alias, same row |
exists | is-not-null | Mongo: property presence |
contains family | case-insensitive | Mongo/ucast: case-sensitive |
Field.condition gates | applied while projecting | SQL adapters: column projected unconditionally, gate runs post-fetch |
Fields & relations projection
The data is already in memory, so relations never adds anything, and it never prunes either (without entity metadata, an embedded value object is indistinguishable from a relation). Projection follows the TypeORM adapter:
- No selected fields → identity: records pass through untouched (same references).
- Selected fields → only the picked properties survive; dotted picks (
items.title) project into nested objects and arrays. --flagged entries are dropped, never subtracted; subtract-from-default is resolved at parse time by the schema.- An included relation without direct picks keeps its whole subtree alongside a sparse field selection; a per-relation fieldset narrows the include to exactly those picks (TypeORM parity, #847). Picks belonging to a deeper relation never narrow the traversed prefix.
Field visibility gates
A schema's fields validate hook may answer with a condition instead of a boolean, which lands on the Field node as Field.condition: this column is visible only on records satisfying the condition. The projector applies those gates automatically: on a record that fails one, the key is omitted from the projected output. No record is ever removed: a gate constrains a value, never the row set.
The SQL backends cannot express that in a statement (a selection has to stay a bare column for entity hydration), so they project the column for every row and enforce the gates afterwards. Two helpers are exported for that post-fetch pass:
import { applyFieldConditions, compileFieldConditions } from '@rapiq/adapter-memory';
const entities = await queryBuilder.getMany();
// array form: returns a new array, input untouched
const output = applyFieldConditions(query.fields, entities);
// single-record form: (record) => redacted record
const redact = compileFieldConditions(query.fields);
const one = redact(entity);Records with nothing to hide are passed through by reference; only affected records are replaced by a shallow redacted copy. Both helpers take the same options as compileFilters as their last argument (e.g. { caseSensitive: [...] }), so a gate evaluates exactly like the equivalent filter would.
Sorting & pagination
- Multi-key stable sort; absent values sort last ascending, first descending (pg semantics).
- Dotted sort paths traverse to-one objects; to-many paths resolve as absent.
- An explicit
limit: 0is a value, not absence: it returns no rows, matching every other backend (TypeORM takes 0 rows; the prisma and drizzle configs return none on a real engine). A negative limit and a non-positive offset are ignored.
Grouped queries
compileQuery / applyQuery refuse a query carrying groups or aggregates. The grouped entry points evaluate it in memory with the same answer the SQL adapters give, which makes them the parity oracle for tests:
import { applyGroupedQuery, compileGroupedQuery } from '@rapiq/adapter-memory';
const { data, total, pagination } = applyGroupedQuery(query, events);
const run = compileGroupedQuery(query);
run(events);The pipeline is filter, group, aggregate, sort (explicit sorts, then every group key they do not name ascending), then paginate; total is the number of groups before pagination. Relations are ignored. Semantics:
- a bucket reads the value like a date operand and truncates it to the UTC hour, day or month (
toISOString()); a value that is not a date lands in thenullgroup; null/ missing is a group of its own, sorted last ascending like PostgreSQL (MySQL and SQLite sort it first); group keys compare exactly and case-sensitively;count()counts records,count(field)non-null values;sumadds every valueNumberreads as finite (numbers, bigints and numeric strings such as a driver-hydrated decimal'1.5'; a blank string, a boolean or aDateis skipped) and isnullwhen there are none;- aggregates without groups give exactly one row (over no records:
count0,sumnull); groups over no records give no rows.
The same options as compileFilters (e.g. { caseSensitive }) apply to the filter step.
Errors
Compilation throws a typed AdapterError for structural problems:
- an unknown filter operator or compound operator →
ErrorCode.OPERATOR_UNSUPPORTED, - a malformed
elemMatchorregexvalue →ErrorCode.FEATURE_UNSUPPORTED, - a grouped query on
compileQuery/applyQuery, or an unresolved or unknown group or aggregate function on the grouped entry points →ErrorCode.FEATURE_UNSUPPORTED(feature tags in Grouping & Aggregates).
Evaluation itself never throws: a guard is if (!predicate(input)) { ... }.