AND, OR and the endpoints
Every piece so far composed filters with AND: chain another Where, narrow the result. That's the right default — but sooner or later somebody asks for "vehicles of brand 3 or with an open intervention", and most APIs answer with a bolted-on filterMode=any flag that nobody quite trusts. This closing piece shows the endpoint surface, and where OR properly lives in it.
The read surface
Registering an entity exposes a consistent set of endpoints. Which ones depends on the registration's shape — and the sample deliberately contains both kinds:
| Method | Route | Returns | Availability |
|---|---|---|---|
GET |
/{entities}/{id} |
one item, all includes | all registrations |
GET |
/{entities} |
{ items } |
all registrations |
GET |
/{entities}/search |
{ items, count } |
all registrations |
POST |
/{entities}/list |
{ items }, from a body |
complex only |
POST |
/{entities}/search |
{ items, count }, from a body |
complex only |
"Complex" means the registration declares the typed TSortBy + TIncludes from the previous article — the sample's Vehicle. A minimal registration — the sample's Product — exposes just the GETs. Try it: POST $URL/products/search answers 405 Method Not Allowed, while the same call on /vehicles works below.
Worth repeating from the basics: /search returns the total matching count next to the page, computed server-side. Pagers point there; items.length is a page length, not a total.
OR is an array
Within one SearchObject, filters AND. To express OR, you post an array of SearchObjects — and the results union, deduplicated:
curl -X POST "$URL/vehicles/search" -H "Content-Type: application/json" \
-d '[{"brandId":[2]},{"hasIntervention":true}]'
# count 3: TRK-001, TRK-003, VAN-001
Look at the arithmetic: brand 2 (Scania) matches TRK-003; hasIntervention matches TRK-001, TRK-003, VAN-001. The union is three vehicles — TRK-003 matched both halves and appears once.
The shape is easy to say out loud: AND within an object, OR across objects — disjunctive normal form as an API contract. There's no new mechanism behind it: each SearchObject in the array runs the same builders from this series, the queries union, and then paging, sorting and includes apply to the combined result. Which is why it composes with everything else instead of fighting it:
curl -X POST "$URL/vehicles/search?sortBy=Code&includes=Brand&pageSize=2" \
-H "Content-Type: application/json" \
-d '[{"brandId":[2]},{"hasIntervention":true}]'
# count 3, first page of 2, code-sorted, brands joined
The series in one paragraph
Come back to the twelve-parameter controller the series opened with, and tally what replaced it. The filter vocabulary is one record, shared by server, OpenAPI and any client. Translation to SQL is a set of small, pure, unit-testable builders. Cross-cutting rules — ids, tenancy, soft delete — are registered once and provably everywhere, with key-type coercion designed so security defaults cannot silently drop. Free text is solved at write time, on a column you can index. Sorting and includes are typed enums, paging is clamped at the boundary, everything composes into one SQL statement — and OR has a principled home instead of a flag.
None of these pieces is exotic on its own. The compounding value is the fixed seam between them: naming filters is data modeling, translating them is query building, and composing them is the pipeline's job — so each new filter is one property and a few lines in a builder, forever.
The pipeline also has a write-side mirror (preppers, primers, related-collection syncing), and the filters above compose with recursive subtree functions for hierarchy queries — both covered in earlier deep dives: hierarchies at any depth and row-level multi-tenancy.
Docs: Regira Entities — web endpoints, services, built-in features. The Fleet sample with a full production Vehicle pipeline is on GitHub.