ADR-0050 — Derived fields come from ORM metadata; core stays expression-agnostic
Status: accepted — supersedes ADR-0019.
Context
Every supported ORM already has its own virtual/generated-column mechanism (@VirtualColumn in TypeORM, @Formula in MikroORM, a client extension in Prisma, a schema virtual in Mongoose), and two of them (TypeORM, MikroORM) expose the derived expression as real SQL — schema metadata Kavo can read and push down to WHERE/ORDER BY, the same way an ordinary column already is. A field with no backing column should be declared once, on the ORM side, and core should read whatever expression the adapter reports and treat it exactly like a column wherever the adapter can make that true — rather than maintaining a separate, Kavo-native resolver mechanism with its own allowlist rules alongside the ORM's own.
Decision
FieldMetadata gains an optional derivedExpression: unknown marker. Core never inspects, parses, or builds it (ADR-0005) — it exists purely to round-trip from the adapter that produced it back to that same adapter, the same opacity EntityMetadata's other adapter-supplied values already have. A field with derivedExpression set has no backing storage column; one without it is an ordinary column, derivedExpression absent entirely (not undefined as a marker value — the key itself is omitted).
A derived field is opt-in to filterable/sortable/selectable via allowlists, never a default. The same rule a relation already follows (ADR-0028): ORM metadata supplies shape, never permission. Concretely, in resolveAllowlists:
- The unconfigured default for every allowlist excludes a derived field — it is computed from the entity's own (non-derived) columns only.
- An explicit array may name a derived field, and it works exactly like naming any other column would, subject to what the adapter can translate (below).
selectable's{ exclude }form resolves against own columns only — matching a relation, a derived field is never reachable through{ exclude }, only through an explicit array.creatable/updatablereject a derived field by name at bootstrap: it has no writable storage, so it can never be written, the same rule ADR-0019 held forcomputed.searchablerejects a derived field unconditionally, opted in or not: there is no ORM-independent way to turn an arbitrary derived expression into aWHERE ... ILIKEfragment.- A
create/update/patchDTO naming a derived field is a bootstrapConfigurationException(rejectDerivedWriteDtoKeys), for the same wire reason ADR-0019 rejected one namingcomputed: the generated OpenAPI body would advertise a property the engine unconditionally discards.
Per-adapter translation, not a Kavo-side expression language. Whether a derived field can actually satisfy an opt-in allowlist entry is entirely the adapter's decision, made once at metadata-derivation time by whether it reports a derivedExpression at all:
@kavo/typeormpopulates it from@VirtualColumn'squeryfunction — the same alias-parameterized SQL fragment TypeORM itself calls to populate the property on load.FilterTranslator.columnRefinlines it, parenthesized, in place ofalias.fieldfor a root-level field named in a filter or sort;SELECTneeds no adapter change at all, because a@VirtualColumnis a real TypeORM column and TypeORM's own entity hydration already includes it whenever the property is on the entity, independent ofselect=(selection is core's own "kept internally, stripped late" narrowing, not aSELECT-clause change).@kavo/mikroormpopulates it from@Formula/@Property({ formula })'s callback. No adapter-side inlining is needed: MikroORM resolves a formula property natively by property name inwhereandorderBy, so the existingFilterTranslatorand sort code already work by referencing the name, unchanged.derivedExpressioncarries the formula callback purely as a presence marker and for debug output.@kavo/prismaand@kavo/mongoosereport noderivedExpressionat all, for the same underlying reason in both cases: their derived-field mechanisms — a Prisma client extension'sresult.<model>.<field>.compute, a Mongooseschema.virtual(...).get(...)— are JavaScript evaluated by the client library on an already-fetched object, never described by the metadata source each adapter reads (Prisma's DMMF, Mongoose'sschema.paths). Such a field therefore produces noFieldMetadataentry at all, not an entry withderivedExpressionabsent — it is invisible to Kavo's query engine entirely. Naming it in a filter or sort is an ordinary unknown-field 400 (KAVO_QUERY_INVALID_FIELD), the same as any other name the entity does not declare. It may still be exposed response-only through a registereditem/listDTO or a custom operation that reaches for the extended client / virtual directly — Kavo itself supplies no wiring for that, unlike ADR-0019'sresolve.
computed is removed, not deprecated. ComputedFieldDescriptor, ComputedFieldMap, EntityConfig.computed, resolveComputedFields, rejectComputedWriteDtoKeys, and ResolvedEntityConfig.computed are gone from @kavo/core; DefaultSerializer/DefaultDeserializer no longer take or reference a computed map. @kavo/nest's applyResponseSchemaDocs drops its separate computedFieldNames loop — a derived field is now an ordinary FieldMetadata entry, typed and gated by selectable exactly like any other field, in the same loop.
Consequences
- Capability regression, accepted and not replaced. ADR-0019's
resolve(entity, context)let a derived value vary by caller — aviewerfield keyed offcontext.app, say. An ORM-derived expression is evaluated by the database once per row; nothing about it can vary by the request that happens to be reading the row. An app that needs a per-caller-varying value now reaches for a custom operation, an explicititem/listDTO computed in application code, or a policy — not a Kavo-native derived-field mechanism. The same is true for the cross-ORM-identical-behavior-over-class-instances-vs-plain-objects guarantee ADR-0019's resolver gave: a derived field's exact behavior (kind coercion, nullability) is now whatever the ORM reports for it, same as any other column, rather than a Kavo-normalized contract. - #174's hazard (a
computedresolver on an included relation target receiving the root request's context) no longer applies. There is no resolver left to hand a context to; a derived field on a relation target is read straight off the target's own hydrated row, exactly like any of its ordinary columns. - #140 (aggregation's home) is untouched by this ADR. A
@VirtualColumnsubquery or@Formulaexpression can express a per-row aggregate (a related-row count, say), but bucketed self-aggregation (GROUP BYacross a whole collection) is a different shape of problem and stays out of scope here, as it did before. - A derived field's
kind/nullablecome from whatever the adapter reports for the ORM-declared column type — there is no way for Kavo to infer a derived SQL expression's result type independently, so an adapter that gets this wrong (declaringnumberfor a boolean expression, say) produces a coercion mismatch the same as a misdeclared ordinary column would.