Skip to content

ftmq.Query

See the query guide.

A query over FtM entities: a filter tree of M / P / G / C 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(P("name"))[:10]
Source code in ftmq/query/main.py
 99
100
101
102
103
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
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
class Query:
    """A query over FtM entities: a filter tree of `M` / `P` / `G` / `C` 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(P("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,
        sort: Sort | None = None,
        slice: slice | None = None,
        selection: Iterable[Ref] | None = None,
        facet_sort: FacetOrder | None = None,
        facet_sizes: Mapping[Ref, int] | 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 | None = None
        self.sort = sort
        self.slice = slice
        self.selection: tuple[Ref, ...] = tuple(sorted(set(selection or ())))
        if facet_sort is not None and not any(
            facet_sort.orders(agg) and agg.groups for agg in self.aggregations
        ):
            raise QueryError(
                f"Invalid facet sort: `{facet_sort.wire}` - not a grouped "
                "aggregation of the query"
            )
        self.facet_sort = facet_sort
        facets = groupers(self.aggregations)
        for ref, size in (facet_sizes or {}).items():
            if ref not in facets:
                raise QueryError(
                    f"Invalid facet size: `{ref.wire}` - not a facet of the query"
                )
            if not isinstance(size, int) or size < 1:
                raise QueryError(f"Invalid facet size for `{ref.wire}`: `{size}`")
        self.facet_sizes: dict[Ref, int] = dict(sorted((facet_sizes or {}).items()))

    def __getitem__(self, value: Any) -> Self:
        """Slice like a list; no negative values or steps.

        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:
        """Whether anything is set: filter, aggregation, projection, sort or slice.

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

    def __hash__(self) -> int:
        """A within-process cache key; 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,
            sort=self.sort,
            slice=self.slice,
            selection=self.selection,
            facet_sort=self.facet_sort,
            facet_sizes=self.facet_sizes,
        )
        data.update(kwargs)
        return self.__class__(**data)

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

    @property
    def limit(self) -> int | None:
        """The limit, inferred from the 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 offset, inferred from the slice (`0` for `q[:10]`)."""
        if self.slice is None:
            return None
        return int(self.slice.start or 0)

    @property
    def sql(self) -> "Sql":
        """A [`Sql`][ftmq.query.sql.Sql] adapter against the default statement table.

        For another table use [`compile`][ftmq.Query.compile].
        """
        return Sql(self)

    def compile(self, source: "SqlSource | None" = None) -> "Select[Any]":
        """Compile to a SQLAlchemy `Select` of statements.

        Args:
            source: The [`SqlSource`][ftmq.query.sql.SqlSource] to compile against
                (default: the nomenklatura statement table).

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

    @property
    def dataset_names(self) -> set[str]:
        """The dataset names any filter leaf refers to, ignoring polarity."""
        names: set[str] = set()
        for f in self._leaves:
            if isinstance(f, DatasetLeaf):
                names.update(ensure_list(f.value))
        return names

    @property
    def schemata_names(self) -> set[str]:
        """The schema names any filter leaf refers to, ignoring polarity.

        A `schemata` (is-a) leaf expands to its non-abstract descendants.
        """
        names: set[str] = set()
        for f in self._leaves:
            if isinstance(f, SchemataLeaf):
                names.update(f.names)
            elif isinstance(f, SchemaLeaf):
                names.update(ensure_list(f.value))
        return names

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

        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)
        if self.facet_sort:
            data["facet_sort"] = self.facet_sort.wire
        if self.facet_sizes:
            data["facet_size"] = {r.wire: n for r, n in self.facet_sizes.items()}
        if self.selection:
            data["select"] = [ref.wire for ref in self.selection]
        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"])
        selection = [ref_from_wire(f) for f in data.get("select") or []]
        facet_sort = None
        if data.get("facet_sort"):
            facet_sort = FacetOrder.from_wire(str(data["facet_sort"]))
        facet_sizes = {
            ref_from_wire(k): v for k, v in (data.get("facet_size") or {}).items()
        }
        return cls(
            q=q,
            sort=sort,
            slice=slice_,
            aggregations=aggregations,
            selection=selection,
            facet_sort=facet_sort,
            facet_sizes=facet_sizes,
        )

    def to_params(self) -> dict[str, list[str]]:
        """Serialize to an Aleph-style param dict.

        Keys: `filter:` / `exclude:` / `empty:`, the aggregation keys (`metric:`,
        `facet`, `facet_sort`, `facet_size:`), `select`, `sort`, `limit`, `offset`.

        Raises:
            QueryError: For a query outside the flat Aleph subset (cross-field
                OR, negated groups).
        """
        params = expr_to_params(self.q)
        if self.aggregations:
            params.update(aggregations_to_params(self.aggregations))
        if self.facet_sort:
            params["facet_sort"] = [self.facet_sort.wire]
        for ref, size in self.facet_sizes.items():
            params[f"facet_size:{ref.wire}"] = [str(size)]
        if self.selection:
            params["select"] = [ref.wire for ref in self.selection]
        if self.sort:
            direction = "asc" if self.sort.ascending else "desc"
            params["sort"] = [f"{self.sort.ref.wire}:{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 value := _single(items, "sort"):
            field, _, direction = value.partition(":")
            sort = Sort(ref_from_wire(field), ascending=direction != "desc")
        facet_sort = None
        if value := _single(items, "facet_sort"):
            facet_sort = FacetOrder.from_wire(value)
        facet_sizes: dict[Ref, int] = {}
        for key in items:
            if key.startswith("facet_size:"):
                size = _single(items, key) or ""
                if not size.isdigit():
                    raise QueryError(f"Invalid facet size for `{key}`: `{size}`")
                facet_sizes[ref_from_wire(key[len("facet_size:") :])] = int(size)
        offset = int(_single(items, "offset") or 0)
        limit = _single(items, "limit")
        slice_ = _make_slice(int(limit) if limit else None, offset)
        return cls(
            q=q,
            sort=sort,
            slice=slice_,
            aggregations=aggregations,
            selection=[ref_from_wire(f) for f in items.get("select", [])],
            facet_sort=facet_sort,
            facet_sizes=facet_sizes,
        )

    def to_string(self) -> str:
        """Serialize to an Aleph URL query string: `filter:properties.name=Jane&...`."""
        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.

        Carries arbitrary `& | ~` nesting, aggregations and the projection.
        """
        if not value:
            return cls()
        expr, aggregations, selection = parse_rql(value)
        return cls(q=expr, aggregations=aggregations, selection=selection)

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

        The only string surface preserving arbitrary `& | ~` nesting; carries
        aggregations and the projection, but no sort or slice.

        Raises:
            QueryError: For a comparator with no RQL equivalent (`null`,
                `startswith`, `endswith`, ...).
        """
        return serialize_rql(self.q, self.aggregations, self.selection)

    def where(self, *nodes: Expr) -> Self:
        """AND nodes into the filter tree.

        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` / `C` 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, ref: Ref, *, ascending: bool = True) -> Self:
        """Sort by a single property: `order_by(P("date"), ascending=False)`.

        Args:
            ref: The property reference; only properties are sortable.
            ascending: Ascending or descending.

        Returns:
            The updated `Query` instance.
        """
        return self._chain(sort=Sort(ref, ascending=ascending))

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

        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`][ftmq.A] nodes, e.g.
                `A(sum=P("amountEur"), by=P("beneficiary"))`.

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

    def order_facets(self, *, ascending: bool = False, **func: Ref) -> Self:
        """Rank facet buckets by a grouped metric: `order_facets(sum=P("amountEur"))`.

        Descending by default; also decides which buckets the facet size keeps.

        Args:
            ascending: Rank the smallest values first.
            **func: Exactly one `func=<ref>` pair of a grouped aggregation.

        Returns:
            The updated `Query` instance.
        """
        if len(func) != 1:
            raise QueryError("Facet sort takes exactly one `func=<ref>` pair")
        [(name, ref)] = func.items()
        return self._chain(facet_sort=make_facet_order(name, ref, ascending))

    def facet_size(self, ref: Ref, size: int) -> Self:
        """Set how many (top) buckets a facet returns.

        Args:
            ref: A facet of the query, e.g. `P("beneficiary")`.
            size: The number of buckets (default 20).

        Returns:
            The updated `Query` instance.
        """
        return self._chain(facet_sizes={**self.facet_sizes, ref: size})

    def get_facet_size(self, ref: Ref) -> int:
        """The number of buckets the facet `ref` returns."""
        return self.facet_sizes.get(ref, DEFAULT_FACET_SIZE)

    def select(self, *refs: Ref) -> Self:
        """Restrict which properties the matching entities are read with.

        A projection, not a filter: it never changes which entities match. An
        entity holding none of the selected properties still comes back; caption
        and edges of a projected entity may be incomplete.

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

            q = Query().where(M(schemata="Document")).select(P("title"), P("fileName"))
            ```

        Args:
            *refs: The `P` / `G` refs to keep, e.g. `P("title")`,
                `G("countries")`.

        Returns:
            The updated `Query` instance.

        Raises:
            QueryError: For a ref of any other family.
        """
        for ref in refs:
            if not isinstance(ref, (PropRef, GroupRef)):
                raise QueryError(
                    f"Cannot select `{ref.wire}`: only a property "
                    "(`P`) or property-type group (`G`) can be projected"
                )
        return self._chain(selection=set(self.selection) | set(refs))

    def _project(self, entity: EntityProxy) -> EntityProxy:
        """Prune an entity to the selected properties, on a clone if anything drops."""
        drop = [
            prop
            for prop in entity.iterprops()
            if not any(ref.selects(prop) for ref in self.selection)
        ]
        if not drop:
            return entity
        clone = entity.clone()
        for prop in drop:
            clone.pop(prop)
        return clone

    def get_aggregator(self) -> Aggregator:
        """Build a fresh in-memory aggregator for the query's aggregations.

        Returns:
            The [`Aggregator`][ftmq.query.aggregations.Aggregator].
        """
        return Aggregator(self.aggregations, self.facet_sizes, self.facet_sort)

    def apply(self, entity: EntityProxy) -> bool:
        """Whether an entity matches the filter tree."""
        if self.q is None:
            return True
        return self.q.apply(entity)

    def apply_iter(self, entities: EntityProxies) -> EntityProxies:
        """Filter, sort, slice, aggregate and project a stream of entities.

        Aggregation results are collected into `self.aggregator`.

        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:
            The matching entities.
        """
        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))
        if self.selection:
            # last, so filters, sort and aggregations read the full entity
            entities = (self._project(e) for e in entities)
        yield from entities

