Skip to main content

Navigation Editors

Navigation Editors provide a zero-allocation-by-default, type-safe API for navigating and mutating parent-child entity relationships. They are generated at build time from parent-side @@navigation declarations in your .conjure schema or from legacy inferred child-side metadata, producing ref struct types that live entirely on the stack.

See also: Transactions · Reactive Queries · Query Language


Overview

For each entity with child relationships, the code generator produces three types:

Generated TypeKindPurpose
{Entity}Editorref structWrite proxy for reading, updating, and deleting an entity
{Child}CollectionHandleref structHandle for the child collection scoped to a parent FK
{Entity}EditorFactoryref structIndexer factory accessed via ctx.Entity[id]

All types are stack-only (ref struct). The default traversal and mutation path stays allocation-free and boxing-free. Explicit snapshot helpers such as ToArray() and ToList() allocate by design when you need an owned copy.

Comparison with Other Patterns

PatternAllocationsType SafetyNavigationCascading
Navigation EditorsZero by default (snapshot helpers allocate)✅ Compile-time✅ Fluent✅ Generated
Entity Framework NavigationYes (tracked proxies)✅ (runtime)
Raw DbSet operationsZero❌ Manual FK management
Repository patternVariesVariesManual

Setup

1. Define Entities with Relationships

table Customer(persistence: local, capacity: 10000) {
Id: int @id
Name: string

@@navigation(name: "Orders", references: Order.CustomerId)
}

table Order(persistence: local, capacity: 50000) {
Id: int @id
CustomerId: int @relation(references: Customer.Id)
TotalAmount: decimal
Status: string

@@index(fields: [CustomerId], name: "Order_Customer", kind: lookup)
@@navigation(name: "OrderItems", references: OrderItem.OrderId)
}

table OrderItem(persistence: local, capacity: 200000) {
Id: int @id
OrderId: int @relation(references: Order.Id)
ProductId: int
Quantity: int
UnitPrice: decimal

@@index(fields: [OrderId], name: "OrderItem_Order", kind: lookup)
}

Key schema annotations:

AnnotationPurpose
@@navigation(name: ..., references: Child.Fk)Preferred parent-side declaration for generated child collection navigation
@relation(references: Parent.Id)Declares the FK relationship for compiler/optimizer metadata
@@index(fields: [Fk], kind: lookup)Creates the lookup index used by CollectionHandle
@idMarks the primary-key field used by Editors and CollectionHandles

2. Generate Code

dotnet run --project ConjureDB.CodeGen.Manual -- MyProject MyProject/Generated

This produces:

  • CustomerEditor.g.cs — Editor for Customer
  • OrderCollectionHandle.g.cs — Handle for Customer's Orders
  • OrderItemCollectionHandle.g.cs — Handle for Order's OrderItems
  • NavigationExtensions.g.cs — Factory structs and context properties

3. Automatic Lookup Index Synthesis

If the relationship metadata identifies a child FK but no explicit lookup index exists, the generator calls SynthesizeMissingLookupIndexes() to create the required lookup index automatically. This works both for explicit @@navigation declarations and for legacy inferred relationships.


Editor API

Creating an Editor

Access an Editor through the DbContext indexer:

// By primary key
var customer = Context.Customer[1];

// By entity reference
var alice = Context.Customers.FindById(1);
var customer = Context.Customer[alice];

Properties

MemberTypeDescription
ValueTReads the current entity snapshot from the DbSet
IdintThe entity's primary key
ExistsboolWhether this entity exists in the DbSet

Methods

MethodReturnsDescription
Update(T value)voidReplaces the entity in its DbSet
Remove()boolRemoves the entity, returns success
RemoveCascade()boolRemoves all children recursively, then the entity
Modify(Func<T, T>)EditorRead-modify-update in one call. Returns this for chaining

Implicit Conversion

Editors implicitly convert to their entity type:

Customer alice = Context.Customer[1]; // equivalent to Context.Customer[1].Value

Fluent Chaining

Modify returns the Editor, enabling expressive chains:

Context.Customer[1]
.Modify(c => c with { Name = "Alice Updated" })
.Orders.Add(new Order { TotalAmount = 100m, Status = "New" });

Parent Reference Navigation

Parent-reference navigation is generated only on the editors of entities that are themselves aggregate roots (they own their own child collections). When such an entity also has a FK to another entity, its Editor exposes a navigation property that returns the parent's Editor. A pure leaf entity gets no generated Editor at all, so it exposes no parent navigation. The property name is derived from the FK (e.g., CustomerId -> Customer):

