Skip to content

ftmq.Query

See the query guide for a narrative introduction.

A filter over FtM entities, built from composable M / P / G nodes.

Examples:

from ftmq import Query, M, P, G

q = Query().where(M(schema="Person"), P(name__ilike="jane%"))
q = q.where(G(countries="de") | G(countries="at"))
q = q.order_by("name")[:10]
Source code in ftmq/query/main.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
class Query:
    """
    A filter over FtM entities, built from composable `M` / `P` / `G` nodes.

    Examples:
        ```python
        from ftmq import Query, M, P, G

        q = Query().where(M(schema="Person"), P(name__ilike="jane%"))
        q = q.where(G(countries="de") | G(countries="at"))
        q = q.order_by("name")[:10]
        ```
    """

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Query):
            return NotImplemented
        return hash(self) == hash(other)

    def __init__(
        self,
        *nodes: Expr,
        q: Expr | None = None,
        aggregations: Iterable[Agg] | None = None,
        aggregator: Aggregator | None = None,
        sort: Sort | None = None,
        slice: slice | None = None,
    ):
        self.q: Expr | None = q if q is not None else combine(*nodes)
        self.aggregations: set[Agg] = set(aggregations or [])
        self.aggregator = aggregator
        self.sort = sort
        self.slice = slice

    def __getitem__(self, value: Any) -> Self:
        """
        Implement list-like slicing. No negative values allowed.

        Examples:
            >>> q[1]
            # 2nd element (0-index)
            >>> q[:10]
            # first 10 elements
            >>> q[10:20]
            # next 10 elements

        Returns:
            The updated `Query` instance
        """
        if isinstance(value, int):
            if value < 0:
                raise QueryError(f"Invalid slicing: `{value}`")
            return self._chain(slice=slice(value, value + 1))
        if isinstance(value, slice):
            if value.step is not None:
                raise QueryError(f"Invalid slicing: `{value}`")
            return self._chain(slice=value)
        raise NotImplementedError

    def __bool__(self) -> bool:
        """
        Detect if any filter, ordering or slicing is defined

        Examples:
            >>> bool(Query())
            False
            >>> bool(Query().where(M(dataset="my_dataset")))
            True
        """
        return bool(self.to_dict())

    def __hash__(self) -> int:
        """
        Generate a unique key of the current state, useful for caching.

        Like any Python object this is a within-process hash (not stable
        across processes); `hash_data` normalizes ordering so equal queries
        hash equal.
        """
        return hash(hash_data(self.to_dict()))

    def _chain(self, **kwargs: Any) -> Self:
        data: dict[str, Any] = dict(
            q=self.q,
            aggregations=self.aggregations,
            aggregator=self.aggregator,
            sort=self.sort,
            slice=self.slice,
        )
        data.update(kwargs)
        return self.__class__(**data)

    # --- filter accessors (tree-walking collectors) ------------------------

    @property
    def _leaves(self) -> list[Leaf]:
        return list(self.q.iter_leaves()) if self.q else []

    @property
    def limit(self) -> int | None:
        """
        The current limit (inferred from a slice)
        """
        if self.slice is None:
            return None
        start, stop = self.slice.start, self.slice.stop
        if start and stop:
            return int(stop) - int(start)
        return None if stop is None else int(stop)

    @property
    def offset(self) -> int | None:
        """
        The current offset (inferred from a slice)

        A start-less slice (`q[:10]`) reports offset `0`, so it serializes and
        round-trips identically to `q[0:10]`.
        """
        if self.slice is None:
            return None
        return int(self.slice.start or 0)

    @property
    def sql(self) -> "Sql":
        """
        An adapter of this query for sql interfaces, against the default
        nomenklatura statement table. For a custom / extended table pass a
        [`SqlSource`][ftmq.query.sql.SqlSource] to [`compile`][ftmq.Query.compile] or
        build `Sql(query, source)` directly.
        """
        return Sql(self)

    def compile(self, source: "SqlSource | None" = None) -> "Select[Any]":
        """
        Compile this query to a SQLAlchemy `Select` of statements against a
        [`SqlSource`][ftmq.query.sql.SqlSource] (a store's table descriptor).

        Args:
            source: The SQL source to compile against (default: the base
                nomenklatura statement table).

        Returns:
            The statements `Select`.
        """
        return Sql(self, source).statements

    @property
    def ids(self) -> set[IdLeaf]:
        """
        The current id filters
        """
        return {f for f in self._leaves if isinstance(f, IdLeaf)}

    @property
    def datasets(self) -> set[DatasetLeaf]:
        """
        The current dataset filters
        """
        return {f for f in self._leaves if isinstance(f, DatasetLeaf)}

    @property
    def dataset_names(self) -> set[str]:
        """
        The names of the current filtered datasets
        """
        names: set[str] = set()
        for f in self.datasets:
            names.update(ensure_list(f.value))
        return names

    @property
    def schemata(self) -> set[SchemaLeaf]:
        """
        The current schema filters
        """
        return {f for f in self._leaves if isinstance(f, SchemaLeaf)}

    @property
    def schemata_names(self) -> set[str]:
        """
        The names of the current filtered schemas

        Exact `schema` leaves contribute their name; `schemata` (is-a) leaves
        expand to the schema plus its non-abstract descendants.
        """
        names: set[str] = set()
        for f in self._leaves:
            if isinstance(f, SchemataLeaf):
                for schema in f.schemata:
                    names.add(schema.name)
                    names.update(d.name for d in schema.descendants if not d.abstract)
            elif isinstance(f, SchemaLeaf):
                names.update(ensure_list(f.value))
        return names

    @property
    def context(self) -> set[ContextLeaf]:
        """
        The current context filters (the `C` family, e.g. `origin`)
        """
        return {f for f in self._leaves if isinstance(f, ContextLeaf)}

    @property
    def countries(self) -> set[str]:
        """
        The current filtered countries
        """
        names: set[str] = set()
        for f in self._leaves:
            if isinstance(f, GroupLeaf) and f.key == "countries":
                names.update(ensure_list(f.value))
        return names

    @property
    def groups(self) -> set[GroupLeaf]:
        """
        The current property groups lookup filters
        """
        return {f for f in self._leaves if isinstance(f, GroupLeaf)}

    @property
    def properties(self) -> set[PropertyLeaf]:
        """
        The current property lookup filters
        """
        return {f for f in self._leaves if isinstance(f, PropertyLeaf)}

    # --- serialization -----------------------------------------------------

    def to_dict(self) -> dict[str, Any]:
        """
        Lossless nested-tree representation of the current object.

        Example:
            ```python
            q = Query().where(M(dataset__in=["d1", "d2"]))
            q = q.where(P(name="Jane") | P(name__ilike="j%"))
            data = q.to_dict()
            assert Query.from_dict(data).to_dict() == data
            ```
        """
        data: dict[str, Any] = {}
        if self.q:
            data["q"] = self.q.to_dict()
        if self.sort:
            data["order_by"] = self.sort.serialize()
        if self.slice:
            data["limit"] = self.limit
            data["offset"] = self.offset
        if self.aggregations:
            data["aggregations"] = aggregations_to_dict(self.aggregations)
        return data

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Self:
        """Rebuild a `Query` from its [`to_dict`][ftmq.Query.to_dict] output."""
        q = Expr.from_dict(data["q"]) if data.get("q") else None
        sort = None
        if data.get("order_by"):
            sort = Sort.deserialize(str(data["order_by"]))
        slice_ = _make_slice(data.get("limit"), data.get("offset"))
        aggregations = None
        if data.get("aggregations"):
            aggregations = aggregations_from_dict(data["aggregations"])
        return cls(q=q, sort=sort, slice=slice_, aggregations=aggregations)

    def to_params(self) -> dict[str, list[str]]:
        """
        Project to an Aleph-style filter param dict (`filter:` / `exclude:` /
        `empty:` keys, `metric:` / `facet` aggregation keys, plus `sort` /
        `limit` / `offset`).

        Raises `QueryError` for queries outside the flat Aleph-expressible
        subset (cross-field OR, negated groups).
        """
        params = {k: list(v) for k, v in expr_to_params(self.q).items()}
        if self.aggregations:
            params.update(aggregations_to_params(self.aggregations))
        if self.sort:
            direction = "asc" if self.sort.ascending else "desc"
            params["sort"] = [f"{self.sort.value}:{direction}"]
        if self.slice:
            if self.offset:
                params["offset"] = [str(self.offset)]
            if self.limit is not None:
                params["limit"] = [str(self.limit)]
        return params

    @classmethod
    def from_params(cls, args: Any) -> Self:
        """Build a `Query` from an Aleph-style param dict / MultiDict."""
        items = normalize_multidict(args)
        q = params_to_expr(items)
        aggregations = params_to_aggregations(items) or None
        sort = None
        if items.get("sort"):
            if len(items["sort"]) > 1:
                raise QueryError("Multi-field sort is not supported")
            field, _, direction = items["sort"][0].partition(":")
            sort = Sort(field, ascending=direction != "desc")
        slice_ = None
        if "limit" in items or "offset" in items:
            offset = int((items.get("offset") or ["0"])[0] or 0)
            _limit = items.get("limit")
            limit = int(_limit[0]) if _limit else None
            slice_ = _make_slice(limit, offset)
        return cls(q=q, sort=sort, slice=slice_, aggregations=aggregations)

    def to_string(self) -> str:
        """
        Project to an Aleph URL query string, e.g.
        `filter:properties.name=Jane&filter:schemata=LegalEntity`.
        """
        return params_to_string(self.to_params())

    @classmethod
    def from_string(cls, value: str) -> Self:
        """Build a `Query` from an Aleph URL query string."""
        return cls.from_params(string_to_params(value))

    @classmethod
    def from_rql(cls, value: str) -> Self:
        """Build a `Query` from an [RQL](https://github.com/pjwerneck/pyrql) string.

        Unlike the flat Aleph grammar, RQL expresses arbitrary `& | ~` nesting,
        e.g. `and(eq(schema,Person),or(eq(properties.name,jane),eq(countries,de)))`,
        and carries aggregations via its `sum` / `aggregate(...)` operators.
        """
        if not value:
            return cls()
        expr, aggregations = parse_rql(value)
        return cls(q=expr, aggregations=aggregations)

    def to_rql(self) -> str:
        """Serialize the filter tree and aggregations to an
        [RQL](https://github.com/pjwerneck/pyrql) string.

        RQL is the only string surface that preserves arbitrary `& | ~` nesting
        (unlike the flat Aleph params) and carries aggregations losslessly, so it
        is the way to hand a full query to another HTTP-like connector. Raises
        `QueryError` for a comparator with no RQL equivalent (`null`,
        `startswith`, `endswith`, ...).
        """
        return serialize_rql(self.q, self.aggregations)

    # --- building ----------------------------------------------------------

    def where(self, *nodes: Expr) -> Self:
        """
        AND another set of `M` / `P` / `G` nodes into the current `Query`.

        Example:
            ```python
            q = Query().where(M(schema="Payment"), P(date__gte="2024-10"))
            q = q.where(G(countries="de") | G(countries="at"))
            ```

        Args:
            *nodes: `M` / `P` / `G` nodes (optionally composed with `&`/`|`/`~`)

        Returns:
            The updated `Query` instance
        """
        new = combine(*nodes)
        if new is None:
            return self._chain()
        q = new if self.q is None else (self.q & new)
        return self._chain(q=q)

    def order_by(self, value: str, *, ascending: bool = True) -> Self:
        """
        Set the sorting (a single field; the SQL adapter never supported more).

        Args:
            value: The field to order by; a leading `-` marks descending
                (`order_by("-date")` == `order_by("date", ascending=False)`)
            ascending: Ascending or descending

        Returns:
            The updated `Query` instance.
        """
        if value.startswith("-"):
            value, ascending = value[1:], False
        return self._chain(sort=Sort(value, ascending=ascending))

    def aggregate(self, *nodes: A) -> Self:
        """Add aggregation projections to the query.

        Example:
            ```python
            from ftmq import Query, M, A

            q = Query().where(M(schema="Payment")).aggregate(
                A(sum="amountEur", by="beneficiary"),
                A(avg="amountEur"),
            )
            ```

        Args:
            *nodes: `A` nodes, e.g. `A(sum="amountEur", by="beneficiary")`.

        Returns:
            The updated `Query` instance.
        """
        aggs = set(self.aggregations)
        for node in nodes:
            aggs.update(node.aggs)
        return self._chain(aggregations=aggs)

    def get_aggregator(self) -> Aggregator:
        """Build an in-memory `Aggregator` from the query's aggregation specs.

        Returns:
            A fresh accumulator over this query's aggregations.
        """
        return Aggregator(self.aggregations)

    # --- execution ---------------------------------------------------------

    def apply(self, entity: EntityProxy) -> bool:
        """
        Test if a entity matches the current `Query` instance.
        """
        if self.q is None:
            return True
        return self.q.apply(entity)

    def apply_iter(self, entities: EntityProxies) -> EntityProxies:
        """
        Apply the current `Query` instance to a generator of entities and return
        a generator of filtered entities

        Example:
            ```python
            entities = [...]
            q = Query().where(M(dataset="my_dataset"), M(schema="Company"))
            for entity in q.apply_iter(entities):
                assert entity.schema.name == "Company"
            ```

        Yields:
            A generator of `EntityProxy` or a sub-type
        """
        if not self:
            yield from entities
            return

        entities = (e for e in entities if self.apply(e))
        if self.sort:
            entities = self.sort.apply_iter(entities)
        if self.slice:
            entities = islice(
                entities, self.slice.start, self.slice.stop, self.slice.step
            )
        if self.aggregations:
            self.aggregator = self.get_aggregator()
            entities = self.aggregator.apply(cast(Any, entities))
        yield from entities

context property

The current context filters (the C family, e.g. origin)

countries property

The current filtered countries

dataset_names property

The names of the current filtered datasets

datasets property

The current dataset filters

groups property

The current property groups lookup filters

ids property

The current id filters

limit property

The current limit (inferred from a slice)

offset property

The current offset (inferred from a slice)

A start-less slice (q[:10]) reports offset 0, so it serializes and round-trips identically to q[0:10].

properties property

The current property lookup filters

schemata property

The current schema filters

schemata_names property

The names of the current filtered schemas

Exact schema leaves contribute their name; schemata (is-a) leaves expand to the schema plus its non-abstract descendants.

sql property

An adapter of this query for sql interfaces, against the default nomenklatura statement table. For a custom / extended table pass a SqlSource to compile or build Sql(query, source) directly.

__bool__()

Detect if any filter, ordering or slicing is defined

Examples:

>>> bool(Query())
False
>>> bool(Query().where(M(dataset="my_dataset")))
True
Source code in ftmq/query/main.py
def __bool__(self) -> bool:
    """
    Detect if any filter, ordering or slicing is defined

    Examples:
        >>> bool(Query())
        False
        >>> bool(Query().where(M(dataset="my_dataset")))
        True
    """
    return bool(self.to_dict())

__getitem__(value)

Implement list-like slicing. No negative values allowed.

Examples:

>>> q[1]
# 2nd element (0-index)
>>> q[:10]
# first 10 elements
>>> q[10:20]
# next 10 elements

