Summary#
A relation is two halves of one idea. The child points up by declaring the parent as a member — Order Order; —
which is the foreign key. The parent reads down through a collection member — [ForeignKey(Order)] LineItem[] LineItems;. Declare both, and you can walk the graph in either direction.
Signature#
entity <Child> {
[Required] <Parent> <Parent>; // the FK — one row of the parent
}
entity <Parent> {
[ForeignKey(<Parent>)] <Child>[] <Children>; // the children pointing back at this row
}Description#
How do I declare both sides of a relation?#
The child's member IS the foreign key — there is no OrderId to declare and keep in step. You assign a row, not an
id, and you read a row back. The parent's collection member carries the [ForeignKey] attribute, naming the
relation it reads down — [ForeignKey(Order)] on the Order's Lines:
entity Order {
[Required] string Code;
decimal Total;
[ForeignKey(Order)] LineItem[] Lines; // read down: this order's lines
}
entity LineItem {
[Required] Order Order; // point up: the order this line belongs to
[MaxLength(200)] string Product;
decimal Amount;
}
void AddLine(string orderCode, string product, decimal amount) {
var order = Order.Single(o => o.Code == orderCode);
var line = new LineItem { Order = order, Product = product, Amount = amount };
// ^^^^^^^^^^^^^ assign the ROW, not an id
}[Required] on the child's parent member means an orphan is impossible: a LineItem with no Order is a violation
at commit, not a row nobody notices for six months.
Read children through the collection, never a filter#
This is the one rule people get wrong. To get an order's lines, go through the collection:
decimal OrderTotal(string code) {
var order = Order.Single(o => o.Code == code);
var total = 0m;
foreach (var line in order.Lines) { // the collection — connected to the order you already loaded
total += line.Amount;
}
return total;
}Do not write a standalone query filtered on the foreign key to fetch children. It looks equivalent and is not: the collection is part of the object graph you already have, so it is loaded once and reused, while a separate query is disconnected from the parent and re-runs every time you touch it. The collection is the connected path; a filtered query is a second, unrelated result set that happens to contain the same rows.
How do I let a reference be unset?#
Leave off [Required] and the reference may be null — a Ticket that nobody is assigned to yet:
entity Person {
[Required] string Name;
}
entity Ticket {
[Required] string Title;
Person Assignee; // may be null — an unassigned ticket is a real state
}
string AssigneeName(Ticket t) {
return t.Assignee?.Name ?? "unassigned"; // null-safe, exactly as in C#
}What [Required] decides about deleting#
A required reference says the child cannot exist without its parent, and the platform takes that literally: deleting the parent deletes those children with it. An optional reference is the other answer — deleting the parent leaves the child and clears its reference.
entity Tag {
[Required] Note Note; // deleting the Note deletes this Tag
}
entity Draft {
Note Note; // deleting the Note leaves this Draft, with Note cleared
}⚠ This is where the decision is made, so it is worth making deliberately. A cascade removes the children
without asking whether the caller could have deleted them on their own — someone allowed to delete a Note can
delete its Tags by deleting the note, even where your rules never grant them delete on Tag. That is the
declaration doing what it says rather than a gap in it: the alternative would be a legal model that fails at run
time, with nothing you could write to fix it.
So if a child's removal should be governed separately from its parent's, do not make its reference required — model it as optional and delete it explicitly.
See also#
- entity — the entities a relation joins
- constraints —
[Required]on a reference is what forbids an orphan - Where / Single / Count — querying the entities themselves