I ran into a small OData problem recently that took just long enough to be annoying.
The request looked like this:
| |
At first glance it feels like a normal filtering problem. It is not.
The interesting part is that the filter(...) is nested inside $apply, so options.Filter is null even though the request absolutely does contain a filter.
That mattered because the frontend was sending grouped stats queries like this and still expected an accurate odata-count header back:
| |
Once that count is wrong, the client can still render data, but it has lost the real total behind the grouped result.
The shape of the problem
The generic controller logic looked like this:
| |
The normal $filter=... case was easy.
The $apply=filter(...)/groupby(...) case was the one that needed a closer look.
Why options.Filter stays null
This is the key detail.
OData does not treat a filter(...) inside $apply as the same thing as a top-level $filter query option.
Instead, $apply is parsed as a transformation pipeline.
That means the filter is sitting inside the apply tree, not in options.Filter.
Once I framed it that way, the right place to inspect was much clearer:
| |
From there, you can look for a FilterTransformationNode.
The final implementation was better than the original idea
My first instinct was to just extract the FilterClause and figure out the rest later.
The final implementation in my project went one step further: it added a small OData extension layer so the controller code could stay clean.
| |
That changed the problem from:
“How do I manually unpack this nested clause?”
to:
“How do I make FilterTransformationNode behave like a first-class query option in my own code?”
I like that version better.
The important part was binding the clause back to LINQ
The real work was not locating the nested filter. It was turning it back into something the repository query could execute.
In the final code, the helper uses FilterBinder and wraps the bound expression into a lambda:
| |
That parameter replacement step is what made the solution feel complete rather than experimental.
It is not only finding the nested filter.
It is turning it into something the base IQueryable can actually use.
The controller ended up staying simple
Once the extension methods existed, the generic controller logic became pretty clean:
| |
That is the version I trust more than the earlier idea of treating this as a one-off parsing trick.
It keeps the special-case logic close to the actual query shape you care about:
- top-level
$filteruses the built-inFilterQueryOption - nested
filter(...)inside$applyusesFilterTransformationNode
Same intent. Different entry points.
What this fixed in practice
This was not theoretical cleanup.
It was needed so grouped stats endpoints could return both:
- the grouped aggregate data
- the original filtered total via
odata-count
That is useful when the client wants grouped cards, charts, or summaries but still needs to know how many entities matched the filter before grouping.
Without this fix, $count=true on an $apply=filter(...)/groupby(...) request would not respect the nested filter logic in the controller-side count header.
The real lesson for me
The biggest mental shift was stopping the question from being:
“Why is OData ignoring my filter?”
and turning it into:
“Which part of the query tree owns this filter?”
Once I did that, the behavior made sense.
Top-level $filter becomes options.Filter.
Nested filter(...) inside $apply becomes a transformation node.
That distinction explains the whole problem.
My takeaway
If you need accurate count behavior for grouped OData queries, extracting the filter from $apply is only the first step.
The better solution is to make that FilterTransformationNode executable against your IQueryable in the same way your top-level $filter already is.
That was the final shape of the solution in my project:
- find the
FilterTransformationNode - bind its
FilterClause - apply it to the base query
- compute
odata-countfrom the filtered query before returning the unmaterialized result
The parser already knows where the filter is.
The real implementation detail is deciding how to turn that transformation node back into a predicate your query pipeline can use.