dataset_names property

The dataset names any filter leaf refers to, ignoring polarity.

limit property

The limit, inferred from the slice.

offset property

The offset, inferred from the slice (0 for q[:10]).

schemata_names property

The schema names any filter leaf refers to, ignoring polarity.

A schemata (is-a) leaf expands to its non-abstract descendants.

sql property

A Sql adapter against the default statement table.

For another table use compile.

__bool__()

Whether anything is set: filter, aggregation, projection, sort or slice.

Examples:

>>> bool(Query())
False
>>> bool(Query().where(M(dataset="my_dataset")))
True
Source code in ftmq/query/main.py
def __bool__(self) -> bool:
    """Whether anything is set: filter, aggregation, projection, sort or slice.

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

__getitem__(value)

Slice like a list; no negative values or steps.

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:
    """Slice like a list; no negative values or steps.

    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__()

A within-process cache key; equal queries hash equal.

Source code in ftmq/query/main.py
def __hash__(self) -> int:
    """A within-process cache key; equal queries hash equal."""
    return hash(hash_data(self.to_dict()))

aggregate(*nodes)

Add aggregation projections.

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=P("amountEur"), by=P("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.

    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`][ftmq.A] nodes, e.g.
            `A(sum=P("amountEur"), by=P("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)

Whether an entity matches the filter tree.

Source code in ftmq/query/main.py
def apply(self, entity: EntityProxy) -> bool:
    """Whether an entity matches the filter tree."""
    if self.q is None:
        return True
    return self.q.apply(entity)

apply_iter(entities)

Filter, sort, slice, aggregate and project a stream of entities.

Aggregation results are collected into self.aggregator.

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

The matching entities.

Source code in ftmq/query/main.py
def apply_iter(self, entities: EntityProxies) -> EntityProxies:
    """Filter, sort, slice, aggregate and project a stream of entities.

    Aggregation results are collected into `self.aggregator`.

    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:
        The matching entities.
    """
    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))
    if self.selection:
        # last, so filters, sort and aggregations read the full entity
        entities = (self._project(e) for e in entities)
    yield from entities

compile(source=None)

Compile to a SQLAlchemy Select of statements.

Parameters:

Name Type Description Default
source 'SqlSource | None'

The SqlSource to compile against (default: the 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 to a SQLAlchemy `Select` of statements.

    Args:
        source: The [`SqlSource`][ftmq.query.sql.SqlSource] to compile against
            (default: the nomenklatura statement table).

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

facet_size(ref, size)

Set how many (top) buckets a facet returns.

Parameters:

Name Type Description Default
ref Ref

A facet of the query, e.g. P("beneficiary").

required
size int

The number of buckets (default 20).

required

Returns:

Type Description
Self

The updated Query instance.

Source code in ftmq/query/main.py
def facet_size(self, ref: Ref, size: int) -> Self:
    """Set how many (top) buckets a facet returns.

    Args:
        ref: A facet of the query, e.g. `P("beneficiary")`.
        size: The number of buckets (default 20).

    Returns:
        The updated `Query` instance.
    """
    return self._chain(facet_sizes={**self.facet_sizes, ref: size})

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"])
    selection = [ref_from_wire(f) for f in data.get("select") or []]
    facet_sort = None
    if data.get("facet_sort"):
        facet_sort = FacetOrder.from_wire(str(data["facet_sort"]))
    facet_sizes = {
        ref_from_wire(k): v for k, v in (data.get("facet_size") or {}).items()
    }
    return cls(
        q=q,
        sort=sort,
        slice=slice_,
        aggregations=aggregations,
        selection=selection,
        facet_sort=facet_sort,
        facet_sizes=facet_sizes,
    )

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 value := _single(items, "sort"):
        field, _, direction = value.partition(":")
        sort = Sort(ref_from_wire(field), ascending=direction != "desc")
    facet_sort = None
    if value := _single(items, "facet_sort"):
        facet_sort = FacetOrder.from_wire(value)
    facet_sizes: dict[Ref, int] = {}
    for key in items:
        if key.startswith("facet_size:"):
            size = _single(items, key) or ""
            if not size.isdigit():
                raise QueryError(f"Invalid facet size for `{key}`: `{size}`")
            facet_sizes[ref_from_wire(key[len("facet_size:") :])] = int(size)
    offset = int(_single(items, "offset") or 0)
    limit = _single(items, "limit")
    slice_ = _make_slice(int(limit) if limit else None, offset)
    return cls(
        q=q,
        sort=sort,
        slice=slice_,
        aggregations=aggregations,
        selection=[ref_from_wire(f) for f in items.get("select", [])],
        facet_sort=facet_sort,
        facet_sizes=facet_sizes,
    )

from_rql(value) classmethod

Build a Query from an RQL string.

Carries arbitrary & | ~ nesting, aggregations and the projection.

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.

    Carries arbitrary `& | ~` nesting, aggregations and the projection.
    """
    if not value:
        return cls()
    expr, aggregations, selection = parse_rql(value)
    return cls(q=expr, aggregations=aggregations, selection=selection)

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 a fresh in-memory aggregator for the query's aggregations.

Returns:

Type Description
Aggregator

The Aggregator.

Source code in ftmq/query/main.py
def get_aggregator(self) -> Aggregator:
    """Build a fresh in-memory aggregator for the query's aggregations.

    Returns:
        The [`Aggregator`][ftmq.query.aggregations.Aggregator].
    """
    return Aggregator(self.aggregations, self.facet_sizes, self.facet_sort)

get_facet_size(ref)

The number of buckets the facet ref returns.

Source code in ftmq/query/main.py
def get_facet_size(self, ref: Ref) -> int:
    """The number of buckets the facet `ref` returns."""
    return self.facet_sizes.get(ref, DEFAULT_FACET_SIZE)

order_by(ref, *, ascending=True)

Sort by a single property: order_by(P("date"), ascending=False).

Parameters:

Name Type Description Default
ref Ref

The property reference; only properties are sortable.

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, ref: Ref, *, ascending: bool = True) -> Self:
    """Sort by a single property: `order_by(P("date"), ascending=False)`.

    Args:
        ref: The property reference; only properties are sortable.
        ascending: Ascending or descending.

    Returns:
        The updated `Query` instance.
    """
    return self._chain(sort=Sort(ref, ascending=ascending))

order_facets(*, ascending=False, **func)

Rank facet buckets by a grouped metric: order_facets(sum=P("amountEur")).

Descending by default; also decides which buckets the facet size keeps.

Parameters:

Name Type Description Default
ascending bool

Rank the smallest values first.

False
**func Ref

Exactly one func=<ref> pair of a grouped aggregation.

{}

Returns:

Type Description
Self

The updated Query instance.

Source code in ftmq/query/main.py
def order_facets(self, *, ascending: bool = False, **func: Ref) -> Self:
    """Rank facet buckets by a grouped metric: `order_facets(sum=P("amountEur"))`.

    Descending by default; also decides which buckets the facet size keeps.

    Args:
        ascending: Rank the smallest values first.
        **func: Exactly one `func=<ref>` pair of a grouped aggregation.

    Returns:
        The updated `Query` instance.
    """
    if len(func) != 1:
        raise QueryError("Facet sort takes exactly one `func=<ref>` pair")
    [(name, ref)] = func.items()
    return self._chain(facet_sort=make_facet_order(name, ref, ascending))

select(*refs)

Restrict which properties the matching entities are read with.

A projection, not a filter: it never changes which entities match. An entity holding none of the selected properties still comes back; caption and edges of a projected entity may be incomplete.

Example
from ftmq import Query, M, P

q = Query().where(M(schemata="Document")).select(P("title"), P("fileName"))

Parameters:

Name Type Description Default
*refs Ref

The P / G refs to keep, e.g. P("title"), G("countries").

()

Returns:

Type Description
Self

The updated Query instance.

Raises:

Type Description
QueryError

For a ref of any other family.

Source code in ftmq/query/main.py
def select(self, *refs: Ref) -> Self:
    """Restrict which properties the matching entities are read with.

    A projection, not a filter: it never changes which entities match. An
    entity holding none of the selected properties still comes back; caption
    and edges of a projected entity may be incomplete.

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

        q = Query().where(M(schemata="Document")).select(P("title"), P("fileName"))
        ```

    Args:
        *refs: The `P` / `G` refs to keep, e.g. `P("title")`,
            `G("countries")`.

    Returns:
        The updated `Query` instance.

    Raises:
        QueryError: For a ref of any other family.
    """
    for ref in refs:
        if not isinstance(ref, (PropRef, GroupRef)):
            raise QueryError(
                f"Cannot select `{ref.wire}`: only a property "
                "(`P`) or property-type group (`G`) can be projected"
            )
    return self._chain(selection=set(self.selection) | set(refs))

to_dict()

Serialize to a lossless nested dict.

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]:
    """Serialize to a lossless nested dict.

    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)
    if self.facet_sort:
        data["facet_sort"] = self.facet_sort.wire
    if self.facet_sizes:
        data["facet_size"] = {r.wire: n for r, n in self.facet_sizes.items()}
    if self.selection:
        data["select"] = [ref.wire for ref in self.selection]
    return data

to_params()

Serialize to an Aleph-style param dict.

Keys: filter: / exclude: / empty:, the aggregation keys (metric:, facet, facet_sort, facet_size:), select, sort, limit, offset.

Raises:

Type Description
QueryError

For a query outside the flat Aleph subset (cross-field OR, negated groups).

Source code in ftmq/query/main.py
def to_params(self) -> dict[str, list[str]]:
    """Serialize to an Aleph-style param dict.

    Keys: `filter:` / `exclude:` / `empty:`, the aggregation keys (`metric:`,
    `facet`, `facet_sort`, `facet_size:`), `select`, `sort`, `limit`, `offset`.

    Raises:
        QueryError: For a query outside the flat Aleph subset (cross-field
            OR, negated groups).
    """
    params = expr_to_params(self.q)
    if self.aggregations:
        params.update(aggregations_to_params(self.aggregations))
    if self.facet_sort:
        params["facet_sort"] = [self.facet_sort.wire]
    for ref, size in self.facet_sizes.items():
        params[f"facet_size:{ref.wire}"] = [str(size)]
    if self.selection:
        params["select"] = [ref.wire for ref in self.selection]
    if self.sort:
        direction = "asc" if self.sort.ascending else "desc"
        params["sort"] = [f"{self.sort.ref.wire}:{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 to an RQL string.

The only string surface preserving arbitrary & | ~ nesting; carries aggregations and the projection, but no sort or slice.

Raises:

Type Description
QueryError

For a comparator with no RQL equivalent (null, startswith, endswith, ...).

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

    The only string surface preserving arbitrary `& | ~` nesting; carries
    aggregations and the projection, but no sort or slice.

    Raises:
        QueryError: For a comparator with no RQL equivalent (`null`,
            `startswith`, `endswith`, ...).
    """
    return serialize_rql(self.q, self.aggregations, self.selection)

to_string()

Serialize to an Aleph URL query string: filter:properties.name=Jane&....

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

where(*nodes)

AND nodes into the filter tree.

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 / C 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 nodes into the filter tree.

    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` / `C` 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

Filter nodes M (meta), P (property), G (property-type group) and C (context), plus the aggregation node A.

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."""

    family = "M"

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."""

    family = "P"

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."""

    family = "G"

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."""

    family = "C"

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

Each keyword is a function (min, max, sum, avg, count) over one or more refs; by= groups by one or more refs. 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 a function (`min`, `max`, `sum`, `avg`, `count`) over one or
    more refs; `by=` groups by one or more refs. 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: an AND / OR connector, an optional negation and children.

Children are Expr nodes or Leaf conditions, canonicalized on construction: non-negated sub-groups of the same connector are spliced in, duplicates dropped.

Source code in ftmq/query/nodes.py
class Expr:
    """A boolean node: an `AND` / `OR` connector, an optional negation and children.

    Children are `Expr` nodes or [`Leaf`][ftmq.query.leaves.Leaf] conditions,
    canonicalized on construction: non-negated sub-groups of the same connector
    are spliced in, duplicates dropped.
    """

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

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

    def _combine(self, other: "Expr", connector: str) -> "Expr":
        # nodes are immutable, so they can be shared
        if not self:
            return other
        if not other:
            return self
        return Expr(self, other, 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":
        return Expr(*self.children, connector=self.connector, negated=not self.negated)

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

        Args:
            entity: The entity to test.

        Returns:
            Whether the entity matches.
        """
        if not self.children:
            result = True
        elif self.connector == OR:
            result = any(c.apply(entity) for c in self.children)
        else:
            result = self._apply_and(entity)
        return (not result) if self.negated else result

    def _apply_and(self, entity: EntityProxy) -> bool:
        """Evaluate a conjunction; co-referring leaves must hold of one value or row."""
        exprs: list[Expr] = []
        leaves: list[Leaf] = []
        for child in self.children:
            (exprs if isinstance(child, Expr) else leaves).append(child)  # type: ignore[arg-type]
        if not all(c.apply(entity) for c in exprs):
            return False
        groups = group_conjunction(leaves)
        matched, joined = self._apply_row_scope(entity, groups)
        if not matched:
            return False
        for group in groups:
            if id(group) in joined:
                continue
            if len(group) == 1:
                if not group[0].apply(entity):
                    return False
            # bounds on one field: all of them must hold of the same value
            elif not any(
                all(leaf.match(value) for leaf in group)
                for value in group[0].values(entity)
            ):
                return False
        return True

    @staticmethod
    def _apply_row_scope(
        entity: EntityProxy, groups: list[list[Leaf]]
    ) -> tuple[bool, set[int]]:
        """Match row-scoped groups against one statement: `(matched, settled ids)`."""
        row_groups = row_scoped_groups(groups)
        statements = getattr(entity, "statements", None)
        # nothing to correlate (or no rows): the caller tests each group alone
        if len(row_groups) < 2 or statements is None:
            return True, set()
        row_leaves = [leaf for group in row_groups for leaf in group]
        matched = any(
            all(leaf.match_row(statement) for leaf in row_leaves)
            for statement in statements
        )
        return matched, {id(group) for group in row_groups}

    def iter_leaves(self, cls: type | None = None) -> Iterator[Leaf]:
        """Yield the tree's leaf conditions, depth-first.

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

        Yields:
            Each matching leaf.
        """
        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 dict, children in canonical order.

        Structurally equal trees serialize, and hash, identically.

        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):
                children.append(child.to_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:
        # over the canonical serialization; within-process only
        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 tree against an entity.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to test.

required

Returns:

Type Description
bool

Whether the entity matches.

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

    Args:
        entity: The entity to test.

    Returns:
        Whether the entity matches.
    """
    if not self.children:
        result = True
    elif self.connector == OR:
        result = any(c.apply(entity) for c in self.children)
    else:
        result = self._apply_and(entity)
    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)

Yield the tree's leaf conditions, depth-first.

Parameters:

Name Type Description Default
cls type | None

Optionally restrict to leaves of this class.

None

Yields:

Type Description
Leaf

Each matching leaf.

Source code in ftmq/query/nodes.py
def iter_leaves(self, cls: type | None = None) -> Iterator[Leaf]:
    """Yield the tree's leaf conditions, depth-first.

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

    Yields:
        Each matching leaf.
    """
    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 dict, children in canonical order.

Structurally equal trees serialize, and hash, identically.

Returns:

Type Description
dict[str, Any]

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

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

    Structurally equal trees serialize, and hash, identically.

    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):
            children.append(child.to_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 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 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, as used by aggregations, sorting and select: a family constructor called with a bare field name (P("amountEur")).

Field references: a leaf without a value.

A Ref names where to read (a property, a property-type group, a meta or context column), reads its values off an entity and has one wire spelling (Ref.wire / ref_from_wire):

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

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]:
        # a statement entity never populates `context`: read each row instead
        statements = getattr(entity, "statements", None)
        if statements is not None:
            seen: set[str] = set()
            for statement in statements:
                value = self.row_value(statement)
                if value is not None and value not in seen:
                    seen.add(value)
                    yield value
            return
        context: dict[str, Any] = getattr(entity, "context", None) or {}
        values = context.get(self.key)
        if values is None:
            # provenance fields (`first_seen`, ...) live on attributes, not context
            attribute = getattr(entity, self.key, None)
            if not callable(attribute):
                values = attribute
        for value in ensure_list(values):
            yield str(value)

    def row_value(self, statement: Any) -> str | None:
        value = getattr(statement, self.key, None)
        return None if value is None else str(value)

    @property
    def wire(self) -> str:
        # open-ended keys always carry the prefix
        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", [])

    def row_value(self, statement: Any) -> str | None:
        value = getattr(statement, "dataset", None)
        return None if value is None else str(value)

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)

    def selects(self, prop: Property) -> bool:
        return bool(prop.type == self.prop_type)

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

