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 $filter string 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 null is 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; }
}