Skip to content

ftmq.Query

See the query guide for a narrative introduction.

A filter over FtM entities, built from composable 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("name")[:10]
Source code in ftmq/query/main.py
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
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
class Query:
    """
    A filter over FtM entities, built from composable `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("name")[:10]
        ```
    """

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        Args:
            *nodes: `M` / `P` / `G` / `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, value: str, *, ascending: bool = True) -> Self:
        """
        Set the sorting (a single field; the SQL adapter never supported more).

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

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

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

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

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

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

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

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

        A projection, not a filter: it never changes *which* entities match,
        only which of their statements are read. On a statement store it
        compiles to a `prop` / `prop_type` predicate on the statement fetch, so
        a query for a document's `title` does not drag its `bodyText` across
        the wire; in memory the assembled entity is pruned to the same fields.

        The entity always comes back, even with none of the selected
        properties set (its `id` statement is always read), so a projection
        cannot silently drop a match. Its `caption` and its edges are
        incomplete by construction - a projected entity is a view of an entity,
        not the entity.

        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 - a meta or context ref
                names a per-row column, not which rows to read.
        """
        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 (the in-memory half of
        [`select`][ftmq.Query.select]).

        Returns the entity untouched when nothing drops; otherwise a clone, so
        the caller's entity is never mutated.
        """
        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 an in-memory `Aggregator` from the query's aggregation specs.

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

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

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

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

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

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

        entities = (e for e in entities if self.apply(e))
        if self.sort:
            entities = self.sort.apply_iter(entities)
        if self.slice:
            entities = islice(
                entities, self.slice.start, self.slice.stop, self.slice.step
            )
        if self.aggregations:
            self.aggregator = self.get_aggregator()
            entities = self.aggregator.apply(cast(Any, entities))
        if self.selection:
            # last: filtering, sorting and aggregating all read the full entity
            entities = (self._project(e) for e in entities)
        yield from entities

context property

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

countries property

The current filtered countries

dataset_names property

The names of the current filtered datasets

datasets property

The current dataset filters

groups property

The current property groups lookup filters

ids property

The current id filters

limit property

The current limit (inferred from a slice)

offset property

The current offset (inferred from a slice)

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

properties property

The current property lookup filters

schemata property

The current schema filters

schemata_names property

The names of the current filtered schemas

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

sql property

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

__bool__()

Detect if any filter, ordering or slicing is defined

Examples:

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

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

__getitem__(value)

Implement list-like slicing. No negative values allowed.

Examples:

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

Returns:

Type Description
Self

The updated Query instance

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

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

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

__hash__()

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

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

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

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

aggregate(*nodes)

Add aggregation projections to the query.

Example
from ftmq import Query, M, A

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

Parameters:

Name Type Description Default
*nodes A

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

()

Returns:

Type Description
Self

The updated Query instance.

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

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

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

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

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

apply(entity)

Test if a entity matches the current Query instance.

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

apply_iter(entities)

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

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

Yields:

Type Description
EntityProxies

A generator of EntityProxy or a sub-type

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

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

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

    entities = (e for e in entities if self.apply(e))
    if self.sort:
        entities = self.sort.apply_iter(entities)
    if self.slice:
        entities = islice(
            entities, self.slice.start, self.slice.stop, self.slice.step
        )
    if self.aggregations:
        self.aggregator = self.get_aggregator()
        entities = self.aggregator.apply(cast(Any, entities))
    if self.selection:
        # last: filtering, sorting and aggregating all read the full entity
        entities = (self._project(e) for e in entities)
    yield from entities

compile(source=None)

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

Parameters:

Name Type Description Default
source 'SqlSource | None'

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

None

Returns:

Type Description
'Select[Any]'

The statements Select.

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

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

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

from_dict(data) classmethod

Rebuild a Query from its to_dict output.

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

from_params(args) classmethod

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

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

from_rql(value) classmethod

Build a Query from an RQL string.

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

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

    Unlike the flat Aleph grammar, RQL expresses arbitrary `& | ~` nesting,
    e.g. `and(eq(schema,Person),or(eq(properties.name,jane),eq(countries,de)))`,
    and carries aggregations via its `sum` / `aggregate(...)` operators.
    """
    if not value:
        return cls()
    expr, aggregations, 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 an in-memory Aggregator from the query's aggregation specs.

Returns:

Type Description
Aggregator

A fresh accumulator over this query's aggregations.

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

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

order_by(value, *, ascending=True)

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

Parameters:

Name Type Description Default
value str

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

required
ascending bool

Ascending or descending

True

Returns:

Type Description
Self

The updated Query instance.

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

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

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

select(*refs)

Restrict the properties the matching entities are read with.

A projection, not a filter: it never changes which entities match, only which of their statements are read. On a statement store it compiles to a prop / prop_type predicate on the statement fetch, so a query for a document's title does not drag its bodyText across the wire; in memory the assembled entity is pruned to the same fields.

The entity always comes back, even with none of the selected properties set (its id statement is always read), so a projection cannot silently drop a match. Its caption and its edges are incomplete by construction - a projected entity is a view of an entity, not the entity.

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 - a meta or context ref names a per-row column, not which rows to read.

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

    A projection, not a filter: it never changes *which* entities match,
    only which of their statements are read. On a statement store it
    compiles to a `prop` / `prop_type` predicate on the statement fetch, so
    a query for a document's `title` does not drag its `bodyText` across
    the wire; in memory the assembled entity is pruned to the same fields.

    The entity always comes back, even with none of the selected
    properties set (its `id` statement is always read), so a projection
    cannot silently drop a match. Its `caption` and its edges are
    incomplete by construction - a projected entity is a view of an entity,
    not the entity.

    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 - a meta or context ref
            names a per-row column, not which rows to read.
    """
    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()

Lossless nested-tree representation of the current object.

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

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

to_params()

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

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

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

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

to_rql()

Serialize the filter tree and aggregations to an RQL string.

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

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

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

to_string()

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

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

where(*nodes)

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

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

Parameters:

Name Type Description Default
*nodes Expr

M / P / G / 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 another set of `M` / `P` / `G` / `C` nodes into the current `Query`.

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

    Args:
        *nodes: `M` / `P` / `G` / `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

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

