← All articles

Filtering in Regira Entities: the basics

How ?brandId=1&hasIntervention=true becomes SQL: one typed object that names your filters, one small builder that translates them - the foundation of the read pipeline, with a runnable sample to curl along.
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:

  • null means "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>? BrandId binds ?brandId=1&brandId=3 naturally, so multi-select is the default instead of a later retrofit.
  • The record binds straight from the query string. GET /vehicles?brand=volvo&hasIntervention=true is 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.