Executing Queries
A validated Query becomes results through an adapter. Five ship with rapiq; pick by where your data lives:
| Adapter | Target | Returns |
|---|---|---|
| @rapiq/adapter-typeorm | TypeORM SelectQueryBuilder | mutates the builder in place |
| @rapiq/adapter-prisma | Prisma Client | a findMany argument object |
| @rapiq/adapter-drizzle | Drizzle relational queries (v1 RQBv2) | a findMany config object |
| @rapiq/adapter-sql | any SQL driver | parameterized SQL fragments |
| @rapiq/adapter-memory | plain objects & arrays | compiled functions / filtered data |
All five consume the same AST, and they agree on semantics: the records a query selects in memory are the records it selects in the database.
TypeORM
The most common server setup: the adapter applies filters as parameterized WHERE conditions, relations as joins, fields/sorts/pagination as select/orderBy/take+skip:
import { TypeormAdapter } from '@rapiq/adapter-typeorm';
const queryBuilder = dataSource.getRepository(User).createQueryBuilder('user');
const adapter = new TypeormAdapter({
queryBuilder,
relations: { joinAndSelect: true },
});
const { pagination } = adapter.execute(query);
const [entities, total] = await queryBuilder.getManyAndCount();execute returns the applied pagination, handy for the response meta block. Existing builder state is preserved: rapiq filters are appended with AND (under namespaced parameter bindings), and a query without sorts or pagination leaves a caller-owned ORDER BY/take/skip untouched, so tenant or authorization scopes applied before execute cannot be erased. Options (join types, the onJoin hook, alias conventions) are on the package page.
Prisma
Prisma takes an argument object rather than a builder, so the adapter is a pure serializer that returns the object and touches nothing:
import type { Prisma } from '@prisma/client';
import { PrismaAdapter, defineMetadata } from '@rapiq/adapter-prisma';
import { prisma } from './prisma';
// hand-written, so it works on every Prisma version: Prisma 7 prunes the
// runtime datamodel, and the adapter rejects pruned input typed.
const datamodel = {
models: [
{
name: 'User',
fields: [
{ name: 'id', kind: 'scalar', type: 'Int', isList: false, isRequired: true },
{ name: 'name', kind: 'scalar', type: 'String', isList: false, isRequired: true },
{ name: 'realm', kind: 'object', type: 'Realm', isList: false, isRequired: false },
],
},
{
name: 'Realm',
fields: [
{ name: 'id', kind: 'scalar', type: 'Int', isList: false, isRequired: true },
{ name: 'name', kind: 'scalar', type: 'String', isList: false, isRequired: true },
],
},
],
};
// The adapter needs model facts a Query cannot carry, and each one
// changes what a VALID Prisma filter looks like. A Prisma 6 classic
// build derives them from `{ model: prisma.user }` alone; Prisma 7
// prunes the runtime datamodel, so supply them explicitly as here.
const adapter = new PrismaAdapter<Prisma.UserFindManyArgs>({
model: prisma.user,
provider: 'postgresql',
metadata: defineMetadata(datamodel, 'User'),
});
const { args, pagination } = adapter.execute(query);
const users = await prisma.user.findMany(args);The Prisma and Drizzle recipe shows the same setup in a complete endpoint, and @rapiq/adapter-prisma covers what each metadata fact decides.
Because this adapter is model-bound, it can also run the request itself:
const rows = await adapter.findMany(query, {
base: { where: { realm_id } }, // an application-owned scope, conjoined
});
const total = await adapter.count(query); // pre-pagination, for the meta blockUnlike the builder-bound adapters, one PrismaAdapter instance is stateless and safely shared across requests. The package page covers the provider presets (mode: 'insensitive' support) and how negation is rendered exactly despite Prisma's three-valued NOT.
Drizzle
Drizzle's relational queries v2 API (drizzle-orm v1) takes a config object, so this adapter is a pure serializer too:
import { DrizzleAdapter, defineMetadata } from '@rapiq/adapter-drizzle';
const adapter = new DrizzleAdapter({
provider: 'pg',
metadata: defineMetadata(datamodel, 'users'),
});
const { config, pagination } = adapter.execute(query, {
base: { where: { realm_id } }, // an application-owned scope, conjoined
});
const users = await db.query.users.findMany(config);One DrizzleAdapter instance is stateless and safely shared across requests. The package page covers the dialect presets, the table metadata and how negation is rendered exactly despite SQL's three-valued NOT.
Raw SQL
No ORM? @rapiq/adapter-sql renders clause fragments you compose into your own statement. Per-database behavior is a small dialect preset (pg, mysql, sqlite, mssql, oracle):
import { Adapter, pg } from '@rapiq/adapter-sql';
const adapter = new Adapter({ ...pg, rootAlias: 'user' });
const fragments = adapter.execute(query);
// {
// columns: ['"user"."id"', '"user"."name"'],
// where: '("user"."age" >= $1)',
// params: [18],
// orderBy: ['"user"."age" DESC'],
// limit: 25, offset: 0,
// relations: ['realm'],
// }Values are always bound as parameters, never interpolated into the SQL string. Composing the final SELECT (in particular FROM/JOIN) stays your job, because it needs knowledge of your table layout. Details: @rapiq/adapter-sql.
In memory
@rapiq/adapter-memory compiles the same query into plain functions: for authorization guards that must agree with the database, filtering already-loaded collections, or mock backends in tests:
import { applyQuery, compileQuery } from '@rapiq/adapter-memory';
// one-shot: filter → sort → paginate → project
const { data, total, pagination } = applyQuery(query, users);
// or compile once and reuse
const compiled = compileQuery<User>(query);
compiled.matches(user); // filters as a predicate -> boolean
compiled.apply(users); // whole query against a collectionSemantics (null handling, string matching, join-row binding) mirror the SQL adapters; see @rapiq/adapter-memory.
One adapter instance per request
SQL and TypeORM adapters accumulate per-call state. Construct them per request; the shareable, long-lived part is the options object, not the adapter instance:
// module scope: the reusable config
const config = { relations: { joinAndSelect: true } };
// per request
new TypeormAdapter({ ...config, queryBuilder }).execute(query);The prisma and drizzle serializers, by contrast, are stateless: one shared instance serves every request, as shown above.
Applying a single parameter
A Query with only some parameters set applies just those; the rest are empty and become no-ops:
import { Query } from '@rapiq/core';
adapter.execute(new Query({ filters: query.filters }));Each backend also exposes per-parameter building blocks (sub-adapters, compile* helpers); see the package pages.
Next steps
- Recipes: REST API with Express & TypeORM: the full endpoint.
- Recipes: Swapping the Backend: Prisma & Drizzle: the same endpoint on the serializer adapters.
- Recipes: Authorization & scoping: injected filters + memory-guard parity.
- The Query AST: implement an adapter for a new backend.