Navigation Property Routing
OhData supports two complementary ways to expose related entities:
- Navigation routes - a standalone
GET /Parents({key})/Childrenendpoint that returns the related collection as a top-level response $expand- embeds related data inline inside the parent entity response
Both require the navigation property to be declared in the EDM model. The approach you choose (or both) depends on what your clients need.
Declaring a navigation property
Use HasMany, HasOptional, or HasRequired inside the profile constructor:
public class OrderProfile : EntitySetProfile<Guid, Order>
{
public OrderProfile(AppDbContext db) : base(x => x.Id)
{
ExpandEnabled = true;
// No delegate: adds the nav property to $metadata, and (on an EF Core-backed GetQueryable)
// makes it SQL-JOIN-expandable automatically via $expand pushdown (#206) - no GET route.
HasMany(x => x.Lines);
HasOptional(x => x.Customer);
GetQueryable = _ => Task.FromResult<IQueryable<Order>>(db.Orders);
}
}
Delegate-less navigations and
$expand(#206): declaring a navigation without a delegate opts it into SQL-JOIN$expandpushdown; supplying a delegate (below) opts it out (the delegate then owns expansion). Mental model: write a delegate only when expansion needs real logic; a plain relationship gets SQL-JOIN expansion for free. See$expandpushdown for the full behavior, eligibility, and fallback rules.
Registering a navigation route
Pass a handler delegate to register a GET /Parents({key})/Children route:
HasMany(x => x.Lines,
getAll: async (orderId, ct) =>
await db.OrderLines.Where(l => l.OrderId == orderId).ToListAsync(ct));
This registers: GET /odata/Orders({key})/Lines
For single-entity navigations (HasOptional, HasRequired):
HasOptional(x => x.Customer,
get: async (orderId, ct) =>
{
var order = await db.Orders.FindAsync([orderId], ct);
return order is null ? null : await db.Customers.FindAsync([order.CustomerId], ct);
});
This registers: GET /odata/Orders({key})/Customer
Batch-loaded navigation routes
HasMany, HasOptional, and HasRequired also accept a batch delegate instead of a
per-entity one. It receives every requested parent key at once instead of a single key,
which is what makes batch-aware $expand possible (see below):
HasMany(x => x.Lines, batchGetAll: async (orderIds, ct) =>
{
var lines = await db.OrderLines.Where(l => orderIds.Contains(l.OrderId)).ToListAsync(ct);
return lines.ToLookup(l => l.OrderId); // ILookup<TKey, TNavigation>
});
HasOptional(x => x.Customer, batchGet: async (orderIds, ct) =>
{
var customerIds = await db.Orders.Where(o => orderIds.Contains(o.Id))
.Select(o => new { o.Id, o.CustomerId }).ToListAsync(ct);
var customers = await db.Customers
.Where(c => customerIds.Select(x => x.CustomerId).Contains(c.Id)).ToDictionaryAsync(c => c.Id, ct);
return customerIds.ToDictionary(x => x.Id, x => (Customer?)customers.GetValueOrDefault(x.CustomerId));
});
Registering only the batch overload is sufficient: the framework auto-derives a per-entity
handler from it (by calling the batch delegate with a single-element key list), so
GET /Orders({key})/Lines, nav $count, and $ref all keep working exactly as if you had
written a separate per-entity handler. A parent key absent from the batch result is treated
as "no children" ([]) for HasMany, or "no related entity" (null) for
HasOptional/HasRequired.
Navigation route behaviour
- Returning
nullfrom the handler produces404 Not Found - Authorization from the parent profile is applied to navigation routes automatically — both the all-operations
RequireAuthorization()/RequireRoles()and per-operationConfigureAuthorization(...)(a nav read isRead, a navPOST/$refwrite isCreate/Update;.RequireResource()checks against the parent entity) - Navigation routes are tagged with the parent entity set name in OpenAPI/Swagger
- For collection navigations,
$orderby,$top,$skip,$count, and$selectare honored on the returned collection via ad-hoc in-memoryIEnumerable/IEnumerable<T>.OrderByoperations (not pushed down to the handler or to SQL). Options are applied in standard OData order:$orderby, then$skip, then$top($countis captured after$skipbut before$top, per spec - the count reflects the collection after skipping but before the page limit is applied).$orderbysupports multiple sort keys (Prop1 asc,Prop2 desc) and is case-insensitive on the property name. An unknown property name in$orderbyreturns400 Bad Request(InvalidQueryOption), matching$select's validation behavior. - Any other system query option (
$filter,$expand,$search,$apply,$compute,$skiptoken,$deltatoken) is not implemented on navigation routes and returns400 Bad Request(UnsupportedQueryOption) rather than being silently ignored (OData 4.0 Minimal conformance item 7: parse the option or reject it). To filter related data, expose the child entity set with its own profile and query it directly.
$expand
When ExpandEnabled = true and navigation properties are declared, clients can embed related data in the parent response:
GET /odata/Orders?$expand=Lines
GET /odata/Orders?$expand=Lines($select=ProductName,Quantity),Customer
GET /odata/Orders(id)?$expand=Lines
There are two expansion paths, and which one a navigation takes is decided purely by whether it was declared with a delegate (#206):
- Delegate-less navigation (a bare
HasMany/HasOptional/HasRequired) → SQL-JOIN pushdown. On the EF Core-backedGetQueryablepath the navigation is folded into the collection query's projection, so one JOIN'd query loads the page and its related rows. No delegate to write, no N+1. (Previously such a navigation was$metadata-only and silently skipped under$expand— that is no longer the case.) - Delegate-backed navigation (
getAll/get/batchGetAll/batchGet) → delegate expansion, never pushed down. It's a post-processing step over the already-serialized parent page, driven by the handler delegate. The delegate may filter/order/authorize, so it is always honored; pushing it down would change results or leak rows. This path is identical on all three collection GET paths (GetQueryable,GetAll, priority-1IODataEntitySetEndpointSource).
Mental model: write a delegate only when expansion needs real logic; a plain relationship gets SQL-JOIN expansion for free. See
$expandpushdown for eligibility (including multi-level nested$expandand$levels) and the silent-fallback rules (non-EF source, a delegate-backed level, a level that is BOTH cyclic AND not member-init-projectable (#323 — a plain bidirectional relationship pushes down fine),$search/$compute/$apply→ the navigation stays EDM-only for that request — the framework doesn't load it itself, but doesn't guarantee it empty either: whatever the handler's own query already put there (a non-EFGetQueryable's eager load, aGetAllhandler that populated it by hand) still serializes; never a500). That "never a500" now holds even for a tracked, EF-relationship-fixed-up graph that is genuinely cyclic (self-referential or bidirectional): as of #325/#326, response serialization itself is bounded by the$expandclause (aSerializeBoundedwalker), never by the object graph, so a reference cycle among EDM-declared navigations is structurally unreachable — including on a plainGETwith no$expandat all.
For a delegate-backed navigation, what differs is how many times the handler is called, and it depends on which overload you registered:
- Per-entity handler (
getAll/get) - called once per parent entity per expanded property: N×P calls for a page of N items and P expanded properties (an N+1 pattern against a database-backed handler). - Batch handler (
batchGetAll/batchGet) - called once per expanded property for the whole page: P calls total, regardless of N. Use this for EF Core-backed navigations; see query-options.md for a worked example and Batch-loaded navigation routes above for the API.
Both delegate forms produce byte-identical $expand output; batch registration only changes the
number of handler invocations, not the response shape. (A delegate-less, pushed navigation has no
delegate to compare against — the JOIN is the source of its related rows.)
Navigation routes vs $expand - when to use each
| Navigation route | $expand |
|
|---|---|---|
| Returns related data as top-level response | ✅ | ❌ (embedded in parent) |
| Supports filtering/ordering on related data | ❌ | ✅ (with nested options) |
| Single SQL join (vs. handler calls) | ❌ (separate query per request) | ✅ for a delegate-less nav (SQL-JOIN pushdown, #206); ❌ for a delegate-backed nav — call count is P with a batch handler, N×P with a per-entity handler |
Works without $expand support on client |
✅ | ❌ |
The two approaches are complementary - declare both to support both access patterns.
$ref - managing links between entities
For many-to-many or reference relationships, OhData supports $ref link management endpoints that add or remove associations without transferring full entity bodies.
HasMany(x => x.Tags,
getAll: async (productId, ct) => await db.ProductTags
.Where(pt => pt.ProductId == productId).Select(pt => pt.Tag).ToListAsync(ct),
addRef: async (productId, tagId, ct) =>
{
db.ProductTags.Add(new ProductTag { ProductId = productId, TagId = int.Parse(tagId) });
await db.SaveChangesAsync(ct);
},
removeRef: async (productId, tagId, ct) =>
{
var link = await db.ProductTags.FindAsync([productId, int.Parse(tagId)], ct);
if (link is not null) db.ProductTags.Remove(link);
await db.SaveChangesAsync(ct);
});
This registers:
| Route | Handler |
|---|---|
GET /Products({key})/Tags |
getAll |
POST /Products({key})/Tags/$ref |
addRef - body: { "@odata.id": "Tags(5)" } |
DELETE /Products({key})/Tags/$ref?$id=Tags(5) |
removeRef |
For optional single-entity navigations, use the setRef / removeRef overload on HasOptional:
HasOptional(x => x.Category,
get: (productId, ct) => ...,
setRef: (productId, categoryId, ct) => ...,
removeRef: (productId, categoryId, ct) => ...);
This registers:
| Route | Handler |
|---|---|
GET /Products({key})/Category |
get |
PUT /Products({key})/Category/$ref |
setRef - body: { "@odata.id": "Categories(3)" } |
DELETE /Products({key})/Category/$ref |
removeRef (no $id — there is only one link) |
The addRef/setRef handler receives the raw @odata.id string from the request body (e.g. "Categories(3)"). Parse the key from it as needed.
HTTP method note: OData 4.0 §11.4.6 requires
POST /$reffor collection navigations (adding a link) andPUT /$reffor single-value navigations (replacing the link). OhData enforces this automatically.
Creating a related entity — POST /Parents({key})/Children
For collection navigations, HasMany accepts a post handler that registers
POST /{EntitySet}({key})/{Property} — creating a brand-new related entity, rather than linking
to one that already exists ($ref above is for the latter; OData §11.4.2.1):
HasMany(x => x.Lines,
getAll: async (orderId, ct) =>
await db.OrderLines.Where(l => l.OrderId == orderId).ToListAsync(ct),
post: async (orderId, line, ct) =>
{
if (!await db.Orders.AnyAsync(o => o.Id == orderId, ct)) return null; // parent not found → 404
line.OrderId = orderId;
db.OrderLines.Add(line);
await db.SaveChangesAsync(ct);
return line;
},
refTargetEntitySet: "OrderLines");
This registers: POST /odata/Orders({key})/Lines
Handler contract:
- The request body is deserialized as the navigation's item type (the same
TNavigationused bygetAll) using the profile's configured JSON options. - The handler receives the parent key and the deserialized child; it is responsible for assigning any server-side values (e.g. the foreign key, the child's own primary key) and persisting the result.
- Return the created child (with its final values) on success.
- Return
nullto indicate the parent was not found — the framework maps this to404 Not Found, mirroring howGetById/nav-GET handlers signal "not found."
Response semantics:
| Condition | Response |
|---|---|
Success, no Prefer header |
201 Created with the created child in the body (@odata.context, and @odata.id/Location header when refTargetEntitySet is configured) |
Success, refTargetEntitySet not configured |
201 Created with the created child in the body; no Location/@odata.id — the framework cannot compute a URL for the child without knowing its entity set and key property |
Prefer: return=minimal |
204 No Content with Preference-Applied: return=minimal; Location/OData-EntityId headers are set only when refTargetEntitySet is configured |
Parent not found (handler returns null) |
404 Not Found (OData error envelope) |
| Malformed / empty JSON body | 400 Bad Request (OData error envelope) |
Non-JSON Content-Type |
415 Unsupported Media Type (OData error envelope) |
No post handler configured |
The route is not registered at all — POST to the nav path returns 405 Method Not Allowed (the GET nav route occupies the same template) |
The Location/@odata.id are built the same way $ref builds populated references: from
refTargetEntitySet plus the child's key property, detected by convention (Id or
{TypeName}Id) — the same ChildEntitySetName/ChildKeyPropertyName machinery $ref uses.
Authorization is inherited from the parent profile — both the all-operations
RequireAuthorization()/RequireRoles() and per-operation ConfigureAuthorization(...) (see
docs/authorization.md) — same as every other route on the entity set.
POST-to-nav vs. deep insert: the
posthandler above creates ONE related entity on an already-existing parent (POST /Orders(id)/Lines). To create a parent and its related entities in a single request (POST /Orderswith a nestedlinesarray in the body), see deep insert (OData §11.4.2.2).