Filtering in Regira Entities: the basics
Filtering is the feature every API grows, and almost none design. It starts with one ?name= parameter handled inline in a controller. Then somebody needs a date range, then a status list, then free text; the controller method sprouts twelve optional parameters and a pyramid of ifs; a second endpoint needs the same filters and copies them. A year in, "search" is the most duplicated, least tested code in the service.
Regira Entities takes a different route, and this series walks it one small piece at a time. The core idea fits in a sentence: name your filters in one typed object, translate them in one small builder, and let the pipeline do the rest. Everything in this series runs against one tiny API — grab the sample project (a dotnet run, a seeded SQLite database, no other setup) and curl along.
One object that names the filters
For every entity you define one record that says what you can filter on. Here is the sample's, for a Vehicle:
public record VehicleSearchObject : SearchObject
{
public string? Code { get; set; }
public string? Model { get; set; }
public ICollection<int>? BrandId { get; set; }
public ICollection<int>? VehicleTypeId { get; set; }
public string? Brand { get; set; } // filter by related entity's text
public bool? HasIntervention { get; set; } // existence of related rows
}
Three small conventions do a lot of quiet work:
nullmeans "not filtered". Every property is nullable, and absence is meaningful. That's what lets one record serve every combination of filters — no flags, no variants.- Id filters are collections.
ICollection<int>? BrandIdbinds?brandId=1&brandId=3naturally, so multi-select is the default instead of a later retrofit. - The record binds straight from the query string.
GET /vehicles?brand=volvo&hasIntervention=trueis the API. No query DSL, no JSON language — and because it's just a parameter object, the whole filter vocabulary shows up in OpenAPI by itself.
The same record is used everywhere — controller binding, service calls (List(so), Count(so)), query builders — so "what can you filter on" has exactly one definition.
The simplest translation: an inline lambda
Naming a filter is data modeling; translating it to a query is a separate job. For simple cases, a lambda at registration time is all it takes. The sample registers a Product this way:
services
.UseEntities<AppDbContext>(options =>
{
options.UseDefaults(); // built-in filters, normalizers - the next articles
options.UseMapsterMapping(); // entity <-> DTO by convention
options.DefaultPageSize = 20;
})
.For<Product, int, ProductSearchObject>(item =>
{
item.Filter((query, so) =>
{
if (so?.MinPrice != null) query = query.Where(x => x.Price >= so.MinPrice);
if (so?.MaxPrice != null) query = query.Where(x => x.Price <= so.MaxPrice);
return query;
});
});
Run the sample and try it:
curl "$URL/products?minPrice=50"
# -> Dash cam (89.90), Roof rack (129.00)
Note the shape of the lambda: it takes an IQueryable and the search object, applies a Where per present filter, and returns the query. Nothing executes here — the pipeline composes everything into one SQL statement and the database does the work.
When it grows: a query builder class
The inline lambda is fine for two conditions. When the logic grows — or needs its own dependencies — it graduates to a class with one method. The sample's vehicle filter (slightly shortened here; one branch uses text normalization, which gets its own article):
public class VehicleFilteredQueryBuilder(IQKeywordHelper qHelper)
: FilteredQueryBuilderBase<Vehicle, int, VehicleSearchObject>
{
public override IQueryable<Vehicle> Build(IQueryable<Vehicle> query, VehicleSearchObject? so)
{
if (so?.Code != null)
query = query.Where(x => x.Code == so.Code);
if (!string.IsNullOrWhiteSpace(so?.Model))
query = query.Where(x => x.Model!.Contains(so.Model));
if (so?.BrandId?.Any() == true)
query = query.Where(x => x.BrandId != null && so.BrandId.Contains(x.BrandId.Value));
if (so?.HasIntervention == true)
query = query.Where(x => x.Interventions!.Any());
return query;
}
}
// registration: e.AddFilter<VehicleFilteredQueryBuilder>();
And the payoff, straight from the query string:
curl "$URL/vehicles/search?brandId=1" # count 2: TRK-001, TRK-002 (the Volvos)
curl "$URL/vehicles?hasIntervention=true" # TRK-001, TRK-003, VAN-001
curl "$URL/vehicles?model=Vito" # VAN-002
Multiple registered filters compose by chaining — each receives the previous one's output, so the semantics are AND. That's the right default; OR gets a principled home at the end of the series.
One property of this design is easy to overlook: a query builder is a pure function from (IQueryable, SearchObject) to IQueryable. You can unit test your predicate logic against an in-memory list — no database, no controller, no DI container in sight.
The endpoints you get for free
Registering an entity gives its controller a consistent read surface. The two you'll use on day one:
curl "$URL/vehicles" # plain list: { items }
curl "$URL/vehicles/search?brandId=1" # list + total: { items, count }
/search exists because counting belongs with paging: count is the total matching rows, computed server-side alongside the page. Point your pagers there — deriving totals from items.length is the classic quiet bug.
Where this is going
You now have the seam that the rest of the series builds on: filters are named in a record, translated in a builder, composed by the pipeline. Each next piece adds one layer, always runnable against the same sample: global filters — the rules every query obeys; archiving — what DELETE really does; free-text search — a search box that hits the index; sorting, includes and paging; and AND, OR and the endpoints.
Docs: Regira Entities — services, web endpoints, built-in features. The registration packages are commercial with a free tier; the sample runs without a license key.