Bases: _FamilyExpr

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

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

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

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

Bases: _FamilyExpr

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

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

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

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

Bases: _FamilyExpr

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

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

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

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

Bases: _FamilyExpr

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

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

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

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

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

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

Examples:

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

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

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

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

Expression tree

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

Children are canonicalized on construction (see [_normalize][ftmq.query.nodes._normalize]), so a node never holds a duplicate child or a nested group it could absorb.

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

    Children are canonicalized on construction (see
    [`_normalize`][ftmq.query.nodes._normalize]), so a node never holds a
    duplicate child or a nested group it could absorb.
    """

    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 _copy(self) -> "Expr":
        clone = Expr(connector=self.connector, negated=self.negated)
        clone.children = list(self.children)  # already normalized
        return clone

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

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

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

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

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

        Args:
            entity: The entity to test.

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

    def _apply_and(self, entity: EntityProxy) -> bool:
        """Evaluate a conjunction, with co-referring conditions sharing a value.

        `group_conjunction` marks a field's leaves as co-referring when they
        are all bounds, so `P(date__gte=a) & P(date__lt=b)` asks for *one* date
        inside the window - testing each bound separately would match an entity
        holding one date below the window and another above it. Everything else
        keeps its own per-leaf test.
        """
        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
            # the leaves of a multi-leaf group are bounds on one field, so any
            # of them reads the same values; they have to agree on one 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]]:
        """Test the conditions addressing distinct columns of one statement row
        against the entity's statements: one row has to satisfy all of them.

        Returns whether they matched, and the ids of the groups this settled so
        the caller skips them. It settles nothing unless the entity carries its
        statements and there is more than one such column to correlate - an
        entity read off a json stream has only the aggregated `context` dict,
        where the correlation between two columns is already lost, so there
        each condition is tested on its own as before.
        """
        row_groups = row_scoped_groups(groups)
        statements = getattr(entity, "statements", None)
        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]:
        """Walk the tree and yield its leaf conditions.

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

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

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

        The children are already flattened and deduplicated (see
        [`_normalize`][ftmq.query.nodes._normalize]); sorting them here makes
        structurally-equivalent trees (e.g. built by different `where()`
        orderings) serialize identically and hash equal.

        Returns:
            A `{"and" | "or": [...], "not": bool}` mapping, round-trippable via
            [`from_dict`][ftmq.query.nodes.Expr.from_dict].
        """
        key = self.connector.lower()
        children: list[Any] = []
        for child in self.children:
            if isinstance(child, Expr):
                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:
        # a within-process hash over a normalized serialization; like any
        # Python object it is not stable across processes (banal's hash_data
        # normalizes key/element order so equal trees hash equal in-process)
        return hash(hash_data(self.to_dict()))

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

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

apply(entity)

Evaluate the boolean expression against an entity.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to test.

required

Returns:

Type Description
bool

True if the entity matches this (possibly nested, possibly

bool

negated) tree of conditions.

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

    Args:
        entity: The entity to test.

    Returns:
        `True` if the entity matches this (possibly nested, possibly
        negated) tree of conditions.
    """
    if not self.children:
        result = True
    elif self.connector == OR:
        result = any(c.apply(entity) for c in self.children)
    else:
        result = 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)

Walk the tree and yield its leaf conditions.

Parameters:

Name Type Description Default
cls type | None

Optionally restrict to leaves of this class.

None

Yields:

Type Description
Leaf

Each matching leaf, depth-first.

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

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

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

to_dict()

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

The children are already flattened and deduplicated (see [_normalize][ftmq.query.nodes._normalize]); sorting them here makes structurally-equivalent trees (e.g. built by different where() orderings) serialize identically and hash equal.

Returns:

Type Description
dict[str, Any]

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

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

    The children are already flattened and deduplicated (see
    [`_normalize`][ftmq.query.nodes._normalize]); sorting them here makes
    structurally-equivalent trees (e.g. built by different `where()`
    orderings) serialize identically and hash equal.

    Returns:
        A `{"and" | "or": [...], "not": bool}` mapping, round-trippable via
        [`from_dict`][ftmq.query.nodes.Expr.from_dict].
    """
    key = self.connector.lower()
    children: list[Any] = []
    for child in self.children:
        if isinstance(child, Expr):
            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 M / P / G / Expr nodes to combine.

()
connector str

AND (default) or OR.

AND

Returns:

Type Description
Expr | None

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

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

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

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

Field references

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

Field references: a leaf without a value.

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

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

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

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

CanonicalIdRef

Bases: IdRef

The canonical_id column (the resolved id).

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

    key = "canonical_id"

ContextRef

Bases: Ref

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

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

    family = "C"

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

    def values(self, entity: EntityProxy) -> Iterator[str]:
        # a statement entity carries the rows themselves, and its `context` slot
        # is never populated - read the column off each statement, the way the
        # SQL backends do. Only an entity without statements (a `ValueEntity`
        # off a json stream) falls back to the aggregated context dict.
        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:
            # `ValueEntity` / `EntityProxy` pop the well-known provenance
            # fields (`first_seen`, `last_seen`, `datasets`) into their own
            # attributes instead of leaving them in `context`, so a field is
            # addressable under one spelling either way
            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:
        # context keys are open-ended (backends add their own columns), so they
        # always carry the prefix rather than competing with the other families
        return f"{CONTEXT_PREFIX}{self.key}"

DatasetRef

Bases: MetaRef

The dataset an entity was observed in.

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

    key = "dataset"

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

    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 __eq__(self, other: Any) -> bool:
        if not isinstance(other, GroupRef):
            return NotImplemented
        return self.key == other.key and self.prop_type == other.prop_type

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

    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. Aggregating it addresses entities, not the referent ids in the value of a prop = "id" statement.

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

    key = "id"

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

MetaRef

Bases: Ref

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

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

    family = "M"

PropRef

Bases: Ref

One followthemoney property (the prop column).

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

    family = "P"

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

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

    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:
        # the same `properties.` prefix the filter grammar uses, so a name that
        # is both a property and a group (`topics`) stays addressable as either
        return f"{PROPERTIES_PREFIX}{self.key}"

Ref

A reference to one field of one family.

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

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

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

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

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

    def row_value(self, statement: Any) -> str | None:
        """This field's value on a single statement, `None` if it has none.

        Only the row-scoped columns have one - the value of `schema` or `id` on
        a row is a partial observation of an entity-wide fact, not a fact about
        that row, so those stay `None` here (see
        [`is_row_scoped`][ftmq.query.leaves.is_row_scoped]).
        """
        return None

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

        Only the families that address the statement's `prop` / `prop_type`
        column can - `Query.select` rejects the others, so this stays `False`
        for them. The in-memory counterpart of the row predicates
        [`Sql.lookup`][ftmq.query.sql.Sql.lookup] returns.
        """
        return False

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

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

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

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

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

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

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