Returns:

Type Description
Self

The updated Query instance

Source code in ftmq/query/main.py
def __getitem__(self, value: Any) -> Self:
    """
    Implement list-like slicing. No negative values allowed.

    Examples:
        >>> q[1]
        # 2nd element (0-index)
        >>> q[:10]
        # first 10 elements
        >>> q[10:20]
        # next 10 elements

    Returns:
        The updated `Query` instance
    """
    if isinstance(value, int):
        if value < 0:
            raise QueryError(f"Invalid slicing: `{value}`")
        return self._chain(slice=slice(value, value + 1))
    if isinstance(value, slice):
        if value.step is not None:
            raise QueryError(f"Invalid slicing: `{value}`")
        return self._chain(slice=value)
    raise NotImplementedError

__hash__()

Generate a unique key of the current state, useful for caching.

Like any Python object this is a within-process hash (not stable across processes); hash_data normalizes ordering so equal queries hash equal.

Source code in ftmq/query/main.py
def __hash__(self) -> int:
    """
    Generate a unique key of the current state, useful for caching.

    Like any Python object this is a within-process hash (not stable
    across processes); `hash_data` normalizes ordering so equal queries
    hash equal.
    """
    return hash(hash_data(self.to_dict()))

aggregate(*nodes)

Add aggregation projections to the query.

Example
from ftmq import Query, M, A

q = Query().where(M(schema="Payment")).aggregate(
    A(sum="amountEur", by="beneficiary"),
    A(avg="amountEur"),
)

Parameters:

Name Type Description Default
*nodes A

A nodes, e.g. A(sum="amountEur", by="beneficiary").

()

Returns:

Type Description
Self

The updated Query instance.

Source code in ftmq/query/main.py
def aggregate(self, *nodes: A) -> Self:
    """Add aggregation projections to the query.

    Example:
        ```python
        from ftmq import Query, M, A

        q = Query().where(M(schema="Payment")).aggregate(
            A(sum="amountEur", by="beneficiary"),
            A(avg="amountEur"),
        )
        ```

    Args:
        *nodes: `A` nodes, e.g. `A(sum="amountEur", by="beneficiary")`.

    Returns:
        The updated `Query` instance.
    """
    aggs = set(self.aggregations)
    for node in nodes:
        aggs.update(node.aggs)
    return self._chain(aggregations=aggs)

apply(entity)

Test if a entity matches the current Query instance.

Source code in ftmq/query/main.py
def apply(self, entity: EntityProxy) -> bool:
    """
    Test if a entity matches the current `Query` instance.
    """
    if self.q is None:
        return True
    return self.q.apply(entity)

apply_iter(entities)

Apply the current Query instance to a generator of entities and return a generator of filtered entities

Example
entities = [...]
q = Query().where(M(dataset="my_dataset"), M(schema="Company"))
for entity in q.apply_iter(entities):
    assert entity.schema.name == "Company"

Yields:

Type Description
EntityProxies

A generator of EntityProxy or a sub-type

Source code in ftmq/query/main.py
def apply_iter(self, entities: EntityProxies) -> EntityProxies:
    """
    Apply the current `Query` instance to a generator of entities and return
    a generator of filtered entities

    Example:
        ```python
        entities = [...]
        q = Query().where(M(dataset="my_dataset"), M(schema="Company"))
        for entity in q.apply_iter(entities):
            assert entity.schema.name == "Company"
        ```

    Yields:
        A generator of `EntityProxy` or a sub-type
    """
    if not self:
        yield from entities
        return

    entities = (e for e in entities if self.apply(e))
    if self.sort:
        entities = self.sort.apply_iter(entities)
    if self.slice:
        entities = islice(
            entities, self.slice.start, self.slice.stop, self.slice.step
        )
    if self.aggregations:
        self.aggregator = self.get_aggregator()
        entities = self.aggregator.apply(cast(Any, entities))
    yield from entities

compile(source=None)