IdRef

Bases: MetaRef

The entity id, not the referent ids in the value of a prop = "id" row.

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

    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)

    def selects(self, prop: Property) -> bool:
        return prop.name == self.key

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

    @property
    def wire(self) -> str:
        # prefixed: `topics` is both a property and a group
        return f"{PROPERTIES_PREFIX}{self.key}"

Ref

A reference to one field of one family.

Built via the M / P / G / C constructors called with a field name, or Year(); subclasses set family / key and implement values().

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

    Built via the `M` / `P` / `G` / `C` constructors called with a field name, or
    `Year()`; subclasses set `family` / `key` and implement `values()`.
    """

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

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

    def row_value(self, statement: Any) -> str | None:
        """This field's value on one statement row, `None` unless row-scoped."""
        return None

    def selects(self, prop: Property) -> bool:
        """Whether a [`select`][ftmq.Query.select] on this ref keeps the property."""
        return False

    @property
    def is_numeric(self) -> bool:
        """Whether the values are read as numbers, not strings."""
        return False

    @property
    def wire(self) -> str:
        """The spelling on every string surface (params, rql, dict keys, CLI)."""
        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 read as numbers, not strings.

wire property

The spelling on every string surface (params, rql, dict keys, CLI).

row_value(statement)

This field's value on one statement row, None unless row-scoped.