is_numeric property

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

wire property

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

row_value(statement)

This field's value on a single statement, None if it has none.

Only the row-scoped columns have one - the value of schema or id on a row is a partial observation of an entity-wide fact, not a fact about that row, so those stay None here (see is_row_scoped).

Source code in ftmq/query/refs.py
def row_value(self, statement: Any) -> str | None:
    """This field's value on a single statement, `None` if it has none.

    Only the row-scoped columns have one - the value of `schema` or `id` on
    a row is a partial observation of an entity-wide fact, not a fact about
    that row, so those stay `None` here (see
    [`is_row_scoped`][ftmq.query.leaves.is_row_scoped]).
    """
    return None

selects(prop)

Whether a select projection on this ref keeps the given property.

Only the families that address the statement's prop / prop_type column can - Query.select rejects the others, so this stays False for them. The in-memory counterpart of the row predicates Sql.lookup returns.

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

    Only the families that address the statement's `prop` / `prop_type`
    column can - `Query.select` rejects the others, so this stays `False`
    for them. The in-memory counterpart of the row predicates
    [`Sql.lookup`][ftmq.query.sql.Sql.lookup] returns.
    """
    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 - a dimension derived from the dates group, not a column of its own.

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

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

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

Year()

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

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

make_meta_ref(key)

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

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

ref_from_wire(value)

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

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

Parameters:

Name Type Description Default
value str

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

required

Returns:

Type Description
Ref

The resolved ref.

Raises:

Type Description
QueryError

If the spelling matches no field of any family.

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

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

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

    Returns:
        The resolved ref.

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

Leaves

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

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

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

CanonicalIdLeaf

Bases: IdLeaf

Matches the canonical_id column (the resolved id).

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

    key = "canonical_id"
    ref = CanonicalIdRef()

ContextLeaf

Bases: RefLeaf

A context field (the C family).

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

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

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

    family = "C"

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

DatasetLeaf

Bases: RefLeaf

Matches an entity's datasets membership.

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

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

EntityIdLeaf

Bases: IdLeaf

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

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

    key = "entity_id"
    ref = EntityIdRef()

GroupLeaf

Bases: RefLeaf

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

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

    family = "G"

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

IdLeaf

Bases: RefLeaf

Matches an entity's id.

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

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

Leaf

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

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

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

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

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

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

    def __hash__(self) -> int:
        # over the canonical serialization, like `Expr.__hash__`: the family is
        # part of a leaf's identity (`topics` is both a property and a
        # property-type group), and an `in` value is order-normalized there
        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 against.

        Args:
            entity: The entity to read values from.

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

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

    def match_row(self, statement: Any) -> bool:
        """Test this condition against a single statement row.

        Only meaningful for a row-scoped leaf (see
        [`is_row_scoped`][ftmq.query.leaves.is_row_scoped]); everything else
        has no per-row value and never matches.
        """
        return False

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

        Args:
            entity: The entity to test.

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

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

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

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

wire property

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

apply(entity)

Test whether the entity matches this condition.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to test.

required

Returns:

Type Description
bool

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

bool

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

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

    Args:
        entity: The entity to test.

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

field_dict()

Serialize this leaf to a family-tagged mapping.

Returns:

Type Description
LeafDict

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

LeafDict

the query-tree serialization.

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

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

match(value)

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

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

match_row(statement)

Test this condition against a single statement row.

Only meaningful for a row-scoped leaf (see is_row_scoped); everything else has no per-row value and never matches.

Source code in ftmq/query/leaves.py
def match_row(self, statement: Any) -> bool:
    """Test this condition against a single statement row.

    Only meaningful for a row-scoped leaf (see
    [`is_row_scoped`][ftmq.query.leaves.is_row_scoped]); everything else
    has no per-row value and never matches.
    """
    return False

values(entity)

Yield the entity values this leaf tests against.

Parameters:

Name Type Description Default
entity EntityProxy

The entity to read values from.

required

Yields:

Type Description
str

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

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

    Args:
        entity: The entity to read values from.

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

LeafDict

Bases: TypedDict

Serialized form of a single Leaf condition.

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

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

PropertyLeaf

Bases: RefLeaf

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

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

    family = "P"

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

RefLeaf

Bases: Leaf

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

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

    ref: Ref

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

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

SchemataLeaf

Bases: Leaf

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

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

    family, key = "M", "schemata"

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

    # defining `__eq__` would set `__hash__` to None; the base hash (over
    # `field_dict`) stays correct, as `schemata` is derived from value +
    # comparator
    __hash__ = Leaf.__hash__

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, SchemataLeaf):
            return False
        return super().__eq__(other) and self.schemata == other.schemata

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

group_conjunction(leaves)

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

The rule both evaluators follow: conditions that could hold of one statement row simultaneously must hold of the same row. Within a conjunction a field's leaves co-refer when there is exactly one of them, or when they are all ordered comparators - a lower and an upper bound describe a single value, so P(date__gte=a) & P(date__lt=b) is one date inside the window rather than two unrelated dates.

Repeated equality / set / substring conditions keep their per-leaf reading ("has each"), so M(dataset="d1") & M(dataset="d2") still selects entities present in both datasets; they come back as separate single-leaf groups. A field mixing the two kinds (first_seen__gte=x & first_seen__not=y) is conservatively not joined either.

Expressing this once is what keeps the SQL compiler and the in-memory evaluator from drifting - as with the tree canonicalization in [_normalize][ftmq.query.nodes._normalize].

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 the order the leaves were given (a

'list[list[Leaf]]'

multi-leaf group holds bounds on one field; every other leaf is its own

'list[list[Leaf]]'

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.

    The rule both evaluators follow: *conditions that could hold of one
    statement row simultaneously must hold of the same row*. Within a
    conjunction a field's leaves co-refer when there is exactly one of them, or
    when they are all ordered comparators - a lower and an upper bound describe
    a single value, so `P(date__gte=a) & P(date__lt=b)` is one date inside the
    window rather than two unrelated dates.

    Repeated equality / set / substring conditions keep their per-leaf reading
    ("has each"), so `M(dataset="d1") & M(dataset="d2")` still selects entities
    present in both datasets; they come back as separate single-leaf groups. A
    field mixing the two kinds (`first_seen__gte=x & first_seen__not=y`) is
    conservatively not joined either.

    Expressing this once is what keeps the SQL compiler and the in-memory
    evaluator from drifting - as with the tree canonicalization in
    [`_normalize`][ftmq.query.nodes._normalize].

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

    Returns:
        One group per co-referring set, in the order the leaves were given (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 that describes one statement row.

The C columns (origin, first_seen, bucket, ...) plus dataset: a row's value for them is a fact about that statement, so AND-ed conditions on distinct ones co-refer. schema / schemata / id / canonical_id are excluded - a row's value there is a partial observation of an entity-wide fact (an entity merged across datasets carries LegalEntity rows and Person ones), so they stay entity-level.

An absence test (__null=True) is excluded as well: it asks whether no row carries the column, which no single row can answer.

entity_id is deliberately not row-scoped: in memory EntityIdRef reads entity.id rather than the pre-resolution column, and co-referring it would widen that existing divergence.

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

    The `C` columns (`origin`, `first_seen`, `bucket`, ...) plus `dataset`: a
    row's value for them is a fact about that statement, so AND-ed conditions
    on distinct ones co-refer. `schema` / `schemata` / `id` / `canonical_id`
    are excluded - a row's value there is a partial observation of an
    entity-wide fact (an entity merged across datasets carries `LegalEntity`
    rows *and* `Person` ones), so they stay entity-level.

    An absence test (`__null=True`) is excluded as well: it asks whether *no*
    row carries the column, which no single row can answer.

    `entity_id` is deliberately not row-scoped: in memory `EntityIdRef` reads
    `entity.id` rather than the pre-resolution column, and co-referring it
    would widen that existing divergence.
    """
    if leaf.comparator == "null" and leaf.value:
        return False
    return isinstance(leaf, (ContextLeaf, DatasetLeaf))

