@rapiq/adapter-typeorm
Applies a parsed Query directly to a TypeORM SelectQueryBuilder: filters become parameterized WHERE conditions, relations become joins, fields/sorts/pagination map to select/orderBy/take+skip.
npm install @rapiq/core @rapiq/adapter-sql @rapiq/adapter-typeormUsage
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();The queryBuilder (the builder to write into) is bound at construction; execute(query) then walks the parsed Query, collects the state into its sub-adapters, and applies it to that builder, returning the applied pagination (e.g. for the response meta block).
Builder state you set before execute is preserved: WHERE conditions stay (rapiq appends its filter tree with AND, and binds its parameters under a private namespace so caller bindings are never rebound), and a query that carries no sorts or pagination leaves a caller-owned ORDER BY / take / skip untouched. An application-owned tenant or authorization predicate therefore remains the baseline even when the client sends no filters. When the client does send sorts, fields or pagination, those replace the corresponding builder state; that is the request's job.
Construct the adapter per request, just like the SelectQueryBuilder you hand it: it holds per-call state. The shareable, long-lived part is your config, which you spread into the per-request options:
// module scope: the reusable config
const config = { relations: { joinAndSelect: true } };
// per request: add the request's builder as `queryBuilder`
new TypeormAdapter({ ...config, queryBuilder }).execute(query);By default each call clears the adapter's own accumulated state. The bound queryBuilder, however, is mutated in place (joins and the selected projection are not rolled back), so build a fresh adapter (with a fresh builder) per query rather than re-running one onto an already-applied builder. execute takes two per-call options as its second argument: { clear: false } accumulates several queries' conditions onto the same builder, and { caseSensitive } opts fields out of case folding:
adapter.execute(query, { clear: false });Options
new TypeormAdapter({
relations: {
joinAndSelect: true,
hydrationMode: 'full',
joinType: 'left',
onJoin: (path, alias, queryBuilder) => {
queryBuilder.addGroupBy(`${alias}.id`);
},
},
});| Option | Description |
|---|---|
relations.joinAndSelect | Hydrate every joined relation, including those joined only for a filter or sort. Off by default; without it, a relation joined purely for filtering/sorting stays unselected (its columns are hydrated only when a fields path references them). The option widens which relations hydrate, never how much of one is selected: fieldset narrowing (below) applies under it the same way. |
relations.hydrationMode | 'full' (default) or 'key'. Select granularity for the relations the adapter hydrates. 'full' selects the whole subtree (leftJoinAndSelect) for a fieldset-free include; 'key' selects only the relation's primary key (plain leftJoin + addSelect(<alias>.<pk>)), so the relation object is hydrated id-only. Use 'key' to keep an included relation defined under GROUP BY <root>.id on strict dialects; see the warning below. |
relations.joinType | 'left' (default) or 'inner'. Left joins keep records whose relation is absent. |
relations.onJoin | Invoked as (path, alias, queryBuilder) for every join the adapter applies, e.g. to addGroupBy per join when the root query is grouped. Skipped (pre-existing) joins don't trigger it. |
relations.relationAlias | Derive the join alias for a relation path (default: collision-free length-prefixed segments, e.g. role.realm → r4_role_5_realm). Filter/sort/field references resolve against the same derivation. |
A hydrated relation is selected as a complete subtree only while the query carries no direct fieldset for it: a per-relation fieldset (a client-sent fields[child]=... or the child schema's fields.default/allowed) narrows the selection to exactly those columns plus the relation's primary key, so the relation object hydrates reliably even when every listed column is NULL. Picks belonging to a deeper relation never narrow the traversed prefix.
Relations are validated against the entity metadata of the attached query builder: a requested relation that doesn't exist on the entity is ignored. Joins are applied idempotently: relations already joined on the query builder (by the adapter or by your own code, matched by alias) are skipped, so applying a query twice does not duplicate joins.
Alias convention
The exported buildRelationAlias(path) helper length-prefixes every segment: realm becomes r5_realm, and role.realm becomes r4_role_5_realm. This remains distinct even from a relation literally named role_realm. Fields, filters, sorts and joins all use the same derivation. Pre-existing joins are matched by that alias; use the helper for joins you apply yourself, or inject one convention via relations.relationAlias. Keep a custom derivation collision-free and within your database's identifier length limit.
include + GROUP BY <root>.id
An included relation without a fieldset is join-and-selected as a complete subtree, which adds all of the child's columns to the SELECT (this matches the @rapiq/adapter-memory projection contract). Strict SQL dialects (postgres) reject those columns under a GROUP BY <root>.id pagination pattern (a relations.onJoin hook that adds addGroupBy(alias + '.id') per join), because each joined column is neither grouped nor aggregated. Hydrating an arbitrary subtree and collapsing to distinct roots are fundamentally incompatible there, regardless of how the columns are selected (the same applies to relations.joinAndSelect: true).
Pick one:
- A per-relation fieldset:
include=<relation>combined withfields[<relation>]=<columns>(or a child schema declaringfields.default/allowed) narrows the include to a plainleftJoinselecting the requested columns plus the relation's primary key (all groupable). Use this when you only need a few columns of the relation under a grouped root; a barefields=<relation>.<column>path without the include behaves the same, minus the automatic primary key. - Id-only hydration: set
relations.hydrationMode: 'key'to select only the relation's primary key, so the relation object is at least defined (id-only) and every selected join column is groupable. The subtree is not materialized; reach for this when a caller relies on the relation being present but does not need its columns. - A non-grouping pagination strategy: a distinct-root subquery or a two-phase ids-then-load when you genuinely need the full include alongside pagination.
include remains the right tool when you are not grouping the root: the full subtree hydrates without conflict.
Dialect detection
The adapter resolves the SQL dialect from the attached query builder's connection type (postgres, mysql/mariadb, sqlite/better-sqlite3, mssql, oracle, …). Field escaping is delegated to the query builder itself; the dialect preset supplies the rest. The contains / startsWith / endsWith operators render as LIKE ... ESCAPE '!' on every connection type; the preset's regexp callback serves the regex operator alone, so on regex-less dialects (SQLite, SQL Server) that one operator throws a typed AdapterError. When the connection type has no matching preset, the postgres preset is the documented last-resort default.
Case folding & column types
Case-insensitive string matching folds through lower() on case-sensitive dialects, for equality (lower(col) = lower(:rapiq_1_0)) and for the anchored operators (lower(col) like lower(:rapiq_1_0) escape '!') alike; parameter names carry a per-application namespace so they cannot collide with caller-owned ones. The adapter resolves each filtered field against the entity metadata (relation paths included) and folds only string-typed columns: filtering an int column with an untyped wire string (filter[age]=18) renders a plain = instead of a lower(...) type error, and non-string columns never pay the folding cost. Unresolvable fields keep the folding default; opt fields out explicitly via execute(query, { caseSensitive: [...] }), which reaches the anchored operators too.
For anchored matching on a known non-string column, PostgreSQL adds a text cast: contains('age', '1') becomes "user"."age"::text like :rapiq_1_0 escape '!' and matches 18, as it does in memory, SQLite and MySQL. Negated matching still includes nulls. String columns, including native citext, keep their existing comparison behavior; numeric equality stays uncast.
Which fold applies is per dialect, taken from the resolved preset's caseFold / caseFoldLike. On a better-sqlite3 connection, startsWith('first_name', 'Aston') renders "user"."first_name" like :rapiq_1_0 escape '!' unfolded (SQLite's LIKE is already ASCII-case-insensitive) while eq still folds; on mysql neither folds (the *_ci collation does the work); on postgres both do.
Date columns
The same entity metadata tells the adapter which columns are temporal, and a date operand is bound in that column's own storage form: a UTC wall-clock literal for a zone-less datetime/timestamp, the ISO instant for a timestamptz, YYYY-MM-DD for a date. A calendar date filtering a date column is bound verbatim: converting it to an instant (UTC midnight) and back through local calendar parts would land on the previous day on any negative-offset host. Only an operand that carries a clock has to pick a day, and that one mirrors the column's utc option. A Date is deliberately never bound: the Postgres and MySQL drivers serialize one in the host's local zone, which a zone-less column then reads as local wall clock, shifting the window by the host's offset. A value that denotes no instant is refused with a typed AdapterError rather than handed to the driver.
UUID columns
An equality or ordering operand on a uuid (or mssql uniqueidentifier) column must be a uuid. Every spelling Postgres accepts (any case, braced, hyphens after any group of four digits or none at all) is bound in the canonical lowercase hyphenated form Postgres outputs and TypeORM generates, so it matches on every dialect, including SQLite, whose text column compares case-sensitively. Anything else, including a number, is refused with an AdapterError carrying ErrorCode.KEY_VALUE_INVALID before the query runs. Without the check the dialects disagree: Postgres fails the whole query (22P02, invalid input syntax for type uuid), while SQLite and MySQL store the column as text and match nothing. in/nin lists are checked per member (null stays legal), and a contains/startsWith/endsWith pattern is not a uuid and passes through untouched.
Field visibility gates
A schema's fields validate hook may gate a column with a condition, meaning visible only on rows satisfying it. The adapter cannot express that: a selection has to stay a bare alias.property for entity hydration, so the column is projected for every row and the gate has to be enforced on the fetched entities.
The gate is compiled from the same AST the adapter walks, so the helper that applies it lives in @rapiq/adapter-memory, the package that evaluates conditions against plain objects. Install it alongside this one when a schema gates a field:
import { applyFieldConditions } from '@rapiq/adapter-memory';
new TypeormAdapter({ queryBuilder }).execute(query);
const entities = await queryBuilder.getMany();
const output = applyFieldConditions(query.fields, entities);The gate never removes a row; it omits the property from the entities that fail it, and a redacted entity keeps its class prototype. The adapter force-projects every column a gate reads, so the post-fetch pass always has its operands, even when the client requested a sparse fieldset that omits them. hasFieldConditions(query) (from @rapiq/core) tells a response path whether the post-fetch call is needed at all.
Skipping the post-fetch call ships the gated value to the client, so treat this as fail-open. Note that on this adapter a plain return false from the hook is weaker than a condition for included relations: an include is fully hydrated, so a boolean denial removes the field from the query while the column still arrives on the entity, with nothing left for the post-fetch pass to act on. A condition keeps the gate on the field, and applyFieldConditions strips the value wherever the row came from.
Embedded columns
Dotted field paths resolve against the entity metadata segment by segment: only real relations join. A path into an embedded entity (@Column(() => Profile)), e.g. profile.firstName, is dotted without anything to join: it renders against its parent alias with the embedded column's database name ("user"."profileFirstname") instead of producing a bogus LEFT JOIN. This applies uniformly to filters, sorts and field selection, and composes with relations: role.profile.firstName joins only role and resolves the embedded remainder against that join's alias.
Deriving schemas from entities
Instead of hand-maintaining a Schema per resource, derive it from the TypeORM entity metadata. defineSchemaRegistryWithDataSource walks all entities of a data source and returns a populated SchemaRegistry: one schema per entity, cross-linked automatically:
import { defineSchemaRegistryWithDataSource } from '@rapiq/adapter-typeorm';
const registry = defineSchemaRegistryWithDataSource(dataSource, {
schemas: {
user: {
filters: { allowed: 'inherit' },
sorts: { allowed: 'inherit' },
},
},
});Every schema gets its structure derived unconditionally: the schema name (lower-camel entity name, RoleDetail → roleDetail), the allowed relations, and the schemaMapping linking each relation to its target entity's schema, so nested paths like role.detail resolve across the registry without any manual wiring.
Column-based allow-lists are opt-in per parameter: allowed: 'inherit' expands to the entity's column property paths. Hidden columns (select: false) and virtual join columns are always excluded; explicitly declared FK columns (e.g. realmId) are included. Any explicit option wins over its derived counterpart:
import { defineSchemaWithEntity } from '@rapiq/adapter-typeorm';
const schema = defineSchemaWithEntity(User, dataSource, {
strict: true,
fields: { allowed: 'inherit' },
filters: { allowed: ['id', 'name'] }, // explicit list, nothing derived
sorts: { default: { id: 'DESC' } },
});defineSchemaWithEntity also accepts an EntityMetadata directly (defineSchemaWithEntity(dataSource.getMetadata(User))), and the registry's schemas options can be keyed by entity class via a Map instead of the derived name. An options key that matches no entity throws, so entity renames fail loudly.
To mix hand-written and derived schemas, pass an existing registry: entities whose derived name is already registered are skipped, so the hand-written schema stays authoritative and derivation fills in the rest:
const registry = new SchemaRegistry();
registry.add(userSchema); // curated by hand
defineSchemaRegistryWithDataSource(dataSource, { registry });Passing schemas options for a skipped (already registered) name throws; options that would be silently ignored are treated as a mistake.
Derivation never sets strict: combined with strict: true, a derived allowed: 'inherit' opens every (non-hidden) column to clients, so opt sensitive resources into explicit lists instead.
TIP
The data source only needs built metadata, not an open connection; deriving schemas at startup before dataSource.initialize() completes is fine as long as the metadata was built.
Validating schemas against entities
Hand-written schemas drift silently: a renamed column leaves a dead key in an allow-list that either never matches again or surfaces as a runtime adapter error on first client use. assertSchemaMatchesEntity checks every key a schema references against the entity metadata; call it at boot so drift fails the deploy, not a request:
import { assertSchemaMatchesEntity } from '@rapiq/adapter-typeorm';
assertSchemaMatchesEntity(userSchema, User, dataSource);
// or, mirroring defineSchemaWithEntity, with metadata directly:
assertSchemaMatchesEntity(userSchema, dataSource.getMetadata(User));Validated keys: fields.default and fields.allowed, filters.allowed and the leaf fields of a filters.default condition tree, sorts.allowed and the keys of sorts.default, the keys of every indexes sequence, and relations.allowed. The rules:
- Plain keys must be column property paths, embedded paths like
profile.firstNameincluded. - Dotted keys that aren't a column path must be headed by a relation. Only the head is checked; the remainder belongs to the related entity, so validate that schema against its own entity.
relations.allowedkeys must be relation property names (dotted keys headed by a relation).
On a mismatch the helper throws a SchemaEntityMismatchError (code schemaEntityMismatch, a SchemaError subclass) that collects every offending key rather than stopping at the first, and exposes schema, entity and keys as properties:
import { SchemaEntityMismatchError } from '@rapiq/adapter-typeorm';
try {
assertSchemaMatchesEntity(userSchema, User, dataSource);
} catch (e) {
if (e instanceof SchemaEntityMismatchError) {
console.error(e.schema, e.entity, e.keys); // 'user', 'User', ['renamedAway']
}
throw e;
}Schemas produced by defineSchemaWithEntity don't need this: their structure comes from the metadata in the first place. The helper targets curated, hand-written schemas kept next to derived ones.
Declared indexes
An indexes declaration is a promise rapiq trusts: it never inspects the database, it just enforces the combinations an indexed filters or sorts policy allows. Drift turns that promise into a lie, so assertSchemaMatchesEntity verifies each declared sequence on top of the key rules: it must be a leftmost prefix of the entity's primary key, of a unique constraint, or of an index. Prefix rather than equality, since a real (realm_id, email) index also serves a declared ['realm_id']:
const userSchema = defineSchema<User>({
name: 'user',
indexes: [['realm_id', 'email'], ['realm_id']], // both backed by @Index(['realm_id', 'email'])
filters: { allowed: ['realm_id', 'email'], indexed: true },
});Both @Unique constraints and @Index declarations count, whichever list the driver keeps them in (the mysql family stores unique constraints as unique indexes). Keys of a declared sequence are checked like any other column key first, so a renamed column surfaces as a SchemaEntityMismatchError; a sequence of live columns that no real index leads throws a SchemaEntityIndexMismatchError (code schemaEntityIndexMismatch) carrying schema, entity and every offending indexes sequence:
import { SchemaEntityIndexMismatchError } from '@rapiq/adapter-typeorm';
try {
assertSchemaMatchesEntity(userSchema, User, dataSource);
} catch (e) {
if (e instanceof SchemaEntityIndexMismatchError) {
console.error(e.indexes); // [['email', 'realm_id']]
}
throw e;
}A schema without an indexes declaration skips the check entirely.
Grouped queries
execute() refuses a query carrying groups or aggregates. executeGrouped(query) writes the grouped read into the bound builder and returns the pagination plus a normalize function for the raw rows:
const queryBuilder = dataSource.getRepository(Event).createQueryBuilder('event');
const adapter = new TypeormAdapter({ queryBuilder });
const { pagination, normalize } = adapter.executeGrouped(query);
const rows = normalize(await queryBuilder.getRawMany());
// [{ createdAt: '2026-09-22T00:00:00.000Z', scope: 'auth', count: 3 }, ...]- The bucket SQL comes from the resolved dialect, and the column kind from the entity metadata: a
timestamptzcolumn is bucketed as an instant, adateor zone-lessdatetime/timestampcolumn as stored (UTC wall clock). A bucket on a column that is not temporal is refused (groups:bucket-type), and so is asumon a column whose type is not numeric (aggregates:sum-type: an integer, decimal or float type, or anumber-typed property, is numeric; an array column is not, normoney, which PostgreSQL sums to formatted text). A MySQLTIMESTAMPcolumn needs the sessiontime_zoneat'+00:00', and SQLite expects text dates (what TypeORM writes), see Buckets are UTC. - Included relations are not joined. A relation a filter traverses is joined and never selected. Joins run before the select list and the
GROUP BYare rebuilt, so aGROUP BYanonJoinhook adds is dropped and cannot change the grain. - Filters that traverse a
OneToMany/ManyToManyrelation are rendered as a correlatedEXISTSover the root entity (alias<alias>_exists, correlated on every primary key column) instead of joins on the builder, so a record counts once however many related rows match, as in@rapiq/adapter-memoryand a record read. The joins these filters need run inside the subquery with the samerelationsoptions, and theonJoinhook receives the subquery's builder, so a condition it adds applies there; a builder that shows soft-deleted rows (withDeleted()) shows them there too. A join already on your builder is not reused inside the subquery: give a relation its condition throughonJoin, not through a join of your own under the adapter's alias. - Aggregates over a to-many join that is on the builder already would count join rows, so a
OneToMany/ManyToManyjoin you added yourself, or one anonJoinhook adds for a to-one filter join, refuses the query (aggregates:fan-out). - A builder that already carries a
GROUP BYis refused (groups:builder) before anything is changed. That includes a builder a previousexecuteGroupedcall with groups has already written to: build a fresh builder per call. - Pagination is applied with
limit/offset, nevertake/skip. Atake,skip,limitoroffsetalready set on the builder is cleared, so only the query's pagination applies. There is no group total:rows.length === limitmeans the series may be truncated. normalizekeeps only the output keys and turnscountandsuminto numbers (see Numbers). The position of thenullgroup differs by engine: PostgreSQL sorts it last ascending, MySQL and SQLite first.
Applying a single parameter
A Query with only some parameters set applies just those: the rest are empty and become no-ops. To apply, say, only the filters of a parsed query:
import { Query } from '@rapiq/core';
const adapter = new TypeormAdapter({ queryBuilder });
adapter.execute(new Query({ filters: query.filters }));For lower-level control, each per-parameter sub-adapter (adapter.filters, adapter.fields, adapter.sorts, adapter.pagination, adapter.relations) pairs with the matching @rapiq/adapter-sql visitor (FiltersVisitor, FieldsVisitor, SortsVisitor, PaginationVisitor, RelationsVisitor) and applies via its own execute(); the query builder is already bound from the adapter's construction.
End-to-end example
The complete Express endpoint (schemas, decoding, error handling, response meta) lives in the Express & TypeORM recipe.
Migrating from typeorm-extension
applyQuery used leftJoinAndSelect and returned the parsed pagination; joinType: 'left' (the default) and the execute(query) return value mirror that contract. See the migration guide.