Source code in ftmq/query/refs.py
def row_value(self, statement: Any) -> str | None:
    """This field's value on one statement row, `None` unless row-scoped."""
    return None

selects(prop)

Whether a select on this ref keeps the property.

Source code in ftmq/query/refs.py
def selects(self, prop: Property) -> bool:
    """Whether a [`select`][ftmq.Query.select] on this ref keeps the property."""
    return False

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: derived from the dates group.

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

    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 into a ref, for every string surface.

properties.<name>, group.<name> and context.<name> name their family; a meta field (id, entity_id, canonical_id, dataset, schema) and year are bare.

Parameters:

Name Type Description Default
value str

E.g. properties.amountEur, group.countries, id or year.

required

Returns:

Type Description
Ref

The resolved ref.

Raises:

Type Description
QueryError

If the spelling matches no field.

Source code in ftmq/query/refs.py
def ref_from_wire(value: str) -> Ref:
    """Resolve a wire spelling into a ref, for every string surface.

    `properties.<name>`, `group.<name>` and `context.<name>` name their family; a
    meta field (`id`, `entity_id`, `canonical_id`, `dataset`, `schema`) and `year`
    are bare.

    Args:
        value: E.g. `properties.amountEur`, `group.countries`, `id` or `year`.

    Returns:
        The resolved ref.

    Raises:
        QueryError: If the spelling matches no field.
    """
    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, one family per statement-table column they target:

  • M (meta): dataset, schema (exact), schemata (is-a), id / entity_id / canonical_id.
  • P: a followthemoney property (the prop column).
  • G: a property-type group (the prop_type column, keyed by registry.groups).
  • C: a context / storage column such as origin, fragment or first_seen.

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 / storage column (origin, fragment, first_seen, ...).

In memory it reads the column off the entity's statements, else entity.context; in SQL the same-named statement-table column. An entity without the key does not match.

Source code in ftmq/query/leaves.py
class ContextLeaf(RefLeaf):
    """A context / storage column (`origin`, `fragment`, `first_seen`, ...).

    In memory it reads the column off the entity's statements, else
    `entity.context`; in SQL the same-named statement-table column. An entity
    without the key 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.

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

    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 / key and implement values() or override apply(). The comparator is not validated here but by parse_lookup.

Source code in ftmq/query/leaves.py
class Leaf:
    """A single condition: a comparator plus a cast value.

    Subclasses set `family` / `key` and implement `values()` or override `apply()`.
    The comparator is not validated here but by
    [`parse_lookup`][ftmq.query.leaves.parse_lookup].
    """

    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:
        # the family is part of the identity: `topics` is a property and a group
        return hash(hash_data(self.field_dict()))

    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.

        Args:
            entity: The entity to read values from.

        Yields:
            The 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)."""
        matcher = MATCHERS.get(self.comparator)
        if matcher is None:
            raise QueryError(f"Comparator not implemented: `{self.comparator}`")
        return bool(matcher(value, self.value))

    def match_row(self, statement: Any) -> bool:
        """Test against one statement row; only a row-scoped leaf ever matches."""
        return False

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

        Args:
            entity: The entity to test.

        Returns:
            Whether any entity value satisfies the comparator (for `null`: the
                presence / absence check).
        """
        if self.comparator == "null":
            present = any(True for _ in self.values(entity))
            # `self.value` is a bool here, cast 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:
        """The field's spelling on a string surface, shared with aggregations."""
        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].
        """
        value = self.value
        if isinstance(value, (set, frozenset)):
            value = sorted(value)
        return LeafDict(t=self.family, f=self.key, op=self.comparator, v=value)

wire property

The field's spelling on a string surface, shared with aggregations.

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

Whether any entity value satisfies the comparator (for null: 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:
        Whether any entity value satisfies the comparator (for `null`: the
            presence / absence check).
    """
    if self.comparator == "null":
        present = any(True for _ in self.values(entity))
        # `self.value` is a bool here, cast 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.

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].
    """
    value = self.value
    if isinstance(value, (set, frozenset)):
        value = sorted(value)
    return LeafDict(t=self.family, f=self.key, op=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)."""
    matcher = MATCHERS.get(self.comparator)
    if matcher is None:
        raise QueryError(f"Comparator not implemented: `{self.comparator}`")
    return bool(matcher(value, self.value))

match_row(statement)

Test against one statement row; only a row-scoped leaf ever matches.

Source code in ftmq/query/leaves.py
def match_row(self, statement: Any) -> bool:
    """Test against one statement row; only a row-scoped leaf ever matches."""
    return False

values(entity)

Yield the entity values this leaf tests.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to read values from.

required

Yields:

Type Description
str

The 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.

    Args:
        entity: The entity to read values from.

    Yields:
        The 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" | "P" | "G" | "C"
    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 reading its field through a Ref.

The ref validates the field name and reads the values; the leaf adds the comparator.

Source code in ftmq/query/leaves.py
class RefLeaf(Leaf):
    """A leaf reading its field through a [`Ref`][ftmq.query.refs.Ref].

    The ref validates the field name and reads the values; the leaf adds the
    comparator.
    """

    ref: Ref

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

    def match_row(self, statement: Any) -> bool:
        value = self.ref.row_value(statement)
        if value is None:
            # a `null=False` leaf asks for the column to be set, which it isn't
            return False
        return True if self.comparator == "null" else self.match(value)

    @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)
        # a `startswith` / `ilike` value is not expected to be a full schema name
        if 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: model[X] in entity.schema.schemata.

