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. The manual compiler emits them 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 patternVaries✅VariesManual

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.Compiler -- generate MyProject/MyProject.csproj \
--output 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​

When an entity has a FK to another entity, the Editor exposes a navigation property that returns the parent's Editor. 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

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. Uses stack-allocated spans for zero-GC batching.


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 of removal
New parent subscriptionNotified of addition
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: Remove
// Customer 2 orders: Add

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

Access:

// All reviews by Customer 1
var customerReviews = Context.Customer[1].ReviewsByCustomer;

// All reviews for Product 42
var productReviews = Context.Product[42].ReviewsByProduct;

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 routing — ChangeRouter<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 (deadlock risk — EventDispatcher dispatches under lock).

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
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 (lookup 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 (stack span)
Handle.ModifyAll(func)O(children)1 delegate
Handle.Transfer(id, newParent)O(1)Zero
foreach (var x in handle)O(children)Zero (struct enumerator)

Error Handling​

Error ConditionExceptionWhen
Entity not foundKeyNotFoundExceptionEditor.Value on non-existent entity
Child not foundKeyNotFoundExceptionHandle[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.