Skip to content

Executing Queries ​

A validated Query becomes results through an adapter. Five ship with rapiq; pick by where your data lives:

AdapterTargetReturns
@rapiq/adapter-typeormTypeORM SelectQueryBuildermutates the builder in place
@rapiq/adapter-prismaPrisma Clienta findMany argument object
@rapiq/adapter-drizzleDrizzle relational queries (v1 RQBv2)a findMany config object
@rapiq/adapter-sqlany SQL driverparameterized SQL fragments
@rapiq/adapter-memoryplain objects & arrayscompiled 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:

typescript
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:

typescript
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:

typescript
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 block

Unlike 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:

typescript
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):

typescript
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:

typescript
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 collection

Semantics (null handling, string matching, join-row binding) mirror the SQL adapters; see @rapiq/adapter-memory.

Grouped queries ​

A query carrying groups or aggregates answers rows, not records. The record entry points above refuse it; each adapter has a separate grouped entry point (executeGrouped on SQL and TypeORM, applyGroupedQuery in memory). Prisma and Drizzle refuse grouped queries. See Executing grouped queries.

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:

typescript
// 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:

typescript
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 ​

Released under the MIT License.