Source code in ftmq/query/leaves.py
class SchemataLeaf(Leaf):
    """Is-a match: `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 self.comparator not in ("eq", "in", "not", "not_in"):
            raise QueryError(f"Invalid comparator for `schemata`: `{self.comparator}`")

    @property
    def names(self) -> set[str]:
        """The concrete schemata matched, including non-abstract descendants."""
        names: set[str] = set()
        for schema in self.schemata:
            names.add(schema.name)
            names.update(d.name for d in schema.descendants if not d.abstract)
        return names

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

names property

The concrete schemata matched, including non-abstract descendants.

group_conjunction(leaves)

Group the leaves of one AND node into the sets that co-refer.

Conditions that could hold of one statement row must hold of the same row. A field's leaves co-refer when they are all bounds (gt / gte / lt / lte), so P(date__gte=a) & P(date__lt=b) is one date inside the window. Any other repeated field keeps a per-leaf reading: M(dataset="d1") & M(dataset="d2") is "in both datasets". Used by both the SQL and the in-memory evaluator.

Parameters:

Name Type Description Default
leaves 'Iterable[Leaf]'

The leaf children of one AND node.

required

Returns:

Type Description
'list[list[Leaf]]'

One group per co-referring set, in input order: a multi-leaf group holds bounds on one field, every other leaf is its own group.

Source code in ftmq/query/leaves.py
def group_conjunction(leaves: "Iterable[Leaf]") -> "list[list[Leaf]]":
    """Group the leaves of one AND node into the sets that co-refer.

    Conditions that could hold of one statement row must hold of the same row. A
    field's leaves co-refer when they are all bounds (`gt` / `gte` / `lt` / `lte`),
    so `P(date__gte=a) & P(date__lt=b)` is one date inside the window. Any other
    repeated field keeps a per-leaf reading: `M(dataset="d1") & M(dataset="d2")`
    is "in both datasets". Used by both the SQL and the in-memory evaluator.

    Args:
        leaves: The leaf children of one AND node.

    Returns:
        One group per co-referring set, in input order: a multi-leaf group holds
            bounds on one field, every other leaf is its own group.
    """
    leaves = list(leaves)
    by_field: dict[tuple[str, str], list[Leaf]] = defaultdict(list)
    for leaf in leaves:
        by_field[(leaf.family, leaf.key)].append(leaf)
    groups: list[list[Leaf]] = []
    emitted: set[tuple[str, str]] = set()
    for leaf in leaves:
        field = (leaf.family, leaf.key)
        group = by_field[field]
        if len(group) == 1:
            groups.append(group)
        elif all(f.comparator in ORDERED_COMPARATORS for f in group):
            if field not in emitted:
                emitted.add(field)
                groups.append(group)
        else:
            groups.append([leaf])
    return groups

is_row_scoped(leaf)

Whether a leaf tests a column describing one statement row.

True for the C columns and dataset, so AND-ed conditions on distinct ones co-refer. Entity-wide fields (schema, schemata, the ids) and absence tests (__null=True) are not row-scoped.

Source code in ftmq/query/leaves.py
def is_row_scoped(leaf: "Leaf") -> bool:
    """Whether a leaf tests a column describing one statement row.

    True for the `C` columns and `dataset`, so AND-ed conditions on distinct ones
    co-refer. Entity-wide fields (`schema`, `schemata`, the ids) and absence tests
    (`__null=True`) are not row-scoped.
    """
    if leaf.comparator == "null" and leaf.value:
        return False
    return isinstance(leaf, (ContextLeaf, DatasetLeaf))

leaf_from_dict(data)

Rebuild a leaf from its 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:
    """Rebuild a leaf from its [`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 make_leaf(data["t"], key, value)

make_leaf(family, key, value)

Build a leaf of a field family from a field__comparator lookup.

Parameters:

Name Type Description Default
family str

M (meta), P (property), G (property-type group) or C (context column; any identifier, checked at SQL compile time).

required
key str

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

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The leaf.

Raises:

Type Description
QueryError

For an unknown family, meta field, property or group.

Source code in ftmq/query/leaves.py
def make_leaf(family: str, key: str, value: Any) -> Leaf:
    """Build a leaf of a field family from a `field__comparator` lookup.

    Args:
        family: `M` (meta), `P` (property), `G` (property-type group) or `C`
            (context column; any identifier, checked at SQL compile time).
        key: A lookup key, e.g. `name`, `amountEur__gte` or `schema__in`.
        value: The lookup value.

    Returns:
        The leaf.

    Raises:
        QueryError: For an unknown family, meta field, property or group.
    """
    field, comparator = parse_lookup(key)
    if family == "M":
        cls = _META_LEAVES.get(field)
        if cls is None:
            raise QueryError(f"Unknown meta field: `{field}`")
        return cls(value, comparator)
    if family == "P":
        return PropertyLeaf(field, value, comparator)
    if family == "G":
        return GroupLeaf(field, value, comparator)
    if family == "C":
        return ContextLeaf(field, value, comparator)
    raise QueryError(f"Unknown field family: `{family}`")

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

row_scoped_groups(groups)

Select the groups that co-refer across fields (columns of one statement row).

A field group_conjunction split (repeated equality) is excluded, so M(dataset="d1") & M(dataset="d2") stays two conditions next to a C(origin=...).

Parameters:

Name Type Description Default
groups 'list[list[Leaf]]'

The output of group_conjunction for one AND node.

required

Returns:

Type Description
'list[list[Leaf]]'

The subset of those groups, as the same list objects.

Source code in ftmq/query/leaves.py
def row_scoped_groups(groups: "list[list[Leaf]]") -> "list[list[Leaf]]":
    """Select the groups that co-refer across fields (columns of one statement row).

    A field [`group_conjunction`][ftmq.query.leaves.group_conjunction] split
    (repeated equality) is excluded, so `M(dataset="d1") & M(dataset="d2")` stays
    two conditions next to a `C(origin=...)`.

    Args:
        groups: The output of `group_conjunction` for one AND node.

    Returns:
        The subset of those groups, as the same list objects.
    """
    split = Counter((g[0].family, g[0].key) for g in groups)
    return [
        g
        for g in groups
        if split[(g[0].family, g[0].key)] == 1 and all(is_row_scoped(f) for f in g)
    ]

Aleph bridge

The filter half of the Aleph URL-param grammar, wrapped by Query.to_params / from_params and to_string / from_string.

The Aleph / OpenAleph URL-param grammar: a bidirectional bridge between a Query filter tree plus its aggregations and the filter: / exclude: / empty: / metric: / facet params of openaleph_search.SearchQueryParser.

The param model is flat (AND across keys, OR within a key), so params_to_expr always yields a flat AND of leaves and expr_to_params raises QueryError for a cross-field OR or a negated group. exclude: is the negated match (~); not / not_in have no param spelling. sort / limit / offset are handled by Query.to_params / Query.from_params.

aggregations_to_params(aggs)

Project aggregation specs to openaleph metric: / facet params.

Each spec becomes metric:<func>=<field>, its groups facet=<field> (fields spelled as the filter keys are). Facets apply across all metrics, so differing per-metric groupings collapse to their union.

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 `metric:<func>=<field>`, its groups `facet=<field>` (fields
    spelled as the filter keys are). Facets apply across all metrics, so
    differing per-metric groupings collapse to their union.

    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.

Inverse of aggregations_to_params: every facet groups every metric, and a facet without a metric: groups an entity count.

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.

    Inverse of
    [`aggregations_to_params`][ftmq.query.aleph.aggregations_to_params]: every
    `facet` groups every metric, and a `facet` without a `metric:` groups an
    entity count.

    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; value order within a key is kept (sort priority).

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; value order within a key is kept (`sort` priority).

    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 nests named operators, e.g. and(eq(schema,Person),or(eq(properties.name,jane),eq(group.countries,de))), so unlike the flat Aleph params it carries any & | ~ tree. Aggregations map to RQL's sum / min / max / mean / count / aggregate(...) and the projection to select(...), side by side with the filter under a top-level and.

Fields use the shared wire spelling (properties.<name>, group.<name>, context.<name>, bare meta fields and year); any other bare name is read as a 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 string into a filter tree, aggregation specs and a projection.

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], tuple[Ref, ...]]:
    """Parse an RQL string into a filter tree, aggregation specs and a projection.

    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()
    selection: tuple[Ref, ...] = ()
    filters: list[Any] = []
    for node in data["args"] if data["name"] == "and" else [data]:
        name = node.get("name") if isinstance(node, dict) else None
        if name == SELECT_OPERATOR:
            selection = _node_selection(node)
        elif name in AGG_OPERATORS:
            aggs.update(_node_aggs(node))
        else:
            filters.append(node)
    expr = combine(*(rql_to_expr(f) for f in filters), connector=AND)
    return expr, aggs, selection

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`."""
    if not isinstance(data, dict):
        raise QueryError(f"Invalid RQL expression: `{data}`")
    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=(), selection=())

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

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] = (),
    selection: Iterable[Ref] = (),
) -> str:
    """Serialize a filter tree, aggregation specs and a projection to an RQL string.

    Raises:
        QueryError: If a filter leaf uses a comparator with no RQL equivalent
            (`null`, `startswith`, `endswith`, ...).
    """
    nodes: list[dict[str, Any]] = []
    if expr:
        filter_ast = expr_to_rql(expr)
        # flatten a top-level `and` filter so aggregations join as siblings
        if filter_ast.get("name") == "and":
            nodes.extend(filter_ast["args"])
        else:
            nodes.append(filter_ast)
    nodes.extend(_aggs_to_rql(aggs))
    fields = [ref.wire for ref in selection]
    if fields:
        nodes.append({"name": SELECT_OPERATOR, "args": fields})
    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 node, the Agg spec and the in-memory Aggregator. See the aggregation guide.

Aggregations: a projection over the matched entities, not a filter.

A builds one immutable Agg spec per func=<ref> pair and is passed to Query.aggregate. Aggregator runs the specs in memory; the SQL backend compiles the same specs.

A

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

Each keyword is a function (min, max, sum, avg, count) over one or more refs; by= groups by one or more refs. 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 a function (`min`, `max`, `sum`, `avg`, `count`) over one or
    more refs; `by=` groups by one or more refs. 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 ref, optionally grouped.

Built via A.

Source code in ftmq/query/aggregations.py
@dataclass(frozen=True)
class Agg:
    """An immutable aggregation spec: a function over a ref, optionally grouped.

    Built via [`A`][ftmq.query.aggregations.A].
    """

    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 running Agg specs.

Holds all mutable state, so use a fresh instance per run.

Source code in ftmq/query/aggregations.py
class Aggregator:
    """In-memory accumulator running [`Agg`][ftmq.query.aggregations.Agg] specs.

    Holds all mutable state, so use a fresh instance per run.
    """

    def __init__(
        self,
        aggs: Iterable[Agg],
        sizes: Mapping[Ref, int] | None = None,
        order: FacetOrder | None = None,
    ) -> None:
        self.aggs: list[Agg] = list(aggs)
        self.sizes: dict[Ref, int] = dict(sizes or {})
        self.order = order
        self._groupers = groupers(self.aggs)
        self._values: dict[Agg, Values] = defaultdict(list)
        self._grouped: dict[Agg, dict[Ref, dict[str, Values]]] = defaultdict(
            lambda: defaultdict(lambda: defaultdict(list))
        )
        # entity ids per group value, to rank buckets by count
        self._entities: dict[Ref, dict[str, set[str | None]]] = defaultdict(
            lambda: defaultdict(set)
        )

    def collect(self, proxy: Entity) -> None:
        """Accumulate one entity's values into every spec."""
        for group in self._groupers:
            for g in group.values(proxy):
                self._entities[group][g].add(proxy.id)
        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

    def _top(self, group: Ref) -> list[str]:
        """Top buckets of `group` by entity count or facet sort, ties by value."""
        order = self.order
        ranking = next(
            (a for a in self.aggs if order and order.orders(a) and group in a.groups),
            None,
        )
        if order is None or ranking is None:
            entities = self._entities[group]
            ranked = sorted(entities, key=lambda g: (-len(entities[g]), g))
        else:
            values = {
                g: reduce_values(ranking.func, v)
                for g, v in self._grouped[ranking][group].items()
            }
            ranked = sorted(g for g, v in values.items() if v is not None)
            ranked.sort(key=lambda g: cast(Any, values[g]), reverse=not order.ascending)
        return ranked[: self.sizes.get(group, DEFAULT_FACET_SIZE)]

    @property
    def result(self) -> AggregatorResult:
        """The reduced result, keyed by the wire spelling of each field.

        `{func: {field: value}, "groups": {group: {func: {field: {bucket: value}}}}}`,
        empties removed, each group capped to its top buckets.
        """
        res: Any = defaultdict(dict)
        groups: Any = defaultdict(lambda: defaultdict(dict))
        top = {group: self._top(group) for group in self._groupers}
        for agg in self.aggs:
            res[agg.func][agg.key] = reduce_values(agg.func, self._values[agg])
            for group in agg.groups:
                grouped = self._grouped[agg][group]
                groups[group.wire][agg.func][agg.key] = {
                    g: reduce_values(agg.func, grouped[g])
                    for g in top[group]
                    if g in grouped
                }
        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: {bucket: value}}}}}, empties removed, each group capped to its top buckets.

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 group in self._groupers:
        for g in group.values(proxy):
            self._entities[group][g].add(proxy.id)
    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)

