Computed Fields
@Field({ computed }) declares a value the database produces, not one the caller writes. It never
takes part in an insert or an update, and reads like any other field. Declare it readonly, and a write
payload naming it is a compile error rather than a value silently dropped.
It takes one of two forms. An expression over the row - arithmetic, a string built from two columns, a date part - which is SQL, below; or a relation aggregate, which is data and reads on every engine.
import { Entity, Id, Field, raw } from 'uql-orm';
@Entity()export class Product { @Id({ type: Number }) id?: number;
@Field({ type: Number }) cost?: number | null;
@Field({ type: Number }) salePrice?: number | null;
@Field({ type: Number, // Each ref renders as its column, qualified and escaped, so the expression survives a join, // a subquery whose columns would shadow a bare name, and a rename in your editor. computed: (product) => raw`${product.salePrice} - ${product.cost}`, }) readonly profit?: number | null;}stored: the one dial
Section titled “stored: the one dial”By default nothing is persisted: the expression is spliced into every statement that reads the field.
Add stored: true and it becomes a real column the engine keeps up to date, so it can be indexed,
constrained and read without recomputing.
@Field({ type: Number, computed: (product) => raw`${product.salePrice} - ${product.cost}`, stored: true,})profit?: number | null;Queries do not change. $select, $where and $sort read the field the same way either side of the
dial, so stored is something you flip after profiling without touching a call site.
| unstored (default) | stored: true |
|
|---|---|---|
| where the value is | recomputed per statement | a column, written by the engine |
| DDL | none | GENERATED ALWAYS AS (...) STORED |
| indexable | no | yes, like any column |
| expression | anything the dialect parses | must be deterministic, over the row |
A stored column needs a type, since a migration has to spell one out. Support is Postgres 12+,
MySQL 5.7+, MariaDB 5.2+, SQLite 3.31+.
Either way the expression is SQL, which MongoDB does not evaluate: a query naming one there is
refused rather than answered with undefined.
Relation aggregates
Section titled “Relation aggregates”The other form reads a relation instead of writing SQL: count(), sum(), min(), max() and
avg(), off the same refs. The aggregate says what the field holds, so it declares no type.
import { Entity, Id, Field, ManyToOne, OneToMany } from 'uql-orm';
@Entity()export class Order { @Id({ type: Number }) id?: number;
@OneToMany({ entity: () => OrderItem, mappedBy: (item) => item.order }) items?: OrderItem[];
@Field({ computed: (order) => order.items.count() }) readonly itemCount?: number;
@Field({ computed: (order) => order.items.sum((item) => item.amount) }) readonly total?: number;
@Field({ computed: (order) => order.items.max((item) => item.amount) }) readonly largestItem?: number | null;
// Which of the related rows it reads. @Field({ computed: (order) => order.items.count({ $where: { refunded: false } }), }) readonly paidCount?: number;
// ...and, for a value aggregate, a page of them. @Field({ computed: (order) => order.items.sum((item) => item.amount, { $sort: { amount: -1 }, $limit: 5, }), }) readonly topFiveTotal?: number;}
@Entity()export class OrderItem { @Id({ type: Number }) id?: number;
@Field({ references: () => Order, type: Number }) orderId?: number | null;
@ManyToOne({ entity: () => Order, references: (item) => item.orderId }) order?: Order;
@Field({ type: Number }) amount?: number | null;
@Field({ type: Boolean }) refunded?: boolean | null;}count and sum read 0 over a parent with no rows; min, max and avg read null, and the
property has to admit it. Only a to-many can be aggregated, and sum and avg only over a numeric
column. A many-to-many’s count tallies its links; a column of its targets reads each target once.
Each one is a correlated subquery inside the parent’s own statement, so no related row is loaded:
const orders = await pool.findMany(Order, { $select: { id: true, total: true }, $where: { itemCount: { $gte: 2 } }, $sort: { total: -1 },});SELECT "id", (SELECT COALESCE(SUM("items"."amount"), 0) FROM "OrderItem" "items" WHERE "items"."orderId" = "Order"."id") "total"FROM "Order"WHERE (SELECT COUNT(*) FROM "OrderItem" "items_2" WHERE "items_2"."orderId" = "Order"."id") >= $1ORDER BY (SELECT COALESCE(SUM("items_3"."amount"), 0) FROM "OrderItem" "items_3" WHERE "items_3"."orderId" = "Order"."id") DESCMongoDB reads the same field: the declaration is data, not SQL, so it renders there as a $lookup
ending in a $count or a $group.
Reading only some of the rows
Section titled “Reading only some of the rows”topFiveTotal above is the shape: a value aggregate reading only some of the related rows takes the
$sort that picks which ones, together with its $limit, since a total over five of them is
defined by nothing else.
A count takes $limit on its own - an order changes which rows a page holds, never how many - and
caps the tally, which is how you ask “at least 500?” without counting a million.
Loading
Section titled “Loading”An aggregate reads the related rows, so, like a relation, it loads only where a query names it in
$select, $where or $sort. eager: true puts it in every read.
stored: true is not accepted on one: a correlated subquery is not something an engine keeps in a
generated column.
Querying
Section titled “Querying”Select and filter them like any other field.
Selection
Section titled “Selection”const products = await pool.findMany(Product, { $select: { id: true, profit: true },});SELECT "id", "salePrice" - "cost" "profit" FROM "Product"Filtering
Section titled “Filtering”const products = await pool.findMany(Product, { $select: { id: true }, $where: { profit: { $gte: 10 }, },});SELECT "id" FROM "Product" WHERE "salePrice" - "cost" >= $1