Computed fields
Virtual/derived columns backed by a SQL expression — @Computed methods, correlated subqueries, SELECT projection with project true, and codegen typing.
Computed fields are virtual columns that don't exist in a table — a value derived at query time from a SQL expression you provide at declaration time (e.g. concatenating two columns into a fullName). The client only ever references the alias — never the expression itself — so a computed field is filterable and sortable like a real column without exposing raw SQL to callers.
The canonical way to declare one is the @Computed method decorator — the alias defaults to the method name, and the method body returns the source:
import { Computed, Filterable } from '@dudousxd/nestjs-filter';
@Filterable({ entity: User })
export class UserFilter extends MikroOrmFilter<User> {
@Computed({ type: 'string' })
fullName() {
return "first_name || ' ' || last_name";
}
}GET /users?fullName[contains]=Jane
GET /users?sort=-fullNameThe expression is developer-supplied and never derived from client input. Only declare computed expressions built from trusted, static strings.
Looking to sort/filter by the count (or sum/avg/min/max) of a to-many relation — e.g. how many posts a user has? That's not a computed field — the runtime auto-discovers <relation>.$count / <relation>.$sum.<column> / … for every @OneToMany/@ManyToMany relation with no declaration needed. See Aggregating a to-many relation.
A computed field is still the right tool for a correlated subquery the native aggregate feature doesn't cover — e.g. a count scoped by an extra condition, or an aggregate over something that isn't a mapped ORM relation. A @Computed method always receives ctx.alias, the query's real build-time alias, so the subquery can reference the outer row:
import { Computed, type ComputedContext } from '@dudousxd/nestjs-filter';
export class AuthorFilter extends MikroOrmFilter<Author> {
@Computed({ type: 'number' })
highRatedBooksCount({ alias }: ComputedContext) {
return `SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id AND books.rating >= 4`;
}
}GET /authors?sort=-highRatedBooksCount
GET /authors?highRatedBooksCount[gt]=0The @Computed method decorator
Call forms:
@Computed()— alias = method name, no options.@Computed({ type: 'number' })— alias = method name, opts-first.@Computed('alias')— explicit alias.@Computed('alias', { type: 'number' })— explicit alias + options.
Options: type (a codegen-only value-type hint, see Codegen typing) and project (opt-in SELECT projection, see Projection).
export class AuthorFilter extends MikroOrmFilter<Author> {
@Computed({ type: 'number' })
booksCount({ alias }: ComputedContext) {
return `SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id`;
}
@Computed('highRatedBooksCount', { type: 'number' })
highRatedCount({ alias }: ComputedContext) {
return `SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id AND books.rating >= 4`;
}
}A @Computed method is always wrapped as a function source (forms 2 and 3 below), so it always receives the full ComputedContext — { alias, em }. If both the inline computed map (below) and a @Computed method declare the same alias, the decorator wins — it's the more specific, closer-to-usage declaration.
Source forms
A computed source can take three forms, implemented identically in both the MikroORM and TypeORM adapters:
-
A SQL string, emitted verbatim into the query — no token substitution. Use this for expressions that don't need to reference the query's own alias (e.g.
"first_name || ' ' || last_name"). This is what a@Computedmethod returning a plain string with noctxusage effectively gives you — the string just gets built at query time. -
A function
(ctx) => string, wherectx = { alias, em }(the real build-time alias and the ORM'sEntityManager/DataSource) — for expressions that need the query's alias or other runtime context, most commonly a correlated subquery. Every@Computedmethod is this form. -
A function that returns an ORM query builder — a correlated subquery built with the ORM's own query-builder API instead of hand-written SQL:
export class AuthorFilter extends MikroOrmFilter<Author> { @Computed({ type: 'number' }) booksCount({ em, alias }: ComputedContext) { return em.createQueryBuilder(Book) .where({ author: raw(`${alias}.id`) }) .count(); } }Adapter-specific mechanics for the QB form: MikroORM uses
raw((alias) => …), resolving the correlated alias via an internal sentinel that's substituted once the outer query compiles — you just use the callback'saliasand it works. TypeORM resolves its alias eagerly, so the subquery is built directly against the outer alias, but it must be param-free — inline the correlating alias as a raw string (.where("b.authorId = " + alias + ".id")) rather than binding it via:param, since TypeORM'sgetQuery()does not carry a subquery's bound parameters to the outer query. See the MikroORM and TypeORM adapter READMEs for full detail.
Parentheses are normalized for you
A subquery source may be written bare — no wrapping parentheses required. The adapters detect a string source (or a function's string return) that starts with SELECT, EXISTS or NOT EXISTS and wrap it in (…) before embedding it as an expression, so both of these are equivalent:
@Computed({ type: 'number' })
booksCount({ alias }: ComputedContext) {
return `SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id`;
}
// identical behavior, explicit parens:
// return `(SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id)`;Already-parenthesized sources and plain expressions (first_name || ' ' || last_name) pass through untouched.
Existence checks
EXISTS / NOT EXISTS is the natural way to ask "does this row have any …?", and it is normalized the same way (since 1.24.1). Declare the field type: 'boolean' and filter it with equals true / equals false:
@Computed({ type: 'boolean' })
hasHighRatedBook({ alias }: ComputedContext) {
return `EXISTS (SELECT 1 FROM books WHERE books.author_id = ${alias}.id AND books.rating >= 4)`;
}GET /authors?hasHighRatedBook=true
GET /authors?sort=-hasHighRatedBookA count is the more portable spelling. SELECT COUNT(*) … filtered with gt 0 / equals 0 expresses the same thing, and the caller controls both sides of the comparison — which matters because booleans are where the dialects diverge most (= 1 on MySQL, = true on PostgreSQL). Reach for EXISTS when the intent reads better; reach for a count when the value crosses a wire.
Inline computed map (alternative)
The same declarations can live inline on @Filterable({ computed }) — a map of alias → source. Handy for one-liners that don't warrant a method:
@Filterable({
entity: User,
computed: {
fullName: "first_name || ' ' || last_name",
},
})
export class UserFilter extends MikroOrmFilter<User> {}The function forms work here too (fullName: ({ alias }) => …). An entry can also be an object instead of a bare source — { source, type?, project? } — to carry the same options @Computed takes. type is optional in the object form; { source, project: true } alone is valid:
@Filterable({
entity: User,
computed: {
fullName: { source: "first_name || ' ' || last_name", type: 'string', project: true },
lastOrderAt: { source: ({ alias }) => `SELECT MAX(o.created_at) FROM orders o WHERE o.user_id = ${alias}.id`, project: true },
},
})Projection (project: true)
By default a computed field is filter/sort-only — the expression is emitted into WHERE/ORDER BY but never into the SELECT list, so returned rows don't carry the value. project: true opts a computed field into SELECT projection: the expression is selected under its alias and executed rows carry the computed value.
export class AuthorFilter extends MikroOrmFilter<Author> {
@Computed({ type: 'number', project: true })
booksCount({ alias }: ComputedContext) {
return `SELECT COUNT(*) FROM books WHERE books.author_id = ${alias}.id`;
}
}Unlike type — a codegen-only hint — project changes the emitted query. It's the one computed option that isn't runtime-invisible.
Executing through the adapter
FilterRunner.apply() (what @ApplyFilter runs) builds the query but never executes it — and a projected computed value isn't a real entity column, so the ORM's own qb.getResultAndCount() would hydrate entities and drop it. The execution seam is the adapter's getResultAndCount(qb): when the runner projected computed aliases onto a builder, the adapter remembers them for that exact builder instance and surfaces the values on the returned rows under their aliases. Inject the adapter via the FILTER_ADAPTER token and execute through it:
import { Controller, Get, Inject } from '@nestjs/common';
import { ApplyFilter, FILTER_ADAPTER, type FilterAdapter } from '@dudousxd/nestjs-filter';
import { AuthorFilter } from './author.filter.js';
@Controller('authors')
export class AuthorsController {
constructor(@Inject(FILTER_ADAPTER) private readonly adapter: FilterAdapter) {}
@Get()
async list(@ApplyFilter(AuthorFilter) qb: QueryBuilder<Author>) {
// Execute via the adapter — not qb.getResultAndCount() — so rows carry
// the projected computed aliases (e.g. row.booksCount).
const { rows, total } = await this.adapter.getResultAndCount!(qb);
return { rows, total };
}
}project: true is dispatched by the static pipeline (FilterRunner.apply(), i.e. @ApplyFilter with a filter class) — dynamic mode (applyDynamic/findAndCount, which take an entity instead of a filter class) has no project dispatch.
If the active adapter doesn't implement applyComputedSelect, the runner logs a warning and skips the projection — rows simply lack the alias; nothing throws.
Interaction with distinct
A distinct projection replaces entity-row output, so project: true is suppressed whenever a distinct projection was applied — a computed value participates in a distinct projection only by being explicitly listed in the distinct field list. Computed aliases in distinct are supported (with or without project: true):
GET /authors?distinct=booksCount
GET /authors?distinct=country,booksCountPlain columns in the list are applied first as one batched SELECT DISTINCT; each computed alias is then added to that distinct projection in request order. The distinct rows come back as plain objects, so the computed value appears under its alias naturally, and the distinct-tuple total counts the full projection including the computed expression.
groupByCount on a computed alias
FilterRunner.groupByCount also accepts a computed alias as its grouping field — the adapter groups by the evaluated expression instead of a column, still returning { value, count } rows:
// static: registry from the filter class (inline computed map + @Computed methods)
await runner.groupByCount(Author, input, { filterClass: AuthorFilter });
// dynamic: registry from @Filterable metadata declared on the entity itself
await runner.groupByCount(Author, input);Computed grouping aliases are dev-declared, so they bypass column validation exactly like computed sort/distinct; the numeric bucket variant applies to the expression the same way it applies to a column.
The where[] path
Since 1.19, a computed alias sent as a column filter dispatches correctly. The typed client builder's .where() emits where[], and codegen puts computed aliases in the field union, so .where('booksCount', 'gt', 0) both typechecks and runs:
filterQuery<Author>().where('booksCount', 'gt', 0);Those clauses are peeled off the where[] array before column validation and routed to applyComputedField, so the alias never reaches applyColumnFilters as a bogus column name. The structured form (?booksCount[gt]=0, filter[booksCount][gt]=0, the client builder's .set() envelope) still works and is equivalent.
One limit: only a TOP-LEVEL computed clause dispatches. A computed alias nested inside an AND/OR group is dropped with a warning, because applyComputedField appends its own top-level andWhere and cannot be composed into a nested boolean group. Lift the computed condition out of the group, or express it through the structured filter.
Before 1.19 this path handed the alias to applyColumnFilters, which emitted it as a column name and let the database reject the query — so a filter that typechecked returned a 500. If you are carrying a workaround that rewrites a computed clause into the structured envelope after .build(), it is no longer needed.
Codegen typing
Both declaration styles surface the field as a NAME in the generated, typed filterQuery() — sort() and where() accept the alias whether it came from @Computed or the inline map. To additionally type the where() value, declare a type hint:
@Computed:@Computed({ type: 'number' })or@Computed('alias', { type: 'number' }).- Inline map:
{ source, type }instead of a bare source.
A declaration with no type (a bare inline entry, or @Computed() / @Computed({ project: true }) without one) types only the field name in filterQuery() — where('booksCount', ...) is accepted, but the value isn't narrowed to a specific type.
project: true is also read statically by @dudousxd/nestjs-filter-codegen: projected aliases are exposed on the route contract as projectedFields, so downstream contract consumers (e.g. response typing in @dudousxd/nestjs-codegen) can know which extra keys appear on returned rows.