Querying
A Breeze client builds a query on the client and sends it as JSON in the URL. The server's job is to turn that into a WHERE, ORDER BY, SKIP/TAKE, SELECT and INCLUDE against your IQueryable, so the database does the work rather than the web server.
BreezeQueryFilterAttribute is what does it.
The filter
Put it on the controller class. Every action that returns IQueryable or IEnumerable is then queryable:
[Route("breeze/[controller]/[action]")]
[BreezeQueryFilter]
public class CustomerQueryController : Controller {
private readonly NorthwindPersistenceManager _pm;
public CustomerQueryController(NorthwindContext context) {
_pm = new NorthwindPersistenceManager(context);
}
[HttpGet]
public IQueryable<Customer> Customers() => _pm.Context.Customers;
}The action returns the unfiltered set. The filter runs after the action, reads the query off the request, applies it to what was returned, and executes it.
GET /breeze/Northwind/Customers?{"where":{"city":"London"},"take":10}becomes SELECT TOP 10 ... WHERE City = 'London'.
NOTE
Return IQueryable<T>, not List<T>. A materialized list can still be filtered — LINQ to Objects — but only after every row has come back from the database.
Sync or async
BreezeAsyncQueryFilterAttribute is the same filter with async execution and cancellation.
[BreezeQueryFilter] | [BreezeAsyncQueryFilter] | |
|---|---|---|
| execution | synchronous | awaited |
| cancellation | no | yes, via CatchCancellations |
MaxDepth, MaxTake, UsePost | yes | yes |
The async one frees the request thread while the database works, which matters under load. The sync one exists because of efcore#18221; the attribute's own remarks say so.
[BreezeAsyncQueryFilter(CatchCancellations = true)]With CatchCancellations, a client that gives up mid-query gets an empty result with status 499 rather than an exception — CancellationStatusCode changes the code.
Limiting what a client may ask for
A query arrives from a browser, so it is user input. Two properties bound it.
MaxTake
[BreezeQueryFilter(MaxTake = 1000)]Adds Take(1000) when the client asked for more, or for nothing at all. Default -1, unlimited — which means a client that forgets .take() selects the whole table.
This is not the same as a Take() in the action: MaxTake is applied after the client's where and orderBy, so it caps the page rather than the candidate set.
WARNING
MaxTake only applies to Entity Framework queryables, and it does not reach inside expand subqueries. Returning an array or a List from an action puts it outside this cap.
MaxDepth
[BreezeQueryFilter(MaxDepth = 2)]Caps how far select and expand may reach, and returns 400 Bad Request when a query goes deeper. Default -1, unlimited.
On IQueryable<Customer>:
MaxDepth | allows | rejects |
|---|---|---|
0 | select=companyName | expand=orders, select=orders |
1 | expand=orders | expand=orders.orderDetails |
2 | expand=orders.orderDetails | a third level |
Depth is what keeps one request from pulling a large part of the database through a chain of navigation properties.
NOTE
MaxDepth bounds how far a query may walk, not which navigations it may walk. To say that Order.Employee is off limits however shallow the request, see Declare which navigations may be expanded.
Named queries with parameters
Not every endpoint is a bare entity set. An action that takes its own parameters and decides for itself what to return is usually called a named query: the client asks for it by action name rather than by resource.
[HttpGet]
public IQueryable<Customer> CustomersStartingWith(string companyName) {
return _pm.Context.Customers.Where(c => c.CompanyName.StartsWith(companyName));
}The parameters are bound by ordinary ASP.NET Core model binding — there is nothing Breeze-specific about them. The client supplies them with withParameters:
EntityQuery.from('CustomersStartingWith').withParameters({ companyName: 'C' });It is still composable
This is the part worth understanding, because it is what makes a named query the right default rather than a compromise.
The action returned an IQueryable, which is an unexecuted expression tree. The filter appends the client's query to it, and only then does anything reach the database. So the client can go on building:
EntityQuery.from('CustomersStartingWith')
.withParameters({ companyName: 'C' })
.where('fax', '!=', null)
.orderBy('companyName')
.skip(20).take(10)
.inlineCount(true);Your StartsWith, the client's fax != null, the ordering, the paging and the count all end up in one SQL statement. The parameter narrows first because it is already in the tree; everything the client sent is layered on top and can only narrow further.
Return a List<T> instead and the parameter still works, but the composition does not:
// The same parameter, but the client's query can no longer reach the database:
// every matching customer is fetched first, and filtered in memory afterwards.
[HttpGet]
public List<Customer> CustomersStartingWithList(string companyName) {
return _pm.Context.Customers.Where(c => c.CompanyName.StartsWith(companyName)).ToList();
}IQueryable<T> | List<T> | |
|---|---|---|
where does the client's where run? | in the database | in memory, after the fetch |
rows fetched for .take(10) | 10 | every row matching the parameter |
MaxTake applies | yes | no — it only acts on EF queryables |
inlineCount | a COUNT in the database | a .Count() on the list |
The difference is invisible until the table is large, and then it is the whole story.
TIP
This is also why a named query is the primary security tool. The action fixes the most a client can ever see, and composition means the client loses nothing by being confined to it — it can still filter, sort and page exactly as it would against a bare entity set. See Security.
Parameter shapes
Anything model binding understands from a query string:
| Client | Server |
|---|---|
withParameters({ companyName: 'C' }) | string companyName |
withParameters({ cities: ['London', 'Paris'] }) | [FromQuery] string[] cities |
withParameters({ CompanyName: 'C', City: 'London' }) | [FromQuery] CustomerQuery qbe |
An array arrives as cities[0]=London&cities[1]=Paris:
[HttpGet]
public IQueryable<Customer> CustomersIn([FromQuery] string[] cities) {
var customers = _pm.Context.Customers.AsQueryable();
if (cities.Length > 0) {
customers = customers.Where(c => cities.Contains(c.City));
}
return customers;
}A query-by-example object binds from flat, top-level parameters named after its properties — CompanyName=C&City=London, not qbe.CompanyName=C. The parameter name on the server is not part of what the client sends:
/// <summary> A query-by-example parameter. Its properties are bound from the query string. </summary>
public class CustomerQuery {
public string? CompanyName { get; set; }
public string? City { get; set; }
}
[HttpGet]
public IQueryable<Customer> SearchCustomers([FromQuery] CustomerQuery qbe) {
var customers = _pm.Context.Customers.AsQueryable();
if (!string.IsNullOrEmpty(qbe.CompanyName)) {
customers = customers.Where(c => c.CompanyName.StartsWith(qbe.CompanyName));
}
if (!string.IsNullOrEmpty(qbe.City)) {
customers = customers.Where(c => c.City == qbe.City);
}
return customers;
}NOTE
Zero, null and empty-string parameters are worth a test of your own. An absent parameter and an empty one are not the same thing to model binding, and which one a client sends depends on how it built the object.
Where the parameters sit in the URL
The client puts the Breeze query first and appends the parameters after it:
GET /breeze/Northwind/CustomersStartingWith?{"where":{"fax":{"ne":null}}}&companyName=CThat order is not decorative. With the default configuration the server looks for the JSON immediately after the ? and ignores it otherwise, so a URL built by hand — in a test, or with curl — must put the JSON first or the query is silently dropped while the parameters still bind. An & inside a quoted JSON string is handled and does not end the query.
Setting QueryParamName makes the JSON a named parameter instead, and ordering stops mattering. See Where the query lives in the URL.
NOTE
On the client, a named query often cannot be matched to an entity type by its resource name, and where needs the type to validate property names against. .toType('Customer') supplies it.
Long queries: UsePost
A complex query can outgrow the URL length a server or proxy will accept. UsePost reads it from the request body instead:
[BreezeQueryFilter(UsePost = true)]There is a cost — the body has to be read and buffered — so put it on endpoints that need it rather than on every controller. If model binding has already consumed the body, it must be rewound before the filter can read it.
Opting out for one action
SkipBreezeQueryFilter turns the filter off for the current request, for an action on a filtered controller that returns something the filter should not touch:
[HttpGet]
public IQueryable<Customer> UnfilteredCustomers() {
this.SkipBreezeQueryFilter();
return _pm.Context.Customers;
}To apply a query by hand instead — to inspect or post-process the result — ApplyBreezeQuery and ApplyBreezeWhere are extension methods on ControllerBase.
Loading several lookup tables in one request
Most applications open onto a screen that needs a dozen small reference lists — regions, categories, statuses, roles, currencies — before it can render a single dropdown. Fetching them one resource at a time costs a round trip each, and on a slow connection that is the whole of the startup delay.
One action can return them all. Return an object whose properties are the sets:
// One request, three lists. The return type is object, not IQueryable, so the query
// filter finds nothing to act on and passes the bag through untouched.
[HttpGet]
public object Lookups() {
var regions = _pm.Context.Regions;
var territories = _pm.Context.Territories;
var categories = _pm.Context.Categories;
return new { regions, territories, categories };
}The client asks for it like any other resource, and every entity in the bag lands in its cache:
await EntityQuery.from('Lookups').using(em).execute();
// Region, Territory and Category entities are all cached nowBreeze does not treat the wrapper as an entity. Each nested object carries its $type, which is how the client recognises the entities inside and merges them. The client side of this is in A bag of lookups.
The return type decides whether the filter applies
object is the right return type, and deliberately so. The filter looks for an IQueryable or an IEnumerable in the result; an anonymous object is neither, so the bag passes through untouched.
Return IEnumerable<object> — a list holding one bag — and the filter does engage, applying the client's query to the outer one-element list rather than to anything inside it. It works, but the query does nothing useful. Prefer object.
WARNING
Because the filter does not engage, MaxTake and MaxDepth do not apply here. This endpoint returns each set in full, however large it has become. That is exactly what you want for a handful of small static tables and exactly what you do not want when somebody adds Orders to the bag a year from now. See Security.
Run the queries before serialising
In the version above the properties are still IQueryable, so nothing touches the database until the serializer enumerates them — one query per set, part-way through writing the response. If one fails there, the response has already begun and the client gets a truncated body rather than an error. The filter takes the same precaution for select and expand, for the same reason.
Materialise them yourself and that problem goes away, along with any doubt about when the work happens:
[HttpGet]
public object LookupsEagerly() {
// ToList() runs each query here rather than inside the serializer, so a failure
// becomes an error response instead of a truncated one - and the sizes are yours
// to check before they go on the wire.
return new {
regions = _pm.Context.Regions.ToList(),
territories = _pm.Context.Territories.ToList(),
categories = _pm.Context.Categories.ToList(),
};
}It also gives you somewhere to assert that these tables really are small.
NOTE
"One pass" means one round trip from the browser, which is the expensive part. It is still one SQL query per set, run sequentially on the one connection — Breeze does not combine them.
Trim the anonymous type out of the payload
UpdateWithDefaults sets TypeNameHandling.Objects, so Newtonsoft writes a $type for the anonymous wrapper as well as for the entities — an assembly-qualified name the client has no use for. NoAnonSerializationBinder drops it:
builder.Services.AddControllers().AddNewtonsoftJson(opt => {
var settings = JsonSerializationFns.UpdateWithDefaults(opt.SerializerSettings);
// Keeps the anonymous wrapper's assembly-qualified name out of the payload.
settings.SerializationBinder = new NoAnonSerializationBinder();
});Optional — the bag works either way — but it shortens every projection response, and it keeps your assembly name out of them.
Cache them
Lookup data is the same for every request and changes rarely, which makes it the easiest caching win available: [ResponseCache], an IMemoryCache around the materialised lists, or an ETag.
WARNING
Cache per tenant, or not at all, if the lists differ by tenant or by user. With a global query filter the sets are already scoped to the caller, so a cache that ignores that will serve one tenant's data to another.
Inline count
When the client asks for a total alongside a page, the response becomes a QueryResult — { "Results": [...], "InlineCount": 235 } — instead of a bare array. The filter does this for you; nothing in the action changes. The client unwraps it.
Where the query lives in the URL
By default the JSON follows the question mark directly:
?{"where":{"city":"London"}}Some proxies dislike that. QueryParamName moves it into a named parameter — set it to "bq" and the client sends ?bq={"where":...}. It must match what the client sends, so change both sides together.