FacetOrder dataclass

Ranks facet buckets by a grouped metric instead of by entity count.

Wire spelling: <func>:<field>[:asc].

Source code in ftmq/query/aggregations.py
@dataclass(frozen=True)
class FacetOrder:
    """Ranks facet buckets by a grouped metric instead of by entity count.

    Wire spelling: `<func>:<field>[:asc]`.
    """

    func: str
    ref: Ref
    ascending: bool = False

    @property
    def wire(self) -> str:
        """E.g. `sum:properties.amountEur` or `count:id:asc`."""
        wire = f"{self.func}:{self.ref.wire}"
        return f"{wire}:asc" if self.ascending else wire

    @classmethod
    def from_wire(cls, value: str) -> FacetOrder:
        """Parse the wire spelling (`:desc` is accepted too).

        Args:
            value: E.g. `sum:properties.amountEur:asc`.

        Returns:
            The facet order.
        """
        func, _, rest = value.partition(":")
        field, _, direction = rest.partition(":")
        if direction not in ("", "asc", "desc"):
            raise QueryError(
                f"Invalid facet sort: `{value}` - expected `<func>:<field>[:asc]`"
            )
        return make_facet_order(func, ref_from_wire(field), direction == "asc")

    def orders(self, agg: Agg) -> bool:
        """Whether this ranks by `agg` (ignoring its grouping).

        Args:
            agg: An aggregation spec.

        Returns:
            `True` if `agg` has this function and field.
        """
        return agg.func == self.func and agg.ref == self.ref

wire property

E.g. sum:properties.amountEur or count:id:asc.

from_wire(value) classmethod

Parse the wire spelling (:desc is accepted too).

Parameters:

Name Type Description Default
value str

E.g. sum:properties.amountEur:asc.

required

Returns:

Type Description
FacetOrder

The facet order.

Source code in ftmq/query/aggregations.py
@classmethod
def from_wire(cls, value: str) -> FacetOrder:
    """Parse the wire spelling (`:desc` is accepted too).

    Args:
        value: E.g. `sum:properties.amountEur:asc`.

    Returns:
        The facet order.
    """
    func, _, rest = value.partition(":")
    field, _, direction = rest.partition(":")
    if direction not in ("", "asc", "desc"):
        raise QueryError(
            f"Invalid facet sort: `{value}` - expected `<func>:<field>[:asc]`"
        )
    return make_facet_order(func, ref_from_wire(field), direction == "asc")

orders(agg)

Whether this ranks by agg (ignoring its grouping).

Parameters:

Name Type Description Default
agg Agg

An aggregation spec.

required

Returns:

Type Description
bool

True if agg has this function and field.

Source code in ftmq/query/aggregations.py
def orders(self, agg: Agg) -> bool:
    """Whether this ranks by `agg` (ignoring its grouping).

    Args:
        agg: An aggregation spec.

    Returns:
        `True` if `agg` has this function and field.
    """
    return agg.func == self.func and agg.ref == self.ref

aggregations_from_dict(data)