// Navigate: Order -> Customer
var customer = Context.Order[orderId].Customer;
Console.WriteLine(customer.Value.Name);

// Full round-trip: Order -> Customer -> Orders -> OrderItems
var items = Context.Order[2].Customer.Orders.Editor(1).OrderItems;

Parent references return an Editor, so all Editor operations are available.


CollectionHandle API

Access a child collection through the Editor's navigation property:

var orders = Context.Customer[1].Orders;

Properties

MemberTypeDescription
this[childId]TFinds a child by primary key (throws if not found)
CountintNumber of children belonging to this parent
Anybooltrue if at least one child exists
IsEmptybooltrue if no children exist

Only the enumeration surface (Count, Any, IsEmpty, GetAll, foreach, RemoveAll, ModifyAll) is scoped to this parent's FK. The id-based operations — this[childId], TryGet, Update, Remove, Modify, and Transfer — resolve by the child's global primary key and do not verify that the child actually belongs to this parent.

CRUD Methods

MethodReturnsDescription
Add(T item)Editor or intAdds child, auto-sets FK. Returns Editor (aggregate-root) or id (leaf)
AddRange(T[] items)voidBatch add. Auto-sets FK on each item
TryGet(childId, out T)boolSafe lookup without exceptions
First()TReturns the first child. Throws if empty
TryGetFirst(out T)boolSafe first-child access
Last()TReturns the last child in current enumeration order. Throws if empty
TryGetLast(out T)boolSafe last-child access
Update(T item)voidUpdates a child entity
Remove(childId)boolRemoves a child by id
Modify(childId, Func<T,T>)voidRead-modify-update a child
ModifyAll(Func<T,T>)voidApplies a transform to every child
Transfer(childId, newParentId)voidReparents a child by updating its FK
RemoveAll()intRemoves all children, returns count
Editor(childId)EditorReturns Editor for aggregate-root child
GetAll()GroupEnumerableReturns the zero-allocation enumerable for querying
ToArray()T[]Explicitly materializes the current children into an array
ToList()List<T>Explicitly materializes the current children into a list

FK Auto-Assignment

When you call Add(), the CollectionHandle automatically sets the FK property to the parent's ID:

// CustomerId is automatically set to 1
Context.Customer[1].Orders.Add(new Order
{
TotalAmount = 100m,
Status = "New"
// CustomerId is set automatically — do not set it manually
});

This uses zero-copy with patterns on record types for efficient value assignment.

Enumeration

CollectionHandles support direct foreach without calling GetAll():

foreach (var order in Context.Customer[1].Orders)
Console.WriteLine($"Order {order.Id}: {order.TotalAmount:C}");

First(), TryGetFirst(), Last(), and TryGetLast() all use this current enumeration order. They do not sort or lazy-load anything.

GetAll() vs explicit materialization

Use GetAll() or direct foreach as the default read path when you want zero-allocation traversal inside the current transaction:

var orders = Context.Customer[1].Orders.GetAll();
if (orders.HasItems)
Console.WriteLine($"First visible order id: {Context.Customer[1].Orders.First().Id}");

foreach (var order in Context.Customer[1].Orders)
Console.WriteLine(order.Status);

Use ToArray() or ToList() only when you explicitly need an owned snapshot, for example to hand data to an API that requires T[]/List<T> or to sort a copy without changing the underlying collection contract:

var orderArray = Context.Customer[1].Orders.ToArray();
var orderList = Context.Customer[1].Orders.ToList();
orderList.Sort((left, right) => right.TotalAmount.CompareTo(left.TotalAmount));

ToArray() and ToList() are escape hatches:

  • They allocate on the heap.
  • They snapshot the current transaction state only.
  • They do not lazy-load or keep a live link to the handle.

Cascading Deletes

Entity-Level Cascade

RemoveCascade() recursively removes all children before removing the entity:

// Removes Customer + all Orders + all OrderItems of those Orders
Context.Customer[1].RemoveCascade();

Cascade Behavior

Entity TypeCascade Action
Aggregate root (has children)Recursively cascade to children first
Leaf (no children)RemoveAll() on the collection

Example: Multi-Level Cascade

Customer 1
├── Order 10
│ ├── OrderItem 100
│ ├── OrderItem 101
│ └── OrderItem 102
├── Order 11
│ └── OrderItem 103
└── Review 20

