Query Options
OhData supports the OData 4.0 system query options. Which ones are applied depends on the collection handler you choose for the entity set.
JSON property casing
By default OhData serializes response property names in PascalCase — the CLR property names,
which are exactly the identifiers declared in $metadata (the EDM). Payload casing therefore
matches $metadata casing, satisfying OData §4.4 and letting case-sensitive OData-native clients
(e.g. Microsoft.OData.Client) bind properties out of the box.
This default is owned by OhData, not inherited from the host's
HttpJsonOptions.SerializerOptions.PropertyNamingPolicy. Configuring ConfigureHttpJsonOptions
does not change OhData response casing (any custom converters/encoder you register there are
still honoured — only the property-naming policy is OhData's own).
To emit camelCase payloads instead, opt in explicitly on the registration:
using System.Text.Json;
builder.Services.AddOhData(o =>
{
o.WithJsonPropertyNamingPolicy(JsonNamingPolicy.CamelCase);
o.AddEntitySetProfile<ProductProfile>();
});
WithJsonPropertyNamingPolicy(null) is the default (PascalCase). The policy applies uniformly to
every response path: collection and single-entity reads, POST/PUT/PATCH echoes, $select/$expand
output, $value, and bound/unbound function/action results.
Known limitation of the camelCase opt-in:
$metadataalways uses the PascalCase CLR/EDM property names (the EDM has no naming policy). Opting into camelCase therefore desyncs your payload casing from$metadata— a case-sensitive OData-native client that reads$metadatato learn property names will not match the camelCase keys on the wire. The PascalCase default keeps payloads and$metadatain agreement (OData §4.4); opt into camelCase only when your clients bind case-insensitively.
Note: this affects response casing only. OData query-option property references (
$select=Name,$filter=…,$orderby=…,$expand=…) and request bodies are matched case-insensitively against the EDM, so a client may use either casing on the way in.
The OpenAPI/Swagger companion packages (OhData.AspNetCore.OpenApi, .NSwag, .Swashbuckle)
follow this same policy: generated schema property names match the wire casing exactly — PascalCase
by default, camelCase when you opt in — instead of the host HttpJsonOptions casing the underlying
generators would otherwise use. A [JsonPropertyName] rename still wins, in the schema and on the
wire alike. So the generated document (and any client code generated from it) agrees with what
responses actually emit.
Handler paths
GetAll - simple in-memory path
GetAll = (ct) => Task.FromResult<IEnumerable<Product>>(myList);
Returns all items. The framework does not apply $filter or $orderby to the returned collection - and it does not silently ignore them either. If the client sends either of these, the request is rejected with 400 Bad Request (UnsupportedQueryOption), regardless of the capability flags - GetAll has no ApplyTo/IQueryable pipeline to push them down to.
$top and $skip, by contrast, are applied on this path: they are pure post-materialization Skip()/Take() calls against the array GetAll (or Search, when $search is also present) returned - the same class of operation as the already-live $select/$expand/$count below. $select, $expand, $count, $top, $skip, and $search (when a Search handler is configured) are all honored on this path - $select/$expand/$count are each gated by its capability flag (SelectEnabled/ExpandEnabled/CountEnabled), exactly like the GetQueryable path: sending a disabled option returns 400 (UnsupportedQueryOption). $top/$skip need no flag - they are always live, mirroring GetQueryable.
MaxTop caps an explicit $top on this path exactly like it does on GetQueryable: a $top value greater than MaxTop returns 400 Bad Request (InvalidQueryOption). As of #201, an omitted $top is also capped to MaxTop (or a smaller Prefer: maxpagesize), and the response carries a @odata.nextLink for the remainder - so GetAll is safe-by-default and can no longer be coerced into returning an unbounded result set. This became possible because GetAll re-enumerates its source on each request, so an offset $skip link is a valid continuation story (the same $skip scheme the Priority-1 path uses; note it is $skip, not the opaque $skiptoken GetQueryable emits). To opt out - return the full set in one response, however large - set MaxTop = null on the profile; an omitted $top then applies no cap and emits no @odata.nextLink. Preference-Applied echoes the honored page size, clamped so maxpagesize can never lift the MaxTop ceiling.
@odata.count ($count=true) reflects the pre-paging total on this path too, per §11.2.6.5 - it is computed from the full materialized array before $skip/$top are applied, not from the length of the returned page.
Use GetAll when your data source is small and in-memory, or when you want complete control over what is returned.
GetODataQueryable - full OData pushdown (advanced)
GetODataQueryable = (opts, ct) => ...
The profile receives the raw ODataQueryOptions<TModel> and is responsible for applying them to the data source. The capability flags and property allowlists are still enforced by the framework before the handler runs: a disabled option present in the request returns 400 (UnsupportedQueryOption) and a non-allowlisted property returns 400 (InvalidQueryOption) without invoking the handler. Use this when:
- You need full control over how query options are translated (e.g. custom SQL, Dapper, a remote API).
- You want to apply paging yourself and return the pre-paging total count alongside the results.
Return an ODataQueryResult<TModel> to supply paging metadata:
GetODataQueryable = async (opts, ct) =>
{
// Apply filtering, ordering, paging - however your data source requires.
var (items, totalCount) = await myDataSource.QueryAsync(opts, ct);
return new ODataQueryResult<TModel>
{
Items = items.AsQueryable(),
TotalCount = totalCount, // pre-paging count; used for $count=true
NextLink = ..., // optional; emitted as @odata.nextLink
};
};
ODataQueryResult<TModel> properties:
| Property | Type | Description |
|---|---|---|
Items |
IQueryable<TModel> |
The (paged) item sequence to materialise. |
TotalCount |
long? |
Pre-paging total count. Used as @odata.count in the response when $count=true is requested. Leave null to fall back to the length of Items. |
NextLink |
string? |
When set, emitted as @odata.nextLink in the response envelope, taking priority over any framework-computed next link. Use this for cursor- or token-based pagination. |
The framework does not prescribe how items or totalCount are obtained. That is entirely up to the profile. Some data sources support retrieving both in a single operation (window functions, COUNT(*) OVER()); others require two separate requests. Either approach satisfies the contract — the framework only requires that TotalCount reflect the number of matching records before paging was applied.
If TotalCount is not set and the client sends $count=true, the count in the response will reflect only the current page size, which is incorrect per the OData spec. Prefer always supplying TotalCount when using this handler.
Deterministic paging is the profile's responsibility
On this path the profile — not the framework — owns query application, including $skip. When you return a lazily-translated IQueryable (e.g. an EF Core queryable) and rely on the framework's MaxTop/Prefer: maxpagesize cap plus its @odata.nextLink continuation, you must give that queryable a stable, total order — a terminal OrderBy (typically the entity key), or by applying the client's $orderby. Without one, the emitted LIMIT/OFFSET runs over an undefined row order, so a row can appear on two pages or be skipped between them, and EF Core logs warning 10102 ("row limiting operation without OrderBy"). The framework does not inject an order for you here: it can't do so safely once you've applied your own $skip (ordering a sliced subset is wrong), and a stable key column is your decision, not the framework's. (The GetQueryable path is different — there the framework owns the whole pipeline and orders paged results by the entity key automatically.)
Note:
GetODataQueryableis available onODataEntitySetProfile<TKey, TModel>, not the baseEntitySetProfile<TKey, TModel>. It requires theOhData.AspNetCorepackage. AnIQueryable<TModel>is implicitly convertible toODataQueryResult<TModel>for backward compatibility with handlers that return a bare queryable.
GetQueryable - IQueryable with pushdown (recommended for databases)
GetQueryable = _ => Task.FromResult<IQueryable<Product>>(db.Products);
Returns a base IQueryable<TModel>. The framework applies $filter, $orderby, $skip, and $top via ApplyTo(IQueryable). With EF Core these become SQL clauses - only matching rows are fetched.
Enable the query capabilities you want to expose:
public class ProductProfile : EntitySetProfile<int, Product>
{
public ProductProfile(AppDbContext db) : base(x => x.Id)
{
FilterEnabled = true; // allow $filter
OrderByEnabled = true; // allow $orderby
CountEnabled = true; // allow $count
SelectEnabled = true; // allow $select
ExpandEnabled = true; // allow $expand
GetQueryable = _ => Task.FromResult<IQueryable<Product>>(db.Products);
}
}
Any disabled capability returns 400 Bad Request (UnsupportedQueryOption, with a message naming the option and the flag that enables it) if the client sends that query option. All capability flags default to false (inheriting from EntitySetDefaults) - an entity set accepts no query options until you opt in.
The single-entity route GET /Products(1) honors the same gates for the options it supports: $select requires SelectEnabled and $expand requires ExpandEnabled. When ExpandEnabled is on, $expand on the single-entity route inlines the requested navigation properties using the same navigation-route handlers (batch handlers included) as the collection route.
Advanced: independent contexts with IDbContextFactory
Profiles are registered scoped, so the request-scoped DbContext injects directly into the
constructor — that is the pattern shown above and the default to reach for. Use
IDbContextFactory<T> only when a handler needs a fresh, independently-scoped context, for
example to run queries concurrently (a single DbContext instance is not thread-safe). Create it
per call and dispose it with await using:
public class ProductProfile : EntitySetProfile<int, Product>
{
public ProductProfile(IDbContextFactory<AppDbContext> factory) : base(x => x.Id)
{
// Simple materializing read path (no deferred IQueryable to keep alive).
GetAll = async ct =>
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Products.ToListAsync(ct);
};
}
}
// Registration:
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlServer(connectionString));
Pair the factory with a materializing handler like GetAll, not GetQueryable: a
factory-created context can't back a deferred IQueryable without leaking (the framework enumerates
it after your method returns, so there is no safe point to dispose). The GetQueryable +
request-scoped DbContext pairing above remains the default when you want $filter/$select/
$expand pushed down to SQL.
$filter
Enabled via FilterEnabled = true. Supports comparison operators (eq, ne, gt, ge, lt, le), logical operators (and, or, not), arithmetic, string functions (contains, startswith, endswith, tolower, toupper, trim), date functions, and more.
GET /odata/Products?$filter=Price gt 10 and contains(Name,'Widget')
GET /odata/Products?$filter=year(CreatedAt) eq 2024
Restrict which properties may appear in $filter:
FilterProperties(x => x.Price, x => x.Name, x => x.Category);
// or string overload:
FilterProperties("Price", "Name", "Category");
A $filter referencing a property outside the allowlist returns 400 Bad Request
(InvalidQueryOption, "The property 'X' cannot be used in the $filter query option.").
FilterProperties restricts this entity's own structural properties only; it never restricts
a path through a navigation property. $filter=Lines/any(l: l/Quantity gt 1) is unaffected by
Orders' own FilterProperties allowlist (or the lack of one) because navigation-target types
(OrderLine here) have no allowlist surface of their own - only FilterProperties on the
navigated-to entity set's own profile (if it has one) governs its properties.
round() midpoint rounding
OData Part 2 §5.1.1.9 specifies that the round() canonical function rounds a midpoint value
away from zero (2.5 → 3, -2.5 → -3). Microsoft.OData's ApplyTo binder instead emits
.NET's single-argument Math.Round(double)/Math.Round(decimal), which default to
round-half-to-even ("banker's rounding": 2.5 → 2). On the GetQueryable path (and its
$count companion), OhData rewrites those calls in the post-ApplyTo expression tree to the
two-argument Math.Round(value, MidpointRounding.AwayFromZero) overload, so round() matches
the spec by default:
GET /odata/Products?$filter=round(Price) eq 3
Control this via the RoundingMode setting (RoundingMode.SpecCompliant, the default, or
RoundingMode.BankersRounding), inheriting from EntitySetDefaults.RoundingMode the same way
PropertyAccessEnabled/AllowDeepInsert do:
// Per profile - opt back into .NET's pre-fix banker's rounding:
RoundingMode = RoundingMode.BankersRounding;
// Or globally across all profiles in the registration:
builder.Services.AddOhData(o => o
.WithDefaults(d => d.RoundingMode = RoundingMode.BankersRounding)
.AddEntitySetProfile<ProductProfile>());
Provider-translation caveat: the two-argument Math.Round(value, MidpointRounding) overload
is not translatable by every EF Core provider - a query using round() that worked before this
fix may throw a translation exception against your provider. If that happens, set
RoundingMode = BankersRounding on the affected profile (or globally) to fall back to the
single-argument overload that provider could already translate; this restores the pre-fix
(banker's rounding) behavior and documents the spec deviation locally. EF Core InMemory (used in
this repo's test suite) is LINQ-to-Objects and is unaffected either way.
Coverage note: this rewrite only reaches the base-class GetQueryable path, where the
framework itself calls ApplyTo. On the Priority-1 ODataEntitySetProfile.GetODataQueryable
path the profile calls ApplyTo itself inside its own handler, so RoundingMode does not
automatically apply there - a profile using that path must apply the same rewrite itself if it
wants spec-compliant round() semantics.
$orderby
Enabled via OrderByEnabled = true. Supports multiple sort keys, ascending (asc, default) and descending (desc).
GET /odata/Products?$orderby=Category asc,Price desc
Restrict which properties may be sorted on:
OrderByProperties(x => x.Price, x => x.Name);
Sorting on a property outside the allowlist returns 400 Bad Request (InvalidQueryOption).
As with FilterProperties, this only restricts the entity's own structural properties -
$orderby=Category/Name (a path through a navigation property) is unaffected.
$top and $skip
Limit and offset the result set. On the GetQueryable path these become SQL LIMIT/OFFSET; on GetAll they are applied as an in-memory Skip()/Take() against the materialized collection, after GetAll/Search runs and before $select/$expand are applied to the page.
GET /odata/Products?$top=20&$skip=40
Cap the maximum $top value server-side:
// Per profile:
MaxTop = 100;
// Or globally across all profiles in the registration:
builder.Services.AddOhData(o => o
.WithDefaults(d => d.MaxTop = 500)
.AddEntitySetProfile<ProductProfile>());
MaxTop defaults to 1000 (EntitySetDefaults.MaxTop) when not overridden per-profile or globally - server-side paging is always active on the GetQueryable/GetAll/Priority-1 paths, even if you never configure it explicitly.
Requests with $top exceeding MaxTop receive 400 Bad Request, on every collection path (GetQueryable, GetAll, and Priority-1).
On GetQueryable and Priority-1 (GetODataQueryable), an omitted $top also gets MaxTop (or a smaller Prefer: maxpagesize) applied implicitly as the default page size, and the response carries @odata.nextLink so the client can retrieve the rest. Prefer: maxpagesize (see the Prefer header docs) is capped at MaxTop when $top is absent: the honored page size is min(maxpagesize, MaxTop). A client cannot use maxpagesize to request a page larger than MaxTop - it can only ask for a smaller page. Preference-Applied always echoes the page size actually honored (the clamped value), not the value the client asked for, per §8.2.8.7.
The two paths differ only in the shape of the continuation link. GetQueryable emits an opaque $skiptoken (which the framework decodes back to a $skip itself). Priority-1 emits a plain $skip instead, because that path hands the incoming ODataQueryOptions to the profile's own ApplyTo, which honors $skip natively but has no handler for $skiptoken. On a Priority-1 continuation request the profile applies the $skip, and the framework re-applies only the MaxTop/maxpagesize Take cap on top. A profile that sets ODataQueryResult.NextLink itself is trusted to be paging on its own terms, and the framework does not add or override the cap in that case.
GetAll now mirrors the "omitted $top" behavior above (#201). An omitted $top is capped to MaxTop (or a smaller Prefer: maxpagesize) with a @odata.nextLink for the remainder, so this path is safe-by-default like the others. The one difference from GetQueryable is the continuation shape: GetAll emits a $skip link (which it re-applies against its re-enumerated source) rather than the opaque $skiptoken. Set MaxTop = null on the profile to opt out and return the full set in one response, however large - see the GetAll section above.
$count
Enabled via CountEnabled = true. Two forms:
Inline count - embed the total (pre-pagination) count in the collection envelope:
GET /odata/Products?$count=true
{
"@odata.context": "https://host/odata/$metadata#Products",
"@odata.count": 1234,
"value": [...]
}
Standalone count - returns a plain integer, $filter is applied if present:
GET /odata/Products/$count
GET /odata/Products/$count?$filter=Price gt 10
Gating: the inline form ($count=true) is gated by CountEnabled. The standalone
/$count route is always registered when a collection handler exists (it is an addressable
resource, not a query option) - on that route only $filter is gated, by FilterEnabled
(and the FilterProperties allowlist).
Behaviour depends on the handler path:
| Handler | $count=true behaviour |
|---|---|
GetODataQueryable |
Uses TotalCount from ODataQueryResult<TModel>. If not supplied, falls back to the current page size - incorrect per spec. Always set TotalCount on this path. |
GetQueryable |
Framework runs a second COUNT(*) query against the IQueryable before paging is applied. |
GetAll |
Full collection is enumerated and counted. |
$select
Enabled via SelectEnabled = true. Reduces the response payload to the specified properties:
GET /odata/Products?$select=Id,Name,Price
The response shape is produced by JSON post-processing (unselected properties are removed from the serialized entity), which is what keeps the output consistent with the configured naming policy (PascalCase by default — see JSON property casing).
Projection pushdown (#206)
On the GetQueryable path, an eligible $select additionally pushes a column projection
down to the data source: the framework composes a member-init projection
(x => new TModel { Id = x.Id, Name = x.Name }) onto the queryable before enumeration, so LINQ
providers emit a column-pruned SELECT instead of reading every column. The wire output is
byte-identical with or without pushdown — the projection changes the SQL, never the
response.
The projected member set is the selected structural properties plus the entity key (needed
for @odata.id and $expand correlation) plus any UseETag properties (so @odata.etag
values are unchanged). Nested $select paths ($select=address/city) project the whole
top-level member.
Pushdown is on by default (EntitySetDefaults.SelectPushdownEnabled, per-profile
SelectPushdownEnabled override) and falls back silently to the full fetch — with a
Debug-level log naming the reason — when a request is ineligible:
- the model has no public parameterless constructor (e.g. positional records),
- a projected member is complex-typed (phase-1 boundary: projecting an EF-owned complex
property under a tracking queryable throws inside EF;
byte[]counts as primitive, so rowversion ETag inputs keep pushdown), - a projected member has no public setter (init-only setters are fine; this arises via
UseETagselectors over get-only computed properties, since the EDM excludes get-only properties from$selectitself), UseETagwas configured with a non-direct (computed) selector, making the ETag property names unknowable,- the model has structural properties whose names differ only by case (the name lookup is case-insensitive, so such models are pushdown-ineligible outright),
- or the profile/server opted out via
SelectPushdownEnabled = false(do this for exoticIQueryableproviders that cannot translate member-init projections; every EF Core relational provider and InMemory can).
GetAll (no queryable) and GetById (no collection query) have no pushdown path. On the
Priority-1 GetODataQueryable path the profile owns the ApplyTo call, so — like
RoundingMode — the framework does not project automatically; a Priority-1 handler that wants
column pruning applies its own Select projection (it already owns the whole query pipeline).
Restrict which properties may be selected:
SelectProperties(x => x.Id, x => x.Name, x => x.Price);
Selecting a property outside the allowlist returns 400 Bad Request (InvalidQueryOption).
$expand
Enabled via ExpandEnabled = true. Embeds related entities inline in the parent response:
GET /odata/Orders?$expand=Lines
GET /odata/Orders?$expand=Lines($select=ProductName,Quantity)
GET /odata/Orders?$expand=Lines,Customer
GET /odata/Orders(3f2a...)?$expand=Lines ← single-entity route too
For a navigation declared with a delegate, $expand does not use EF Core's Include() or push the join into SQL. Instead the framework invokes that navigation's registered handler. This is a generic mechanism with no EF Core dependency, and it behaves identically on the GetQueryable, GetAll, and Priority-1 (IODataEntitySetEndpointSource) paths. See navigation-routing.md for details. A navigation declared without a delegate takes a different path — SQL-JOIN pushdown — described in Delegate-less navigations JOIN automatically below.
There are two ways to register the handler, and they have very different $expand performance:
- Per-entity (
getAll/get) - invoked once per parent entity per expanded property. For a page of N items with P expanded properties, that's N×P sequential awaited calls (an N+1 query pattern when the handler hits a database). Simple to write; fine for small pages or handlers with no per-call cost. - Batch (
batchGetAll/batchGet) - invoked once per expanded property per page, receiving every parent key on the page at once. N×P collapses to P. This is the recommended form for EF Core-backed handlers.
Navigation properties must be declared in the profile:
public class OrderProfile : EntitySetProfile<Guid, Order>
{
public OrderProfile(AppDbContext db) : base(x => x.Id)
{
ExpandEnabled = true;
// Batch form: ONE query loads every order's lines for the whole page.
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);
});
// Per-entity form: one query PER order (N+1 under $expand).
HasOptional(x => x.Customer,
get: async (orderId, ct) => await db.Customers.FindAsync([orderId], ct));
GetQueryable = _ => Task.FromResult<IQueryable<Order>>(db.Orders);
}
}
HasMany's batch overload returns an ILookup<TKey, TNavigation> (e.g. via .ToLookup(...)); HasOptional/HasRequired's batch overloads return an IReadOnlyDictionary<TKey, TNavigation?>/IReadOnlyDictionary<TKey, TNavigation>. A parent key missing from the result is treated as "no children" ([]) for a collection nav, or "no related entity" (null) for a single-valued nav.
Registering only the batch overload is enough - the framework auto-derives a single-key handler from it, so the standalone GET /Orders(id)/Lines route, nav $count, and $ref endpoints all keep working without writing a second handler. You may still register both explicitly (e.g. if the single-key path warrants a different query shape), in which case the per-entity handler you supply is used for those standalone routes and the batch handler is used only for $expand.
Restrict which navigation properties may be expanded:
ExpandProperties(x => x.Lines, x => x.Customer);
Expanding a navigation property outside the allowlist returns 400 Bad Request (InvalidQueryOption).
$expand pushdown: delegate-less navigations JOIN automatically (#206)
The one rule to remember: writing an expand delegate opts a navigation out of pushdown; a bare declaration opts it in.
Mental model: write a delegate only when expansion needs real logic (filtering, ordering, authorization, a custom query shape). A plain relationship gets SQL-JOIN expansion for free.
A navigation declared without any expand delegate — a bare HasMany(x => x.Lines) / HasOptional(x => x.Ref) / HasRequired(x => x.Ref) with no getAll/get/batchGetAll/batchGet — is now SQL-JOIN-expandable automatically. On the EF Core-backed GetQueryable path, $expand'ing such a navigation folds it into the collection query's projection (x => new Order { …, Lines = x.Lines.ToList() }), so one JOIN'd query loads the page and all its related rows — no delegate to write, no N+1. This is why the earlier caveat ("a HasMany(x => x.Lines) alone is silently skipped under $expand") no longer holds: a bare declaration is a first-class, pushed expansion.
The behavior is decided purely by whether a delegate exists — there is no global flag to flip and no per-navigation opt-in:
| Declaration | $expand path |
Why |
|---|---|---|
HasMany(x => x.Lines) — no delegate |
SQL-JOIN pushdown (one query) | There is no delegate to bypass; the Include/JOIN is the definition of the expansion. |
HasMany(x => x.Lines, getAll: …) / batchGetAll: … — has a delegate |
Delegate (never pushed down) | The delegate may filter/order/authorize; pushing it down would change results or leak rows, so it is always honored. |
This is not "byte-identical to the delegate path" — for a pushed navigation there is no delegate to compare against; the JOIN is the source of the related rows. (The un-pushed, delegate path stays exactly as documented above.)
Pushdown is on by default (EntitySetDefaults.ExpandPushdownEnabled, per-profile ExpandPushdownEnabled override). It engages only on the EF Core-backed GetQueryable path, and for a navigation whose related type is either free of a back-reference cycle or — as of #323 — itself member-init-projectable: a projectable element is always materialized through a fresh POCO (never the bare EF-tracked entity), which forecloses a serialization cycle structurally regardless of what navigations the related type declares, so a standard bidirectional relationship (e.g. Author.Books / Book.Author) now pushes down and JOINs like any other navigation. Only a related type that is BOTH cyclic AND not member-init-projectable (no public parameterless constructor, or a complex/unsettable structural member) still keeps today's conservative defer. Whenever pushdown is structurally ineligible for a request — a non-EF provider, a cyclic and non-projectable navigation, or a deferred nested option (see the table below) — it falls back silently: the delegate-less navigation simply stays EDM-only for that request (as it was before this feature), and the reason is Debug-logged. Falling back does not itself surface a 500, and — as of #325/#326 (Option B, the SerializeBounded walker) — it no longer risks one either: whatever the handler's own query already produced for a deferred navigation, tracked/cyclic object graph included, now serializes through the SAME clause-bounded walker the rest of the response body does, so a reference cycle among those tracked entities is structurally unreachable regardless of how far pushdown deferred. "Stays EDM-only" means the framework doesn't itself load the navigation — it does not mean the navigation is guaranteed to come back empty. No pushdown does not automatically mean nulling either: whatever the handler's GetQueryable/GetAll query already produced for that navigation (a non-EF IQueryable's own eager load, an EF Include the handler wrote itself, or a hand-built object graph) passes through and serializes as-is whenever the framework composes no projection of its own — $expand'd navigations are never stripped by OmitUnexpandedNavigations regardless of how they got their data. Two GetQueryable-path exceptions do force it empty anyway, because a member-init projection structurally omits any navigation it doesn't bind: (1) another navigation in the same $expand pushed down — TryApplySelectProjection then binds only the structural properties and the navigation(s) that engaged pushdown, so a sibling that stayed EDM-only is never bound and comes back empty even if the handler had populated it; and (2) $select pushdown is eligible for the request ($select + $expand, SelectPushdownEnabled, on by default) — ApplySelectPushdown is not gated on an EF Core provider, so ?$select=Name&$expand=Children against e.g. a List.AsQueryable() source still composes a member-init of only the selected + key properties, structurally omitting Children. Outside those two shapes, only a handler that left the navigation genuinely unpopulated will actually report it empty.
Wire change (#323, accepted by design): every pushed-down expand — including a leaf (no nested $expand of its own) — is now materialized through the same member-init projection $levels and intermediate multi-level expands already used. A public CLR property on the related type that is not an EDM structural property (e.g. a [NotMapped] field, a get-only computed property not derived from bound scalars, or a member excluded from the model) is therefore no longer materialized on a leaf-expanded entity — it comes back as its type's default value, exactly as it already did at intermediate levels. Leaves are now consistent with intermediate levels rather than being a special case. A computed get-only property whose getter derives purely from bound scalar properties still serializes correctly (the scalar inputs are bound; the getter still runs against the projected POCO).
One shape keeps the pre-#323 behavior entirely: a related type where an EDM structural property itself is get-only (no public setter) is not IsMemberInitProjectable at all — a public setter on every structural property is a hard requirement, not just a nice-to-have — so that type falls back to the bare (untransformed) leaf, exactly as it always did. No wire change happens there (the get-only structural property still serializes, since nothing is projected), but the type also doesn't get the #323 pushdown/cycle fix — a back-reference on that type still defers the branch off pushdown under the pre-#323 rules.
A root model that can't support a member-init $select projection at all — no public parameterless constructor, an unknowable ETag selector, or a complex/unsettable structural member — is a separate case, and is no longer silently dropped to EDM-only (#305). The engaged, delegate-less $expand navigations are instead served through EF Core's own Include (resolved via reflection — this package carries no compile-time EF Core dependency), bounded by MaxExpandTop exactly like the projection path, and a nested $count/$select/$top/$skip is shaped afterward exactly as it is on the projection path. A nested $filter/$orderby can't ride a plain Include (it's a SQL-only capability of the member-init path), so it still fails loud with 400 rather than silently degrading, and so does a nested $expand/$levels under this fallback (out of scope for #305) — both error messages point at making the root model projection-eligible, or writing an expand delegate for that navigation, instead. A leaf expand whose related type has a back-reference (to the root model, to a sibling leaf, or to itself) is now served rather than rejected (#325/#326, Option B): Include populates tracked entities, and EF Core's own relationship fixup can wire the back-reference up, but the response now serializes through the same clause-bounded SerializeBounded walker every other path uses, which never hands an un-expanded navigation to System.Text.Json at all — a reference cycle among the tracked entities is structurally unreachable regardless of which two instances it closes between. (#323 originally introduced a 400 here for the root-back-reference case specifically — "Change C" — before #326 identified two further cycle classes that guard missed; #325/#326 removed the guard entirely rather than widen it, since the underlying request can now be answered correctly.) The one residual gap: a cycle closed by an entity-typed CLR property that is not an EDM navigation (excluded from the EDM model entirely) is outside what SerializeBounded bounds, and still rethrows as a generic 500 — by that point the query itself already succeeded, so this is the one case where fail-loud means an actual server error, not a 400.
A translation failure is different, and fails loud (FAIL LOUD, post-#298/#300 review). If a nested $filter/$orderby cannot be bound, or the composed query cannot be translated by the provider even though everything looked eligible, the request now returns 400 (InvalidQueryOption) instead of silently degrading to EDM-only under a 200 — before this fix, a translation failure could mean the affected navigation (or, for the specific shapes #298/#300 identified, the whole parent collection) came back wrong or empty with no indication anything failed. Simplify the nested option combination, or write an expand delegate for that navigation to take full control of its query shape. See the wire-change note above for what remains possible outside pushdown.
A delegate-backed navigation is never affected by any of this — it always expands through its delegate. Set ExpandPushdownEnabled = false (per profile or in WithDefaults) to keep every delegate-less navigation unexpandable.
$expand pushdown composes with $select pushdown: ?$select=name&$expand=Lines prunes the parent's column list and JOINs the lines in the same single query. The two capabilities are independent — disabling SelectPushdownEnabled does not disable $expand pushdown, and an $expand push never column-prunes the parent on its own.
Nested options on a pushed $expand
A pushed (delegate-less) $expand honors the nested options of the expanded collection. $filter, $orderby, and $top/$skip are pushed down to SQL as a filtered / ordered / paged Include (translated by Microsoft's own OData FilterBinder/OrderByBinder, so the semantics match a top-level $filter/$orderby), producing a single JOIN'd query — no per-parent N+1. $count and $select are then applied to the serialized result (in whatever naming policy is configured — PascalCase by default).
| Nested option (on a delegate-less pushed nav) | Supported | How |
|---|---|---|
$select — Children($select=name) |
✅ | JSON projection of the expanded elements (configured naming policy preserved) |
$filter — Children($filter=active eq true) |
✅ | filtered Include (SQL WHERE in the JOIN) |
$orderby — Children($orderby=name desc) |
✅ | ordered Include (SQL ORDER BY in the JOIN) |
$top / $skip — Children($orderby=name;$top=5) |
✅ | paged Include (SQL ROW_NUMBER window); $top is capped by MaxExpandTop |
$count — Children($count=true) |
✅ | inline Children@odata.count = full filtered count (paging is applied after counting, per §11.2.4.2); bounded by MaxExpandTop |
nested $expand — Children($expand=Grandkids) |
✅ | multi-level pushdown: folded into the same query as an Include→ThenInclude JOIN when every level is delegate-less (see Multi-level $expand below) |
$levels — Children($levels=2) / Children($levels=max) |
✅ | recursive self-referential expand, bounded by MaxExpansionDepth; may carry $filter/$orderby/$skip/$top/$count/$select, applied at every level (see below) |
$search / $compute / $apply |
❌ (deferred) | not implemented on the pushdown path |
A deferred nested option is not an error: the request still returns 200, but the delegate-less navigation that carried it stays EDM-only for that request — the framework doesn't load it via pushdown, though whatever the handler's own query already populated (or didn't) is what serializes (see the caveat above). Nested options on a delegate-backed navigation follow the delegate path and are subject to that path's own support (see navigation-routing.md); they never engage pushdown. One option is not merely unsupported there but actively rejected: a nested $top/$skip against a delegate-backed navigation returns 400 (InvalidQueryOption) rather than being forwarded to (or silently dropped by) the delegate (#294) — the delegate's Handler/BatchHandler returns its full answer for a given parent key and nothing downstream re-windows it, so honoring the option would mean quietly serving every related row under an unsuspicious 200. This applies to any delegate-backed navigation under $expand, self-referential or not (see SelfReferentialNavMaxTopTests.cs and BatchExpandTests.cs).
Multi-level $expand and $levels (#206)
A nested $expand is pushed recursively: ?$expand=Books($expand=Chapters($expand=Pages)) folds all three levels into one JOIN'd query (EF Core Include→ThenInclude), applying each level's own nested $filter/$orderby/$top/$skip/$count/$select. A branch is pushed only when it is delegate-less at every level; the moment a level's navigation carries a delegate (or is cyclic / a non-projectable type), that whole branch is deferred off pushdown and resolves through the existing path — a delegate-backed navigation is never EF-included at any depth, so the delegate is never bypassed. A delegate-backed navigation reached directly from the root (or under delegate-backed ancestors) still expands through its delegate exactly as before; a delegate navigation nested beneath a delegate-less parent is never JOIN-loaded and its delegate is never invoked — but, exactly as for any deferral, that is not a guarantee of emptiness: if the parent handler's own query populated it (an Include/ThenInclude it wrote, or a hand-built graph), it serializes as-is.
$levels=N recursively expands a self-referential navigation (a tree/hierarchy) N levels deep — ?$expand=Children($levels=2) — as a bounded, cycle-free projection (each level is a fresh POCO; the deepest loaded level terminates the recursion). $levels=max resolves to the configured MaxExpansionDepth. Both are capped at MaxExpansionDepth: a $levels (or a nested $expand) that resolves deeper is rejected with 400 before any handler runs (see Complexity limits).
A $levels expand may also carry $filter, $orderby, $skip, $top, $count, and $select (#254). Those options apply at every level of the recursion, not just the first — the semantics Microsoft's own OData stack implements ($levels=N is rewritten into N nested expands each carrying the same options) and the reading the spec's equivalence example implies. So ?$expand=Children($levels=2;$filter=active eq true) prunes inactive nodes at both levels (an inactive node's whole subtree disappears with it), ($levels=2;$count=true) emits Children@odata.count on every level, and ($levels=2;$select=name) keeps the self-navigation itself at every level while pruning the other properties.
One caveat, now fixed (#296/#294, PR #321): a nested $top on a self-referential navigation used to be rejected by the underlying OData validator before OhData's pushdown code ever ran, because the navigation's target type is necessarily its own entity set — the same thing that makes $levels legal on it at all — so its model-bound MaxTop always defaulted to 0. OhDataBuilder.MarkNavigationTargetTypesFullyQueryable now clears that model-bound MaxTop for a root-and-nav-target ("shared"/self-referential) type exactly as it already did for a pure nav-target-only type (#296; the fix generalizes to any non-self-referential "shared type" too — a type that is both a root entity set and someone else's navigation target, see SharedNavTargetTypePushdownTests.cs) — so that pre-emptive 400 no longer fires. What happens instead depends on whether the navigation is delegate-backed:
- On a delegate-less navigation, the nested
$topnow genuinely reaches OhData, and — like$skip— is applied in the JSON pass (ApplyNestedWindow/ShapeLevelsInJson) rather than pushed to SQL, for the sameAPPLY/LATERAL-shaped translation problem the$countcaveat below describes:?$expand=Children($levels=2;$top=1)windows to one child at every level of the recursion. - On a delegate-backed navigation, a nested
$top/$skipis instead rejected with a different400(InvalidQueryOption) — OhData's own check (#294), not the old model-bound one — since the delegate returns its full per-parent answer and nothing downstream re-windows it (see the caveat above, andSelfReferentialNavMaxTopTests.cs).
A plain (non-$levels) $expand=Children($top=…) against a delegate-less self-referential navigation used to be a separate, orthogonal limitation: the plain member-init projection for a self-reference was treated as genuinely cyclic, so it never engaged pushdown regardless of $top, and the $top silently went unapplied. Resolved by #323: a self-referential related type is still projectable (a public parameterless constructor plus settable scalar structural properties is all IsMemberInitProjectable requires — cyclicity is orthogonal to that), so it now clears the narrowed back-reference guard and genuinely engages pushdown even without $levels. ?$expand=Children($top=1) now actually windows to one child (see LevelsWithOptionsPushdownSqliteTests, T19). The projected elements are leaves — the self-navigation property on each is not itself bound (consistent with the leaf-projection wire change above), so the result stays finite without needing $levels' explicit termination. $skip never carried a model-bound ceiling and always reached OhData's code even before #294/#296. ?$expand=Children($levels=2;$orderby=name desc;$skip=1) windows deterministically at every level; the other options are unaffected.
The one combination still deferred off pushdown is a $levels expand carrying its own nested $expand (Children($levels=2;$expand=Tags)): depth accounting between the $levels budget and the nested branch's own remaining depth is ambiguous against MaxExpansionDepth. As with any deferral the request still returns 200; the navigation just stays EDM-only for that request — not guaranteed empty, per the caveat above.
The ceiling is advertised in $metadata as the Org.OData.Capabilities.V1.ExpandRestrictions/MaxLevels annotation on each entity set, so a client can discover it before issuing a request.
Caveats.
- Nested options are not gated by the parent profile's property allowlists.
FilterProperties/OrderByProperties/SelectPropertiesrestrict the root entity set only; a navigation-target type has no allowlist surface of its own and is treated as fully queryable (this is the same design decision that lets nav-path$filterwork — seeMarkNavigationTargetTypesFullyQueryable). So$expand=Children($filter=…)/($orderby=…)/($select=…)may reference any column of the child type regardless of what the parent restricted. Model your navigation targets accordingly (e.g. don't expose a sensitive column on a type reachable via a delegate-less navigation you$expand), or write an expand delegate for that navigation (which opts it out of pushdown and lets you enforce your own shaping). $counton a pushed expand materializes the full filtered child collection; whetherMaxExpandTopbounds that materialization in SQL depends on the shape — and, since #304, the same shape question governs a plain nested$top/$skip(no$count) too. To reportNav@odata.countaccurately, the whole filtered set is loaded before$top/$skippaging is applied — the same amount of data a bare$expand=Navalready loads. As of #254, at a projection leaf — a level with no nested$expandof its own —$top/$skippaging (with or without$count) pushes into SQL and transfers only the page: the framework composes aTake(MaxExpandTop + 1)for the$countcase, and a related collection larger than the ceiling is rejected with400(InvalidQueryOption) rather than reported with a truncated count — §11.2.4.2 requiresNav@odata.countto be the count of the full filtered collection, so silent truncation would be a lie. At a level that also carries its own nested$expand(a level with children), or anywhere inside a$levelsrecursion, the SQL bound is not composed for$countor for a plain$top/$skip(#304) — windowing a collection and projecting a further collection out of it in the same query requires SQLAPPLY/LATERAL, which not every provider (SQLite among them) translates — so the window (and, for$count, the count) is instead computed after an unbounded materialization in the JSON pass, and the request is rejected with the same400if the collection exceedsMaxExpandTop. Before #304, a nested$top/$skipat a level with its own nested$expandfailed loud with400outright (e.g.?$expand=Books($top=1;$expand=Chapters)); it is now windowed correctly instead, the same JSON-pass trade$levelsand$countalready made — and #316 closed the matching ceiling gap on the$levelsJSON-windowing path. The correctness of the ceiling is enforced either way (never a truncated count, never an untranslatable-query failure); #299 tracks tightening the unbounded-materialize-then-400cost, which stays open. Narrow the collection with a nested$filter, or raise/removeMaxExpandTop.- An omitted nested
$topis deliberately left unbounded ($expand=Navwith no$count). Silently windowing an expanded collection without aNav@odata.nextLinkto continue from would be a worse spec violation than the cost; nested server-driven paging is not implemented.MaxExpandTopbounds an explicit nested$topand the nested-$countmaterialization only. - Nested paging without a nested
$orderbyis stabilized by the child's key. When$top/$skipare pushed to SQL without a nested$orderby, the navigation element's single key is appended as a deterministic tiebreaker (mirroring the root path). A composite-keyed child type is left to the provider's order.
To also expose navigation as a standalone HTTP route (GET /Orders(id)/Lines), provide a handler to HasMany - see navigation-routing.md.
Complexity limits (#202)
Five ceilings bound how expensive a single request's query options may be. Each is configurable globally via WithDefaults or per entity set on the profile (the profile value overrides the global default); a request that exceeds a limit is rejected with 400 before any handler runs. They apply on all three collection read paths (GetQueryable, GetAll, Priority-1).
| Limit | Default | Bounds |
|---|---|---|
MaxExpansionDepth |
3 |
Nesting depth of $expand, and the ceiling $levels is resolved and capped to ($levels=max becomes exactly this value). Enforced as of #202 — a deeper $expand/$levels returns 400 rather than a silently-truncated result. Advertised per entity set in $metadata as Org.OData.Capabilities.V1.ExpandRestrictions/MaxLevels (#206). Raise it to allow deeper graph/hierarchy queries, or lower it to harden. |
MaxExpandTop |
1000 |
Per-navigation ceiling on a nested $top inside a $expand (?$expand=Children($top=N)), and the bound on how many related entities a nested $count may materialize (#254). An over-large nested $top returns 400 (InvalidQueryOption) at any depth, on any read path, and whether or not the navigation would have been pushed down — the same "what may a client ask for" rule as the root MaxTop. A nested $count whose related collection exceeds the ceiling also returns 400 rather than a truncated count (§11.2.4.2). The root entity set's resolved value governs at every nesting depth, exactly like MaxExpansionDepth. Set the default to null for no ceiling (WithDefaults(d => d.MaxExpandTop = null)) — on a profile, MaxExpandTop = null means inherit the resolved default instead; a profile-level null does not itself remove the ceiling. Cost caveat (#299): where the ceiling applies — an explicit nested $top, and the nested-$count/deferred-window materialization — it is always correct: the request 400s rather than returning a truncated count or a silently-clipped page. It isn't always cheap to enforce, though. At a level with its own nested $expand, or anywhere inside a $levels recursion, the check can't be pushed into SQL as a Take (the same APPLY/LATERAL translation problem the nested-$count caveat above describes), so the 400 is thrown only after the full related collection — for $levels, the full recursive hierarchy — is materialized in memory. A hostile $expand=Children($levels=N;$count=true) therefore buys that full materialization before being rejected on breach — a broad but under-cap hierarchy just materializes fully and returns 200 like any other under-cap page; the cost only bites once the collection actually exceeds the ceiling. The ceiling does not apply at all to a bare $expand=Nav (no nested $count, no explicit $top) — per the "omitted nested $top" bullet above, that shape is deliberately left unbounded with no SQL Take and no post-hoc size check, so today a 5,000-row related collection under a MaxExpandTop of 1000 returns all 5,000 rows, not a 400. |
MaxFilterNodeCount |
10000 |
Number of nodes in a $filter expression tree. |
MaxOrderByNodeCount |
1000 |
Number of nodes in an $orderby. |
MaxAnyAllExpressionDepth |
1000 |
Nesting depth of any()/all() lambdas in a $filter. |
builder.Services.AddOhData(o => o
.WithDefaults(d => { d.MaxExpansionDepth = 3; d.MaxFilterNodeCount = 200; })
.AddEntitySetProfile<OrderProfile>());
public class OrderProfile : EntitySetProfile<int, Order>
{
public OrderProfile() { MaxExpansionDepth = 5; /* this set allows deeper expands than the default */ }
}
The node-count defaults are unchanged from what OhData already applied (they were previously hardcoded); #202 makes them lowerable. Note that a root $top/$skip is governed separately by MaxTop (see above), not by these node counts; a nested $top inside a $expand is governed by MaxExpandTop.
builder.Services.AddOhData(o => o
.WithDefaults(d => d.MaxExpandTop = 200) // or null to remove the ceiling entirely
.AddEntitySetProfile<OrderProfile>());
$search
Register a Search handler to support free-text search:
Search = async (term, ct) => await db.Products
.Where(p => p.Name.Contains(term) || p.Description.Contains(term))
.ToListAsync(ct);
GET /odata/Products?$search=widget
Without a Search handler, $search requests return 400 Bad Request (UnsupportedQueryOption). The interpretation of the search term is entirely up to the handler.
On the GetQueryable path, $search composes with the other query options: the handler's results become the base sequence, and $filter, $orderby, $top, and $skip are then applied on top of the search results (in that order). On the GetAll path, $search composes the same way with the options GetAll supports: the handler's results become the base sequence, and $top/$skip are applied on top of them ($filter/$orderby remain unsupported on this path regardless of $search).
$skiptoken (server-driven paging)
When a response includes @odata.nextLink (emitted once the page size reaches MaxTop or the client-requested maxpagesize), the link contains a $skiptoken value:
GET /odata/Products?$top=20
→ "@odata.nextLink": "https://host/odata/Products?$top=20&$skiptoken=MjA="
$skiptoken is a Base64-encoded raw 4-byte little-endian integer - the literal skip offset - not an opaque or cryptographically-protected cursor. A client (or anyone who intercepts a link) can trivially decode, predict, or forge a token to jump to an arbitrary offset; it provides no more protection than sending $skip directly. Don't rely on it to gate access to specific pages or ranges of data - apply authorization/filtering in the handler itself if that matters.
A malformed or corrupted $skiptoken (wrong length, invalid Base64) returns 400 Bad Request (InvalidSkipToken). If both $skip and $skiptoken are present, $skip takes precedence.
Error responses
Invalid or disabled query options return 400 Bad Request with an OData error body. A disabled
capability flag produces UnsupportedQueryOption:
{ "error": { "code": "UnsupportedQueryOption", "message": "This resource does not support $filter. Set FilterEnabled = true on the profile (or the corresponding EntitySetDefaults property) to enable it." } }
A syntactically invalid option, an unknown property, or a property outside a configured
allowlist produces InvalidQueryOption:
{ "error": { "code": "InvalidQueryOption", "message": "The property 'Id' cannot be used in the $filter query option." } }