Rebuild 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 specs from the output of `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 specs to sorted {"func", "field", "by"?} dicts (wire spellings).

Source code in ftmq/query/aggregations.py
def aggregations_to_dict(aggs: Iterable[Agg]) -> list[dict[str, Any]]:
    """Serialize specs to sorted `{"func", "field", "by"?}` dicts (wire spellings)."""
    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

groupers(aggs)

The fields the given specs group by (the facets of a query).

Source code in ftmq/query/aggregations.py
def groupers(aggs: Iterable[Agg]) -> set[Ref]:
    """The fields the given specs group by (the facets of a query)."""
    return {g for agg in aggs for g in agg.groups}

make_agg(func, ref, groups=())

Validate and build an Agg spec; groups are sorted, so their order is moot.

Source code in ftmq/query/aggregations.py
def make_agg(func: str, ref: Ref, groups: Iterable[Ref] = ()) -> Agg:
    """Validate and build an `Agg` spec; groups are sorted, so their order is moot."""
    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)))
    )

make_facet_order(func, ref, ascending=False)

Validate and build a FacetOrder.

Parameters:

Name Type Description Default
func str

The aggregation function.

required
ref Ref

The aggregated field.

required
ascending bool

Rank the smallest values first.

False

Returns:

Type Description
FacetOrder

The facet order.

Source code in ftmq/query/aggregations.py
def make_facet_order(func: str, ref: Ref, ascending: bool = False) -> FacetOrder:
    """Validate and build a `FacetOrder`.

    Args:
        func: The aggregation function.
        ref: The aggregated field.
        ascending: Rank the smallest values first.

    Returns:
        The facet order.
    """
    agg = make_agg(func, ref)
    return FacetOrder(func=agg.func, ref=agg.ref, ascending=ascending)

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

A store passes its SqlSource to Query.compile (or builds Sql(query, source) directly). Partition pruning is prune={column: function}, one rule per partition column.

The statement source a Query compiles against.

Stores own one and pass it to Sql / Query.compile; a store with extra columns (a lake / sharded table) supplies its own.

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 dict[str, PruneFn] | None

Partition-pruning rules as {column: function}: each function returns that column's possible values for a query (None or empty for no pruning), folded in as a column IN (...) row predicate (see prune_by_schema). A rule for a missing column is ignored; rules only run for a flat positive conjunction.

None
base_filter Any | None

A predicate folded into every compiled select and sub-select (e.g. a lake store's view filter).

None
Source code in ftmq/query/sql.py
class SqlSource:
    """The statement source a [`Query`][ftmq.Query] compiles against.

    Stores own one and pass it to [`Sql`][ftmq.query.sql.Sql] /
    [`Query.compile`][ftmq.Query.compile]; a store with extra columns (a lake /
    sharded table) supplies its own.

    Args:
        table: The SQLAlchemy `Table` / `TableClause` to query.
        id_column: The entity-identity column name (default `canonical_id`).
        prune: Partition-pruning rules as `{column: function}`: each function
            returns that column's possible values for a query (`None` or empty
            for no pruning), folded in as a `column IN (...)` row predicate (see
            [`prune_by_schema`][ftmq.query.sql.prune_by_schema]). A rule for a
            missing column is ignored; rules only run for a flat positive
            conjunction.
        base_filter: A predicate folded into every compiled select and
            sub-select (e.g. a lake store's view filter).
    """

    def __init__(
        self,
        table: Any,
        id_column: str = "canonical_id",
        prune: dict[str, PruneFn] | None = None,
        base_filter: Any | None = None,
    ) -> None:
        self.table = table
        self.id_column = id_column
        self.prune = prune or {}
        self.base_filter = base_filter

Build a prune rule for a partition column derived from the schema.

Used as a SqlSource prune rule (the lake store's bucket): maps the query's schema / schemata filters to the partitions of their schemata. Without a schema filter, or with a not / not_in schema comparator, nothing is pruned.

Parameters:

Name Type Description Default
get_partition Callable[[str], str]

Maps a schema name to its partition value.

required

Returns:

Type Description
PruneFn

The prune rule.

Source code in ftmq/query/sql.py
def prune_by_schema(get_partition: Callable[[str], str]) -> PruneFn:
    """Build a prune rule for a partition column derived from the schema.

    Used as a [`SqlSource`][ftmq.query.sql.SqlSource] `prune` rule (the lake
    store's `bucket`): maps the query's `schema` / `schemata` filters to the
    partitions of their schemata. Without a schema filter, or with a `not` /
    `not_in` schema comparator, nothing is pruned.

    Args:
        get_partition: Maps a schema name to its partition value.

    Returns:
        The prune rule.
    """

    def prune(q: "Query") -> set[str] | None:
        for leaf in q._leaves:
            if isinstance(leaf, (SchemaLeaf, SchemataLeaf)):
                if leaf.comparator not in ("eq", "in"):
                    return None
        return {get_partition(s) for s in q.schemata_names}

    return prune

Compile a Query to SQLAlchemy selects over a statement table.

Every leaf lifts to an entity-level canonical_id IN (...) membership, so a filter selects whole entities, assembled from all of their statements.

Parameters:

Name Type Description Default
q Query

The query.

required
source SqlSource | None

The statement source (default: the nomenklatura statement table).

None
scope Iterable[str] | None

Restrict to entities with a statement in one of these datasets.

None
Source code in ftmq/query/sql.py
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
class Sql:
    """Compile a [`Query`][ftmq.Query] to SQLAlchemy selects over a statement table.

    Every leaf lifts to an entity-level `canonical_id IN (...)` membership, so a
    filter selects whole entities, assembled from all of their statements.

    Args:
        q: The query.
        source: The statement source (default: the nomenklatura statement table).
        scope: Restrict to entities with a statement in one of these datasets.
    """

    COMPARATORS = {
        "eq": "__eq__",
        "not": "__ne__",
        "in": "in_",
        "not_in": "not_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]:
        """The source's base filter, folded into every select and sub-select."""
        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":
            # presence test; property / group presence is `_family_clause`
            return column.is_(None) if f.value else column.is_not(None)
        # autoescape so `%` / `_` in the value match literally
        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
        # leaf values are strings, a Boolean column (`external`) needs bools
        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:
        """Sub-select of the entity ids with a (base-filtered) row matching `pred`."""
        return (
            select(self.id_col)
            .distinct()
            .where(and_(true(), *self._base_clauses, pred))
        )

    def _absent(self, present: Any) -> Any:
        """Lift a row predicate to an entity-level absence (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: some row matches."""
        return self.id_col.in_(self._entity_ids(pred))

    def _family_clause(self, leaf: Leaf, lookup: Lookup) -> Any:
        """Clause for a property / group leaf; `null` tests presence of such a row."""
        if self._is_null(leaf):
            if leaf.value:
                return self._absent(lookup.where)
            return self._membership(lookup.where)
        return self._membership(
            and_(lookup.where, self.get_expression(lookup.value, leaf))
        )

    def _schema_clause(self, f: Leaf) -> Any:
        """Entity-level schema / is-a clause: membership, or anti-join if negated."""
        negated = f.comparator in ("not", "not_in")
        if isinstance(f, SchemataLeaf):
            positive = self.table.c.schema.in_(f.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._membership(self.get_expression(self.table.c.schema, f))
        if negated:
            return self._absent(positive)
        return self._membership(positive)

    def _row_membership(self, leaves: Iterable[Leaf]) -> Any:
        """One membership for co-referring row-scoped leaves, all on a single row."""
        rows = [
            self.get_expression(self.lookup(f.ref).value, f)
            for f in sorted(leaves, key=lambda f: (f.key, f.comparator))
        ]
        return self._membership(and_(true(), *rows))

    def _bound_clause(self, leaves: list[Leaf]) -> Any:
        """One membership for bounds on one property / group, all on a single row."""
        lookup = self.lookup(leaves[0].ref)
        return self._membership(
            and_(
                lookup.where,
                *(
                    self.get_expression(lookup.value, f)
                    for f in sorted(leaves, key=lambda f: f.comparator)
                ),
            )
        )

    def _leaf_clause(self, leaf: Leaf) -> Any:
        """Lift one leaf to an entity-level `canonical_id IN (...)` clause."""
        if isinstance(leaf, (SchemaLeaf, SchemataLeaf)):
            # already entity-level, membership or anti-join
            return self._schema_clause(leaf)
        lookup = self.lookup(leaf.ref)
        if lookup.where is not None:  # a property / group family
            return self._family_clause(leaf, lookup)
        column = lookup.value
        if self._is_null(leaf) and leaf.value:
            return self._absent(column.is_not(None))
        row = self.get_expression(column, leaf)
        if column is self.id_col:
            # already true for every row of a matching entity
            return row
        return self._membership(row)

    def _expr_clause(self, expr: Expr) -> Any:
        """Compile a node; co-referring leaves of an AND share one sub-select."""
        leaves = [c for c in expr.children if isinstance(c, Leaf)]
        if expr.connector == OR:
            clauses = {leaf: self._leaf_clause(leaf) for leaf in leaves}
        else:
            clauses = self._conjunction_clauses(leaves)
        parts: list[Any] = []
        for child in expr.children:
            if isinstance(child, Expr):
                parts.append(self._expr_clause(child))
            elif child in clauses:
                parts.append(clauses[child])
        # an empty node is `true` (`false` when negated)
        combined = (
            or_(*parts) if parts and expr.connector == OR else and_(true(), *parts)
        )
        return not_(combined) if expr.negated else combined

    def _conjunction_clauses(self, leaves: Iterable[Leaf]) -> dict[Leaf, Any]:
        """Clauses for an AND node's leaves, co-referring ones joined, keyed by leaf."""
        groups = group_conjunction(leaves)
        joined = {id(g) for g in row_scoped_groups(groups)}
        row_scoped = [leaf for g in groups if id(g) in joined for leaf in g]
        clauses: dict[Leaf, Any] = {}
        for group in groups:
            if id(group) in joined:
                if row_scoped:
                    clauses[group[0]] = self._row_membership(row_scoped)
                    row_scoped = []
            elif len(group) == 1:
                clauses[group[0]] = self._leaf_clause(group[0])
            elif isinstance(group[0], (PropertyLeaf, GroupLeaf)):
                clauses[group[0]] = self._bound_clause(group)
            else:
                # entity-scoped field: per-leaf and joined clauses agree
                for leaf in group:
                    clauses[leaf] = self._leaf_clause(leaf)
        return clauses

    @cached_property
    def _is_flat_and(self) -> bool:
        """Whether the tree is a plain conjunction with at most one leaf per field."""

        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_clauses(self) -> list[Any]:
        """One `column IN (...)` row predicate per prune rule, for a flat AND only."""
        if not self.source.prune or not self._is_flat_and:
            return []
        clauses: list[Any] = []
        for column, prune_fn in self.source.prune.items():
            if column not in self.table.c:
                continue
            values = prune_fn(self.q)
            if values:
                clauses.append(self.table.c[column].in_(sorted(set(values))))
        return clauses

    @cached_property
    def _clauses(self) -> list[Any]:
        """The filter tree and view scope as entity-level predicates."""
        clauses = [self._expr_clause(self.q.q)] if self.q.q is not None else []
        # the scope selects entities, not rows: a match keeps its out-of-scope rows
        if self.scope:
            clauses.append(
                self._membership(self.table.c.dataset.in_(sorted(self.scope)))
            )
        return clauses

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

    @cached_property
    def _projection_clauses(self) -> list[Any]:
        """Projection row filter for statement selects only, keeping the `id` row."""
        if not self.q.selection:
            return []
        rows = [w for r in self.q.selection if (w := self.lookup(r).where) is not None]
        return [or_(*rows, self.table.c.prop == "id")]

    @property
    def _limit(self) -> int | None:
        # an offset without limit renders `LIMIT -1`, which duckdb rejects
        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:
            if self.q.slice is not None:
                # pages need a total order, or they repeat and skip entities
                q = q.order_by(self.id_col)
            # omit a redundant offset 0
            q = q.limit(self._limit).offset(self.q.offset or None)
        return q

    @cached_property
    def _unsorted_statements(self) -> Select:
        # a slice (even limit 0) applies in the `canonical_ids` sub-select
        if self.q.slice is not None:
            where = and_(
                true(),
                *self._base_clauses,
                *self._projection_clauses,
                self.id_col.in_(self.canonical_ids),
            )
        else:
            # entity-level clause: already true for every row of a matching entity
            where = and_(true(), self.clause, *self._projection_clauses)
        return select(self.table).where(where).order_by(self.id_col)

    @cached_property
    def _sorted_statements(self) -> Select:
        prop = self.q.sort.ref.key
        value = self.table.c.value
        if self.q.sort.ref.is_numeric:
            value = numeric_value(self.table.c.value)
        group_func = func.min if self.q.sort.ascending else func.max

        def order(col: Any) -> Any:
            # a missing value sorts last either way, as in memory
            return (col.asc() if self.q.sort.ascending else col.desc()).nulls_last()

        # the `id` rows keep entities without the sort prop (NULL value)
        sortable_value = group_func(case((self.table.c.prop == prop, value)))
        inner = (
            select(self.id_col, sortable_value.label("sortable_value"))
            .where(and_(self.table.c.prop.in_([prop, "id"]), self.clause))
            .group_by(self.id_col)
            .limit(self._limit)
            .offset(self.q.offset or None)
        )
        # explicit subquery: an implicit one via `Select.c` is deprecated
        sub = inner.order_by(order(literal_column("sortable_value")), self.id_col)
        sub = sub.subquery()
        sortable = sub.c["sortable_value"]
        outer = select(
            self.table.join(sub, self.id_col == sub.c[self.source.id_column])
        )
        # the joined rows still need the base filter and the projection
        read = [*self._base_clauses, *self._projection_clauses]
        if read:
            outer = outer.where(*read)
        return outer.order_by(order(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.

        A meta / context ref reads its own column (no row predicate); a property
        or group ref reads `value`, selecting its rows by `prop` / `prop_type`.
        """
        raise QueryError(f"Cannot compile field reference: `{ref!r}`")

    @lookup.register
    def _(self, ref: IdRef) -> Lookup:
        # the resolved entity id, not the referent id in a `prop = "id"` row
        return Lookup(self.id_col)

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

    @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:
        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, extra_where: BooleanClauseList | None = None
    ) -> Select:
        count = func.count(self.id_col.distinct()).label("count")
        lookup = self.lookup(group)
        where = and_(true(), *lookup.clauses, self.clause)
        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), lookup.value)
        )

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

    @property
    def _specs(self) -> list[Agg]:
        return sorted(self.q.aggregations, key=lambda a: (a.func, a.key))

    @staticmethod
    def _tags(agg: Agg) -> tuple[Any, Any]:
        """The `(field, func)` literals naming a spec's rows in a union."""
        return text(f"'{agg.key}'"), text(f"'{agg.func}'")

    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":
            # distinct raw values, no numeric cast
            return func.count(distinct(value))
        if agg.ref.is_numeric:
            # min / max too: a lexicographic min over numbers is wrong
            value = numeric_value(value)
        return getattr(func, agg.func)(value)

    @cached_property
    def aggregations(self) -> Select:
        qs = [
            select(*self._tags(agg), self._aggregator(agg)).where(
                *self.lookup(agg.ref).clauses, self.clause
            )
            for agg in self._specs
        ]
        return union_all(*qs)

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

        Rows are `(field, func, group_value, value)`. Specs aggregate over the
        distinct `(entity, group value)` pairs of the matching entities, so a
        multi-valued group property does not multiply rows.

        Args:
            grouper: The field reference to group by.
            limit: Keep the top `limit` group values, by entity count or by
                the query's `facet_sort` metric, ties by value.

        Returns:
            The unioned select.
        """
        g = self.lookup(grouper)
        pairs = (
            select(self.id_col.label("cid"), g.value.label("gval"))
            .where(and_(true(), *g.clauses, self.clause))
            .distinct()
        )
        if limit is not None:
            top = self._top_groups(grouper, pairs.subquery(), limit)
            pairs = pairs.where(g.value.in_(select(top.c[0])))
        sub = pairs.subquery()
        qs = []
        for agg in self._specs:
            if grouper in agg.groups:
                grouped = self._grouped_value(agg, sub)
                columns = grouped.selected_columns
                qs.append(grouped.with_only_columns(*self._tags(agg), *columns))
        return union_all(*qs)

    def _top_groups(self, grouper: Ref, pairs: Any, limit: int) -> Any:
        """Top `limit` values of `grouper`, by facet sort metric or entity count."""
        order = self.q.facet_sort
        ranking = next(
            (
                a
                for a in self.q.aggregations
                if order is not None and order.orders(a) and grouper in a.groups
            ),
            None,
        )
        if order is not None and ranking is not None:
            ranked = self._grouped_value(ranking, pairs)
            value = ranked.selected_columns[1]
            rank = (value.asc() if order.ascending else value.desc()).nulls_last()
        else:
            count = func.count().label("count")
            ranked = select(pairs.c.gval, count).group_by(pairs.c.gval)
            rank = desc(ranked.selected_columns[1])
        return ranked.order_by(rank, pairs.c.gval).limit(limit).subquery()

    def _grouped_value(self, agg: Agg, pairs: Any) -> Select:
        """`(gval, value)` rows of `agg` per group value of `pairs`."""
        lookup = self.lookup(agg.ref)
        return (
            select(pairs.c.gval, self._aggregator(agg))
            .select_from(self.table.join(pairs, self.id_col == pairs.c.cid))
            .where(and_(true(), *self._base_clauses, *lookup.clauses))
            .group_by(pairs.c.gval)
        )

    @cached_property
    def group_props(self) -> set[Ref]:
        return groupers(self.q.aggregations)

grouped_aggregations(grouper, limit=None)

Every aggregation spec grouped by grouper, as one unioned select.

Rows are (field, func, group_value, value). Specs aggregate over the distinct (entity, group value) pairs of the matching entities, so a multi-valued group property does not multiply rows.

Parameters:

Name Type Description Default
grouper Ref

The field reference to group by.

required
limit int | None

Keep the top limit group values, by entity count or by the query's facet_sort metric, ties by value.

None

Returns:

Type Description
Select

The unioned select.

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 unioned select.

    Rows are `(field, func, group_value, value)`. Specs aggregate over the
    distinct `(entity, group value)` pairs of the matching entities, so a
    multi-valued group property does not multiply rows.

    Args:
        grouper: The field reference to group by.
        limit: Keep the top `limit` group values, by entity count or by
            the query's `facet_sort` metric, ties by value.

    Returns:
        The unioned select.
    """
    g = self.lookup(grouper)
    pairs = (
        select(self.id_col.label("cid"), g.value.label("gval"))
        .where(and_(true(), *g.clauses, self.clause))
        .distinct()
    )
    if limit is not None:
        top = self._top_groups(grouper, pairs.subquery(), limit)
        pairs = pairs.where(g.value.in_(select(top.c[0])))
    sub = pairs.subquery()
    qs = []
    for agg in self._specs:
        if grouper in agg.groups:
            grouped = self._grouped_value(agg, sub)
            columns = grouped.selected_columns
            qs.append(grouped.with_only_columns(*self._tags(agg), *columns))
    return union_all(*qs)

lookup(ref)

Where a field reference reads from in this source.

A meta / context ref reads its own column (no row predicate); a property or group ref reads value, selecting its rows by 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.

    A meta / context ref reads its own column (no row predicate); a property
    or group ref reads `value`, selecting its rows by `prop` / `prop_type`.
    """
    raise QueryError(f"Cannot compile field reference: `{ref!r}`")

Errors

Bases: ValueError

Raised for an invalid query.

An unknown field or comparator, or a query the requested serialization cannot express. Subclasses ValueError.

Source code in ftmq/query/exceptions.py
1
2
3
4
5
6
class QueryError(ValueError):
    """Raised for an invalid query.

    An unknown field or comparator, or a query the requested serialization cannot
    express. Subclasses `ValueError`.
    """