Unions
A union is a read-only fact set assembled from several models. You declare one shape, then map each contributing model's rows into it with a from arm. Keel concatenates the arms into a database view, which get and list actions and a read-only Model API then read exactly as they read a table.
Reach for a union when the same question is answered by rows living in different models. A projected stock position, for example, is committed movements plus promises from purchase orders less demand that has not been allocated yet: three models, one fact set.
union StockOutlook {
fields {
kind MovementKind
item Item
location StockLocation?
occursOn Date
quantity Decimal
}
from StockEntry {
kind @computed(MovementKind.Committed)
item @computed(stockEntry.item)
location @computed(stockEntry.location)
occursOn @computed(stockEntry.occurredAt)
quantity @computed(stockEntry.quantity)
}
from PurchaseOrderLine {
@where(purchaseOrderLine.confirmed)
kind @computed(MovementKind.PlannedIn)
item @computed(purchaseOrderLine.item)
location @computed(purchaseOrderLine.destination)
occursOn @computed(purchaseOrderLine.expectedOn)
quantity @computed(purchaseOrderLine.outstandingQuantity)
}
from SalesOrderLine {
@where(salesOrderLine.status == SoStatus.Open)
kind @computed(MovementKind.PlannedOut)
item @computed(salesOrderLine.item)
occursOn @computed(salesOrderLine.shipBy)
quantity @computed(0 - salesOrderLine.unallocatedQuantity)
}
actions {
get getMovement(id)
list listMovements(item.id?, kind?, occursOn?)
}
@permission(expression: true)
}The models behind it are ordinary models:
enum MovementKind {
Committed
PlannedIn
PlannedOut
}
enum SoStatus {
Open
Closed
}
model Item {
fields {
sku Text
category Text
}
}
model StockLocation {
fields {
code Text
}
}
model StockEntry {
fields {
item Item
location StockLocation
occurredAt Date
quantity Decimal
}
}
model PurchaseOrderLine {
fields {
item Item
destination StockLocation
expectedOn Date
outstandingQuantity Decimal
confirmed Boolean
}
}
model SalesOrderLine {
fields {
item Item
shipBy Date
unallocatedQuantity Decimal
status SoStatus
}
}
model TransferLine {
fields {
item Item
fromLocation StockLocation
toLocation StockLocation
departsOn Date
arrivesOn Date
quantity Decimal
}
}A union has no write surface at all. There is no storage, no backfill and no create, update or delete, and because it is a view rather than a copy it is always current: change a source row and the fact changes with it.
Sections
A union body accepts these sections, in any order.
| Section | Purpose | Required |
|---|---|---|
fields { … } | the shape every arm maps into | yes |
from <Model> [as <Alias>] { … } | one arm, and you need at least one | yes |
actions { … } | get and list | no |
@permission(…) | who can read the fact set | yes, once there are actions |
Union names are written in UpperCamelCase and must be unique against every other declaration in your schema: models, unions, enums, messages and tasks. The name becomes a database relation, which is why it shares that namespace.
There already exists a model with the name 'StockOutlook'Fields
The fields block uses ordinary field syntax, <name> <Type>, with names in lowerCamelCase. A field can be any of the built-in Keel types, an enum, or a model, in which case it behaves as a relationship and you can traverse it in filters and @embed just as you would from a model.
Mark a field optional with ?. An optional field is one an arm is allowed to leave unmapped, which puts that arm's rows in the field's null group. In the example above, unallocated demand has no warehouse yet, so location is optional and the SalesOrderLine arm skips it.
A union field takes no attributes at all. It is not a column, so there is nothing to make unique, index or default, and the derivation lives on the arm's mapping rather than on the field.
union field 'quantity' has an unrecognised attribute @uniqueThree names are supplied by the runtime and cannot be declared: id, sourceModel and sourceArm. A field also cannot be typed as another union, because a relationship needs a key to point at and a view has none.
field 'outlook' cannot be of union type 'StockOutlook'Arms
An arm says where a group of facts comes from and how each field is derived.
from StockEntry {
@where(stockEntry.quantity != 0)
quantity @computed(stockEntry.quantity)
occursOn @computed(stockEntry.occurredAt)
}The model is written in UpperCamelCase, the same way you reference it as a type. Each mapping names a field the union declares and gives it exactly one @computed expression, rooted at the arm's source model in lowerCamelCase. That expression can be a literal or enum value, a field, a to-one traversal such as stockEntry.item.category, or arithmetic over them, and it follows the same rules as a model's computed fields.
@where on the arm narrows which of the source model's rows become facts. It is optional, may be written before or after the mappings, and is the only attribute an arm accepts. A mapping accepts only @computed, and can be written in block form if you prefer:
from StockEntry {
quantity {
@computed(stockEntry.quantity)
}
}Every required field, every arm
Nothing is matched up by name. Each arm maps each field explicitly, so every required field has to be mapped by every arm.
this arm does not map required field 'location'
map it with 'location @computed(...)', or declare 'location' optional to leave these rows in its null groupA mapping can only name a field the union declares, and only once per arm.
'warehouse' is not a field of union 'StockOutlook'
'quantity' is already mapped by this armNaming an arm
An arm's name is its alias if it has one, otherwise the model name, and arm names must be unique within the union. Give an arm an alias with as when one model contributes more than one kind of fact. A stock transfer, for instance, is one row that produces two movements on two dates:
union StockTransfers {
fields {
item Item
location StockLocation
occursOn Date
quantity Decimal
}
from TransferLine as Outbound {
item @computed(transferLine.item)
location @computed(transferLine.fromLocation)
occursOn @computed(transferLine.departsOn)
quantity @computed(0 - transferLine.quantity)
}
from TransferLine as Inbound {
item @computed(transferLine.item)
location @computed(transferLine.toLocation)
occursOn @computed(transferLine.arrivesOn)
quantity @computed(transferLine.quantity)
}
actions {
list listLegs()
}
@permission(expression: true)
}Two arms over one model must be aliased, otherwise there is no way to tell their rows apart:
'TransferLine' is used by more than one arm, so each must be named
write 'from TransferLine as <name> { ... }' so the arms can be told apartAn alias is written in UpperCamelCase too. It is what every row reports as sourceArm, so it is cased the same way whether the arm is aliased or not.
arm alias names must use UpperCamelCaseBuilt-in fields
Every union row carries three fields you do not declare.
| Field | Type | Value |
|---|---|---|
id | ID | the source row's primary key, passed straight through |
sourceModel | Text | the source model's name, for example "StockEntry" |
sourceArm | Text | the arm's name: the alias when there is one, otherwise the model name |
Keel ids are ksuids, so a source row's id identifies it across tables and get(id) works without any extra key. sourceModel and sourceArm let a caller navigate on to the underlying record, or tell two arms apart when they draw on the same model.
There is no createdAt or updatedAt. A fact is derived at read time and has no life of its own.
For each relationship-typed field the union declares, Keel also supplies an <name>Id field, so item Item gives you itemId. That is what relationship traversal joins through, and it is the form the field takes in generated types and in the API response.
Actions
A union serves get and list actions, declared exactly as they are on a model.
actions {
get getMovement(id)
list listMovements(item.id?, kind?, occursOn?)
}Filtering, pagination and ordering behave as they do for a model, and they apply across the whole fact set rather than one arm at a time. Inputs are the union's own fields plus relationship keys, so use item.id rather than a bare relationship:
'item' refers to a model which cannot be used as an input
Inputs must target fields on models only, e.g item.idA write action is a build error, because there is nothing to write to:
a union cannot serve a 'create' actionA get needs something unique to look up by. When any one model feeds more than one arm, its rows share an id, so declaring get is a build error:
a union cannot serve a get action when 'TransferLine' feeds more than one armAction names must be unique across your whole schema, models included, the same rule that applies to a model's actions.
The attributes a union action accepts are the read-only subset: @where, @permission, @orderBy, @sortable and @embed. There is no @set or @validate, since neither has anything to act on.
union action 'listMovements' has an unrecognised attribute @setAPI exposure
A union's actions join your app's default API automatically, so they are served over the JSON API and the JSON-RPC API, appear in the generated OpenAPI spec, and become queries in the GraphQL API.
A union cannot be listed in an explicit api block, which accepts models only:
api 'Web' has an unrecognised model StockOutlookKeel does not currently generate Console tools for a union's actions.
Permissions
@permission is mandatory as soon as a union declares any actions. A union does not inherit its source models' permission rules, so without one it would be a read surface with nothing guarding it. A model can leave permissions out because nothing is reachable until an action grants it; a union cannot.
union 'StockOutlook' serves actions but declares no @permission
a union does not inherit its source models' permissions, so it must declare its ownPut @permission at the top level of the union, or on an individual action, and write it the way you would on a model. Roles and expressions work as they do everywhere else, so see Permissions for the rules themselves:
union StockOutlook {
// fields and arms
actions {
get getMovement(id)
list listMovements(item.id?)
}
@permission(roles: [Ops])
}A union's @permission with no actions: argument grants all of the action types the union can serve. A union only reads, so there is no write to leave ungated and nothing to be gained from spelling the list out. Naming action types still narrows the rule in the usual way.
Because permissions are not inherited, a union is a deliberate decision to publish a combined view of its source models. Write its rules with the whole fact set in mind rather than assuming a source model's rules still apply. @permission is the only attribute the union declaration itself accepts.
Reading a union in code
Every union gets an entry on models in the generated @teamkeel/sdk, with a read-only API.
import { models, MovementKind } from "@teamkeel/sdk";
const facts = await models.stockOutlook.findMany({
where: { item: { id: { equals: widget.id } } },
});
const one = await models.stockOutlook.findOne({ id: facts[0].id });
const planned = await models.stockOutlook
.where({ kind: { equals: MovementKind.PlannedIn } })
.findMany();findOne, findMany and where are the whole surface. There is no create, update, delete or upsert, and no create or update value types, so a write is a compile error rather than a runtime one. Postgres refuses writes to the view as well.
findOne takes the id on its own, since a union row has no other unique field:
export type StockOutlookUniqueConditions = { id: string };The row interface holds the fields you declared, the three built-ins, and an id for each relationship field:
export interface StockOutlook {
kind: MovementKind;
occursOn: Date;
quantity: number;
id: string;
sourceModel: string;
sourceArm: string;
itemId: string;
locationId: string | null;
}Calling the actions works as it does for a model. Rows from an aliased pair of arms are told apart by sourceArm:
const { results } = await actions.listLegs();
const byArm = Object.fromEntries(results.map((r) => [r.sourceArm, r]));
byArm.Outbound.quantity; // -6
byArm.Inbound.quantity; // 6Unions are skipped by resetDatabase in tests, because there is no table to truncate. Their contents reset along with the source tables.
How a union is stored
Each union compiles to one Postgres view named after the union in snake_case, with each arm contributing a SELECT and the arms joined by UNION ALL. There is no table, no column, no index, no constraint and no trigger.
UNION ALL rather than UNION is deliberate. A fact set is a bag, not a set: two arms can legitimately produce identical facts, and deduplicating them would silently drop one and corrupt every total that counts them.
A few consequences are worth knowing about:
- Every arm selects the union's fields in the same order, and an optional field an arm leaves unmapped becomes a typed
NULLin that arm'sSELECT. - An arm's
@whereand every mapping expression are baked into the view definition, with literals written inline. A view definition cannot carry bind parameters. - Keel stamps the views it owns with a comment, so a hand-written view in the same database is never touched.
- On every migration, Keel drops the union views it owns before diffing tables and recreates them afterwards, all in one transaction. This lets a source model change shape without you having to drop a view by hand, and it retires the view of a union you deleted. Readers never see a missing relation.
Limitations
Keel does not support unary negation in expressions, in a union or anywhere else, so write a negated value as a subtraction from zero:
quantity @computed(0 - salesOrderLine.unallocatedQuantity)Mapping expressions are not yet checked by keel validate, so a mapping that references a field the source model does not have, or produces a type the union field cannot hold, is reported when the migration runs rather than at build time. If a keel run or a deploy fails on a union view, the mapping is the first place to look.
Unions are read-only by design. If you need to write to combined data, keep the write path on the source models and use the union for reading.