RemoveCascade(Customer 1):
1. RemoveCascade(Order 10) → Remove OrderItems 100, 101, 102 → Remove Order 10
2. RemoveCascade(Order 11) → Remove OrderItem 103 → Remove Order 11
3. RemoveAll(Reviews of Customer 1) → Remove Review 20
4. Remove Customer 1

Batch Operations

AddRange — Batch Insert

Context.Customer[1].Orders.AddRange(new Order[]
{
new() { TotalAmount = 10m, Status = "New" },
new() { TotalAmount = 20m, Status = "New" },
new() { TotalAmount = 30m, Status = "New" },
});
// All three orders have CustomerId = 1 set automatically

Uses DbSet.Add(T[]) with pre-sized capacity for zero-resize insertion.

ModifyAll — Batch Transform

// Cancel all orders for a customer
Context.Customer[1].Orders.ModifyAll(o => o with { Status = "Cancelled" });

Iterates all children and applies the transform. Each modification goes through the transaction buffer.

RemoveAll — Batch Delete

int removed = Context.Customer[1].Orders.RemoveAll();
Console.WriteLine($"Removed {removed} orders");

Returns the count of removed entities. The temporary id buffer is stack-allocated only when the child count is ≤ 256 (StackAllocThreshold); above that it allocates a transient int[].


Transfer / Reparenting

Move a child from one parent to another by updating its FK:

// Move order 5 from Customer 1 to Customer 2
Context.Customer[1].Orders.Transfer(orderId: 5, newParentId: 2);

Transfer Semantics

