Skip to content
UQL

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;
}

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.

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:

You write
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") >= $1
ORDER BY (SELECT COALESCE(SUM("items_3"."amount"), 0) FROM "OrderItem" "items_3"
WHERE "items_3"."orderId" = "Order"."id") DESC

MongoDB reads the same field: the declaration is data, not SQL, so it renders there as a $lookup ending in a $count or a $group.

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.

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.

Select and filter them like any other field.

You write
const products = await pool.findMany(Product, {
$select: { id: true, profit: true },
});
SELECT "id", "salePrice" - "cost" "profit" FROM "Product"
You write
const products = await pool.findMany(Product, {
$select: { id: true },
$where: {
profit: { $gte: 10 },
},
});
SELECT "id" FROM "Product" WHERE "salePrice" - "cost" >= $1