Relation Filtering
Cross-entity filtering with @Relations — delegate input keys to related entity filters via ORM joins.
The @Relations decorator lets you delegate input keys to a related entity's filter. When those keys appear in the input, the runner creates a join via the adapter and applies the related filter's methods on the joined query.
Basic usage
import { Injectable } from '@nestjs/common';
import { Filterable, FilterFor, Relations } from '@dudousxd/nestjs-filter';
import { MikroOrmFilter } from '@dudousxd/nestjs-filter-mikro-orm';
import { User } from './user.entity.js';
import { PostFilter } from './post.filter.js';
@Injectable()
@Filterable({ entity: User })
@Relations({
posts: { filter: PostFilter, keys: ['postTitle', 'postStatus'] },
})
export class UserFilter extends MikroOrmFilter<User> {
@FilterFor('name')
applyName(value: string) {
this.whereLike('name', value);
}
// postTitle and postStatus are handled by PostFilter
// via a join on the 'posts' relation
}When the input { name: 'Al', postTitle: 'Hello' } arrives:
nameis dispatched toUserFilter.applyName()postTitleis recognized as belonging to thepostsrelation- The adapter creates a join on
postsand delegates{ postTitle: 'Hello' }toPostFilter
The related filter
The related filter is a normal filter class. It receives the relation-bound keys as input:
@Injectable()
@Filterable({ entity: Post })
export class PostFilter extends MikroOrmFilter<Post> {
@FilterFor('postTitle')
applyTitle(value: string) {
this.whereLike('title', value);
}
@FilterFor('postStatus')
applyStatus(value: string) {
this.$query.andWhere({ status: value });
}
}The keys in the related filter's @FilterFor decorators must match the keys listed in the @Relations keys array. In this example, PostFilter uses @FilterFor('postTitle') because that is the key listed in the relation config.
@Relations decorator reference
@Relations(map: RelationsMap)Where RelationsMap is:
type RelationsMap = Record<string, RelationConfig>;
interface RelationConfig {
filter: Type<unknown>; // The filter class for the related entity
keys: readonly string[]; // Input keys that belong to this relation
}Example with multiple relations:
@Relations({
posts: { filter: PostFilter, keys: ['postTitle', 'postStatus'] },
company: { filter: CompanyFilter, keys: ['companyName', 'companySize'] },
})How the adapter handles relations
MikroORM
The MikroORM adapter uses joinAndSelect() to join the relation and applies the callback filter on the same query builder:
// Internally:
parentQb.joinAndSelect(relationName, relationName);
await callback(qb); // PostFilter methods run on the same QBTypeORM
The TypeORM adapter uses leftJoinAndSelect():
// Internally:
parentQb.leftJoinAndSelect(`${alias}.${relationName}`, relationName);
await callback(parentQb); // PostFilter methods run on the same QBDot-notation (auto-join)
With auto-fields enabled (the default), you can filter by related entity fields using dot-notation. No @Relations decorator needed for simple cases:
GET /users?posts.title[contains]=Hello&posts.status=publishedThe adapter detects posts.title, verifies posts is a relation on User via entity metadata, auto-joins, and applies the WHERE clause on the related field.
This works with all operators:
posts.title=Hello-- equalsposts.title[contains]=Hello-- LIKEposts.createdAt[gte]=2024-01-01-- >=posts.status[in]=draft,published-- IN
@Injectable()
@Filterable({ entity: User })
export class UserFilter extends MikroOrmFilter<User> {
// No @FilterFor or @Relations needed for dot-notation.
// GET /users?posts.title[contains]=Hello → auto-joins posts, WHERE posts.title LIKE '%Hello%'
}Dot-notation requires the adapter to implement getEntityRelations() and applyAutoRelationField(). Both the MikroORM and TypeORM adapters support this. For complex cases (e.g. nested relations, custom join conditions), use @Relations or manual relation filtering instead.
Projecting a related column with distinct
distinct takes the same dot-notation, which is what a filter dropdown over a relation column needs -- "which authors appear in these posts?":
GET /posts?distinct=author.namefilterQuery<Post>().where('status', 'equals', 'published').distinct('author.name');The relation is joined and its column projected, so the rows come back keyed by the path you asked for -- for a single-column projection the field name is the contract:
{ "data": [{ "author.name": "Ada" }, { "author.name": "Grace" }], "totalCount": 2 }Every other clause still applies, so the values are the ones actually reachable under the current filters, not the whole related table. The distinct-tuple total counts the projection as a whole, and a relation path mixes freely with root columns (distinct=status,author.name).
To-one only. A to-many hop would multiply the rows, which is not what a column-values lookup asked for -- use aggregate paths for those. A bare relation (distinct=author) has no single column to project and is dropped, as is a JSON sub-path. MikroORM only: the TypeORM adapter resolves scalar columns only here.
Aggregating a to-many relation
Dot-notation sort (sort=posts.title) orders the list by a column of a to-one
relation via an auto-join. To sort or filter by an aggregate of a to-many
collection instead (e.g. how many posts a user has, or the sum of their
posts' views), you don't need a join, and you don't need to hand-write a
computed field either — the
runtime auto-discovers virtual aggregate sub-paths straight from your ORM's
relation metadata:
<relation>.$count— number of related rows.<relation>.$sum.<column>/.$avg.<column>/.$min.<column>/.$max.<column>— one aggregate per numeric column on the related entity.
These work as sort keys and filter fields, exactly like a real column:
GET /users?sort=-posts.$count # users ordered by how many posts they have, most first
GET /users?posts.$sum.views[gt]=100 # only users whose posts' views add up to more than 100Or through the typed client:
filterQuery()
.sortDesc('posts.$count')
.where('posts.$sum.views', 'gt', 100)
.build();This works for both to-many cardinalities — @OneToMany and @ManyToMany:
GET /users?sort=-tags.$count # order users by how many tags they have (many-to-many)No declaration is required beyond the relation itself: with autoFields: true
(the default) and no allowed list, every @OneToMany/@ManyToMany relation
on the entity gets .$count plus a .$sum/.$avg/.$min/.$max set for
each of its numeric child columns. Exclude a relation from aggregation the same
way you'd exclude any other field — add its name to @Filterable.blocked.
$sum/$avg are numeric-only — averaging a date or a string is either a
SQL error or nonsensical. Since 1.19, $min/$max also accept date
child columns, because ordering a date is neither ("last service visit",
"most recent login"). Strings, booleans and json still never qualify for any
of the four, so a client can't probe arbitrary child columns through the
aggregate path. The comparison value (gt=100 above) is always bound as a
query parameter, never inlined into SQL.
Empty collections
A row with no related children still participates in sort/filter — the
aggregate just resolves to the identity value for that function, not NULL
across the board:
| Function | Value when the collection is empty |
|---|---|
$count | 0 |
$sum | 0 (COALESCEd) |
$avg / $min / $max | NULL — there's nothing to average, and no minimum/maximum of an empty set |
So posts.$avg.views[isNull]=true matches users with zero posts, while
posts.$count[equals]=0 is the equivalent check phrased as a count.
How it compiles
Every aggregate path — one-to-many or many-to-many — compiles to a single
correlated scalar subquery, never a JOIN + GROUP BY on the outer query.
That's what keeps pagination correct: root rows are never multiplied, no
matter how many (or how few) children a row has. For a many-to-many relation,
the subquery additionally joins through the pivot table to reach the child
column, but that join lives entirely inside the parenthesized scalar
expression and can't affect the outer row count either way. Implemented
identically on both the MikroORM and TypeORM adapters.
This orders the root rows by the relation's aggregate. To order the
collection inside each row instead (each user's posts), that's an ORM
concern — set the orderBy option on the @OneToMany relation, not a
filter field.
Manual relation filtering
If you need more control, you can handle relation filtering manually in your filter methods instead of using @Relations:
@FilterFor('postTitle')
applyPostTitle(value: string) {
// MikroORM example
this.$query.joinAndSelect('posts', 'posts');
this.$query.andWhere({ 'posts.title': { $like: `%${value}%` } });
}Helper methods for LIKE queries
Both MikroOrmFilter and TypeOrmFilter provide convenience methods:
| Method | SQL equivalent |
|---|---|
this.whereLike(field, value) | field LIKE '%value%' |
this.whereBeginsWith(field, value) | field LIKE 'value%' |
this.whereEndsWith(field, value) | field LIKE '%value' |
All values are escaped via escapeLike() to prevent LIKE wildcard injection.
If the adapter does not support applyRelationConstraint, relation keys are silently skipped with a warning log. Both the MikroORM and TypeORM adapters support it.
Cursor Pagination
Stable keyset (seek) pagination with FilterRunner.findPage() — opaque before/after cursors, multi-column sort with a primary-key tiebreaker.
Full-Text Search
Global search — ILIKE across string columns, or a Postgres tsvector column via websearch_to_tsquery with optional ts_rank relevance ordering.