Entity Relationships
Typed
EntityLink<T>references plus declarative cascade delete and reactive FK joins.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ต Core ยท Category: Ecs
๐ฏ What it solves
Entities rarely stand alone โ bags own items, characters own equipment, guilds own members. An ECS needs a
relationship model with the same guarantees as the rest of the database: type-checked endpoints (not raw
integer IDs), indexed reverse lookups, predictable cleanup when a parent goes away, and reactive joins for views
that must stay live as relationships change. EntityLink<T> plus the indexing/cascade/navigation machinery
built on top of it covers 1:1, 1:N, and N:M relationships, and hierarchical trees, without ad-hoc bookkeeping.
โ๏ธ How it works (in brief)
EntityLink<TArchetype> is an 8-byte, archetype-checked wrapper around EntityId โ EntityLink<Building>
accepts Building or any descendant archetype, EntityLink.Null means "no reference." Marking the field
[Index] gives an indexed FK: a child stores a link to its parent (1:N, "FK on child"), or a parent stores a
ComponentCollection<EntityLink<T>> of children (1:N, "collection on parent"); the two compose for bidirectional
access. [Index(OnParentDelete = CascadeAction.Delete)] on an FK field makes tx.Destroy() on the parent
recursively destroy every child still pointing to it โ validated cycle- and diamond-free at registration.
Reactive FK joins (NavigateField/NavigationView, see Query System) walk a plain
long-typed indexed field rather than EntityLink<T> directly โ model that field as [Index] public long OwnerId; alongside (or instead of) a typed EntityLink<T> field if a relationship needs one.
๐ป Usage
public struct ItemData
{
[Index(AllowMultiple = true, OnParentDelete = CascadeAction.Delete)]
public EntityLink<Bag> Owner;
public int Damage;
}
public struct BagData
{
public int Capacity;
}
// FK on child โ O(1) reverse lookup, no contention on the parent
EntityId itemId = tx.Spawn<Item>(Item.Data.Set(new ItemData { Owner = bagId, Damage = 15 }));
foreach (EntityRef item in tx.Query<Item>().Where(i => i.Owner == bagId))
{
ref readonly ItemData data = ref item.Read<ItemData>();
}
// Safe follow of a possibly-stale link
if (tx.TryOpen(item.Read<ItemData>().Owner, out EntityRef bag))
{
// alive โ use it
}
// Cascade delete โ destroying the bag destroys every Item whose Owner points to it
tx.Destroy(bagId);
| Relationship shape | Mechanism | Reverse lookup | Reactive join (NavigateField) |
|---|---|---|---|
| 1:1 | EntityLink<T> field, optionally [Index] |
Only if indexed | Needs a separate long [Index] field |
| 1:N (FK on child) | [Index(AllowMultiple = true)] EntityLink<T> |
O(1) field read | Needs a separate long [Index] field |
| 1:N (collection on parent) | ComponentCollection<EntityLink<T>> |
Not indexed | Not applicable |
| N:M | Junction archetype with two indexed EntityLink<T> fields |
Both sides indexed | Needs a separate long [Index] field |
โ ๏ธ Guarantees & limits
EntityLink<T>is 8 bytes, same layout asEntityIdโ zero overhead over a raw reference; construction asserts (debug builds) that the target archetype is inT's subtree.- A unique
[Index](noAllowMultiple) on anEntityLink<T>field rejects a second entity claiming the same target โ enforced at index-update time, at commit. - Cascade delete (
OnParentDelete = CascadeAction.Delete) only follows indexed FK fields; the cascade graph is validated at registration โ cycles and diamonds both fail startup with a descriptive error, so cascade is always a bounded tree traversal. All cascaded destroys commit atomically with the parent destroy. ComponentCollection<EntityLink<T>>relationships are not indexed โ no reverse lookup, no independent query on members, and concurrent writers to the same parent collection retry on conflict (microsecond cost, in-process). Cascade for these is a parent-side iterate-and-destroy, not an index scan.- Opening a stale (destroyed, non-cascaded) link fails the MVCC visibility check; use
tx.TryOpento handle it without an exception. NavigateField/NavigationViewtake anExpression<Func<TSource, long>>FK selector โ a raw indexedlongfield, not anEntityLink<T>field directly (no implicit conversion exists between the two).EntityLink<T>fields work withWhere, cascade delete, andtx.Open/tx.TryOpen, just not a reactive FK join today.
๐งช Tests
- CascadeDeleteTests โ cascade graph validation, multi-level and mixed-ownership cascade, already-dead children, same-transaction cascade
- EcsHardeningTests โ
EntityLink<T>implicit conversion round-trip, null detection,IsAlive/Openvia a link, deep cascade hierarchies - EcsNavigationTests โ
NavigateFieldreactive FK join, combined source/target predicates, incremental view over the join
๐ Related
- Source:
src/Typhon.Engine/Ecs/public/EntityLink.cs,src/Typhon.Engine/Ecs/public/EcsNavigationQueryBuilder.cs,src/Typhon.Engine/Ecs/internals/ArchetypeRegistry.cs(cascade graph validation) - Related features: Query System, Automatic Secondary Indexes, ECS Query API