leaf_from_dict(data)

Reconstruct a leaf from its serialized LeafDict.

Parameters:

Name Type Description Default
data LeafDict

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

required

Returns:

Type Description
Leaf

The reconstructed leaf.

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

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

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

make_context_leaf(key, value)

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

Parameters:

Name Type Description Default
key str

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

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved context leaf.

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

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

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

make_group_leaf(key, value)

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

Parameters:

Name Type Description Default
key str

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

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved group leaf.

Raises:

Type Description
QueryError

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

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

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

    Returns:
        The resolved group leaf.

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

make_meta_leaf(key, value)

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

Parameters:

Name Type Description Default
key str

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

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved meta leaf.

Raises:

Type Description
QueryError

If the field is not a known meta field.

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

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

    Returns:
        The resolved meta leaf.

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

make_property_leaf(key, value)

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

Parameters:

Name Type Description Default
key str

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

required
value Any

The lookup value.

required

Returns:

Type Description
Leaf

The resolved property leaf.

Raises:

Type Description
QueryError

If the property is not a valid FtM property.

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

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

    Returns:
        The resolved property leaf.

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

parse_lookup(key)

Split a field__comparator lookup key into its parts.

Parameters:

Name Type Description Default
key str

A lookup key such as name, date__gte or schema__in.

required

Returns:

Type Description
tuple[str, str]

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

Raises:

Type Description
QueryError

If the comparator suffix is not a valid comparator.

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

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

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

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

row_scoped_groups(groups)

The groups of group_conjunction whose conditions co-refer across fields - they address different columns of one statement row.

