Skip to content

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:

cs
[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]
executionsynchronousawaited
cancellationnoyes, via CatchCancellations
MaxDepth, MaxTake, UsePostyesyes

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.

cs
[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 ​

cs
[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 ​

cs
[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>:

MaxDepthallowsrejects
0select=companyNameexpand=orders, select=orders
1expand=ordersexpand=orders.orderDetails
2expand=orders.orderDetailsa 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.

cs
[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:

ts
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:

ts
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:

cs
// 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 databasein memory, after the fetch
rows fetched for .take(10)10every row matching the parameter
MaxTake appliesyesno — it only acts on EF queryables
inlineCounta COUNT in the databasea .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:

ClientServer
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:

cs
[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:

cs
/// <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=C

That 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:

cs
[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:

cs
[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:

cs
// 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:

ts
await EntityQuery.from('Lookups').using(em).execute();
// Region, Territory and Category entities are all cached now

Breeze 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:

cs
[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:

cs
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.

See also ​

Released under the MIT License.