Relationships
Entities relate to each other through reference properties (foreign keys) and collections (inverse navigation). The relationship system supports cascading deletes, one-to-one and one-to-many patterns, and virtual references.
[ReferenceProperty]
Defines a foreign key relationship to another entity.
[ReferenceProperty("Label", DeleteAction)]
public virtual partial TargetEntity? PropertyName { get; set; }
Reference properties must be virtual partial and are typically nullable.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
label |
string |
Yes | Display label for the relationship |
deleteAction |
DeleteAction |
Yes | What happens when the referenced entity is deleted |
referenceType |
ReferenceType |
No | Relationship cardinality (default: OneToMany) |
Description |
string? |
No | User-facing description |
DeleteAction
Controls what happens to this entity when the entity it references is deleted:
| Value | Behavior |
|---|---|
DeleteAction.Restrict |
Block deletion — cannot delete the referenced entity while this reference exists |
DeleteAction.Cascade |
Delete together — delete this entity when the referenced entity is deleted |
DeleteAction.SetNull |
Clear reference — set this property to null when the referenced entity is deleted |
// Cannot delete a customer while sales orders reference it
[Required]
[ReferenceProperty("Customer", DeleteAction.Restrict)]
public virtual partial Customer? SellToCustomer { get; set; }
// Delete details when the parent order is deleted
[Required]
[ReferenceProperty("Sales order", DeleteAction.Cascade)]
public virtual partial SalesOrder? SalesOrder { get; set; }
// Clear the billing customer reference if that customer is deleted
[ReferenceProperty("Billing customer", DeleteAction.SetNull,
Description = "This is the account that is being billed.")]
public virtual partial Customer? BillingCustomer { get; set; }
ReferenceType
| Value | Description |
|---|---|
ReferenceType.OneToMany |
Default. Many entities can reference the same target |
ReferenceType.OneToOne |
Only one entity can reference this target |
ReferenceType.OwnedType |
Complex owned type embedded in the parent |
// One-to-one: only one product can have this image
[ReferenceProperty("Image", DeleteAction.Restrict, ReferenceType.OneToOne,
Description = "An image of the product")]
public virtual partial Blob? Image { get; set; }
Entity filters (EntityFilter<T>)
An EntityFilter<T> restricts which rows of an entity are selectable as a reference target. You
declare it as a named public static member on the target entity, then each reference property opts
in with the Filter named argument:
// On the target entity — a public static filter with a short label for the valid set:
[ApiEntity]
public partial class Category
{
[Property<DataTypes.Boolean>("Active")]
public partial bool IsActive { get; set; }
public static readonly EntityFilter<Category> ActiveOnly =
new(c => c.IsActive, "active categories");
}
// On the referencing side — opt in per reference property:
[ReferenceProperty("Category", DeleteAction.Restrict, Filter = nameof(Category.ActiveOnly))]
public virtual partial Category? Category { get; set; }
// Another reference to the same entity can use a different filter, or none:
[ReferenceProperty("Archive category", DeleteAction.SetNull)]
public virtual partial Category? ArchiveCategory { get; set; }
Filter names a public static EntityFilter<TTarget> member on the target entity type. The
label names the valid set, so the rejection message reads "Only {label} can be selected."
(e.g. "Only active categories can be selected."); phrase the label positively. It is reused by
every reference, and a reference can override it with FilterLabel. Declaring the filter wrong
(missing member, wrong type, unsupported expression) fails at startup.
What it does:
- Pickers only offer matching rows. The expression is translated to an OData
$filterstring surfaced on the property's entity metadata (ReferenceFilter/ReferenceFilterDescription), and the dynamic reference editors apply it to their candidate queries automatically. - The server rejects non-matching assignments. Core registers a validate event on the reference property, so assigning a target that fails the filter — by navigation, FK guid, natural key, nested payload, or collection add — produces an Error prompt on the property and blocks the save. Direct API calls that bypass the picker are therefore still enforced.
- Stored values are grandfathered. If a stored reference later falls out of the filter
(e.g. the category is deactivated), the record still saves: unrelated edits work, and re-sending
the unchanged reference works. Only changing the reference to a different non-matching target
is rejected. Assigning
nullis never rejected by the filter — use[Required]for that.
Supported expression subset (anything else fails at startup): comparisons
(==, !=, <, <=, >, >=), logical &&, ||, !, member paths over single-valued
navigations on the lambda parameter (c.IsActive, c.Level > 3,
c.Classification.Reconcile != Reconcile.SystemReconciled — translated to
Classification/Reconcile ne 'SystemReconciled'), and constants — string, numeric, bool, Guid,
enum, null, and date types, including closure-captured values.
Null-guard rule for navigation paths. Expression trees forbid ?., so a filter over a
nullable navigation must guard explicitly:
public static readonly EntityFilter<Category> NotSystemReconciled = new(
c => c.Classification != null && c.Classification.Reconcile != Reconcile.SystemReconciled,
"reconcilable categories"); // message: "Only reconcilable categories can be selected."
The explicit guard is what makes the compiled in-memory validation, OData null semantics, and SQL
three-valued logic agree when the navigation is null: the row is excluded from pickers and the
assignment is rejected. This means a target with no classification is not assignable under the
filter above — write c.Classification == null || ... instead if unclassified targets should pass.
Collection conditions (.Any()/.All()) are not supported. Model those as a computed flag on the
target entity — a compute event maintaining a stored bool (full C# and data
access available there) — and filter on the flag: c => c.IsSelectable.
An
EntityFilter<T>is an assignment constraint, not row security. Non-matching rows stay readable and queryable; only their selection as a reference target is restricted.
[OppositeSideCollection]
Placed on the same reference property, this generates a collection navigation on the target entity — the inverse side of the relationship.
[OppositeSideCollection("PropertyName", "Label", CollectionLoadMode)]
Parameters
| Parameter | Type | Description |
|---|---|---|
propertyName |
string |
Name of the collection property generated on the target entity |
propertyLabel |
string |
Display label for the collection |
loadMode |
CollectionLoadMode |
How the collection is loaded |
Description |
string? |
User-facing description |
TechnicalDescription |
string? |
Developer notes |
Collection loading mode
This is important for performance! If the collection will be large, use paged. Events on paged collections are limited. See Compute event and Collection changed event.
| Value | When to Use |
|---|---|
LoadAll |
Small collections always loaded with the parent (e.g., order details, product UOMs) |
Paged |
Large collections loaded on demand with paging (e.g., customer's orders) |
Example: Parent-Child with LoadAll
// On SalesOrderDetail:
[Required]
[ReferenceProperty("Sales order", DeleteAction.Cascade)]
[OppositeSideCollection("Details", "Details", CollectionLoadMode.LoadAll,
Description = "Sales order details",
TechnicalDescription = "Details of an invoice can be both materials or non-materials")]
public virtual partial SalesOrder? SalesOrder { get; set; }
This generates a Details collection property on SalesOrder that loads all detail lines when the order is loaded.
Example: Paged Collection
// On SalesOrder (via ISalesDoc interface):
[Required]
[ReferenceProperty("Customer", DeleteAction.Restrict)]
[OppositeSideCollection("SalesOrders", "Sales orders", CollectionLoadMode.Paged)]
public virtual partial Customer? SellToCustomer { get; set; }
This generates a paged SalesOrders collection on Customer — fetched separately with OData $skip/$top.
Placeholders
When multiple entity types implement the same interface, use placeholders to generate unique collection names:
| Placeholder | Replaced With |
|---|---|
[EntityName] |
The implementing entity's class name |
[EntityLabel] |
The implementing entity's display label |
// In ISalesDoc interface — works for SalesOrder, Invoice, etc.
[ReferenceProperty("Customer", DeleteAction.Restrict)]
[OppositeSideCollection("[EntityName]s", "[EntityLabel]s", CollectionLoadMode.Paged)]
public virtual partial Customer? SellToCustomer { get; set; }
When SalesOrder implements ISalesDoc, this generates a SalesOrders collection on Customer. See Interfaces for more.
Self-Referencing Relationships
An entity can reference itself:
[ApiEntity]
public partial class SalesOrderDetail
{
[ReferenceProperty("Accessory parent", DeleteAction.Cascade)]
[OppositeSideCollection("Accessories", "Accessories", CollectionLoadMode.LoadAll)]
public virtual partial SalesOrderDetail? AccessoryParent { get; set; }
}
[VirtualReferenceProperty]
A computed reference that is not persisted to the database. The referenced entity is resolved at runtime by business logic.
[VirtualReferenceProperty("Default selling unit")]
public partial ProductUom? DefaultSellingUnit { get; set; }
[VirtualReferenceProperty("Main unit")]
public partial ProductUom? MainUnit { get; set; }
Virtual references:
- Have no foreign key column in the database
- Are resolved by compute events in business logic
- Can reference entities from any collection on the parent
[OppositeSideProperty]
Like [OppositeSideCollection] but generates a single navigation property instead of a collection (for one-to-one inverse navigation). Used less commonly.
Complete Example
namespace Benevia.ERP.Model.Products;
[ApiEntity]
public partial class ProductUom
{
// Parent reference — deleting the product deletes all its UOMs
[Required]
[ReferenceProperty("Product", DeleteAction.Cascade)]
[OppositeSideCollection("Uoms", "Uoms", CollectionLoadMode.LoadAll,
Description = "Units of measure for the product")]
public virtual partial Product? Product { get; set; }
[MaxLength(10)]
[Property<DataTypes.Text>("Name",
Description = "Name of the unit such as ea, lb, kg, or case")]
public partial string Name { get; set; }
[Property<DataTypes.Enum>("Operation")]
[DefaultValue(UomOperation.Multiply)]
public partial UomOperation Operation { get; set; }
[DefaultValue(1)]
[Property<DataTypes.PositiveDecimal>("Factor",
Description = "The factor to use in the operation")]
public partial decimal Factor { get; set; }
}