A field that group_conjunction had to split (repeated equality) did not co-refer with itself, so it must not co-refer with anything else either: M(dataset="d1") & M(dataset="d2") stays two conditions even next to a C(origin=..) that would otherwise join them.

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]]":
    """The groups of [`group_conjunction`][ftmq.query.leaves.group_conjunction]
    whose conditions co-refer *across* fields - they address different columns
    of one statement row.

    A field that `group_conjunction` had to split (repeated equality) did not
    co-refer with itself, so it must not co-refer with anything else either:
    `M(dataset="d1") & M(dataset="d2")` stays two conditions even next to a
    `C(origin=..)` that would otherwise join them.

    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. Query.to_params / from_params and to_string / from_string wrap these.

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

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

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

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

aggregations_to_params(aggs)

Project aggregation specs to openaleph metric / facet params.

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

Parameters:

Name Type Description Default
aggs set[Agg]

The query's aggregation specs.

required

Returns:

Type Description
dict[str, list[str]]

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

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

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

    Args:
        aggs: The query's aggregation specs.

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

expr_to_params(expr)

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

Parameters:

Name Type Description Default
expr Expr | None

The filter tree (or None).

required

Returns:

Type Description
dict[str, list[str]]

The Aleph param mapping.

Raises:

Type Description
QueryError

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

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

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

    Returns:
        The Aleph param mapping.

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

normalize_multidict(args)

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

Parameters:

Name Type Description Default
args Any

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

required

Returns:

Type Description
dict[str, list[str]]

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

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

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

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

params_to_aggregations(items)

Rebuild aggregation specs from openaleph metric: / facet params.

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

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

Parameters:

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

A normalized param mapping.

required

Returns:

Type Description
set[Agg]

The reconstructed aggregation specs (empty if there is neither a

set[Agg]

metric: nor a facet param).

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

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

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

    Args:
        items: A normalized param mapping.

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

params_to_expr(items)

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

Parameters:

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

A normalized param mapping (see normalize_multidict).

required

Returns:

Type Description
Expr | None

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

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

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

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

params_to_selection(items)

Rebuild a projection from select= params - the inverse of selection_to_params.

Parameters:

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

A normalized param mapping.

required

Returns:

Type Description
tuple[Ref, ...]

The selected refs (empty without a select param).

Source code in ftmq/query/aleph.py
def params_to_selection(items: dict[str, list[str]]) -> tuple[Ref, ...]:
    """Rebuild a projection from `select=` params - the inverse of
    [`selection_to_params`][ftmq.query.aleph.selection_to_params].

    Args:
        items: A normalized param mapping.

    Returns:
        The selected refs (empty without a `select` param).
    """
    return tuple(ref_from_wire(f) for f in sorted(set(items.get("select", []))))

params_to_string(params)

Render an Aleph param mapping as a URL query string.

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

Parameters:

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

The param mapping.

required

Returns:

Type Description
str

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

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

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

    Args:
        params: The param mapping.

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

selection_to_params(refs)

Project a select projection to select= params.

Fields are spelled exactly as the filter keys are (properties.<name>, group.<name>), so one spelling addresses a field wherever it appears.

Parameters:

Name Type Description Default
refs 'Iterable[Ref]'

The query's selected refs.

required

Returns:

Type Description
dict[str, list[str]]

The {"select": [fields]} param mapping (empty without a selection).

Source code in ftmq/query/aleph.py
def selection_to_params(refs: "Iterable[Ref]") -> dict[str, list[str]]:
    """Project a [`select`][ftmq.Query.select] projection to `select=` params.

    Fields are spelled exactly as the filter keys are (`properties.<name>`,
    `group.<name>`), so one spelling addresses a field wherever it appears.

    Args:
        refs: The query's selected refs.

    Returns:
        The `{"select": [fields]}` param mapping (empty without a selection).
    """
    fields = sorted(ref.wire for ref in refs)
    return {"select": fields} if fields else {}

string_to_params(value)

Parse an Aleph URL query string into a param mapping.

Parameters:

Name Type Description Default
value str

A key=value&... query string.

required

Returns:

Type Description
dict[str, list[str]]

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

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

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

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

RQL bridge

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

RQL (Resource Query Language) bridge.

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

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

expr_to_rql(expr)

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

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

parse_rql(value)

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

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

Raises:

Type Description
QueryError

If the RQL uses an unsupported operator or field.

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

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

    Raises:
        QueryError: If the RQL uses an unsupported operator or field.
    """
    data = pyrql.parse(value)
    if not data:
        return None, set(), ()
    aggs: set[Agg] = set()
    selection: tuple[Ref, ...] = ()
    if data["name"] == SELECT_OPERATOR:
        return None, aggs, _node_selection(data)
    if data["name"] in AGG_OPERATORS:
        aggs.update(_node_aggs(data))
        return None, aggs, selection
    if data["name"] == "and":
        filters: list[dict[str, Any]] = []
        for child in data["args"]:
            if not isinstance(child, dict):
                filters.append(child)
            elif child.get("name") == SELECT_OPERATOR:
                selection = _node_selection(child)
            elif child.get("name") in AGG_OPERATORS:
                aggs.update(_node_aggs(child))
            else:
                filters.append(child)
        expr = combine(*(rql_to_expr(f) for f in filters), connector=AND)
        return expr, aggs, selection
    return rql_to_expr(data), 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`."""
    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 field projection to an RQL query string.

Filters, aggregations and the select(...) projection sit side by side under a top-level and.

Raises:

Type Description
QueryError

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

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

    Filters, aggregations and the `select(...)` projection sit side by side
    under a top-level `and`.

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

Aggregations for the ftmq query language.

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

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

A

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

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

Examples:

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

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

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

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

Agg dataclass

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

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

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

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

key property

The wire spelling of the aggregated field.

Aggregator

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

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

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

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

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

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

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

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

result property

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

apply(proxies)

Collect every entity while passing the stream through unchanged.

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

collect(proxy)

Accumulate one entity's values into every spec.

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

aggregations_from_dict(data)

Rebuild aggregation specs from the output of aggregations_to_dict.

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

aggregations_to_dict(aggs)

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

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

make_agg(func, ref, groups=())

Validate and build a single Agg spec.

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

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

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

reduce_values(func, values)

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

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

SQL

The SQL translation. A store passes its SqlSource to Query.compile (or builds Sql(query, source) directly). A partitioned table describes its pruning there as prune={column: function}, one rule per partition column.

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

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

Parameters:

Name Type Description Default
table Any

The SQLAlchemy Table / TableClause to query.

required
id_column str

The entity-identity column name (default canonical_id).

'canonical_id'
prune dict[str, PruneFn] | None

Optional partition-pruning rules as {column: function}: each function derives that column's possible values from the query and folds them into every compiled query as a column IN (...) row predicate (e.g. the lake store's {"bucket": ...} rule mapping a schema filter to its buckets - see prune_by_schema). Returning None or nothing means "cannot prune this query". A rule for a column the table doesn't have is ignored. Rules only run for a flat positive conjunction: under an OR, a ~ or a repeated field a filter no longer restricts matching entities to its partitions, so any such query skips pruning entirely.

None
base_filter Any | None

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

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

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

    Args:
        table: The SQLAlchemy `Table` / `TableClause` to query.
        id_column: The entity-identity column name (default `canonical_id`).
        prune: Optional partition-pruning rules as `{column: function}`: each
            function derives that column's possible values from the query and
            folds them into every compiled query as a `column IN (...)` row
            predicate (e.g. the lake store's `{"bucket": ...}` rule mapping a
            schema filter to its buckets - see
            [`prune_by_schema`][ftmq.query.sql.prune_by_schema]). Returning
            `None` or nothing means "cannot prune this query". A rule for a
            column the table doesn't have is ignored. Rules only run for a flat
            positive conjunction: under an `OR`, a `~` or a repeated field a
            filter no longer restricts matching entities to its partitions, so
            any such query skips pruning entirely.
        base_filter: Optional SQLAlchemy predicate folded into every compiled
            select *and* sub-select (e.g. a lake store's view filter). Unlike a
            predicate added post-hoc to the top-level select, this also scopes
            the entity-level membership / absence subqueries.
    """

    def __init__(
        self,
        table: Any,
        id_column: str = "canonical_id",
        prune: 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 that is a function of the statement schema (the lake store's bucket).

The returned rule maps the query's schema filters to the partitions their schemata live in. schemata_names expands an is-a filter to its non-abstract descendants, so a schemata filter prunes to every partition they live in; no schema filter at all means no pruning.

Pruning is only sound for a positive filter: a not / not_in schema comparator doesn't restrict matching entities to the partitions of its own schemata, so such a query prunes nothing (the caller already skips anything but a flat positive conjunction).

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`][ftmq.query.sql.SqlSource] rule for a partition column
    that is a function of the statement schema (the lake store's `bucket`).

    The returned rule maps the query's schema filters to the partitions their
    schemata live in. `schemata_names` expands an is-a filter to its
    non-abstract descendants, so a `schemata` filter prunes to every partition
    they live in; no schema filter at all means no pruning.

    Pruning is only sound for a positive filter: a `not` / `not_in` schema
    comparator doesn't restrict matching entities to the partitions of its own
    schemata, so such a query prunes nothing (the caller already skips anything
    but a flat positive conjunction).

    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
Source code in ftmq/query/sql.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
class Sql:
    COMPARATORS = {
        "eq": "__eq__",
        "not": "__ne__",
        "in": "in_",
        "gt": "__gt__",
        "gte": "__ge__",
        "lt": "__lt__",
        "lte": "__le__",
    }

    def __init__(
        self,
        q: Query,
        source: SqlSource | None = None,
        scope: Iterable[str] | None = None,
    ) -> None:
        self.q = q
        self.metadata = MetaData()
        if source is None:
            source = SqlSource(make_statement_table(self.metadata))
        self.source = source
        self.table = source.table
        self.id_col = self.table.c[source.id_column]
        self.scope: set[str] | None = set(scope) if scope else None
        self._row_level = False
        """Set on the :attr:`_rows` twin – see :meth:`_membership`."""

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

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

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

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

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

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

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

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

        The two lifting points of the compiler – :attr:`row_statements`
        switches both off to expose the un-lifted predicate.
        """
        if self._row_level:
            return pred
        return self.id_col.in_(self._entity_ids(pred))

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

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

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

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

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

        Positive comparators become a membership (an is-a filter expands to the
        schema plus its non-abstract descendants); `not` / `not_in` an
        anti-join. In-memory both test the entity's single resolved schema, so
        a row predicate would be wrong twice over: it would match any merged
        entity holding one row outside an excluded set, and - for a positive
        filter - assemble the matching entity from only its rows of that
        schema.
        """
        negated = f.comparator in ("not", "not_in")
        if isinstance(f, SchemataLeaf):
            names: set[str] = set()
            for schema in f.schemata:
                names.add(schema.name)
                names.update(d.name for d in schema.descendants if not d.abstract)
            positive = self.table.c.schema.in_(names)
        elif negated:
            values = f.value if isinstance(f.value, (set, frozenset)) else {f.value}
            positive = self.table.c.schema.in_(sorted(values))
        else:
            return self._membership(self.get_expression(self.table.c.schema, f))
        if negated:
            return self._absent(positive)
        return self._membership(positive)

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

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

    def _row_scoped_column(self, leaf: Leaf) -> Any:
        if isinstance(leaf, ContextLeaf):
            return self._context_column(leaf)
        return self.table.c.dataset

    def _row_membership(self, leaves: Iterable[Leaf]) -> Any:
        """One entity-level clause for co-referring row-scoped conditions: the
        entity has a *single* statement row satisfying all of them.

        These columns describe one statement's provenance / storage, so
        `C(origin="crawl") & C(first_seen__gte=d)` means "has a crawl statement
        seen since d", not "has a crawl statement and, unrelatedly, a statement
        seen since d" - one membership per leaf answers the second question.

        The matching entity is still assembled from *all* of its statements -
        this narrows which entities match, never which rows come back (see
        [`row_statements`][ftmq.query.sql.Sql.row_statements] for that).
        """
        rows = [
            self.get_expression(self._row_scoped_column(f), 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], selector: Callable[[Any], Any]) -> Any:
        """One entity-level clause for several bounds on the same property or
        group: a single row of that family whose `value` satisfies all of them.

        `P(date__gte=a) & P(date__lt=b)` is one date inside the window; a
        membership per bound would match an entity holding one date below the
        window and another above it.
        """
        return self._membership(
            and_(
                selector(leaves[0]),
                *(
                    self.get_expression(self.table.c.value, f)
                    for f in sorted(leaves, key=lambda f: f.comparator)
                ),
            )
        )

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

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

    def _expr_clause(self, expr: Expr) -> Any:
        """Compile a boolean node by combining its children's entity-level
        predicates - the general path for trees the flat collectors below
        cannot represent (cross-field `OR`, negation).

        In a conjunction, co-referring conditions share one sub-select
        (`group_conjunction` decides which). Under `OR` nothing joins, and a
        negated node negates the joined clause - the de Morgan of the same
        reading. Children keep their order, so the emitted SQL still reads like
        the query that was written.
        """
        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 matches everything - unless negated, when it matches
        # nothing (`not_(true())` compiles to `false`)
        combined = (
            or_(*parts) if parts and expr.connector == OR else and_(true(), *parts)
        )
        return not_(combined) if expr.negated else combined

    def _conjunction_clauses(self, leaves: Iterable[Leaf]) -> dict[Leaf, Any]:
        """The entity-level clauses of one AND node's leaves, joining the ones
        that co-refer (see
        [`group_conjunction`][ftmq.query.leaves.group_conjunction]).

        The co-referring row-scoped groups
        ([`row_scoped_groups`][ftmq.query.leaves.row_scoped_groups]) collapse
        into a single [`_row_membership`][ftmq.query.sql.Sql._row_membership] -
        they address different columns of the same row. Repeated bounds on one
        property or group become one
        [`_bound_clause`][ftmq.query.sql.Sql._bound_clause]; everything else
        keeps its own per-leaf clause.

        Returns:
            The clauses, keyed by the leaf each is anchored at, so the caller
            can emit them in the order the conditions were written. A leaf
            folded into another leaf's clause is absent from the mapping.
        """
        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):
                clauses[group[0]] = self._bound_clause(group, self._prop_selector)
            elif isinstance(group[0], GroupLeaf):
                clauses[group[0]] = self._bound_clause(group, self._group_selector)
            else:
                # bounds on an entity-scoped field (`schema`, an id column):
                # every row of a matching entity carries the same value, so
                # per-leaf and joined agree - keep the simpler compilation
                for leaf in group:
                    clauses[leaf] = self._leaf_clause(leaf)
        return clauses

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

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

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

    @cached_property
    def _prune_clauses(self) -> list[Any]:
        """One `column IN (...)` row predicate per prune rule of the source
        (e.g. the lake `bucket` column), folded into every compiled query - so
        `count` prunes partitions too, not just statements.

        Pruning is only sound for a plain positive conjunction: under `~` / `|`
        or a repeated field a filter no longer restricts matching entities to
        the partitions it derives, so any such shape disables pruning entirely.
        Whether a rule's *own* fields are used positively is up to the rule
        (see [`prune_by_schema`][ftmq.query.sql.prune_by_schema]).
        """
        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 compiled query, as entity-level predicates.

        Every leaf compiles to an id membership or anti-join, so each clause is
        already true for *every* row of a matching entity. That is what makes
        `Query` an entity language: a filter selects entities, and the selected
        entity is then assembled from all of its statements. A row predicate
        would instead assemble it from only the rows that matched - a partial
        entity, which is never what a caller filtering by `origin`, `dataset`
        or `schema` means.

        Callers who genuinely want the matching *statements* rather than the
        matching entities compile their own select against `self.table`.
        """
        if self._is_flat_and:
            clauses = self._flat_clauses()
        else:
            # a boolean tree compiles entirely to entity-level predicates
            clauses = [self._expr_clause(self.q.q)]
        # the view scope selects *entities* (those with at least one statement
        # in a scoped dataset), matching the in-memory store views: filters and
        # assembly still see the full canonical entity. A row-level `dataset`
        # predicate would instead silently drop the out-of-scope fragments of
        # matching entities.
        if self.scope:
            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 _selection_clause(self) -> Any | None:
        """The row predicate of a [`select`][ftmq.Query.select] projection:
        the statement rows to actually read back, `None` without one.

        Folded into the statement selects only - never into the membership
        sub-selects, `count` or the aggregations, which have to see the whole
        entity. The entity's `id` statement always comes back, so an entity
        holding none of the selected properties is still returned (empty)
        rather than silently dropped from the result.
        """
        if not self.q.selection:
            return None
        # every selectable ref has a row predicate (`Query.select` rejects the
        # families that read a column instead of selecting rows)
        rows = [self.lookup(ref).where for ref in self.q.selection]
        return or_(*[row for row in rows if row is not None], self.table.c.prop == "id")

    @cached_property
    def _projection_clauses(self) -> list[Any]:
        """The projection as a clause list (empty without a selection)."""
        clause = self._selection_clause
        return [] if clause is None else [clause]

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

        Every compiled clause is entity-level, so the conjunction already says
        exactly that - no `canonical_id IN (...)` indirection needed. The prune
        clauses ride along: they restrict partitions, not entities.
        """
        return and_(true(), *self._prune_clauses, *self._clauses)

    def _flat_clauses(self) -> list[Any]:
        """Compile a flat conjunction from the query's leaf collectors: one
        entity-level clause per field, AND-ed together (`_is_flat_and`
        guarantees at most one leaf per field)."""
        clauses: list[Any] = []
        by_key: Callable[[Leaf], str] = lambda f: f.key  # noqa: E731
        # the different id fields (`id` / `entity_id` / `canonical_id`) are
        # separate fields and AND together like any other. A predicate on the
        # source's own id column already holds for every row of a matching
        # entity, so it needs no membership wrapper; the others do - on a
        # resolved store an entity's rows can carry several `entity_id`s.
        for f in sorted(self.q.ids, key=by_key):
            column = self._id_column(f)
            expression = self.get_expression(column, f)
            if column is self.id_col:
                clauses.append(expression)
            else:
                clauses.append(self._membership(expression))
        # `dataset` and the context columns describe one statement row, so they
        # share a single membership (`_is_flat_and` guarantees one leaf each).
        # An absence test is the exception - it can only be an anti-join.
        row_scoped: list[Leaf] = []
        for ctx in sorted(self.q.context, key=by_key):
            if self._is_null(ctx) and ctx.value:
                clauses.append(self._absent(self._context_column(ctx).is_not(None)))
            else:
                row_scoped.append(ctx)
        row_scoped.extend(self.q.datasets)
        if row_scoped:
            clauses.append(self._row_membership(row_scoped))
        # exact-schema and is-a (`schemata`) filters
        schema_leaves = list(self.q.schemata) + [
            s for s in self.q._leaves if isinstance(s, SchemataLeaf)
        ]
        for f in schema_leaves:
            clauses.append(self._schema_clause(f))
        # properties and prop-type groups: one entity-level clause per field, so
        # they AND across fields ("has a name AND a german country"). A single
        # row predicate would instead force one statement row to satisfy every
        # field at once, which no row can - a row holds exactly one prop.
        for f in sorted(self.q.properties, key=by_key):
            clauses.append(self._family_clause(f, self._prop_selector))
        # the reverse lookup `G(entities=...)` is not special here, it is just
        # the `entity` prop-type group
        for f in sorted(self.q.groups, key=by_key):
            clauses.append(self._family_clause(f, self._group_selector))
        return clauses

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

    @cached_property
    def _rows(self) -> "Sql":
        """A twin compiler that leaves every predicate at row level.

        Same query, same source, same scope - only :meth:`_membership` /
        :meth:`_absent` stop lifting, so each leaf stays the predicate that
        would otherwise sit *inside* the `IN (SELECT DISTINCT ...)` wrapper.
        """
        twin = Sql(self.q, self.source, self.scope)
        twin._row_level = True
        return twin

    @cached_property
    def row_clause(self) -> BooleanClauseList:
        """The query's predicates as *row* filters, un-lifted.

        The inner half of :attr:`clause`: what each leaf tests about a single
        statement row, before the entity membership wrapper. Absence leaves
        (`null=True`) negate rather than anti-join, since no single row can
        answer "this entity has no name".
        """
        return self._rows.clause

    @cached_property
    def row_statements(self) -> Select:
        """The matching statement *rows*, not the statements of matching
        entities.

        The escape hatch out of the entity semantics every other select has:
        `C(origin="x")` here means the x-origin rows, where
        :attr:`statements` means all statements of entities having one. Use it
        to read a subset of an entity's statements - a per-origin export, a
        provenance slice - and compose your own select on top; ordering,
        sorting and slicing are the caller's to add, because a limit over rows
        does not mean a limit over entities.
        """
        where = and_(true(), self.row_clause, *self._projection_clauses)
        return select(self.table).where(where).order_by(self.id_col)

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

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

    @cached_property
    def _unsorted_statements(self) -> Select:
        # a slice (even offset-only or limit 0) must go through the
        # `canonical_ids` sub-select, where limit/offset are applied.
        if self.q.slice is not None:
            where = and_(
                true(),
                *self._base_clauses,
                *self._projection_clauses,
                self.id_col.in_(self.canonical_ids),
            )
        else:
            # the clause is purely entity-level - already true for every row of
            # a matching entity - so it needs no second pass
            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.value
        value = self.table.c.value
        if prop in NUMERIC_PROPS:
            value = numeric_value(self.table.c.value)
        group_func = func.min if self.q.sort.ascending else func.max
        inner = (
            select(
                self.id_col,
                group_func(value).label("sortable_value"),
            )
            .where(
                and_(
                    true(),
                    *self._base_clauses,
                    self.table.c.prop == prop,
                    self.id_col.in_(self.canonical_ids),
                )
            )
            .group_by(self.id_col)
            .limit(self._limit)
            .offset(self.q.offset or None)
        )
        inner_order = (
            "sortable_value" if self.q.sort.ascending else desc("sortable_value")
        )
        # an explicit subquery: reading `.c` off a `Select` builds one
        # implicitly, which sqlalchemy deprecates
        sub = inner.order_by(inner_order, self.id_col).subquery()
        sortable = sub.c["sortable_value"]
        outer = select(
            self.table.join(sub, self.id_col == sub.c[self.source.id_column])
        )
        # the join rows still need the base scope - a matching entity may
        # have out-of-scope statements - and the projection
        read = [*self._base_clauses, *self._projection_clauses]
        if read:
            outer = outer.where(*read)
        return outer.order_by(
            sortable if self.q.sort.ascending else desc(sortable), self.id_col
        )

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

row_clause cached property

The query's predicates as row filters, un-lifted.

The inner half of :attr:clause: what each leaf tests about a single statement row, before the entity membership wrapper. Absence leaves (null=True) negate rather than anti-join, since no single row can answer "this entity has no name".

row_statements cached property

The matching statement rows, not the statements of matching entities.

The escape hatch out of the entity semantics every other select has: C(origin="x") here means the x-origin rows, where :attr:statements means all statements of entities having one. Use it to read a subset of an entity's statements - a per-origin export, a provenance slice - and compose your own select on top; ordering, sorting and slicing are the caller's to add, because a limit over rows does not mean a limit over entities.

grouped_aggregations(grouper, limit=None)

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

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

Parameters:

Name Type Description Default
grouper Ref

The field reference to group by.

required
limit int | None

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

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

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

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

lookup(ref)

Where a field reference reads from in this source.

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

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

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

Errors

Bases: ValueError

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

Subclasses ValueError so existing except ValueError handlers keep working.

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

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