Adapters
How nestjs-rest-query talks to TypeORM, Prisma and Drizzle - one semantic core, three compilers, and the limits each one declares.
nestjs-rest-query separates what a request means from how one ORM
executes it. The core parses the query string, authorises it against the
endpoint rules, coerces every value by its declared kind and produces a frozen
TypedQueryPlan. An adapter only compiles that plan.
The plan, then the adapter
Two consequences worth stating plainly:
- Normalisation is the core's job, not the adapter's. The adapter hydrates
rows; the core turns them into the canonical JSON. That is the only place
where PostgreSQL's
bigint, MySQL'snumberand SQL Server'sstringfor the same column become the same output. - There is no global adapter setting.
forRoot({ adapter })is refused at startup. The adapter arrives with the source you pass toexecute().
Choosing an adapter
| ORM | Source factory | Subpath | What you declare |
|---|---|---|---|
| TypeORM | typeormSource(repository) | nestjs-rest-query/typeorm | The logical schema, or derive it with buildSchemaRegistry(repository). Companion columns follow the _folded / _order convention. |
| Prisma | prismaSource({ client, model, manifest }) | nestjs-rest-query/prisma | The logical schema and a hand-written manifest (provider, registry, models). |
| Drizzle | drizzleSource({ db, dialect, table, relations }) | nestjs-rest-query/drizzle | A logical table descriptor plus relations by dotted path; drizzleDatabase({ client, dialect }) once. |
All three factories are generic in the row type.
typeormSource<T>(repository) infers T from the repository;
drizzleSource<TRow> takes it from the executor you built with
drizzleDatabase<TRow>({ ... }), so it is inferred too; prismaSource<TRow>
is the one you name explicitly. A service can annotate
Promise<NormalizedQueryResult<UserDto>> without a cast anywhere.
Parity, and how it is measured
The promise is that the same request produces the same observable outcome in
nine combinations — TypeORM, Prisma and Drizzle × PostgreSQL, MySQL and SQL
Server — measured by one corpus with one set of expectations, including
byte-for-byte 400 messages.
SQLite is not a matrix cell. It is the reference dialect: it proves each adapter's compiler implements the plan's semantics, it runs without a container, and a green result there is not parity.
The current per-cell state, including which gates are still open, is in
docs/v3/status.md;
the supported version matrix is in
docs/v3/versions.md.
Declared divergences and limits
A divergence — same input, different observable outcome by adapter — is allowed only where the underlying model makes "the same as TypeORM" ambiguous or unsafe. Each one is declared as data on the corpus case itself, with a mandatory reason, so an adapter that starts agreeing again breaks the build and forces the exception to be deleted.
| Adapter | Case | Outcome |
|---|---|---|
| Prisma | like, notLike, ilike, notIlike, search on sqlite and sqlserver | Refused, 400 CAPABILITY_UNAVAILABLE. Prisma compiles contains with no ESCAPE clause and those two dialects have no default escape character, so % and _ cannot be literal. Works on postgresql and mysql. |
| Drizzle | Projecting a to-many collection nested under another relation | Refused, ADAPTER_CONTRACT_VIOLATION. First-level collections are supported. |
Existential conditions are not on that list. A filter or search target
that crosses a many relation compiles in all three adapters at any depth —
one hop, a further one hop (posts.author.name), a second collection, or a
many-to-many (posts.tags.label) — and each of those is a corpus case with
no declared divergence. TypeORM emits one EXISTS correlated to the root
exactly once, with the later hops as INNER JOINs inside the subquery; for a
many-to-many the subquery's FROM is the junction table and the target joins in
from there.
The search whitelist in
02-app-with-postgres
is a good illustration: it keeps user.firstName (one one hop) and
items.company.name (through the items collection and then company), and
the same rules run unchanged under Prisma and Drizzle. items.company.cnpj
stays out for an unrelated reason — search compares through a folded column,
and cnpj declares none.
Everything else — filter operators, search, pagination shape, paginate=false,
the customize hook, isNull on a one or many relation, repeated filters,
whitelist rejection, dotted-path filters — is exercised by the parity corpus and
produces identical outcomes across all three adapters.