Implementing CQL2 Filters Against OGC API - Features in Python

Check /conformance for the filtering classes, read /collections/{id}/queryables to learn which properties are filterable, then send a filter parameter with an explicit filter-lang. Build the expression through a small AST rather than by string concatenation — CQL2 text has quoting rules that make naive interpolation both wrong and unsafe.

The Core Challenge: Filtering Is an Optional Extension

The OGC API - Features core specification defines exactly two ways to narrow a result set: a bbox parameter and equality on a property name used directly as a query parameter. Everything richer — comparison, ranges, pattern matching, spatial predicates, boolean composition — comes from the Common Query Language extension, which a server may or may not implement.

One filter language, two serialisations Two panels showing the same predicate in both CQL2 encodings. The text form is a readable infix expression sent as a query parameter and is what a human writes. The JSON form is a nested operator tree sent in a request body and is what a machine generates. Both are selected with the filter-lang parameter and both express exactly the same filter. CQL2 Text filter=pop > 50000 AND type = 'city' filter-lang=cql2-text (readable, URL-encoded) CQL2 JSON {"op":"and","args":[ {"op":">","args":[ {"property":"pop"},50000]} filter-lang=cql2-json Servers advertise which they support in their conformance declaration — check before sending either.

That has a consequence worth internalising: a server which does not implement filtering does not reject a filter parameter, it ignores it. The response is a valid, successful, unfiltered page of features, and a client that trusts it silently processes the entire collection as though it matched. This is the OGC API equivalent of the empty-result trap in WFS, inverted: instead of getting nothing, you get everything.

Production-Ready Code

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any, Sequence

import requests

CONF_FILTER = "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter"
CONF_TEXT = "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text"
CONF_JSON = "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json"

class FilterUnsupported(RuntimeError):
    """Raised rather than letting an ignored filter return everything."""

@dataclass(frozen=True)
class Expr:
    """A CQL2 node. Building an AST is what makes quoting correct by design."""
    op: str
    args: tuple[Any, ...]

    # ── composition ──────────────────────────────────────────────────
    def __and__(self, other: "Expr") -> "Expr":
        return Expr("and", (self, other))

    def __or__(self, other: "Expr") -> "Expr":
        return Expr("or", (self, other))

    # ── serialisation ────────────────────────────────────────────────
    def to_text(self) -> str:
        if self.op in {"and", "or"}:
            joiner = f" {self.op.upper()} "
            return "(" + joiner.join(a.to_text() for a in self.args) + ")"
        if self.op == "not":
            return f"NOT ({self.args[0].to_text()})"
        if self.op == "between":
            prop, low, high = self.args
            return f"{prop} BETWEEN {_lit(low)} AND {_lit(high)}"
        if self.op == "in":
            prop, values = self.args
            return f"{prop} IN ({', '.join(_lit(v) for v in values)})"
        if self.op in {"s_intersects", "t_after", "t_before"}:
            prop, value = self.args
            return f"{self.op.upper()}({prop}, {value})"
        prop, value = self.args
        return f"{prop} {self.op} {_lit(value)}"

    def to_json(self) -> dict[str, Any]:
        def encode(node: Any) -> Any:
            if isinstance(node, Expr):
                return node.to_json()
            return node
        if self.op in {"and", "or", "not"}:
            return {"op": self.op, "args": [encode(a) for a in self.args]}
        prop, *rest = self.args
        return {"op": self.op,
                "args": [{"property": prop}, *[encode(r) for r in rest]]}

def _lit(value: Any) -> str:
    """Serialise a literal for CQL2 text.

    A single quote inside a string literal is escaped by doubling it —
    the SQL rule, not the backslash rule. Getting this wrong is both a
    syntax error and, on a naive server, an injection vector.
    """
    if isinstance(value, bool):
        return "TRUE" if value else "FALSE"
    if isinstance(value, (int, float)):
        return f"{value:g}"
    if value is None:
        return "NULL"
    return "'" + str(value).replace("'", "''") + "'"

# ── convenience constructors ─────────────────────────────────────────
def eq(prop: str, value: Any) -> Expr: return Expr("=", (prop, value))
def gt(prop: str, value: Any) -> Expr: return Expr(">", (prop, value))
def lt(prop: str, value: Any) -> Expr: return Expr("<", (prop, value))
def like(prop: str, pattern: str) -> Expr: return Expr("LIKE", (prop, pattern))
def between(prop: str, low: Any, high: Any) -> Expr:
    return Expr("between", (prop, low, high))
def in_(prop: str, values: Sequence[Any]) -> Expr:
    return Expr("in", (prop, tuple(values)))
def intersects(prop: str, wkt: str) -> Expr:
    return Expr("s_intersects", (prop, wkt))

class FeaturesClient:
    def __init__(self, base: str, session: requests.Session | None = None) -> None:
        self.base = base.rstrip("/")
        self.http = session or requests.Session()
        self._conformance: set[str] | None = None

    def conformance(self) -> set[str]:
        if self._conformance is None:
            resp = self.http.get(f"{self.base}/conformance", timeout=30)
            resp.raise_for_status()
            self._conformance = set(resp.json().get("conformsTo", []))
        return self._conformance

    def queryables(self, collection: str) -> set[str]:
        """Properties this collection will actually filter on."""
        resp = self.http.get(f"{self.base}/collections/{collection}/queryables",
                             timeout=30, headers={"Accept": "application/schema+json"})
        if resp.status_code == 404:
            return set()
        resp.raise_for_status()
        return set((resp.json().get("properties") or {}).keys())

    def search(self, collection: str, expr: Expr, limit: int = 500,
               lang: str = "cql2-text") -> list[dict[str, Any]]:
        """Refuse to run rather than silently returning an unfiltered page."""
        conf = self.conformance()
        if CONF_FILTER not in conf:
            raise FilterUnsupported(
                f"{self.base} does not declare the filter conformance class; "
                "a filter parameter would be ignored and you would get everything")
        wanted = CONF_TEXT if lang == "cql2-text" else CONF_JSON
        if wanted not in conf:
            raise FilterUnsupported(f"{self.base} does not declare {lang}")

        params = {"limit": str(limit), "filter-lang": lang, "f": "json"}
        if lang == "cql2-text":
            params["filter"] = expr.to_text()
            resp = self.http.get(f"{self.base}/collections/{collection}/items",
                                 params=params, timeout=120)
        else:
            resp = self.http.post(f"{self.base}/collections/{collection}/search",
                                  params=params, timeout=120,
                                  json={"filter": expr.to_json(),
                                        "filter-lang": "cql2-json"})
        resp.raise_for_status()
        return resp.json().get("features", [])

if __name__ == "__main__":
    client = FeaturesClient("https://example.org/ogcapi")
    predicate = (gt("population", 50000)
                 & eq("country", "O'Brien County")
                 & intersects("geom", "POLYGON((5 45,11 45,11 48,5 48,5 45))"))
    print(predicate.to_text())
    print(json.dumps(predicate.to_json(), indent=2)[:200])

Step-by-Step Walkthrough

The AST is the point. Building CQL2 as a tree and serialising at the end means the quoting rule lives in exactly one function. Composing filters by string formatting means the rule is re-implemented at every call site, and the first value containing an apostrophe — a place name, a surname — produces a syntax error at best.

Six predicate classes and whether the backend can push them down A six-row grid of CQL2 predicate classes with their text form and whether a typical PostGIS-backed implementation pushes them into SQL. Equality, range and set membership push cleanly; pattern matching pushes but only uses an index for prefix patterns; spatial intersection pushes when a spatial index exists; and temporal comparison pushes only when the underlying column is a real timestamp rather than text. Predicate class CQL2 operator Pushed to SQL? Equality prop = 'value' yes Range prop BETWEEN a AND b yes Set prop IN ('a','b') yes Pattern prop LIKE 'A%' yes, with an index caveat Spatial S_INTERSECTS(geom, POLYGON(...)) yes, with a spatial index Temporal T_AFTER(ts, DATE('2024-01-01')) yes if the column is typed

_lit doubles single quotes, it does not backslash them. CQL2 inherits SQL’s escaping convention, so O'Brien is written 'O''Brien'. A backslash escape is not merely a different style, it is invalid, and on an implementation that concatenates the filter into SQL it is the shape of an injection.

Conformance is checked before the request, not after. search refuses when the server has not declared the filter class, because the alternative outcome — a successful response containing every feature in the collection — is the failure mode that costs the most. Turning a silent superset into an exception is the single most valuable line in the client.

Queryables tell you which properties are filterable. A collection’s queryables document is a JSON Schema listing exactly the properties the server will filter on. A property absent from it may be present on every feature and still unusable in a filter, which is the third branch of the diagnosis and the least obvious one.

Verification

Print the serialised forms and confirm the escaping, then compare filtered and unfiltered counts:

((population > 50000 AND country = 'O''Brien County') AND S_INTERSECTS(geom, POLYGON((5 45,11 45,11 48,5 48,5 45))))
all_features = client.search("cities", eq("country", "CH"), limit=1)
big = client.search("cities", eq("country", "CH") & gt("population", 50000), limit=1)
print(len(all_features), len(big))     # a filter that changes nothing is suspicious

If a narrowing filter returns the same count as a broad one, the server is ignoring it — which is exactly what the conformance check exists to prevent, and worth asserting in a test even so.

Gotchas & Edge Cases

filter-lang has no reliable default. Some servers assume CQL2 text, some require the parameter, and some reject a filter without it. Sending it explicitly costs nothing and removes the ambiguity entirely.

Filtering is an extension, not a core capability A decision diamond for a rejected or ignored CQL2 filter. The filtering extension is not part of the OGC API - Features core, so a server that does not declare the relevant conformance class may ignore the parameter entirely; the filter language must be stated because no default is guaranteed; a property must appear in the collection's queryables to be filterable; and only once all three hold is an empty result a genuine one. The server rejected the filter or ignored it. Which assumption failed? Check /conformance filtering is an extension Declare it explicitly the default is not guaranteed Read the queryables not every field is filterable Filter is honoured an empty result is real no filter conformance wrong filter-lang unknown property all declared

Text goes in a query string; JSON goes in a body. CQL2 text is URL-encoded into the filter parameter, which means long spatial predicates can exceed a server’s URL length limit. CQL2 JSON is posted to the /search endpoint of the filtering extension, which has no such limit and is the right choice for anything containing a polygon with more than a handful of vertices.

Property names are case-sensitive. They must match the queryables exactly, and a PostGIS-backed collection typically publishes lower-cased names. This is the same trap as SLD filters, and it produces the same symptom: a filter that is simply never true.

Spatial literals are WKT in CRS84. S_INTERSECTS takes a well-known-text geometry whose coordinates are longitude first, regardless of any bbox-crs on the request. Passing a polygon derived from a latitude-first source without swapping produces an empty result that looks like a data problem.

Frequently Asked Questions

How do I know whether a server supports CQL2 at all?

Fetch /conformance and look for the filter conformance class alongside the cql2-text or cql2-json class. This is not optional diligence: a server without the filter class does not reject your filter, it ignores it and returns unfiltered features with a 200, which is far more dangerous than an error.

Should I use CQL2 text or CQL2 JSON?

Text for short predicates written by humans and for anything you want to see in a log; JSON for anything generated, anything containing a geometry, and anything long enough to risk a URL length limit. Since both serialise from the same AST in the code above, the choice is one argument rather than a rewrite.

Can I filter on a property that is not in queryables?

Usually not. The queryables document is the server’s statement of what it will filter on, and a property outside it is typically ignored or rejected. Where a property you need is missing, that is a service configuration issue rather than a client one — the publisher has to expose it.

Does CQL2 replace the WFS filter encoding?

For OGC API - Features, yes — it is the filtering language that specification adopted. The older fes filter encoding remains what WFS 2.0 uses, and the two are not interchangeable, which is why a service exposing both interfaces needs a translation layer of the kind described in the endpoint conversion guide.


Back to OGC API - Features and the REST Transition

Related