Skip to content

Filtering

CQL2 filter/sortby query parameters, plus the queryables/sortables resources — derived from your pydantic model — with malformed input rendered as a 400 problem.

Filtering is the largest and most-reimplemented slice of request-side OGC machinery: parsing CQL2, validating that a filter only touches filterable fields, advertising which fields those are, and applying sortby. gazebo.filtering owns that plumbing — and deliberately does not own a CQL2 parser. Writing one (comparison, logical, spatial, temporal, and array operators across two encodings) is a large, perpetual maintenance burden that mature libraries already carry. So gazebo adopts a CQL2 engine behind a narrow seam and spends its effort on the parts no library provides.

The bundled engine adapts cql2-rs; install it with the extra:

pip install 'gazebo[cql2]'

Queryables from your model

A queryables resource is a JSON Schema, and pydantic already emits JSON Schema — so the /queryables body and the filter allow-list both fall out of the model you already wrote. Nested models flatten to dotted accessors (location.lat), so nested data is filterable; geometry fields are advertised as spatial queryables; arrays surface their item type. sortables_from_model is the scalar subset — the fields that have a total order to sort by.

from datetime import date

from pydantic import BaseModel

from gazebo.filtering import queryables_from_model, sortables_from_model


class Coord(BaseModel):
    lat: float
    lon: float


class BedProps(BaseModel):
    name: str
    sun: Literal['full', 'part', 'shade']
    planted: date
    location: Coord | None = None


# The queryables resource *is* a JSON Schema — derived from the model you already wrote.
# Nested models flatten to dotted accessors, so `location.lat` is filterable.
queryables = queryables_from_model(BedProps, id='beds')
assert queryables.names == {'name', 'sun', 'planted', 'location.lat', 'location.lon'}

# Sortables are the scalar subset (no arrays/geometry to order by).
sortables = sortables_from_model(BedProps)

The resulting property-name set is the authoritative artifact: a filter that references anything outside it is rejected before evaluation. Everything else the schema carries (types, enums, formats, constraints) is advisory metadata for clients.

In a route

The FastAPI adapters mirror the query-parameter idiom: FilterParam(queryables) and SortByParam(sortables) drop into a route signature as Annotated metadata. A malformed filter, an unknown property, an unsupported filter-crs, or a non-sortable sortby field each short-circuit to a 400 application/problem+json before your handler runs; a valid filter arrives as a ready-to-use Filter.

from gazebo.ext.fastapi import FilterParam, GazeboApp, Providers, SortByParam
from gazebo.filtering import Filter, SortBy

BED_QUERYABLES = queryables_from_model(BedProps, id='beds')
BED_SORTABLES = sortables_from_model(BedProps)

app = GazeboApp(Providers())

_BEDS = [
    {'name': 'roses', 'sun': 'full', 'planted': '2021-04-01'},
    {'name': 'ferns', 'sun': 'shade', 'planted': '2020-06-01'},
]


@app.get('/items')
async def items(
    filter: Annotated[Filter | None, FilterParam(BED_QUERYABLES)] = None,
    sortby: Annotated[SortBy | None, SortByParam(BED_SORTABLES)] = None,
) -> dict:
    rows = [r for r in _BEDS if filter is None or filter.matches(r)]
    if sortby is not None:
        rows = sortby.apply(rows)
    return {'names': [r['name'] for r in rows]}


# `?filter=sun = 'full'` is parsed, validated against the queryables, and evaluated;
# a malformed filter or a non-queryable property becomes a 400 problem+json.

Filter.matches is the in-memory convenience used above; it inherits SQL WHERE semantics — a row whose referenced property is absent or null simply doesn't match (rather than raising), so it is safe to use directly over sparse data. For a database backend, reach through to the engine-native expression on filter.compiled (the cql2 adapter exposes .native for to_sql() and friends) instead of evaluating in Python.

?f= filter language and CRS

filter-lang selects the encoding (cql2-text or cql2-json); when omitted it is inferred from the value. filter-crs is validated against an allow-list (default CRS84) — like CrsParam, validating a CRS is not reprojecting it, so only advertise a CRS whose geometries you actually handle.

Bringing your own engine

gazebo ships exactly one engine but keeps the FilterEngine Protocol open. The seam is what keeps the core free of the CQL2 dependency, so it costs nothing to leave it usable: pass any object implementing compile(...) -> Compiled as FilterParam(..., engine=...) to back filtering with a different CQL2 implementation, without gazebo bundling a second one.

The garden example wires all of this into its GET /collections/beds/items endpoint, with /collections/beds/queryables and /collections/beds/sortables.

Reference

See gazebo.filtering, the engine adapter in gazebo.filtering.cql2, and the FilterParam / SortByParam adapters in gazebo.ext.fastapi.