AspectBehavior
FK updateSets the child's FK to newParentId
Children of transferred entityUnaffected (they reference the child's PK, not the grandparent)
Old parent subscriptionNotified via a ChangeType.Update (the child's FK moved off this parent)
New parent subscriptionNotified via a ChangeType.Update (the child's FK moved onto this parent)
Transaction requiredYes

Example: Transfer with Notification

// Subscribe to both parents' order collections
Context.Customer[1].Orders.OnChanged(c =>
Console.WriteLine($"Customer 1 orders: {c.Type}"));
Context.Customer[2].Orders.OnChanged(c =>
Console.WriteLine($"Customer 2 orders: {c.Type}"));

Context.BeginTransaction();
Context.Customer[1].Orders.Transfer(orderId: 5, newParentId: 2);
Context.Commit();
// Output:
// Customer 1 orders: Update
// Customer 2 orders: Update

Dual-FK Disambiguation

When a child entity has FK references to multiple parents, handles are disambiguated with the parent name:

table Review(persistence: local) {
Id: int @id
CustomerId: int @relation(references: Customer.Id)
ProductId: int @relation(references: Product.Id)
Rating: int
Text: string
}

This generates:

Generated TypeFKIndex
ReviewByCustomerCollectionHandleSets CustomerIdQueries ReviewCustomerIndex
ReviewByProductCollectionHandleSets ProductIdQueries ReviewProductIndex

Disambiguation applies to the generated handle type only. The navigation property keeps the child table name (Reviews) on both editors unless an explicit @@navigation(name: ...) overrides it:

// All reviews by Customer 1 (returns a ReviewByCustomerCollectionHandle)
var customerReviews = Context.Customer[1].Reviews;

// All reviews for Product 42 (returns a ReviewByProductCollectionHandle)
var productReviews = Context.Product[42].Reviews;

Single-FK children keep the simpler name: OrderCollectionHandle.


Reactive Subscriptions

Editors and CollectionHandles support per-entity and per-parent change notifications. Under the hood, a single ChangeRouter<T> subscribes once to the DbSet and dispatches to subscribers by key — O(batch_size) per commit, not O(subscribers x batch_size).

Entity-Level Subscription (OnChanged)

Subscribe to changes for a specific entity by ID:

IDisposable sub = Context.Customer[1].OnChanged((in StateChange<Customer> change) =>
{
Console.WriteLine($"Customer 1: {change.Type}");
});

context.BeginTransaction();
Context.Customer[1].Update(Context.Customers.FindById(1) with { Name = "Updated" });
context.Commit(); // handler fires here (synchronous, during Commit)

sub.Dispose(); // stop listening

Collection-Level Subscription (OnChanged)

Subscribe to changes for children of a specific parent:

IDisposable sub = Context.Customer[1].Orders.OnChanged((in StateChange<Order> change) =>
{
Console.WriteLine($"Order {change.Id} of Customer 1: {change.Type}");
});

// Fires on add, update, remove, AND transfers
context.BeginTransaction();
Context.Customer[1].Orders.Add(new Order { TotalAmount = 50m, Status = "New" });
context.Commit(); // handler fires

Weak Subscriptions (OnChangedWeak)

Subscribe with a weak reference to a target object. The subscription auto-cleans when the target is garbage-collected:

IDisposable sub = Context.Customer[1].OnChangedWeak(panel,
static (p, change) => p.Refresh(change));

The callback must be static (or non-capturing) — it receives the target as the first argument. This prevents capturing the target, which would defeat the weak reference.

Batched Subscriptions (OnBatchChanged)

Accumulate all changes during a commit and deliver as a single list:

IDisposable sub = Context.Customer[1].Orders.OnBatchChanged(changes =>
{
Console.WriteLine($"Received {changes.Count} order changes");
foreach (var change in changes)
Console.WriteLine($" {change.Type}: Order {change.Id}");
});

context.BeginTransaction();
Context.Customer[1].Orders.Add(new Order { TotalAmount = 10m, Status = "New" });
Context.Customer[1].Orders.Add(new Order { TotalAmount = 20m, Status = "New" });
context.Commit(); // handler fires ONCE with list of 2 changes

Deep Watch (OnDeepChanged)

Subscribe to changes on an entity AND all its child collections. Child changes are forwarded as synthetic Update notifications on the parent:

IDisposable sub = Context.Customer[1].OnDeepChanged((in StateChange<Customer> change) =>
{
// Fires when:
// - Customer 1 is updated
// - Any Order of Customer 1 is added/updated/removed
// - Any Review of Customer 1 is added/updated/removed
Console.WriteLine("Customer 1 or its children changed");
});

// Disposing unsubscribes from ALL sub-subscriptions (entity + children)
sub.Dispose();

The returned IDisposable is a CompositeDisposable that groups the entity subscription and all child FK router subscriptions.

No-Op Update Suppression

Updates that produce a value-equal entity are automatically suppressed:

var alice = Context.Customers.FindById(1);
context.BeginTransaction();
Context.Customer[1].Update(alice with { }); // identical copy
context.Commit(); // OnChanged does NOT fire

Uses EqualityComparer<T>.Default.Equals (leveraging record value equality).

Subscription Comparison

ModeScopeDeliveryAllocationUse Case
OnChanged (entity)Single entityPer-changeZero (in-delegate)Entity detail view
OnChanged (collection)Parent's childrenPer-changeZero (in-delegate)Child list view
OnChangedWeakSingle entity/collectionPer-changeZero (weak ref)UI with lifecycle
OnBatchChangedParent's childrenBatched (once/commit)List allocationBatch UI update
OnDeepChangedEntity + all childrenPer-change (synthetic)CompositeDisposableAggregate views

Design Notes

  • Indexed routingChangeRouter<T> subscribes once to the DbSet and dispatches by int key (entity ID or FK value).
  • Main-thread delivery — handlers fire synchronously during Commit().
  • Transfer notifications — both old and new parent subscribers are notified.
  • Constraint — do not Subscribe or Dispose from within a handler callback. ChangeRouter<T> invokes handlers while iterating its subscriber list (under a reentrant lock), so subscribing or disposing mid-dispatch reentrantly mutates the list being iterated. The hazard is that reentrant modification, not a deadlock.

Chained Navigation

Navigate multiple levels of the entity hierarchy:

// Customer -> Orders -> pick an order -> OrderItems
var items = Context.Customer[1].Orders.Editor(orderId).OrderItems.GetAll();

// Create order + items in one chain
Context.Customer[1].Orders.Add(new Order { TotalAmount = 200m, Status = "New" })
.OrderItems.Add(new OrderItem { ProductId = 1, Quantity = 2, UnitPrice = 99.99m });
PatternCode
Parent -> ChildrenContext.Customer[1].Orders
Child -> ParentContext.Order[1].Customer
Parent -> Child -> GrandchildrenContext.Customer[1].Orders.Editor(1).OrderItems
Child -> Parent -> SiblingsContext.Order[1].Customer.Orders
Create + navigateContext.Customer[1].Orders.Add(order).OrderItems.Add(item)

Transaction Safety

All mutations through Editors and CollectionHandles require an active transaction:

Context.BeginTransaction();

Context.Customer[1]
.Modify(c => c with { Name = "Updated" })
.Orders.Add(new Order { TotalAmount = 50m, Status = "New" });

Context.Commit(); // or Context.Rollback()

Rollback reverts all mutations made through Editors and Handles, because they operate on the same StateChangeBuffer<T> as direct DbSet operations.

See Transactions for full transaction semantics.


Performance Characteristics

OperationTime ComplexityAllocations
Context.Entity[id] (create Editor)O(1)Zero (stack)
Editor.Value (read entity)O(1)Zero (returns by value)
Editor.Update(value)O(1)Zero
Editor.Remove()O(1)Zero
Editor.RemoveCascade()O(children)Zero for ≤ 256 children (stack span); transient int[] above
Editor.Modify(func)O(1)1 delegate (if not cached)
Handle.Add(item)O(1) amortizedZero
Handle.AddRange(items)O(N)Zero
Handle[childId]O(1)Zero (primary-key index)
Handle.CountO(1)Zero (index-maintained)
Handle.GetAll()O(1)Zero
Handle.First() / Handle.TryGetFirst()O(1)Zero
Handle.Last() / Handle.TryGetLast()O(children)Zero
Handle.ToArray()O(children)Array allocation
Handle.ToList()O(children)List allocation
Handle.RemoveAll()O(children)Zero for ≤ 256 children (stack span); transient int[] above
Handle.ModifyAll(func)O(children)1 delegate; id buffer stack-allocated for ≤ 256 children, transient int[] above
Handle.Transfer(id, newParent)O(1)Zero
foreach (var x in handle)O(children)Zero (struct enumerator)

Error Handling

Error ConditionExceptionWhen
Entity not foundArgumentOutOfRangeExceptionEditor.Value on non-existent entity
Child not foundArgumentOutOfRangeExceptionHandle[childId] on non-existent child
No active transactionInvalidOperationExceptionAny mutation without BeginTransaction()
Empty collectionInvalidOperationExceptionHandle.First() or Handle.Last() on empty collection

Use Exists and TryGet for safe access:

var editor = Context.Customer[999];
if (editor.Exists)
{
// Safe to access .Value
Console.WriteLine(editor.Value.Name);
}

if (Context.Customer[1].Orders.TryGet(orderId, out var order))
{
Console.WriteLine(order.TotalAmount);
}

Complete Example

Context.BeginTransaction();

// Create a customer with orders and items in one transaction
var newCustomer = new Customer { Name = "Alice" };
Context.Customers.Add(newCustomer);
var customer = Context.Customer[newCustomer.Id];

var order = customer.Orders.Add(new Order
{
OrderDate = DateTime.UtcNow,
TotalAmount = 149.98m,
Status = "Pending",
ShippingCountry = "US"
});

order.OrderItems.AddRange(new OrderItem[]
{
new() { ProductId = 1, Quantity = 1, UnitPrice = 99.99m },
new() { ProductId = 3, Quantity = 2, UnitPrice = 24.99m },
});

Context.Commit();

// ---- Read back ----
Customer alice = Context.Customer[customer.Id]; // implicit conversion
Console.WriteLine($"{alice.Name}: {customer.Orders.Count} orders");

foreach (var o in customer.Orders)
Console.WriteLine($" Order {o.Id}: {o.TotalAmount:C}");

// ---- Batch cancel all orders ----
Context.BeginTransaction();
customer.Orders.ModifyAll(o => o with { Status = "Cancelled" });
Context.Commit();

// ---- Transfer an order to another customer ----
Context.BeginTransaction();
var newBob = new Customer { Name = "Bob" };
Context.Customers.Add(newBob);
var bob = Context.Customer[newBob.Id];
customer.Orders.Transfer(orderId: order.Id, newParentId: bob.Id);
Context.Commit();

// ---- Deep watch for dashboard ----
IDisposable watch = Context.Customer[customer.Id].OnDeepChanged(
(in StateChange<Customer> _) => RefreshDashboard());

// ---- Clean up ----
Context.BeginTransaction();
customer.RemoveCascade(); // removes customer + remaining orders + items
bob.RemoveCascade();
Context.Commit();

watch.Dispose();

Generated Code Annotations

All generated types are marked with:

  • [GeneratedCode("ConjureDB.CodeGen", "1.0")]
  • #pragma warning disable CS1591 (suppresses missing XML doc warnings)

This ensures clean static analysis integration and clear identification of generated vs hand-written code.