Compile this query to a SQLAlchemy Select of statements against a SqlSource (a store's table descriptor).

Parameters:

Name Type Description Default
source 'SqlSource | None'

The SQL source to compile against (default: the base nomenklatura statement table).

None

Returns:

Type Description
'Select[Any]'

The statements Select.

Source code in ftmq/query/main.py
def compile(self, source: "SqlSource | None" = None) -> "Select[Any]":
    """
    Compile this query to a SQLAlchemy `Select` of statements against a
    [`SqlSource`][ftmq.query.sql.SqlSource] (a store's table descriptor).

    Args:
        source: The SQL source to compile against (default: the base
            nomenklatura statement table).

    Returns:
        The statements `Select`.
    """
    return Sql(self, source).statements

from_dict(data) classmethod

Rebuild a Query from its to_dict output.

Source code in ftmq/query/main.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
    """Rebuild a `Query` from its [`to_dict`][ftmq.Query.to_dict] output."""
    q = Expr.from_dict(data["q"]) if data.get("q") else None
    sort = None
    if data.get("order_by"):
        sort = Sort.deserialize(str(data["order_by"]))
    slice_ = _make_slice(data.get("limit"), data.get("offset"))
    aggregations = None
    if data.get("aggregations"):
        aggregations = aggregations_from_dict(data["aggregations"])
    return cls(q=q, sort=sort, slice=slice_, aggregations=aggregations)

from_params(args) classmethod

Build a Query from an Aleph-style param dict / MultiDict.

Source code in ftmq/query/main.py
@classmethod
def from_params(cls, args: Any) -> Self:
    """Build a `Query` from an Aleph-style param dict / MultiDict."""
    items = normalize_multidict(args)
    q = params_to_expr(items)
    aggregations = params_to_aggregations(items) or None
    sort = None
    if items.get("sort"):
        if len(items["sort"]) > 1:
            raise QueryError("Multi-field sort is not supported")
        field, _, direction = items["sort"][0].partition(":")
        sort = Sort(field, ascending=direction != "desc")
    slice_ = None
    if "limit" in items or "offset" in items:
        offset = int((items.get("offset") or ["0"])[0] or 0)
        _limit = items.get("limit")
        limit = int(_limit[0]) if _limit else None
        slice_ = _make_slice(limit, offset)
    return cls(q=q, sort=sort, slice=slice_, aggregations=aggregations)

from_rql(value) classmethod

Build a Query from an RQL string.

Unlike the flat Aleph grammar, RQL expresses arbitrary & | ~ nesting, e.g. and(eq(schema,Person),or(eq(properties.name,jane),eq(countries,de))), and carries aggregations via its sum / aggregate(...) operators.

Source code in ftmq/query/main.py
@classmethod
def from_rql(cls, value: str) -> Self:
    """Build a `Query` from an [RQL](https://github.com/pjwerneck/pyrql) string.

    Unlike the flat Aleph grammar, RQL expresses arbitrary `& | ~` nesting,
    e.g. `and(eq(schema,Person),or(eq(properties.name,jane),eq(countries,de)))`,
    and carries aggregations via its `sum` / `aggregate(...)` operators.
    """
    if not value:
        return cls()
    expr, aggregations = parse_rql(value)
    return cls(q=expr, aggregations=aggregations)

from_string(value) classmethod

Build a Query from an Aleph URL query string.

Source code in ftmq/query/main.py
@classmethod
def from_string(cls, value: str) -> Self:
    """Build a `Query` from an Aleph URL query string."""
    return cls.from_params(string_to_params(value))

get_aggregator()

Build an in-memory Aggregator from the query's aggregation specs.

Returns:

Type Description
Aggregator

A fresh accumulator over this query's aggregations.

Source code in ftmq/query/main.py
def get_aggregator(self) -> Aggregator:
    """Build an in-memory `Aggregator` from the query's aggregation specs.

    Returns:
        A fresh accumulator over this query's aggregations.
    """
    return Aggregator(self.aggregations)

order_by(value, *, ascending=True)

Set the sorting (a single field; the SQL adapter never supported more).

Parameters:

Name Type Description Default
value str

The field to order by; a leading - marks descending (order_by("-date") == order_by("date", ascending=False))

required
ascending bool

Ascending or descending

True

Returns:

Type Description
Self

The updated Query instance.

Source code in ftmq/query/main.py
def order_by(self, value: str, *, ascending: bool = True) -> Self:
    """
    Set the sorting (a single field; the SQL adapter never supported more).

    Args:
        value: The field to order by; a leading `-` marks descending
            (`order_by("-date")` == `order_by("date", ascending=False)`)
        ascending: Ascending or descending

    Returns:
        The updated `Query` instance.
    """
    if value.startswith("-"):
        value, ascending = value[1:], False
    return self._chain(sort=Sort(value, ascending=ascending))

to_dict()

Lossless nested-tree representation of the current object.

Example
q = Query().where(M(dataset__in=["d1", "d2"]))
q = q.where(P(name="Jane") | P(name__ilike="j%"))
data = q.to_dict()
assert Query.from_dict(data).to_dict() == data
Source code in ftmq/query/main.py
def to_dict(self) -> dict[str, Any]:
    """
    Lossless nested-tree representation of the current object.

    Example:
        ```python
        q = Query().where(M(dataset__in=["d1", "d2"]))
        q = q.where(P(name="Jane") | P(name__ilike="j%"))
        data = q.to_dict()
        assert Query.from_dict(data).to_dict() == data
        ```
    """
    data: dict[str, Any] = {}
    if self.q:
        data["q"] = self.q.to_dict()
    if self.sort:
        data["order_by"] = self.sort.serialize()
    if self.slice:
        data["limit"] = self.limit
        data["offset"] = self.offset
    if self.aggregations:
        data["aggregations"] = aggregations_to_dict(self.aggregations)
    return data

to_params()

Project to an Aleph-style filter param dict (filter: / exclude: / empty: keys, metric: / facet aggregation keys, plus sort / limit / offset).

Raises QueryError for queries outside the flat Aleph-expressible subset (cross-field OR, negated groups).

Source code in ftmq/query/main.py
def to_params(self) -> dict[str, list[str]]:
    """
    Project to an Aleph-style filter param dict (`filter:` / `exclude:` /
    `empty:` keys, `metric:` / `facet` aggregation keys, plus `sort` /
    `limit` / `offset`).

    Raises `QueryError` for queries outside the flat Aleph-expressible
    subset (cross-field OR, negated groups).
    """
    params = {k: list(v) for k, v in expr_to_params(self.q).items()}
    if self.aggregations:
        params.update(aggregations_to_params(self.aggregations))
    if self.sort:
        direction = "asc" if self.sort.ascending else "desc"
        params["sort"] = [f"{self.sort.value}:{direction}"]
    if self.slice:
        if self.offset:
            params["offset"] = [str(self.offset)]
        if self.limit is not None:
            params["limit"] = [str(self.limit)]
    return params

to_rql()

Serialize the filter tree and aggregations to an RQL string.

RQL is the only string surface that preserves arbitrary & | ~ nesting (unlike the flat Aleph params) and carries aggregations losslessly, so it is the way to hand a full query to another HTTP-like connector. Raises QueryError for a comparator with no RQL equivalent (null, startswith, endswith, ...).

Source code in ftmq/query/main.py
def to_rql(self) -> str:
    """Serialize the filter tree and aggregations to an
    [RQL](https://github.com/pjwerneck/pyrql) string.

    RQL is the only string surface that preserves arbitrary `& | ~` nesting
    (unlike the flat Aleph params) and carries aggregations losslessly, so it
    is the way to hand a full query to another HTTP-like connector. Raises
    `QueryError` for a comparator with no RQL equivalent (`null`,
    `startswith`, `endswith`, ...).
    """
    return serialize_rql(self.q, self.aggregations)

to_string()

Project to an Aleph URL query string, e.g. filter:properties.name=Jane&filter:schemata=LegalEntity.

Source code in ftmq/query/main.py
def to_string(self) -> str:
    """
    Project to an Aleph URL query string, e.g.
    `filter:properties.name=Jane&filter:schemata=LegalEntity`.
    """
    return params_to_string(self.to_params())

where(*nodes)

AND another set of M / P / G nodes into the current Query.

Example
q = Query().where(M(schema="Payment"), P(date__gte="2024-10"))
q = q.where(G(countries="de") | G(countries="at"))

Parameters:

Name Type Description Default
*nodes Expr

M / P / G nodes (optionally composed with &/|/~)

()

Returns:

Type Description
Self

The updated Query instance

Source code in ftmq/query/main.py
def where(self, *nodes: Expr) -> Self:
    """
    AND another set of `M` / `P` / `G` nodes into the current `Query`.

    Example:
        ```python
        q = Query().where(M(schema="Payment"), P(date__gte="2024-10"))
        q = q.where(G(countries="de") | G(countries="at"))
        ```

    Args:
        *nodes: `M` / `P` / `G` nodes (optionally composed with `&`/`|`/`~`)

    Returns:
        The updated `Query` instance
    """
    new = combine(*nodes)
    if new is None:
        return self._chain()
    q = new if self.q is None else (self.q & new)
    return self._chain(q=q)

Query nodes

The composable filter-node constructors: M (meta), P (property), G (property-type group) and C (context). A is the aggregation-projection node (see Aggregations below).

Bases: _FamilyExpr

Meta fields: dataset, schema, schemata, id, ... - M(schema="Person") as a condition, M("dataset") as a reference.

Source code in ftmq/query/nodes.py
class M(_FamilyExpr):
    """Meta fields: `dataset`, `schema`, `schemata`, `id`, ... - `M(schema="Person")`
    as a condition, `M("dataset")` as a reference."""

    @staticmethod
    def _make(key: str, value: Any) -> Leaf:
        return make_meta_leaf(key, value)

    @staticmethod
    def _ref(field: str) -> Ref:
        return make_meta_ref(field)

Bases: _FamilyExpr

A specific FtM property: P(name="Jane", amountEur__gte=1000) as a condition, P("amountEur") as a reference.

Source code in ftmq/query/nodes.py
class P(_FamilyExpr):
    """A specific FtM property: `P(name="Jane", amountEur__gte=1000)` as a
    condition, `P("amountEur")` as a reference."""

    @staticmethod
    def _make(key: str, value: Any) -> Leaf:
        return make_property_leaf(key, value)

    @staticmethod
    def _ref(field: str) -> Ref:
        return PropRef(field)

Bases: _FamilyExpr

A property-type group: G(countries="de") as a condition, G("countries") as a reference.

Source code in ftmq/query/nodes.py
class G(_FamilyExpr):
    """A property-type group: `G(countries="de")` as a condition,
    `G("countries")` as a reference."""

    @staticmethod
    def _make(key: str, value: Any) -> Leaf:
        return make_group_leaf(key, value)

    @staticmethod
    def _ref(field: str) -> Ref:
        return GroupRef(field)

Bases: _FamilyExpr

A context / storage column: C(origin="crawl") as a condition, C("origin") as a reference.

Source code in ftmq/query/nodes.py
class C(_FamilyExpr):
    """A context / storage column: `C(origin="crawl")` as a condition,
    `C("origin")` as a reference."""

    @staticmethod
    def _make(key: str, value: Any) -> Leaf:
        return make_context_leaf(key, value)

    @staticmethod
    def _ref(field: str) -> Ref:
        return ContextRef(field)

An aggregation projection node: A(sum=P("amountEur"), by=P("beneficiary")).

Each keyword is an aggregation function (min, max, sum, avg, count) whose value is the field reference (or references) to aggregate; by= groups by one or more references. Fields are addressed with the same M / P / G / C markers the filter families use, called with a bare field name (plus Year()), so an aggregation says which family it means instead of leaving it to be guessed from the name. Unlike the filter nodes, A is not a boolean leaf - it does not compose with & | ~; pass it to Query.aggregate.

Examples:

A(sum=P("amountEur"), by=P("beneficiary"))
A(count=M("id"), by=[G("countries"), Year()])
A(sum=[P("amountEur"), P("amount")])
Source code in ftmq/query/aggregations.py
class A:
    """An aggregation projection node: `A(sum=P("amountEur"), by=P("beneficiary"))`.

    Each keyword is an aggregation function (`min`, `max`, `sum`, `avg`,
    `count`) whose value is the field reference (or references) to aggregate;
    `by=` groups by one or more references. Fields are addressed with the same
    `M` / `P` / `G` / `C` markers the filter families use, called with a bare
    field name (plus `Year()`), so an aggregation says which family it means
    instead of leaving it to be guessed from the name. Unlike the filter nodes,
    `A` is not a boolean leaf - it does not compose with `& | ~`; pass it to
    [`Query.aggregate`][ftmq.Query.aggregate].

    Examples:
        ```python
        A(sum=P("amountEur"), by=P("beneficiary"))
        A(count=M("id"), by=[G("countries"), Year()])
        A(sum=[P("amountEur"), P("amount")])
        ```
    """

    def __init__(
        self,
        *,
        by: Ref | Iterable[Ref] | None = None,
        **funcs: Ref | Iterable[Ref],
    ) -> None:
        groups: tuple[Ref, ...] = tuple(cast("list[Ref]", ensure_list(by)))
        aggs: list[Agg] = []
        for func, refs in funcs.items():
            for ref in cast("list[Ref]", ensure_list(refs)):
                aggs.append(make_agg(func, ref, groups))
        if not aggs:
            raise QueryError("Empty aggregation: pass at least one `func=<ref>`")
        self.aggs: tuple[Agg, ...] = tuple(aggs)

Expression tree

A boolean node: a connector (AND/OR), an optional negation, and a list of children (nested Expr nodes and/or Leaf conditions).

Source code in ftmq/query/nodes.py
class Expr:
    """A boolean node: a connector (`AND`/`OR`), an optional negation, and a
    list of children (nested `Expr` nodes and/or `Leaf` conditions)."""

    def __init__(
        self,
        *children: "Expr | Leaf",
        connector: str = AND,
        negated: bool = False,
    ) -> None:
        self.connector = connector
        self.negated = negated
        self.children: list[Expr | Leaf] = list(children)

    def __bool__(self) -> bool:
        return bool(self.children) or self.negated

    def _copy(self) -> "Expr":
        clone = Expr(connector=self.connector, negated=self.negated)
        clone.children = list(self.children)
        return clone

    def _combine(self, other: "Expr", connector: str) -> "Expr":
        if not self:
            return other._copy()
        if not other:
            return self._copy()
        return Expr(self._copy(), other._copy(), connector=connector)

    def __and__(self, other: Any) -> "Expr":
        if not isinstance(other, Expr):
            return NotImplemented
        return self._combine(other, AND)

    def __or__(self, other: Any) -> "Expr":
        if not isinstance(other, Expr):
            return NotImplemented
        return self._combine(other, OR)

    def __invert__(self) -> "Expr":
        clone = self._copy()
        clone.negated = not self.negated
        return clone

    def apply(self, entity: EntityProxy) -> bool:
        """Evaluate the boolean expression against an entity.

        Args:
            entity: The entity to test.

        Returns:
            `True` if the entity matches this (possibly nested, possibly
            negated) tree of conditions.
        """
        if not self.children:
            result = True
        elif self.connector == OR:
            result = any(c.apply(entity) for c in self.children)
        else:
            result = all(c.apply(entity) for c in self.children)
        return (not result) if self.negated else result

    def iter_leaves(self, cls: type | None = None) -> Iterator[Leaf]:
        """Walk the tree and yield its leaf conditions.

        Args:
            cls: Optionally restrict to leaves of this class.

        Yields:
            Each matching leaf, depth-first.
        """
        for child in self.children:
            if isinstance(child, Expr):
                yield from child.iter_leaves(cls)
            elif cls is None or isinstance(child, cls):
                yield child

    def to_dict(self) -> dict[str, Any]:
        """Serialize the tree to a nested, canonically-ordered dict.

        Nested nodes that share the connector and are not negated are flattened
        (associativity) and children are sorted, so structurally-equivalent
        trees (e.g. built by different `where()` orderings) serialize
        identically and hash equal.

        Returns:
            A `{"and" | "or": [...], "not": bool}` mapping, round-trippable via
            [`from_dict`][ftmq.query.nodes.Expr.from_dict].
        """
        key = self.connector.lower()
        children: list[Any] = []
        for child in self.children:
            if isinstance(child, Expr):
                child_dict = child.to_dict()
                if not child.negated and child.connector == self.connector:
                    children.extend(child_dict[key])
                else:
                    children.append(child_dict)
            else:
                children.append({"leaf": child.field_dict()})
        children.sort(key=hash_data)
        data: dict[str, Any] = {key: children}
        if self.negated:
            data["not"] = True
        return data

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Expr":
        """Rebuild a tree from its [`to_dict`][ftmq.query.nodes.Expr.to_dict] form.

        Args:
            data: The nested mapping to deserialize.

        Returns:
            The reconstructed expression.
        """
        connector = OR if "or" in data else AND
        children: list[Expr | Leaf] = []
        for child in data.get(connector.lower(), []):
            if "leaf" in child:
                children.append(leaf_from_dict(child["leaf"]))
            else:
                children.append(cls.from_dict(child))
        return cls(*children, connector=connector, negated=bool(data.get("not")))

    def __hash__(self) -> int:
        # a within-process hash over a normalized serialization; like any
        # Python object it is not stable across processes (banal's hash_data
        # normalizes key/element order so equal trees hash equal in-process)
        return hash(hash_data(self.to_dict()))

    def __eq__(self, other: Any) -> bool:
        return isinstance(other, Expr) and hash(self) == hash(other)

    def __repr__(self) -> str:
        return f"<Expr {self.to_dict()}>"

apply(entity)

Evaluate the boolean expression against an entity.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to test.

required

Returns:

Type Description
bool

True if the entity matches this (possibly nested, possibly

bool

negated) tree of conditions.

Source code in ftmq/query/nodes.py
def apply(self, entity: EntityProxy) -> bool:
    """Evaluate the boolean expression against an entity.

    Args:
        entity: The entity to test.

    Returns:
        `True` if the entity matches this (possibly nested, possibly
        negated) tree of conditions.
    """
    if not self.children:
        result = True
    elif self.connector == OR:
        result = any(c.apply(entity) for c in self.children)
    else:
        result = all(c.apply(entity) for c in self.children)
    return (not result) if self.negated else result

from_dict(data) classmethod

Rebuild a tree from its to_dict form.

Parameters:

Name Type Description Default
data dict[str, Any]

The nested mapping to deserialize.

required

Returns:

Type Description
'Expr'

The reconstructed expression.

Source code in ftmq/query/nodes.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Expr":
    """Rebuild a tree from its [`to_dict`][ftmq.query.nodes.Expr.to_dict] form.

    Args:
        data: The nested mapping to deserialize.

    Returns:
        The reconstructed expression.
    """
    connector = OR if "or" in data else AND
    children: list[Expr | Leaf] = []
    for child in data.get(connector.lower(), []):
        if "leaf" in child:
            children.append(leaf_from_dict(child["leaf"]))
        else:
            children.append(cls.from_dict(child))
    return cls(*children, connector=connector, negated=bool(data.get("not")))

iter_leaves(cls=None)

Walk the tree and yield its leaf conditions.

Parameters:

Name Type Description Default
cls type | None

Optionally restrict to leaves of this class.

None

Yields:

Type Description
Leaf

Each matching leaf, depth-first.

Source code in ftmq/query/nodes.py
def iter_leaves(self, cls: type | None = None) -> Iterator[Leaf]:
    """Walk the tree and yield its leaf conditions.

    Args:
        cls: Optionally restrict to leaves of this class.

    Yields:
        Each matching leaf, depth-first.
    """
    for child in self.children:
        if isinstance(child, Expr):
            yield from child.iter_leaves(cls)
        elif cls is None or isinstance(child, cls):
            yield child

to_dict()

Serialize the tree to a nested, canonically-ordered dict.

Nested nodes that share the connector and are not negated are flattened (associativity) and children are sorted, so structurally-equivalent trees (e.g. built by different where() orderings) serialize identically and hash equal.

Returns:

Type Description
dict[str, Any]

A {"and" | "or": [...], "not": bool} mapping, round-trippable via

dict[str, Any]
Source code in ftmq/query/nodes.py
def to_dict(self) -> dict[str, Any]:
    """Serialize the tree to a nested, canonically-ordered dict.

    Nested nodes that share the connector and are not negated are flattened
    (associativity) and children are sorted, so structurally-equivalent
    trees (e.g. built by different `where()` orderings) serialize
    identically and hash equal.

    Returns:
        A `{"and" | "or": [...], "not": bool}` mapping, round-trippable via
        [`from_dict`][ftmq.query.nodes.Expr.from_dict].
    """
    key = self.connector.lower()
    children: list[Any] = []
    for child in self.children:
        if isinstance(child, Expr):
            child_dict = child.to_dict()
            if not child.negated and child.connector == self.connector:
                children.extend(child_dict[key])
            else:
                children.append(child_dict)
        else:
            children.append({"leaf": child.field_dict()})
    children.sort(key=hash_data)
    data: dict[str, Any] = {key: children}
    if self.negated:
        data["not"] = True
    return data

Combine a series of nodes with a single connector, skipping empties.

Parameters:

Name Type Description Default
*nodes Expr

The M / P / G / Expr nodes to combine.

()
connector str

AND (default) or OR.

AND

Returns:

Type Description
Expr | None

The combined expression, or None if no non-empty node was passed.

Source code in ftmq/query/nodes.py
def combine(*nodes: Expr, connector: str = AND) -> Expr | None:
    """Combine a series of nodes with a single connector, skipping empties.

    Args:
        *nodes: The `M` / `P` / `G` / `Expr` nodes to combine.
        connector: `AND` (default) or `OR`.

    Returns:
        The combined expression, or `None` if no non-empty node was passed.
    """
    result: Expr | None = None
    for node in nodes:
        if not node:
            continue
        if result is None:
            result = node
        elif connector == OR:
            result = result | node
        else:
            result = result & node
    return result

Field references

A field without a value: what an aggregation projects over, and what a leaf carries besides its comparator. Built by the same family constructors, called with a bare field name (P("amountEur")).

Field references: a leaf without a value.

A Ref names where to read - a followthemoney property, a property-type group, a meta column, a context column - without saying what to match. It is what a filter leaf carries besides its comparator and value, and it is what an aggregation (A) projects over:

Query().where(P(amountEur__gte=1000))      # a leaf: ref + comparator + value
Query().aggregate(A(sum=P("amountEur")))   # an aggregation: just the ref

Refs are built by the same family constructors as filter leaves, called with a positional field name instead of field=value lookups: M("dataset"), P("amountEur"), G("countries"), C("origin"), plus Year() for the date-derived year dimension.

Every ref knows how to read its values off an entity (used by the in-memory evaluator, and by the filter leaves, which delegate here) and how it is spelled on the wire (Ref.wire / ref_from_wire). The SQL side maps refs to columns in ftmq.query.sql, keeping sqlalchemy out of the query IR.

CanonicalIdRef

Bases: IdRef

The canonical_id column (the resolved id).

Source code in ftmq/query/refs.py
class CanonicalIdRef(IdRef):
    """The `canonical_id` column (the resolved id)."""

    key = "canonical_id"

ContextRef

Bases: Ref

A context / storage column: origin, plus backend-specific columns such as fragment, first_seen or bucket.

Source code in ftmq/query/refs.py
class ContextRef(Ref):
    """A context / storage column: `origin`, plus backend-specific columns
    such as `fragment`, `first_seen` or `bucket`."""

    family = "C"

    def __init__(self, key: str) -> None:
        self.key = key

    def values(self, entity: EntityProxy) -> Iterator[str]:
        context: dict[str, Any] = getattr(entity, "context", None) or {}
        for value in ensure_list(context.get(self.key)):
            yield str(value)

    @property
    def wire(self) -> str:
        # context keys are open-ended (backends add their own columns), so they
        # always carry the prefix rather than competing with the other families
        return f"{CONTEXT_PREFIX}{self.key}"

DatasetRef

Bases: MetaRef

The dataset an entity was observed in.

Source code in ftmq/query/refs.py
class DatasetRef(MetaRef):
    """The dataset an entity was observed in."""

    key = "dataset"

    def values(self, entity: EntityProxy) -> Iterator[str]:
        # `.datasets` is added by the StatementEntity / ValueEntity subclasses
        yield from getattr(entity, "datasets", [])

EntityIdRef

Bases: IdRef

The entity_id column (the pre-resolution id).

Source code in ftmq/query/refs.py
class EntityIdRef(IdRef):
    """The `entity_id` column (the pre-resolution id)."""

    key = "entity_id"

GroupRef

Bases: Ref

A followthemoney property-type group (the prop_type column): names, dates, countries, entities, ...

Source code in ftmq/query/refs.py
class GroupRef(Ref):
    """A followthemoney property-type group (the `prop_type` column):
    `names`, `dates`, `countries`, `entities`, ..."""

    family = "G"

    def __init__(self, group: str) -> None:
        if group not in registry.groups:
            raise QueryError(f"Invalid property group: `{group}`")
        self.key = group
        self.prop_type: PropertyType = registry.groups[group]

    def values(self, entity: EntityProxy) -> Iterator[str]:
        yield from entity.get_type_values(self.prop_type)

    @property
    def wire(self) -> str:
        return f"{GROUP_PREFIX}{self.key}"

IdRef

Bases: MetaRef

The entity id. Aggregating it addresses entities, not the referent ids in the value of a prop = "id" statement.

Source code in ftmq/query/refs.py
class IdRef(MetaRef):
    """The entity id. Aggregating it addresses *entities*, not the referent
    ids in the `value` of a `prop = "id"` statement."""

    key = "id"

    def values(self, entity: EntityProxy) -> Iterator[str]:
        if entity.id is not None:
            yield entity.id

MetaRef

Bases: Ref

A meta column, carried by every statement of an entity.

Source code in ftmq/query/refs.py
class MetaRef(Ref):
    """A meta column, carried by every statement of an entity."""

    family = "M"

PropRef

Bases: Ref

One followthemoney property (the prop column).

Source code in ftmq/query/refs.py
class PropRef(Ref):
    """One followthemoney property (the `prop` column)."""

    family = "P"

    def __init__(self, prop: str | Property) -> None:
        if isinstance(prop, Property):
            prop = prop.name
        if prop not in PROP_NAMES:
            raise QueryError(f"Invalid prop: `{prop}`")
        self.key = prop

    def values(self, entity: EntityProxy) -> Iterator[str]:
        yield from entity.get(self.key, quiet=True)

    @property
    def is_numeric(self) -> bool:
        return self.key in NUMERIC_PROPS

    @property
    def wire(self) -> str:
        # the same `properties.` prefix the filter grammar uses, so a name that
        # is both a property and a group (`topics`) stays addressable as either
        return f"{PROPERTIES_PREFIX}{self.key}"

Ref

A reference to one field of one family.

Subclasses set family / key and implement values(); they are built via the M / P / G / C constructors rather than directly.

Source code in ftmq/query/refs.py
class Ref:
    """A reference to one field of one family.

    Subclasses set `family` / `key` and implement `values()`; they are built
    via the `M` / `P` / `G` / `C` constructors rather than directly.
    """

    family: ClassVar[str] = ""
    key: str = ""

    def values(self, entity: EntityProxy) -> Iterator[str]:
        """Yield this field's values for an entity."""
        raise NotImplementedError

    @property
    def is_numeric(self) -> bool:
        """Whether the values are numbers (read through followthemoney's
        number parser instead of as strings)."""
        return False

    @property
    def wire(self) -> str:
        """How this ref is spelled on a string surface (params, rql, dict keys,
        CLI flags): the same spelling the filter grammar uses."""
        return self.key

    def __str__(self) -> str:
        return self.wire

    def __repr__(self) -> str:
        return f"<{type(self).__name__} {self.wire}>"

    def __eq__(self, other: Any) -> bool:
        return isinstance(other, Ref) and (self.family, self.key) == (
            other.family,
            other.key,
        )

    def __hash__(self) -> int:
        return hash((self.family, self.key))

    def __lt__(self, other: "Ref") -> bool:
        return self.wire < other.wire

is_numeric property

Whether the values are numbers (read through followthemoney's number parser instead of as strings).

wire property

How this ref is spelled on a string surface (params, rql, dict keys, CLI flags): the same spelling the filter grammar uses.

values(entity)

Yield this field's values for an entity.

Source code in ftmq/query/refs.py
def values(self, entity: EntityProxy) -> Iterator[str]:
    """Yield this field's values for an entity."""
    raise NotImplementedError

SchemaRef

Bases: MetaRef

The entity schema.

Source code in ftmq/query/refs.py
class SchemaRef(MetaRef):
    """The entity schema."""

    key = "schema"

    def values(self, entity: EntityProxy) -> Iterator[str]:
        yield entity.schema.name

YearRef

Bases: Ref

The year of any date-typed value - a dimension derived from the dates group, not a column of its own.

Source code in ftmq/query/refs.py
class YearRef(Ref):
    """The year of any date-typed value - a dimension derived from the `dates`
    group, not a column of its own."""

    family = "Y"
    key = "year"
    prop_type: PropertyType = registry.date

    def values(self, entity: EntityProxy) -> Iterator[str]:
        for value in entity.get_type_values(self.prop_type):
            yield value[:4]

Year()

The year dimension: A(count=M("id"), by=Year()).

Source code in ftmq/query/refs.py
def Year() -> YearRef:
    """The year dimension: `A(count=M("id"), by=Year())`."""
    return YearRef()

make_meta_ref(key)

Build a meta ref (the M family) by field name.

Source code in ftmq/query/refs.py
def make_meta_ref(key: str) -> MetaRef:
    """Build a meta ref (the `M` family) by field name."""
    cls = META_REFS.get(key)
    if cls is None:
        raise QueryError(
            f"Unknown meta field: `{key}` - one of ({', '.join(META_REFS)})"
        )
    return cls()

ref_from_wire(value)

Resolve a wire spelling back into a ref - the single place a string becomes a field reference, used by every string surface (URL params, RQL, to_dict keys, CLI flags).

The family is encoded in the spelling: properties.<name> for a property, group.<name> for a property-type group, context.<name> for a context column; a meta field (id, entity_id, canonical_id, dataset, schema) and year are bare.

Parameters:

Name Type Description Default
value str

A wire key such as properties.amountEur, group.countries, id, year or context.origin.

required

Returns:

Type Description
Ref

The resolved ref.

Raises:

Type Description
QueryError

If the spelling matches no field of any family.

Source code in ftmq/query/refs.py
def ref_from_wire(value: str) -> Ref:
    """Resolve a wire spelling back into a ref - the single place a string
    becomes a field reference, used by every string surface (URL params, RQL,
    `to_dict` keys, CLI flags).

    The family is encoded in the spelling: `properties.<name>` for a property,
    `group.<name>` for a property-type group, `context.<name>` for a context
    column; a meta field (`id`, `entity_id`, `canonical_id`, `dataset`,
    `schema`) and `year` are bare.

    Args:
        value: A wire key such as `properties.amountEur`, `group.countries`,
            `id`, `year` or `context.origin`.

    Returns:
        The resolved ref.

    Raises:
        QueryError: If the spelling matches no field of any family.
    """
    if value.startswith(PROPERTIES_PREFIX):
        return PropRef(value[len(PROPERTIES_PREFIX) :])
    if value.startswith(GROUP_PREFIX):
        return GroupRef(value[len(GROUP_PREFIX) :])
    if value.startswith(CONTEXT_PREFIX):
        return ContextRef(value[len(CONTEXT_PREFIX) :])
    if value in META_REFS:
        return make_meta_ref(value)
    if value == YearRef.key:
        return YearRef()
    raise QueryError(
        f"Unknown field: `{value}` - expected `{PROPERTIES_PREFIX}<name>`, "
        f"`{GROUP_PREFIX}<name>`, `{CONTEXT_PREFIX}<name>`, "
        f"a meta field ({', '.join(META_REFS)}) or `{YearRef.key}`"
    )

Leaves

Leaf conditions for the ftmq query language, split by the statement-table column they target:

  • meta leaves (M): dataset, schema (exact), schemata (is-a), id / entity_id / canonical_id.
  • the property leaf (P): a specific FtM property (the prop column).
  • the group leaf (G): a followthemoney property-type group (the prop_type column, keyed by registry.groups: names, dates, countries, entities, ...).
  • the context leaf (C): a provenance / storage column such as origin, fragment or first_seen (read from entity.context in-memory).

Leaf handles comparator matching and value casting; its subclasses add the per-family entity access plus correct null (present/absent) semantics.

CanonicalIdLeaf

Bases: IdLeaf

Matches the canonical_id column (the resolved id).

Source code in ftmq/query/leaves.py
class CanonicalIdLeaf(IdLeaf):
    """Matches the `canonical_id` column (the resolved id)."""

    key = "canonical_id"
    ref = CanonicalIdRef()

ContextLeaf

Bases: RefLeaf

A context field (the C family).

In-memory it reads entity.context[key] (always treated as multi-valued); in SQL it maps to the same-named statement-table column. This is the general form of provenance / storage fields - origin, and extra columns such as fragment, first_seen, bucket - that are not followthemoney properties. An entity without the key (or without a context) simply does not match.

Source code in ftmq/query/leaves.py
class ContextLeaf(RefLeaf):
    """A context field (the `C` family).

    In-memory it reads `entity.context[key]` (always treated as multi-valued);
    in SQL it maps to the same-named statement-table column. This is the general
    form of provenance / storage fields - `origin`, and extra columns such as
    `fragment`, `first_seen`, `bucket` - that are not followthemoney properties.
    An entity without the key (or without a `context`) simply does not match.
    """

    family = "C"

    def __init__(self, key: str, value: Any, comparator: str | None = None):
        super().__init__(value, comparator)
        self.ref = ContextRef(key)
        self.key = key

DatasetLeaf

Bases: RefLeaf

Matches an entity's datasets membership.

Source code in ftmq/query/leaves.py
class DatasetLeaf(RefLeaf):
    """Matches an entity's `datasets` membership."""

    family, key = "M", "dataset"
    ref = DatasetRef()

EntityIdLeaf

Bases: IdLeaf

Matches the entity_id column (the pre-resolution id).

Source code in ftmq/query/leaves.py
class EntityIdLeaf(IdLeaf):
    """Matches the `entity_id` column (the pre-resolution id)."""

    key = "entity_id"
    ref = EntityIdRef()

GroupLeaf

Bases: RefLeaf

A property-type group (the prop_type column). entities is the reverse-lookup group.

Source code in ftmq/query/leaves.py
class GroupLeaf(RefLeaf):
    """A property-type group (the `prop_type` column). `entities` is the
    reverse-lookup group."""

    family = "G"

    def __init__(self, group: str, value: Any, comparator: str | None = None):
        self.ref = GroupRef(group)
        super().__init__(value, comparator)
        self.key = self.ref.key
        self.prop_type = self.ref.prop_type

IdLeaf

Bases: RefLeaf

Matches an entity's id.

Source code in ftmq/query/leaves.py
class IdLeaf(RefLeaf):
    """Matches an entity's id."""

    family, key = "M", "id"
    ref = IdRef()

Leaf

A single condition: a comparator plus a cast value. Subclasses set family and implement values() (the entity values to test) or override apply().

The comparator is validated upstream by parse_lookup; here it is a plain string.

Source code in ftmq/query/leaves.py
class Leaf:
    """A single condition: a comparator plus a cast value. Subclasses set
    `family` and implement `values()` (the entity values to test) or override
    `apply()`.

    The comparator is validated upstream by
    [`parse_lookup`][ftmq.query.leaves.parse_lookup]; here it is a plain string.
    """

    family: str = ""
    key: str = ""

    def __init__(self, value: Any, comparator: str | None = None) -> None:
        self.comparator: str = comparator or "eq"
        self.value: Any = self.get_casted_value(value)

    def __hash__(self) -> int:
        return hash((self.key, self.comparator, str(self.value)))

    def __eq__(self, other: Any) -> bool:
        return hash(self) == hash(other)

    def get_casted_value(self, value: Any) -> Any:
        if self.comparator in ("in", "not_in"):
            return set(self.stringify(v) for v in ensure_list(value))
        if self.comparator == "null":
            return as_bool(value)
        if is_listish(value):
            raise QueryError(f"Invalid value for `{self.comparator}`: {value}")
        return self.stringify(value) if value is not None else None

    def stringify(self, value: Any) -> str:
        if hasattr(value, "name"):
            return str(value.name)
        return str(value)

    def values(self, entity: EntityProxy) -> Iterator[str]:
        """Yield the entity values this leaf tests against.

        Args:
            entity: The entity to read values from.

        Yields:
            The relevant string values (property values, schema name, ...).
        """
        raise NotImplementedError

    def match(self, value: Any) -> bool:
        """Apply the comparator to one entity value (the in-memory match)."""
        c = self.comparator
        if c == "eq":
            return bool(value == self.value)
        if c == "not":
            return bool(value != self.value)
        if c == "in":
            return value in self.value
        if c == "not_in":
            return value not in self.value
        if c == "startswith":
            return bool(value.startswith(self.value))
        if c == "endswith":
            return bool(value.endswith(self.value))
        if c == "gt":
            return bool(value > self.value)
        if c == "gte":
            return bool(value >= self.value)
        if c == "lt":
            return bool(value < self.value)
        if c == "lte":
            return bool(value <= self.value)
        if c == "like":
            return self.value in value
        if c == "ilike":
            return bool(self.value.lower() in value.lower())
        if c == "notlike":
            return self.value not in value
        if c == "notilike":
            return bool(self.value.lower() not in value.lower())
        raise QueryError(f"Comparator not implemented: `{c}`")

    def apply(self, entity: EntityProxy) -> bool:
        """Test whether the entity matches this condition.

        Args:
            entity: The entity to test.

        Returns:
            `True` if any of the entity's values satisfy the comparator (or,
            for the `null` comparator, the presence / absence check).
        """
        if self.comparator == "null":
            present = any(True for _ in self.values(entity))
            # value was cast to a bool by `get_casted_value`
            return (not present) if self.value else present
        return any(self.match(v) for v in self.values(entity))

    @property
    def wire(self) -> str:
        """How this leaf's field is spelled on a string surface (Aleph params,
        RQL). Ref-backed leaves defer to their ref, so a filter and an
        aggregation over the same field are spelled identically."""
        return self.key

    def field_dict(self) -> LeafDict:
        """Serialize this leaf to a family-tagged mapping.

        Returns:
            The `{t, f, op, v}` [`LeafDict`][ftmq.query.leaves.LeafDict] used by
            the query-tree serialization.
        """
        value = self.value
        if isinstance(value, (set, frozenset)):
            value = sorted(value)
        return LeafDict(t=self.family, f=self.key, op=str(self.comparator), v=value)

wire property

How this leaf's field is spelled on a string surface (Aleph params, RQL). Ref-backed leaves defer to their ref, so a filter and an aggregation over the same field are spelled identically.

apply(entity)

Test whether the entity matches this condition.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to test.

required

Returns:

Type Description
bool

True if any of the entity's values satisfy the comparator (or,

bool

for the null comparator, the presence / absence check).

Source code in ftmq/query/leaves.py
def apply(self, entity: EntityProxy) -> bool:
    """Test whether the entity matches this condition.

    Args:
        entity: The entity to test.

    Returns:
        `True` if any of the entity's values satisfy the comparator (or,
        for the `null` comparator, the presence / absence check).
    """
    if self.comparator == "null":
        present = any(True for _ in self.values(entity))
        # value was cast to a bool by `get_casted_value`
        return (not present) if self.value else present
    return any(self.match(v) for v in self.values(entity))

field_dict()

Serialize this leaf to a family-tagged mapping.

Returns:

Type Description
LeafDict

The {t, f, op, v} LeafDict used by

LeafDict

the query-tree serialization.

Source code in ftmq/query/leaves.py
def field_dict(self) -> LeafDict:
    """Serialize this leaf to a family-tagged mapping.

    Returns:
        The `{t, f, op, v}` [`LeafDict`][ftmq.query.leaves.LeafDict] used by
        the query-tree serialization.
    """
    value = self.value
    if isinstance(value, (set, frozenset)):
        value = sorted(value)
    return LeafDict(t=self.family, f=self.key, op=str(self.comparator), v=value)

match(value)

Apply the comparator to one entity value (the in-memory match).

Source code in ftmq/query/leaves.py
def match(self, value: Any) -> bool:
    """Apply the comparator to one entity value (the in-memory match)."""
    c = self.comparator
    if c == "eq":
        return bool(value == self.value)
    if c == "not":
        return bool(value != self.value)
    if c == "in":
        return value in self.value
    if c == "not_in":
        return value not in self.value
    if c == "startswith":
        return bool(value.startswith(self.value))
    if c == "endswith":
        return bool(value.endswith(self.value))
    if c == "gt":
        return bool(value > self.value)
    if c == "gte":
        return bool(value >= self.value)
    if c == "lt":
        return bool(value < self.value)
    if c == "lte":
        return bool(value <= self.value)
    if c == "like":
        return self.value in value
    if c == "ilike":
        return bool(self.value.lower() in value.lower())
    if c == "notlike":
        return self.value not in value
    if c == "notilike":
        return bool(self.value.lower() not in value.lower())
    raise QueryError(f"Comparator not implemented: `{c}`")

values(entity)

Yield the entity values this leaf tests against.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to read values from.

required

Yields:

Type Description
str

The relevant string values (property values, schema name, ...).

Source code in ftmq/query/leaves.py
def values(self, entity: EntityProxy) -> Iterator[str]:
    """Yield the entity values this leaf tests against.

    Args:
        entity: The entity to read values from.

    Yields:
        The relevant string values (property values, schema name, ...).
    """
    raise NotImplementedError

LeafDict

Bases: TypedDict

Serialized form of a single Leaf condition.

Source code in ftmq/query/leaves.py
class LeafDict(TypedDict):
    """Serialized form of a single [`Leaf`][ftmq.query.leaves.Leaf] condition."""

    t: str  # family tag: "M" (meta) | "P" (property) | "G" (group)
    f: str  # field / property / group name
    op: str  # comparator, e.g. "eq", "in", "gte", "null"
    v: "str | bool | list[str]"  # cast value (list for `in` / `not_in`)

PropertyLeaf

Bases: RefLeaf

Matches a specific FtM property value (the prop column).

Source code in ftmq/query/leaves.py
class PropertyLeaf(RefLeaf):
    """Matches a specific FtM property value (the `prop` column)."""

    family = "P"

    def __init__(self, prop: str | Property, value: Any, comparator: str | None = None):
        super().__init__(value, comparator)
        self.ref = PropRef(prop)
        self.key = self.ref.key

RefLeaf

Bases: Leaf

A leaf whose field access is a Ref: the ref validates the field name and reads the entity values, the leaf adds the comparator. Aggregations project over the same refs.

Source code in ftmq/query/leaves.py
class RefLeaf(Leaf):
    """A leaf whose field access is a [`Ref`][ftmq.query.refs.Ref]: the ref
    validates the field name and reads the entity values, the leaf adds the
    comparator. Aggregations project over the same refs."""

    ref: Ref

    def values(self, entity: EntityProxy) -> Iterator[str]:
        yield from self.ref.values(entity)

    @property
    def wire(self) -> str:
        return self.ref.wire

SchemaLeaf

Bases: RefLeaf

Exact schema match.

Source code in ftmq/query/leaves.py
class SchemaLeaf(RefLeaf):
    """Exact schema match."""

    family, key = "M", "schema"
    ref = SchemaRef()

    def __init__(self, value: Any, comparator: str | None = None) -> None:
        super().__init__(value, comparator)
        # validate real schema names for equality-style comparators (a
        # `startswith`/`ilike` prefix is not expected to be a full schema)
        if str(self.comparator) in ("eq", "in", "not", "not_in"):
            for name in ensure_list(value):
                if model.get(name) is None:
                    raise QueryError(f"Invalid schema: `{name}`")

SchemataLeaf

Bases: Leaf

is-a match: the entity's schema (or one of its ancestors) is the queried schema, i.e. model[X] in entity.schema.schemata.

Source code in ftmq/query/leaves.py
class SchemataLeaf(Leaf):
    """`is-a` match: the entity's schema (or one of its ancestors) is the
    queried schema, i.e. `model[X] in entity.schema.schemata`."""

    family, key = "M", "schemata"

    def __init__(self, value: Any, comparator: str | None = None) -> None:
        super().__init__(value, comparator)
        self.schemata: set[Schema] = set()
        for item in ensure_list(value):
            schema = item if isinstance(item, Schema) else model.get(item)
            if schema is None:
                raise QueryError(f"Invalid schema: `{item}`")
            self.schemata.add(schema)
        if not self.schemata:
            raise QueryError(f"Invalid schemata: `{value}`")
        if str(self.comparator) not in ("eq", "in", "not", "not_in"):
            raise QueryError(f"Invalid comparator for `schemata`: `{self.comparator}`")

    def apply(self, entity: EntityProxy) -> bool:
        hit = bool(self.schemata & entity.schema.schemata)
        if str(self.comparator) in ("not", "not_in"):
            return not hit
        return hit

leaf_from_dict(data)

Reconstruct a leaf from its serialized LeafDict.

Parameters:

Name Type Description Default
data LeafDict

The {t, f, op, v} mapping produced by Leaf.field_dict.

required

Returns:

Type Description
Leaf

The reconstructed leaf.

Source code in ftmq/query/leaves.py
def leaf_from_dict(data: LeafDict) -> Leaf:
    """Reconstruct a leaf from its serialized [`LeafDict`][ftmq.query.leaves.LeafDict].

    Args:
        data: The `{t, f, op, v}` mapping produced by
            [`Leaf.field_dict`][ftmq.query.leaves.Leaf.field_dict].

    Returns:
        The reconstructed leaf.
    """
    field, op, value = data["f"], data["op"], data["v"]
    key = field if op == "eq" else f"{field}__{op}"
    return LEAF_FACTORIES[data["t"]](key, value)

make_context_leaf(key, value)

Build a context leaf (the C family) from a lookup.

Parameters:

Name Type Description Default
key str

A context / column key, e.g. origin, fragment or first_seen__gte. Any identifier is accepted; validity of a SQL column is checked at compile time.

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved context leaf.

Source code in ftmq/query/leaves.py
def make_context_leaf(key: str, value: Any) -> Leaf:
    """Build a context leaf (the `C` family) from a lookup.

    Args:
        key: A context / column key, e.g. `origin`, `fragment` or
            `first_seen__gte`. Any identifier is accepted; validity of a SQL
            column is checked at compile time.
        value: The lookup value.

    Returns:
        The resolved context leaf.
    """
    field, comparator = parse_lookup(key)
    return ContextLeaf(field, value, comparator)

make_group_leaf(key, value)

Build a property-type group leaf (the G family) from a lookup.

Parameters:

Name Type Description Default
key str

A group lookup key, e.g. countries, dates__gte or entities.

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved group leaf.

Raises:

Type Description
QueryError

If the group is not a valid registry.groups name.

Source code in ftmq/query/leaves.py
def make_group_leaf(key: str, value: Any) -> Leaf:
    """Build a property-type group leaf (the `G` family) from a lookup.

    Args:
        key: A group lookup key, e.g. `countries`, `dates__gte` or `entities`.
        value: The lookup value.

    Returns:
        The resolved group leaf.

    Raises:
        QueryError: If the group is not a valid `registry.groups` name.
    """
    group, comparator = parse_lookup(key)
    return GroupLeaf(group, value, comparator)

make_meta_leaf(key, value)

Build a meta leaf (the M family) from a lookup.

Parameters:

Name Type Description Default
key str

A meta lookup key, e.g. dataset__in, schema or id__startswith.

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved meta leaf.

Raises:

Type Description
QueryError

If the field is not a known meta field.

Source code in ftmq/query/leaves.py
def make_meta_leaf(key: str, value: Any) -> Leaf:
    """Build a meta leaf (the `M` family) from a lookup.

    Args:
        key: A meta lookup key, e.g. `dataset__in`, `schema` or `id__startswith`.
        value: The lookup value.

    Returns:
        The resolved meta leaf.

    Raises:
        QueryError: If the field is not a known meta field.
    """
    field, comparator = parse_lookup(key)
    cls = _META_LEAVES.get(field)
    if cls is None:
        raise QueryError(f"Unknown meta field: `{field}`")
    return cls(value, comparator)

make_property_leaf(key, value)

Build a property leaf (the P family) from a lookup.

Parameters:

Name Type Description Default
key str

A property lookup key, e.g. name or amountEur__gte.

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved property leaf.

Raises:

Type Description
QueryError

If the property is not a valid FtM property.

Source code in ftmq/query/leaves.py
def make_property_leaf(key: str, value: Any) -> Leaf:
    """Build a property leaf (the `P` family) from a lookup.

    Args:
        key: A property lookup key, e.g. `name` or `amountEur__gte`.
        value: The lookup value.

    Returns:
        The resolved property leaf.

    Raises:
        QueryError: If the property is not a valid FtM property.
    """
    prop, comparator = parse_lookup(key)
    return PropertyLeaf(prop, value, comparator)

parse_lookup(key)

Split a field__comparator lookup key into its parts.

Parameters:

Name Type Description Default
key str

A lookup key such as name, date__gte or schema__in.

required

Returns:

Type Description
tuple[str, str]

A (field, comparator) tuple; the comparator defaults to eq.

Raises:

Type Description
QueryError

If the comparator suffix is not a valid comparator.

Source code in ftmq/query/leaves.py
def parse_lookup(key: str) -> tuple[str, str]:
    """Split a `field__comparator` lookup key into its parts.

    Args:
        key: A lookup key such as `name`, `date__gte` or `schema__in`.

    Returns:
        A `(field, comparator)` tuple; the comparator defaults to `eq`.

    Raises:
        QueryError: If the comparator suffix is not a valid comparator.
    """
    field, _, comparator = key.partition("__")
    comparator = comparator or "eq"
    if comparator not in COMPARATORS:
        raise QueryError(f"Invalid comparator in lookup: `{key}`")
    return field, comparator

Aleph bridge

The filter half of the Aleph URL-param grammar. Query.to_params / from_params and to_string / from_string wrap these.

The Aleph / OpenAleph URL-param grammar: a bidirectional bridge between a Query filter tree and the filter: / exclude: / empty: param convention used by openaleph_search.SearchQueryParser.

The filter half (the mapping between the boolean tree and the param keys) lives here, plus the aggregation half (metric:<func>=<prop> and facet=<field>, matching openaleph's metric aggregations). sort / limit / offset are query-level concerns handled by Query.to_params / Query.from_params.

The param model is flat (AND across keys, OR within a key, exclude: and empty: for negation / absence), so:

  • params_to_expr is total and always yields a flat AND-of-leaves.
  • expr_to_params is defined on that flat subset and raises QueryError for a cross-field OR or a negated group.

aggregations_to_params(aggs)

Project aggregation specs to openaleph metric / facet params.

Each spec becomes a metric:<func>=<field> entry (the convention openaleph_search.SearchQueryParser reads as metrics = {func: {fields}}); grouped fields become facet=<field> values, spelled as the filter keys are (properties.<name>, group.<name>, context.<name>, bare meta fields and year). Facet groups apply across all metrics - a per-metric grouping that differs between metrics collapses to their union here.

Parameters:

Name Type Description Default
aggs set[Agg]

The query's aggregation specs.

required

Returns:

Type Description
dict[str, list[str]]

The {"metric:<func>": [fields], "facet": [groups]} param mapping.

Source code in ftmq/query/aleph.py
def aggregations_to_params(aggs: set[Agg]) -> dict[str, list[str]]:
    """Project aggregation specs to openaleph metric / facet params.

    Each spec becomes a `metric:<func>=<field>` entry (the convention
    `openaleph_search.SearchQueryParser` reads as
    `metrics = {func: {fields}}`); grouped fields become `facet=<field>`
    values, spelled as the filter keys are (`properties.<name>`,
    `group.<name>`, `context.<name>`, bare meta fields and `year`). Facet
    groups apply across all metrics - a per-metric grouping that differs
    between metrics collapses to their union here.

    Args:
        aggs: The query's aggregation specs.

    Returns:
        The `{"metric:<func>": [fields], "facet": [groups]}` param mapping.
    """
    params: dict[str, list[str]] = defaultdict(list)
    facets: set[str] = set()
    for agg in sorted(aggs, key=lambda a: (a.func, a.key)):
        key = f"metric:{agg.func}"
        if agg.key not in params[key]:
            params[key].append(agg.key)
        facets.update(g.wire for g in agg.groups)
    if facets:
        params["facet"] = sorted(facets)
    return dict(params)

expr_to_params(expr)

Project a filter tree to Aleph filter: / exclude: / empty: params.

Parameters:

Name Type Description Default
expr Expr | None

The filter tree (or None).

required

Returns:

Type Description
dict[str, list[str]]

The Aleph param mapping.

Raises:

Type Description
QueryError

If the tree is not Aleph-expressible (a cross-field OR, or a negated multi-leaf group).

Source code in ftmq/query/aleph.py
def expr_to_params(expr: Expr | None) -> dict[str, list[str]]:
    """Project a filter tree to Aleph `filter:` / `exclude:` / `empty:` params.

    Args:
        expr: The filter tree (or `None`).

    Returns:
        The Aleph param mapping.

    Raises:
        QueryError: If the tree is not Aleph-expressible (a cross-field `OR`, or
            a negated multi-leaf group).
    """
    params: dict[str, list[str]] = defaultdict(list)
    if expr:
        for leaf, inverted in _collect_terms(expr):
            prefix, key, values = _leaf_to_param(leaf, inverted)
            params[f"{prefix}{key}"].extend(values)
    return dict(params)

normalize_multidict(args)

Coerce params into a plain dict[str, list[str]].

Parameters:

Name Type Description Default
args Any

A werkzeug MultiDict, a plain dict, or a dict of lists.

required

Returns:

Type Description
dict[str, list[str]]

A mapping of each key to its list of string values.

Source code in ftmq/query/aleph.py
def normalize_multidict(args: Any) -> dict[str, list[str]]:
    """Coerce params into a plain `dict[str, list[str]]`.

    Args:
        args: A werkzeug `MultiDict`, a plain dict, or a dict of lists.

    Returns:
        A mapping of each key to its list of string values.
    """
    items: dict[str, list[str]] = defaultdict(list)
    if hasattr(args, "lists"):  # werkzeug MultiDict
        for key, values in args.lists():
            items[key].extend(str(v) for v in values)
    elif hasattr(args, "items"):
        for key, value in args.items():
            if isinstance(value, (list, tuple, set)):
                items[key].extend(str(v) for v in value)
            else:
                items[key].append(str(value))
    return dict(items)

params_to_aggregations(items)

Rebuild aggregation specs from openaleph metric: / facet params.

The inverse of aggregations_to_params: every facet field groups every metric:<func>=<prop> (matching how openaleph computes a metric within each facet bucket).

A facet with no metric: alongside it groups an entity count - the idiomatic Aleph facet. Dropping it instead would discard the param in silence and answer with empty facets.

Parameters:

Name Type Description Default
items dict[str, list[str]]

A normalized param mapping.

required

Returns:

Type Description
set[Agg]

The reconstructed aggregation specs (empty if there is neither a

set[Agg]

metric: nor a facet param).

Source code in ftmq/query/aleph.py
def params_to_aggregations(items: dict[str, list[str]]) -> set[Agg]:
    """Rebuild aggregation specs from openaleph `metric:` / `facet` params.

    The inverse of [`aggregations_to_params`][ftmq.query.aleph.aggregations_to_params]:
    every `facet` field groups every `metric:<func>=<prop>` (matching how
    openaleph computes a metric within each facet bucket).

    A `facet` with no `metric:` alongside it groups an entity count - the
    idiomatic Aleph facet. Dropping it instead would discard the param in
    silence and answer with empty facets.

    Args:
        items: A normalized param mapping.

    Returns:
        The reconstructed aggregation specs (empty if there is neither a
        `metric:` nor a `facet` param).
    """
    groups = tuple(ref_from_wire(g) for g in sorted(set(items.get("facet", []))))
    aggs: set[Agg] = set()
    for key, values in items.items():
        if key.startswith("metric:"):
            func = key[len("metric:") :]
            for field in values:
                aggs.add(make_agg(func, ref_from_wire(field), groups))
    if groups and not aggs:
        aggs.add(make_agg("count", IdRef(), groups))
    return aggs

params_to_expr(items)

Build a filter tree from Aleph params (non-filter keys are ignored).

Parameters:

Name Type Description Default
items dict[str, list[str]]

A normalized param mapping (see normalize_multidict).

required

Returns:

Type Description
Expr | None

The flat AND-of-leaves filter tree, or None if there are no filters.

Source code in ftmq/query/aleph.py
def params_to_expr(items: dict[str, list[str]]) -> Expr | None:
    """Build a filter tree from Aleph params (non-filter keys are ignored).

    Args:
        items: A normalized param mapping (see
            [`normalize_multidict`][ftmq.query.aleph.normalize_multidict]).

    Returns:
        The flat AND-of-leaves filter tree, or `None` if there are no filters.
    """
    nodes: list[Expr] = []
    for key, values in items.items():
        for prefix in ("filter:", "exclude:", "empty:"):
            if key.startswith(prefix):
                nodes.append(_param_to_node(prefix, key[len(prefix) :], values))
                break
    return combine(*nodes) if nodes else None

params_to_string(params)

Render an Aleph param mapping as a URL query string.

Keys are sorted for deterministic output; value order within a key is preserved (multi-field sort priority must not be reordered).

Parameters:

Name Type Description Default
params dict[str, list[str]]

The param mapping.

required

Returns:

Type Description
str

A key=value&... string with url-encoded values, sorted by key.

Source code in ftmq/query/aleph.py
def params_to_string(params: dict[str, list[str]]) -> str:
    """Render an Aleph param mapping as a URL query string.

    Keys are sorted for deterministic output; value order within a key is
    preserved (multi-field `sort` priority must not be reordered).

    Args:
        params: The param mapping.

    Returns:
        A `key=value&...` string with url-encoded values, sorted by key.
    """
    parts = []
    for key in sorted(params):
        for value in params[key]:
            parts.append(f"{key}={quote(str(value))}")
    return "&".join(parts)

string_to_params(value)

Parse an Aleph URL query string into a param mapping.

Parameters:

Name Type Description Default
value str

A key=value&... query string.

required

Returns:

Type Description
dict[str, list[str]]

A mapping of each key to its list of url-decoded values.

Source code in ftmq/query/aleph.py
def string_to_params(value: str) -> dict[str, list[str]]:
    """Parse an Aleph URL query string into a param mapping.

    Args:
        value: A `key=value&...` query string.

    Returns:
        A mapping of each key to its list of url-decoded values.
    """
    items: dict[str, list[str]] = defaultdict(list)
    for part in value.split("&"):
        if not part:
            continue
        key, _, val = part.partition("=")
        items[key].append(unquote(val))
    return dict(items)

RQL bridge

RQL support for nested filter trees, used by Query.from_rql.

RQL (Resource Query Language) bridge.

RQL is a URL-friendly query language of nestable named operators - e.g. and(eq(schema,Person),or(eq(properties.name,jane),eq(countries,de))) - which maps directly onto the ftmq Expr tree (and/or/not + comparison leaves). Unlike the flat Aleph param grammar, RQL expresses arbitrary nesting, so this is the way to carry a full M & (P | G) tree through a single string. It also carries aggregations: RQL's native sum / min / max / mean / count and aggregate(...) operators map onto ftmq A nodes, side by side with the filter under a top-level and.

Field names use the shared wire spelling (properties.<name>, group.<name>, context.<name>, bare meta fields and year); a bare name that matches none of those is treated as an FtM property.

expr_to_rql(expr)

Convert an Expr tree to an RQL AST ({"name": ..., "args": [...]}).

Source code in ftmq/query/rql.py
def expr_to_rql(expr: Expr) -> dict[str, Any]:
    """Convert an `Expr` tree to an RQL AST (`{"name": ..., "args": [...]}`)."""
    group = "or" if expr.connector == OR else "and"
    parts: list[dict[str, Any]] = []
    for child in expr.children:
        if isinstance(child, Expr):
            child_ast = expr_to_rql(child)
            # flatten a non-negated same-connector subgroup into this one
            if not child.negated and child_ast.get("name") == group:
                parts.extend(child_ast["args"])
            else:
                parts.append(child_ast)
        else:
            parts.append(_leaf_to_rql(child))
    if not parts:
        raise QueryError("Cannot serialize an empty query to RQL")
    # a single-child group is just that child
    body = parts[0] if len(parts) == 1 else {"name": group, "args": parts}
    if expr.negated:
        return {"name": "not", "args": [body]}
    return body

parse_rql(value)

Parse an RQL query string into a filter Expr and aggregation specs.

Filter operators (and / or / not + comparisons) build the tree; the aggregate operators (sum / min / max / mean / count / aggregate) build the aggregations. At the top level they sit side by side under and.

Raises:

Type Description
QueryError

If the RQL uses an unsupported operator or field.

Source code in ftmq/query/rql.py
def parse_rql(value: str) -> tuple[Expr | None, set[Agg]]:
    """Parse an RQL query string into a filter `Expr` and aggregation specs.

    Filter operators (`and` / `or` / `not` + comparisons) build the tree; the
    aggregate operators (`sum` / `min` / `max` / `mean` / `count` / `aggregate`)
    build the aggregations. At the top level they sit side by side under `and`.

    Raises:
        QueryError: If the RQL uses an unsupported operator or field.
    """
    data = pyrql.parse(value)
    if not data:
        return None, set()
    aggs: set[Agg] = set()
    if data["name"] in AGG_OPERATORS:
        aggs.update(_node_aggs(data))
        return None, aggs
    if data["name"] == "and":
        filters: list[dict[str, Any]] = []
        for child in data["args"]:
            if isinstance(child, dict) and child.get("name") in AGG_OPERATORS:
                aggs.update(_node_aggs(child))
            else:
                filters.append(child)
        expr = combine(*(rql_to_expr(f) for f in filters), connector=AND)
        return expr, aggs
    return rql_to_expr(data), aggs

rql_to_expr(data)

Convert a parsed RQL AST ({"name": ..., "args": [...]}) to an Expr.

Source code in ftmq/query/rql.py
def rql_to_expr(data: dict[str, Any]) -> Expr:
    """Convert a parsed RQL AST (`{"name": ..., "args": [...]}`) to an `Expr`."""
    op, args = data["name"], data["args"]
    if op == "and":
        result = combine(*(rql_to_expr(a) for a in args), connector=AND)
    elif op == "or":
        result = combine(*(rql_to_expr(a) for a in args), connector=OR)
    elif op == "not":
        return ~rql_to_expr(args[0])
    else:
        return _rql_leaf(op, args)
    if result is None:
        raise QueryError(f"Empty RQL group: `{op}`")
    return result

to_rql(expr, aggs=())

Serialize a filter tree and aggregation specs to an RQL query string.

Filters and aggregations sit side by side under a top-level and.

Raises:

Type Description
QueryError

If a filter leaf uses a comparator with no RQL equivalent (null, startswith, endswith, ...).

Source code in ftmq/query/rql.py
def to_rql(expr: Expr | None, aggs: Iterable[Agg] = ()) -> str:
    """Serialize a filter tree and aggregation specs to an RQL query string.

    Filters and aggregations sit side by side under a top-level `and`.

    Raises:
        QueryError: If a filter leaf uses a comparator with no RQL equivalent
            (`null`, `startswith`, `endswith`, ...).
    """
    nodes: list[dict[str, Any]] = []
    if expr is not None and expr:
        filter_ast = expr_to_rql(expr)
        # flatten a top-level `and` filter so aggregations join as siblings
        if not expr.negated and filter_ast.get("name") == "and":
            nodes.extend(filter_ast["args"])
        else:
            nodes.append(filter_ast)
    nodes.extend(_aggs_to_rql(aggs))
    if not nodes:
        return ""
    if len(nodes) == 1:
        return str(pyrql.unparse(nodes[0]))
    return str(pyrql.unparse({"name": "and", "args": nodes}))

Aggregations

The A projection node, the immutable Agg spec and the in-memory Aggregator. See the aggregation guide.

Aggregations for the ftmq query language.

An aggregation is a projection over the matched entities (a SELECT-list / GROUP BY concern), not a filter predicate: the A node does not compose with the & | ~ boolean tree the M/P/G/C filter nodes build. It is declared with Query.aggregate, parallel to where() and order_by().

A(sum=P("amountEur"), by=P("beneficiary")) builds one immutable Agg spec per func=<ref> pair, where the field is a Ref built by the same M / P / G / C markers as a filter leaf. Aggregator is the in-memory accumulator that runs those specs over a stream of entities; the SQL backend reads the same specs (see ftmq.query.sql).

A

An aggregation projection node: A(sum=P("amountEur"), by=P("beneficiary")).

Each keyword is an aggregation function (min, max, sum, avg, count) whose value is the field reference (or references) to aggregate; by= groups by one or more references. Fields are addressed with the same M / P / G / C markers the filter families use, called with a bare field name (plus Year()), so an aggregation says which family it means instead of leaving it to be guessed from the name. Unlike the filter nodes, A is not a boolean leaf - it does not compose with & | ~; pass it to Query.aggregate.

Examples:

A(sum=P("amountEur"), by=P("beneficiary"))
A(count=M("id"), by=[G("countries"), Year()])
A(sum=[P("amountEur"), P("amount")])
Source code in ftmq/query/aggregations.py
class A:
    """An aggregation projection node: `A(sum=P("amountEur"), by=P("beneficiary"))`.

    Each keyword is an aggregation function (`min`, `max`, `sum`, `avg`,
    `count`) whose value is the field reference (or references) to aggregate;
    `by=` groups by one or more references. Fields are addressed with the same
    `M` / `P` / `G` / `C` markers the filter families use, called with a bare
    field name (plus `Year()`), so an aggregation says which family it means
    instead of leaving it to be guessed from the name. Unlike the filter nodes,
    `A` is not a boolean leaf - it does not compose with `& | ~`; pass it to
    [`Query.aggregate`][ftmq.Query.aggregate].

    Examples:
        ```python
        A(sum=P("amountEur"), by=P("beneficiary"))
        A(count=M("id"), by=[G("countries"), Year()])
        A(sum=[P("amountEur"), P("amount")])
        ```
    """

    def __init__(
        self,
        *,
        by: Ref | Iterable[Ref] | None = None,
        **funcs: Ref | Iterable[Ref],
    ) -> None:
        groups: tuple[Ref, ...] = tuple(cast("list[Ref]", ensure_list(by)))
        aggs: list[Agg] = []
        for func, refs in funcs.items():
            for ref in cast("list[Ref]", ensure_list(refs)):
                aggs.append(make_agg(func, ref, groups))
        if not aggs:
            raise QueryError("Empty aggregation: pass at least one `func=<ref>`")
        self.aggs: tuple[Agg, ...] = tuple(aggs)

Agg dataclass

An immutable aggregation spec: a function over a field reference, optionally grouped by others. Built via the A node or Query.aggregate.

Source code in ftmq/query/aggregations.py
@dataclass(frozen=True)
class Agg:
    """An immutable aggregation spec: a function over a field reference,
    optionally grouped by others. Built via the
    [`A`][ftmq.query.aggregations.A] node or
    [`Query.aggregate`][ftmq.Query.aggregate]."""

    func: str
    ref: Ref
    groups: tuple[Ref, ...] = ()

    @property
    def key(self) -> str:
        """The wire spelling of the aggregated field."""
        return self.ref.wire

key property

The wire spelling of the aggregated field.

Aggregator

In-memory accumulator: runs a set of Agg specs over an entity stream.

A fresh instance per run holds all mutable state, so applying the same query twice never double-counts (the specs themselves are immutable).

Source code in ftmq/query/aggregations.py
class Aggregator:
    """In-memory accumulator: runs a set of [`Agg`][ftmq.query.aggregations.Agg]
    specs over an entity stream.

    A fresh instance per run holds all mutable state, so applying the same
    query twice never double-counts (the specs themselves are immutable).
    """

    def __init__(self, aggs: Iterable[Agg]) -> None:
        self.aggs: list[Agg] = list(aggs)
        self._values: dict[Agg, Values] = defaultdict(list)
        self._grouped: dict[Agg, dict[Ref, dict[str, Values]]] = defaultdict(
            lambda: defaultdict(lambda: defaultdict(list))
        )

    def collect(self, proxy: Entity) -> None:
        """Accumulate one entity's values into every spec."""
        for agg in self.aggs:
            for raw in agg.ref.values(proxy):
                value: Any = (
                    registry.number.to_number(raw) if agg.ref.is_numeric else raw
                )
                if value is None:
                    continue
                self._values[agg].append(value)
                for group in agg.groups:
                    for g in group.values(proxy):
                        self._grouped[agg][group][g].append(value)

    def apply(self, proxies: Iterable[Entity]) -> Iterator[Entity]:
        """Collect every entity while passing the stream through unchanged."""
        for proxy in proxies:
            self.collect(proxy)
            yield proxy

    @property
    def result(self) -> AggregatorResult:
        """The reduced result, keyed by the wire spelling of each field:
        `{func: {field: value}, "groups": {group: {func: {field: {group_value:
        value}}}}}` (empties removed)."""
        res: Any = defaultdict(dict)
        groups: Any = defaultdict(lambda: defaultdict(dict))
        for agg in self.aggs:
            res[agg.func][agg.key] = reduce_values(agg.func, self._values[agg])
            for group in agg.groups:
                groups[group.wire][agg.func][agg.key] = {
                    g: reduce_values(agg.func, values)
                    for g, values in self._grouped[agg][group].items()
                }
        res["groups"] = groups
        return clean_dict(res)

result property

The reduced result, keyed by the wire spelling of each field: {func: {field: value}, "groups": {group: {func: {field: {group_value: value}}}}} (empties removed).

apply(proxies)

Collect every entity while passing the stream through unchanged.

Source code in ftmq/query/aggregations.py
def apply(self, proxies: Iterable[Entity]) -> Iterator[Entity]:
    """Collect every entity while passing the stream through unchanged."""
    for proxy in proxies:
        self.collect(proxy)
        yield proxy

collect(proxy)

Accumulate one entity's values into every spec.

Source code in ftmq/query/aggregations.py
def collect(self, proxy: Entity) -> None:
    """Accumulate one entity's values into every spec."""
    for agg in self.aggs:
        for raw in agg.ref.values(proxy):
            value: Any = (
                registry.number.to_number(raw) if agg.ref.is_numeric else raw
            )
            if value is None:
                continue
            self._values[agg].append(value)
            for group in agg.groups:
                for g in group.values(proxy):
                    self._grouped[agg][group][g].append(value)

aggregations_from_dict(data)

Rebuild aggregation specs from the output of aggregations_to_dict.

Source code in ftmq/query/aggregations.py
def aggregations_from_dict(data: Iterable[dict[str, Any]]) -> set[Agg]:
    """Rebuild aggregation specs from the output of
    [`aggregations_to_dict`][ftmq.query.aggregations.aggregations_to_dict]."""
    return {
        make_agg(
            spec["func"],
            ref_from_wire(spec["field"]),
            [ref_from_wire(g) for g in spec.get("by", [])],
        )
        for spec in data
    }

aggregations_to_dict(aggs)

Serialize aggregation specs to the query to_dict shape: one {"func": ..., "field": ..., "by": [...]} mapping per spec (fields spelled as on the wire, by omitted when ungrouped), deterministically ordered.

Source code in ftmq/query/aggregations.py
def aggregations_to_dict(aggs: Iterable[Agg]) -> list[dict[str, Any]]:
    """Serialize aggregation specs to the query `to_dict` shape: one
    `{"func": ..., "field": ..., "by": [...]}` mapping per spec (fields spelled
    as on the wire, `by` omitted when ungrouped), deterministically ordered."""
    specs: list[dict[str, Any]] = []
    for agg in sorted(aggs, key=lambda a: (a.func, a.key, a.groups)):
        spec: dict[str, Any] = {"func": agg.func, "field": agg.key}
        if agg.groups:
            spec["by"] = [g.wire for g in agg.groups]
        specs.append(spec)
    return specs

make_agg(func, ref, groups=())

Validate and build a single Agg spec.

Groups are sorted (by wire spelling), so two specs over the same fields compare and serialize identically regardless of input order.

Source code in ftmq/query/aggregations.py
def make_agg(func: str, ref: Ref, groups: Iterable[Ref] = ()) -> Agg:
    """Validate and build a single [`Agg`][ftmq.query.aggregations.Agg] spec.

    Groups are sorted (by wire spelling), so two specs over the same fields
    compare and serialize identically regardless of input order.
    """
    if func not in FUNCTIONS:
        raise QueryError(
            f"Invalid aggregation function: `{func}` - one of "
            f"({', '.join(sorted(FUNCTIONS))})"
        )
    return Agg(
        func=func, ref=_ensure_ref(ref), groups=tuple(sorted(map(_ensure_ref, groups)))
    )

reduce_values(func, values)

Reduce collected values with an aggregation function (None if empty).

Source code in ftmq/query/aggregations.py
def reduce_values(func: str, values: Values) -> Value | None:
    """Reduce collected values with an aggregation function (`None` if empty)."""
    if not values:
        return None
    if func == "min":
        return min(values)
    if func == "max":
        return max(values)
    if func == "sum":
        return sum(cast("list[float]", values))
    if func == "avg":
        return statistics.mean(cast("list[float]", values))
    if func == "count":
        return len(set(values))
    return None

SQL

The SQL translation. A store passes its SqlSource to Query.compile (or builds Sql(query, source) directly).

Describes the SQL statement source a Query compiles against: the SQLAlchemy table (or view), the entity-identity column, and an optional partition-pruning rule.

Stores own one and pass it to Sql / Query.compile. A downstream store with extra columns (a lake / sharded table) supplies its own SqlSource so the same Query compiles against it unchanged.

Parameters:

Name Type Description Default
table Any

The SQLAlchemy Table / TableClause to query.

required
id_column str

The entity-identity column name (default canonical_id).

'canonical_id'
prune_schema PruneFn | None

Optional function folding a schema/schemata filter into a prune_column IN (...) partition predicate on every compiled query (e.g. the lake store's schema -> bucket mapping).

None
prune_column str | None

The partition column the pruned values target (e.g. bucket).

None
base_filter Any | None

Optional SQLAlchemy predicate folded into every compiled select and sub-select (e.g. a lake store's view filter). Unlike a predicate added post-hoc to the top-level select, this also scopes the entity-level membership / absence subqueries.

None
Source code in ftmq/query/sql.py
class SqlSource:
    """Describes the SQL statement source a [`Query`][ftmq.Query] compiles
    against: the SQLAlchemy table (or view), the entity-identity column, and an
    optional partition-pruning rule.

    Stores own one and pass it to [`Sql`][ftmq.query.sql.Sql] /
    [`Query.compile`][ftmq.Query.compile]. A downstream store with extra
    columns (a lake / sharded table) supplies its own `SqlSource` so the same
    `Query` compiles against it unchanged.

    Args:
        table: The SQLAlchemy `Table` / `TableClause` to query.
        id_column: The entity-identity column name (default `canonical_id`).
        prune_schema: Optional function folding a schema/schemata filter into a
            `prune_column IN (...)` partition predicate on every compiled query
            (e.g. the lake store's schema -> `bucket` mapping).
        prune_column: The partition column the pruned values target
            (e.g. `bucket`).
        base_filter: Optional SQLAlchemy predicate folded into every compiled
            select *and* sub-select (e.g. a lake store's view filter). Unlike a
            predicate added post-hoc to the top-level select, this also scopes
            the entity-level membership / absence subqueries.
    """

    def __init__(
        self,
        table: "Any",
        id_column: str = "canonical_id",
        prune_schema: "PruneFn | None" = None,
        prune_column: str | None = None,
        base_filter: "Any | None" = None,
    ) -> None:
        self.table = table
        self.id_column = id_column
        self.prune_schema = prune_schema
        self.prune_column = prune_column
        self.base_filter = base_filter
Source code in ftmq/query/sql.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
class Sql:
    COMPARATORS = {
        "eq": "__eq__",
        "not": "__ne__",
        "in": "in_",
        "gt": "__gt__",
        "gte": "__ge__",
        "lt": "__lt__",
        "lte": "__le__",
    }

    def __init__(
        self,
        q: "Query",
        source: "SqlSource | None" = None,
        scope: "Iterable[str] | None" = None,
    ) -> None:
        self.q = q
        self.metadata = MetaData()
        if source is None:
            source = SqlSource(make_statement_table(self.metadata))
        self.source = source
        self.table = source.table
        self.id_col = self.table.c[source.id_column]
        self.scope: set[str] | None = set(scope) if scope else None

    @cached_property
    def _base_clauses(self) -> list[Any]:
        """Row predicates folded into every select *and* sub-select: the
        source's base filter (e.g. a lake view filter), which defines the
        statement rows that exist at all for this source.

        The view `scope` is *not* a row predicate - see `clause`.
        """
        if self.source.base_filter is not None:
            return [self.source.base_filter]
        return []

    def get_expression(self, column: Column, f: Leaf):
        c = f.comparator
        if c == "null":
            # `null` tests presence, not a value: `null=True` means the column
            # is unset. (For the `prop` / `prop_type` families presence is a
            # row-existence test, handled in `clause`.)
            return column.is_(None) if f.value else column.is_not(None)
        # substring / prefix / suffix comparators: autoescape so `%` and `_` in
        # the value match literally, like the in-memory substring test
        if c in ("like", "notlike"):
            like = column.contains(f.value, autoescape=True)
            return not_(like) if c == "notlike" else like
        if c in ("ilike", "notilike"):
            like = column.icontains(f.value, autoescape=True)
            return not_(like) if c == "notilike" else like
        if c == "startswith":
            return column.startswith(f.value, autoescape=True)
        if c == "endswith":
            return column.endswith(f.value, autoescape=True)
        op = self.COMPARATORS.get(c)
        if op is None:
            raise QueryError(f"Comparator not supported in SQL: `{c}`")
        value = f.value
        # the leaf layer stringifies values, but typed columns (e.g. the
        # Boolean `external`) need the original type to compare correctly
        if isinstance(column.type, Boolean):
            if isinstance(value, (set, frozenset, list, tuple)):
                value = sorted({as_bool(v) for v in value})
            else:
                value = as_bool(value)
        return getattr(column, op)(value)

    @staticmethod
    def _is_null(f: Leaf) -> bool:
        return f.comparator == "null"

    def _entity_ids(self, pred: Any) -> Select:
        """A sub-select of the entity ids having a row matching `pred`,
        over the rows visible to this source (the base filter)."""
        return select(self.id_col.distinct()).where(
            and_(true(), *self._base_clauses, pred)
        )

    def _absent(self, present: Any) -> Any:
        """Lift a row-presence predicate to an entity-level absence check.

        `null=True` asks whether an entity has *no* such row at all, which no
        single statement row can answer - it becomes a `canonical_id` anti-join.
        """
        return self.id_col.not_in(self._entity_ids(present))

    def _membership(self, pred: Any) -> Any:
        """Lift a row predicate to an entity-level membership clause: the
        entity has at least one row matching it."""
        return self.id_col.in_(self._entity_ids(pred))

    def _family_clause(self, leaf: Leaf, selector: Callable[[Any], Any]) -> Any:
        """One entity-level clause for a property / group leaf.

        `selector` builds the family predicate (e.g. `prop = "name"`). `null`
        tests presence of such a row, not the value: `null=False` is any row
        for the family, `null=True` the absence of one.
        """
        family = selector(leaf)
        if self._is_null(leaf):
            if leaf.value:
                return self._absent(family)
            return self._membership(family)
        return self._membership(
            and_(family, self.get_expression(self.table.c.value, leaf))
        )

    def _prop_selector(self, f: Any) -> Any:
        return self.table.c.prop == f.key

    def _group_selector(self, f: Any) -> Any:
        return self.table.c.prop_type == str(f.prop_type)

    def _schema_clause(self, f: Leaf) -> Any:
        """A clause for exact-schema / is-a (`schemata`) filters.

        Positive comparators stay row predicates (an is-a filter expands to the
        schema plus its non-abstract descendants). `not` / `not_in` compile as
        entity-level anti-joins: in-memory they test the entity's single
        resolved schema, and a row predicate would wrongly match any merged
        entity that has one statement row outside the excluded set.
        """
        negated = f.comparator in ("not", "not_in")
        if isinstance(f, SchemataLeaf):
            names: set[str] = set()
            for schema in f.schemata:
                names.add(schema.name)
                names.update(d.name for d in schema.descendants if not d.abstract)
            positive = self.table.c.schema.in_(names)
        elif negated:
            values = f.value if isinstance(f.value, (set, frozenset)) else {f.value}
            positive = self.table.c.schema.in_(sorted(values))
        else:
            return self.get_expression(self.table.c.schema, f)
        if negated:
            return self._absent(positive)
        return positive

    def _context_column(self, f: ContextLeaf) -> Any:
        if f.key not in self.table.c:
            raise QueryError(f"Unknown context column: `{f.key}`")
        return self.table.c[f.key]

    def _id_column(self, f: IdLeaf) -> Any:
        # `M(id=...)` addresses the entity: in a statement table that is the
        # resolved id column, not `statement.id` (the statement's own id)
        if f.key == "id":
            return self.id_col
        return self.table.c[f.key]

    def _context_clauses(self) -> tuple[list[Any], list[Any]]:
        """`(row, entity)` clauses for context / storage columns
        (`C(origin=...)`, `C(fragment=...)`, ...).

        A single column stays a row predicate; several distinct columns each
        lift to an entity-level membership - in-memory a context value is the
        entity's aggregate over its statements, so `C(origin=..) & C(lang=..)`
        may be satisfied by two different rows.
        """
        rows: list[Any] = []
        entities: list[Any] = []
        context = sorted(self.q.context, key=lambda f: f.key)
        entity_level = len(context) > 1
        for f in context:
            # "unset" means no row carries the column
            if self._is_null(f) and f.value:
                entities.append(self._absent(self._context_column(f).is_not(None)))
            elif entity_level:
                entities.append(
                    self._membership(self.get_expression(self._context_column(f), f))
                )
            else:
                rows.append(self.get_expression(self._context_column(f), f))
        return rows, entities

    def _leaf_clause(self, leaf: Leaf) -> Any:
        """An entity-level predicate for a single leaf.

        Lifting every leaf to entity level (`canonical_id IN (...)`) is what
        makes an arbitrary `& | ~` tree composable: `OR` / `NOT` over row
        predicates would ask a single statement row a question about the whole
        entity ("this entity has no name" is not a property of any one row).
        """
        if isinstance(leaf, PropertyLeaf):
            return self._family_clause(leaf, self._prop_selector)
        if isinstance(leaf, GroupLeaf):
            return self._family_clause(leaf, self._group_selector)
        if isinstance(leaf, (SchemaLeaf, SchemataLeaf)):
            clause = self._schema_clause(leaf)
            if leaf.comparator in ("not", "not_in"):
                return clause  # already an entity-level anti-join
            return self._membership(clause)
        if isinstance(leaf, ContextLeaf):
            if self._is_null(leaf) and leaf.value:
                return self._absent(self._context_column(leaf).is_not(None))
            row = self.get_expression(self._context_column(leaf), leaf)
        elif isinstance(leaf, IdLeaf):
            row = self.get_expression(self._id_column(leaf), leaf)
        elif isinstance(leaf, DatasetLeaf):
            row = self.get_expression(self.table.c.dataset, leaf)
        else:
            raise QueryError(f"Cannot compile filter to sql: `{leaf.key}`")
        return self._membership(row)

    def _expr_clause(self, expr: Expr) -> Any:
        """Compile a boolean node by combining its children's entity-level
        predicates - the general path for trees the flat collectors below
        cannot represent (cross-field `OR`, negation)."""
        parts = [
            self._expr_clause(c) if isinstance(c, Expr) else self._leaf_clause(c)
            for c in expr.children
        ]
        # an empty node matches everything - unless negated, when it matches
        # nothing (`not_(true())` compiles to `false`)
        combined = (
            or_(*parts) if parts and expr.connector == OR else and_(true(), *parts)
        )
        return not_(combined) if expr.negated else combined

    @cached_property
    def _is_flat_and(self) -> bool:
        """Whether the query tree is a plain conjunction with at most one leaf
        per field - the shape the flat collectors below represent losslessly.
        Anything else (OR, negation, repeated fields, whose leaves AND in the
        language) compiles through `_expr_clause`."""

        def walk(expr: Expr) -> bool:
            if expr.negated or (expr.connector == OR and len(expr.children) > 1):
                return False
            return all(walk(c) for c in expr.children if isinstance(c, Expr))

        if self.q.q is None:
            return True
        if not walk(self.q.q):
            return False
        keys = [(type(f).__name__, f.key) for f in self.q._leaves]
        return len(keys) == len(set(keys))

    @cached_property
    def _prune_values(self) -> set[str] | None:
        """Partition values for a schema/schemata filter (e.g. the lake
        `bucket` column), folded into every compiled query - so `count` prunes
        partitions too, not just statements.

        Pruning is only sound for positive schema conjuncts: under `~` / `|` or
        a `not` comparator the filter no longer restricts matching entities to
        those partitions, so any such shape disables pruning entirely.
        """
        prune_fn = self.source.prune_schema
        if (
            prune_fn is None
            or not self.source.prune_column
            or self.source.prune_column not in self.table.c
            or not self._is_flat_and
            or not self.q.schemata_names
        ):
            return None
        for f in self.q._leaves:
            if isinstance(f, (SchemaLeaf, SchemataLeaf)):
                if f.comparator not in ("eq", "in"):
                    return None
        return {prune_fn(s) for s in self.q.schemata_names}

    @cached_property
    def _clauses(self) -> tuple[list[Any], list[Any]]:
        """The compiled query as `(row_clauses, entity_clauses)`.

        A *row* clause constrains individual statement rows (`dataset = 'x'`);
        an *entity* clause is a `canonical_id` membership or anti-join, which is
        already true for every row of a matching entity. Keeping them apart lets
        the statement / facet selects skip the `canonical_id IN (...)`
        indirection when nothing is row-constrained - it would be a second pass
        over the same rows for the same answer.
        """
        if self._is_flat_and:
            rows, entities = self._flat_clauses()
        else:
            # a boolean tree compiles entirely to entity-level predicates
            rows, entities = [], [self._expr_clause(self.q.q)]
        if self._prune_values:
            rows.append(self.table.c[self.source.prune_column].in_(self._prune_values))
        # the view scope selects *entities* (those with at least one statement
        # in a scoped dataset), matching the in-memory store views: filters and
        # assembly still see the full canonical entity. A row-level `dataset`
        # predicate would instead silently drop the out-of-scope fragments of
        # matching entities.
        if self.scope:
            entities.append(
                self._membership(self.table.c.dataset.in_(sorted(self.scope)))
            )
        return rows, entities

    @cached_property
    def clause(self) -> BooleanClauseList:
        rows, entities = self._clauses
        # `and_(true(), x)` collapses to `x`; an empty conjunction is `true`
        return and_(true(), *self._base_clauses, *rows, *entities)

    @cached_property
    def _all_entities(self) -> Any:
        """A predicate matching every row of the entities this query selects,
        ignoring any slice.

        When nothing is row-constrained the clause already says exactly that,
        so it is used as-is; otherwise the id set has to be materialized first.
        """
        rows, entities = self._clauses
        if rows:
            return self.id_col.in_(self.all_canonical_ids)
        return and_(true(), *entities)

    def _flat_clauses(self) -> tuple[list[Any], list[Any]]:
        """Compile a flat conjunction from the query's leaf collectors into
        `(row, entity)` clauses: one per field, AND-ed together (`_is_flat_and`
        guarantees at most one leaf per field)."""
        rows: list[Any] = []
        entities: list[Any] = []
        by_key: Callable[[Leaf], str] = lambda f: f.key  # noqa: E731
        # the different id fields (`id` / `entity_id` / `canonical_id`) are
        # separate fields and AND together like any other
        for f in sorted(self.q.ids, key=by_key):
            rows.append(self.get_expression(self._id_column(f), f))
        for f in self.q.datasets:  # at most one in a flat tree
            rows.append(self.get_expression(self.table.c.dataset, f))
        # exact-schema and is-a (`schemata`) filters; negations compile as
        # entity-level anti-joins
        schema_leaves = list(self.q.schemata) + [
            s for s in self.q._leaves if isinstance(s, SchemataLeaf)
        ]
        for f in schema_leaves:
            clause = self._schema_clause(f)
            if f.comparator in ("not", "not_in"):
                entities.append(clause)
            else:
                rows.append(clause)
        context_rows, context_entities = self._context_clauses()
        rows.extend(context_rows)
        entities.extend(context_entities)
        # properties and prop-type groups: one entity-level clause per field, so
        # they AND across fields ("has a name AND a german country"). A single
        # row predicate would instead force one statement row to satisfy every
        # field at once, which no row can - a row holds exactly one prop.
        for f in sorted(self.q.properties, key=by_key):
            entities.append(self._family_clause(f, self._prop_selector))
        # the reverse lookup `G(entities=...)` is not special here, it is just
        # the `entity` prop-type group
        for f in sorted(self.q.groups, key=by_key):
            entities.append(self._family_clause(f, self._group_selector))
        return rows, entities

    @property
    def _limit(self) -> int | None:
        # sqlalchemy renders an offset without a limit as `LIMIT -1`, which
        # duckdb rejects - emit an explicit no-op limit instead
        if self.q.limit is None and self.q.offset:
            return 2**63 - 1
        return self.q.limit

    @cached_property
    def canonical_ids(self) -> Select:
        q = select(self.id_col.distinct()).where(self.clause)
        if self.q.sort is None:
            # offset 0 (a start-less slice) is redundant; omit it from the SQL
            q = q.limit(self._limit).offset(self.q.offset or None)
        return q

    @cached_property
    def all_canonical_ids(self) -> Select:
        return self.canonical_ids.limit(None).offset(None)

    @cached_property
    def _unsorted_statements(self) -> Select:
        rows, entities = self._clauses
        # a slice (even offset-only or limit 0) must go through the
        # `canonical_ids` sub-select, where limit/offset are applied. So must
        # any mix of row and entity clauses, or only the row-matching rows of
        # the matching entities come back instead of the whole entity.
        if self.q.slice is not None or (rows and entities):
            where = and_(
                true(), *self._base_clauses, self.id_col.in_(self.canonical_ids)
            )
        else:
            # the clause is either purely entity-level (already true for every
            # row of a matching entity) or a deliberate row filter - either way
            # it needs no second pass
            where = self.clause
        return select(self.table).where(where).order_by(self.id_col)

    @cached_property
    def _sorted_statements(self) -> Select:
        prop = self.q.sort.value
        value = self.table.c.value
        if prop in NUMERIC_PROPS:
            value = numeric_value(self.table.c.value)
        group_func = func.min if self.q.sort.ascending else func.max
        inner = (
            select(
                self.id_col,
                group_func(value).label("sortable_value"),
            )
            .where(
                and_(
                    true(),
                    *self._base_clauses,
                    self.table.c.prop == prop,
                    self.id_col.in_(self.canonical_ids),
                )
            )
            .group_by(self.id_col)
            .limit(self._limit)
            .offset(self.q.offset or None)
        )
        inner_order = (
            "sortable_value" if self.q.sort.ascending else desc("sortable_value")
        )
        # an explicit subquery: reading `.c` off a `Select` builds one
        # implicitly, which sqlalchemy deprecates
        sub = inner.order_by(inner_order, self.id_col).subquery()
        sortable = sub.c["sortable_value"]
        outer = select(
            self.table.join(sub, self.id_col == sub.c[self.source.id_column])
        )
        # the join rows still need the base scope - a matching entity may
        # have out-of-scope statements
        if self._base_clauses:
            outer = outer.where(*self._base_clauses)
        return outer.order_by(
            sortable if self.q.sort.ascending else desc(sortable), self.id_col
        )

    @cached_property
    def statements(self) -> Select:
        if self.q.sort:
            return self._sorted_statements
        return self._unsorted_statements

    @cached_property
    def count(self) -> Select:
        return (
            select(func.count(self.id_col.distinct()))
            .select_from(self.table)
            .where(self.clause)
        )

    @singledispatchmethod
    def lookup(self, ref: Ref) -> Lookup:
        """Where a field reference reads from in this source.

        One registration per ref family, so nothing has to recover a field's
        family from its name. A meta / context ref reads its own column and
        needs no row predicate (every statement of an entity carries it); a
        property or group ref reads the shared `value` column and selects its
        rows via `prop` / `prop_type`.
        """
        raise QueryError(f"Cannot compile field reference: `{ref!r}`")

    @lookup.register
    def _(self, ref: IdRef) -> Lookup:
        # `M("id")` addresses the *entity*: in a statement table that is the
        # resolved id column, not the `value` of a `prop = "id"` row (which
        # holds the unresolved referent id)
        return Lookup(self.id_col)

    @lookup.register
    def _(self, ref: EntityIdRef) -> Lookup:
        return Lookup(self.table.c.entity_id)

    @lookup.register
    def _(self, ref: DatasetRef) -> Lookup:
        return Lookup(self.table.c.dataset)

    @lookup.register
    def _(self, ref: SchemaRef) -> Lookup:
        return Lookup(self.table.c.schema)

    @lookup.register
    def _(self, ref: PropRef) -> Lookup:
        return Lookup(self.table.c.value, self.table.c.prop == ref.key)

    @lookup.register
    def _(self, ref: GroupRef) -> Lookup:
        return Lookup(self.table.c.value, self.table.c.prop_type == str(ref.prop_type))

    @lookup.register
    def _(self, ref: YearRef) -> Lookup:
        # a derived dimension: the year is part of the value expression, so it
        # groups and filters like any other lookup
        return Lookup(
            func.substring(self.table.c.value, 1, 4),
            self.table.c.prop_type == str(ref.prop_type),
        )

    @lookup.register
    def _(self, ref: ContextRef) -> Lookup:
        if ref.key not in self.table.c:
            raise QueryError(f"Unknown context column: `{ref.key}`")
        return Lookup(self.table.c[ref.key])

    def get_group_counts(
        self,
        group: Ref,
        limit: int | None = None,
        extra_where: BooleanClauseList | None = None,
    ) -> Select:
        count = func.count(self.id_col.distinct()).label("count")
        # group over the rows of matching entities (entity-level) so flat and
        # tree queries facet identically
        lookup = self.lookup(group)
        where = and_(true(), *self._base_clauses, *lookup.clauses, self._all_entities)
        if extra_where is not None:
            where = and_(where, extra_where)
        return (
            select(lookup.value, count)
            .where(where)
            .group_by(lookup.value)
            .order_by(desc(count))
            .limit(limit)
        )

    @cached_property
    def date_range(self) -> Select:
        return select(
            func.min(self.table.c.value),
            func.max(self.table.c.value),
        ).where(
            *self._base_clauses,
            self.table.c.prop_type == "date",
            self._all_entities,
        )

    def _aggregator(self, agg: Agg) -> Any:
        """The aggregate expression for one spec, over its ref's value."""
        value = self.lookup(agg.ref).value
        if agg.func == "count":
            # `count` stays over the raw values - it counts distinct readings,
            # which needs no arithmetic
            return func.count(distinct(value))
        if agg.ref.is_numeric:
            # min / max included: a lexicographic min over numbers is wrong,
            # and returning a string for min / max but a number for sum / avg
            # of the same property makes every consumer parse defensively
            value = numeric_value(value)
        return getattr(func, agg.func)(value)

    @cached_property
    def aggregations(self) -> Select:
        qs = []
        for agg in sorted(self.q.aggregations, key=lambda a: (a.func, a.key)):
            qs.append(
                select(
                    text(f"'{agg.key}'"),
                    text(f"'{agg.func}'"),
                    self._aggregator(agg),
                ).where(
                    *self._base_clauses,
                    *self.lookup(agg.ref).clauses,
                    self._all_entities,
                )
            )
        return union_all(*qs)

    def grouped_aggregations(self, grouper: Ref, limit: int | None = None) -> Select:
        """Every aggregation spec grouped by `grouper`, as one select per spec
        unioned to `(field, func, group_value, value)` rows.

        The specs' rows join against the distinct `(entity, group value)` pairs
        of the matching entities - distinct, so a multi-valued group property
        does not multiply the aggregated rows within its buckets. One round
        trip per grouper, instead of one per group value.

        Args:
            grouper: The field reference to group by.
            limit: Only the `limit` most frequent group values (by entity
                count, matching `get_group_counts`).
        """
        g = self.lookup(grouper)
        pairs = (
            select(self.id_col.label("cid"), g.value.label("gval"))
            .where(and_(true(), *self._base_clauses, *g.clauses, self._all_entities))
            .distinct()
        )
        if limit is not None:
            top = self.get_group_counts(grouper, limit=limit).subquery()
            pairs = pairs.where(g.value.in_(select(top.c[0])))
        sub = pairs.subquery()
        qs = []
        for agg in sorted(self.q.aggregations, key=lambda a: (a.func, a.key)):
            if grouper not in agg.groups:
                continue
            lookup = self.lookup(agg.ref)
            qs.append(
                select(
                    text(f"'{agg.key}'"),
                    text(f"'{agg.func}'"),
                    sub.c.gval,
                    self._aggregator(agg),
                )
                .select_from(self.table.join(sub, self.id_col == sub.c.cid))
                .where(and_(true(), *self._base_clauses, *lookup.clauses))
                .group_by(sub.c.gval)
            )
        return union_all(*qs)

    @cached_property
    def group_props(self) -> set[Ref]:
        refs: set[Ref] = set()
        for agg in self.q.aggregations:
            refs.update(agg.groups)
        return refs

grouped_aggregations(grouper, limit=None)

Every aggregation spec grouped by grouper, as one select per spec unioned to (field, func, group_value, value) rows.

The specs' rows join against the distinct (entity, group value) pairs of the matching entities - distinct, so a multi-valued group property does not multiply the aggregated rows within its buckets. One round trip per grouper, instead of one per group value.

Parameters:

Name Type Description Default
grouper Ref

The field reference to group by.

required
limit int | None

Only the limit most frequent group values (by entity count, matching get_group_counts).

None
Source code in ftmq/query/sql.py
def grouped_aggregations(self, grouper: Ref, limit: int | None = None) -> Select:
    """Every aggregation spec grouped by `grouper`, as one select per spec
    unioned to `(field, func, group_value, value)` rows.

    The specs' rows join against the distinct `(entity, group value)` pairs
    of the matching entities - distinct, so a multi-valued group property
    does not multiply the aggregated rows within its buckets. One round
    trip per grouper, instead of one per group value.

    Args:
        grouper: The field reference to group by.
        limit: Only the `limit` most frequent group values (by entity
            count, matching `get_group_counts`).
    """
    g = self.lookup(grouper)
    pairs = (
        select(self.id_col.label("cid"), g.value.label("gval"))
        .where(and_(true(), *self._base_clauses, *g.clauses, self._all_entities))
        .distinct()
    )
    if limit is not None:
        top = self.get_group_counts(grouper, limit=limit).subquery()
        pairs = pairs.where(g.value.in_(select(top.c[0])))
    sub = pairs.subquery()
    qs = []
    for agg in sorted(self.q.aggregations, key=lambda a: (a.func, a.key)):
        if grouper not in agg.groups:
            continue
        lookup = self.lookup(agg.ref)
        qs.append(
            select(
                text(f"'{agg.key}'"),
                text(f"'{agg.func}'"),
                sub.c.gval,
                self._aggregator(agg),
            )
            .select_from(self.table.join(sub, self.id_col == sub.c.cid))
            .where(and_(true(), *self._base_clauses, *lookup.clauses))
            .group_by(sub.c.gval)
        )
    return union_all(*qs)

lookup(ref)

Where a field reference reads from in this source.

One registration per ref family, so nothing has to recover a field's family from its name. A meta / context ref reads its own column and needs no row predicate (every statement of an entity carries it); a property or group ref reads the shared value column and selects its rows via prop / prop_type.

Source code in ftmq/query/sql.py
@singledispatchmethod
def lookup(self, ref: Ref) -> Lookup:
    """Where a field reference reads from in this source.

    One registration per ref family, so nothing has to recover a field's
    family from its name. A meta / context ref reads its own column and
    needs no row predicate (every statement of an entity carries it); a
    property or group ref reads the shared `value` column and selects its
    rows via `prop` / `prop_type`.
    """
    raise QueryError(f"Cannot compile field reference: `{ref!r}`")

Errors

Bases: ValueError

Raised for an invalid query: an unknown field, an invalid comparator, or a query that cannot be projected to the requested serialization.

Subclasses ValueError so existing except ValueError handlers keep working.

Source code in ftmq/query/exceptions.py
1
2
3
4
5
6
7
class QueryError(ValueError):
    """Raised for an invalid query: an unknown field, an invalid comparator,
    or a query that cannot be projected to the requested serialization.

    Subclasses `ValueError` so existing `except ValueError` handlers keep
    working.
    """