Skip to content

Queries

Model.where(...) and Model.select() return a Query — an immutable, chainable builder that executes when awaited via all(), first(), count(), exists(), update(), or delete(). Predicates are lambda-only (User.where(lambda user: user.age >= 18)) — a fresh QueryProxy validates column names against the model at build time.

Prefix ~ negates any predicate — leaf comparison or &/| compound — rendering as SQL NOT (...) over the condition it wraps (ADR-0008). It is the universal negation rule: there are no per-operator negative forms (~t.role.in_([...]) is NOT IN, ~t.email.like(p) is NOT LIKE), and double negation nests. Like SQL NOT and the != operator, a negated comparison excludes rows where the compared column is NULL — see Negation and NULL values.

where() and order_by() lambdas may traverse a forward-FK relation (lambda t: t.account.ledger_id == 1): each hop renders one INNER join, deduplicated by relation path (ADR-0006). join() forces a join on a relation path (a bare join() is an existence filter on a nullable relation), and left_join() marks the whole path LEFT to keep relation-less rows. See the Querying Across Relationships guide for worked examples.

A reverse (BackRef) or many-to-many relation in a predicate supports exactly one verb — the existence test t.rel.exists(inner_lambda=None) (ADR-0007). It renders as a correlated EXISTS at every cardinality (never a join, so the result stays root-shaped and each matching root returns once), negates with ~, and the optional inner lambda is a full ferro predicate over the related model (operators, &/|/~, forward traversal rendered inside the subquery, nested tests). Everything else on a reverse edge — column access, comparisons (including != None), in_ (including a query RHS), join()/left_join() — raises at build time naming .exists(); an inner lambda referencing any scope but its own parameter is likewise rejected (#309). See Existence Tests for worked examples.

include() and populated relations

include(lambda t: t.account) delivers each result with the relation populated (ADR-0008): access becomes a plain attribute holding the complete related instance — no await, no query — while unpopulated relations keep the awaitable contract. Include is the third orthogonal query axis (joins decide membership, projection decides shape, include decides attached data): it never changes which rows come back, .all() still returns list[Model], and count()/exists() are unaffected. Paths populate whole (include(lambda t: t.account.owner) populates both hops); includes are cumulative, order-free, and idempotent; populated instances run the full session identity-map protocol, and a refresh keeps a population only while the row's FK still points at it.

Loud limits, at build time: forward-FK lambda paths only (a BackRef/M2M selector, a string, or a column selector raises TypeError); combining include with a projection raises ValueError in either chain order (one materialization plan per query; record results are flat — reach across a relation with traversed projection instead); update()/delete() on an included query raise ValueError. See Populating Relations with include() for worked examples.

select() overloads

select() has four forms, resolved at build time:

Call Returns Result of .all()
Model.select() Query[Model] list[Model] — the full query, unchanged.
Model.select(lambda t: (t.id, t.account.name)) ProjectedQuery[Model] Rows[Row] — projected records; fields may traverse forward-FK paths at any depth and take the bare leaf column name (single-field form: select(lambda t: t.amount)).
Model.select(lambda t: {"owner_email": t.account.owner.email}) ProjectedQuery[Model] Rows[Row] — dict keys name the output fields (output aliases); values are field references or aggregate expressions.
Model.select("id", "amount") ProjectedQuery[Model] Rows[Row]order_by()'s string contract: root columns only, never traversal, never mixed with a lambda.

A ProjectedQuery composes like any Query (where() with traversal, order_by() by unselected columns, limit()/offset(), first(), count(), exists()); update()/delete(), a second select(), output-name collisions, and nested selector shapes raise at build time. Projection traversal narrows per ADR-0006 (INNER, one join per path, shared with where()/order_by()); left_join() keeps relation-less rows with their traversed fields decoded to None. See Selecting a Column Subset for worked examples and the complete-instance invariant behind the Row result shape.

Aggregates and grouped queries

Five methods on column references build aggregate fields for the dict selector: t.amount.count() / .sum() / .avg() / .min() / .max() (traversal included: t.account.balance.avg()). Source families validate at build time — sum/avg take numeric columns, min/max orderable ones (numeric, text, date/time), count any column; enum/uuid/json/bool are rejected. Result types are a pinned cross-backend contract derived from the source column (count → int, min/max → source type, sum → source numeric type, avgfloat for int/float and Decimal for Decimal); empty input passes SQL through verbatim (None, count → 0).

An aggregate-only projection collapses to one record (read with first()). Mixing plain fields in makes the query grouped: every plain field is a group key — GROUP BY is derived from the projection, never declared. On a projected query order_by() strings resolve output field names first, then root columns; the lambda form spells source expressions including aggregates (order_by(lambda t: t.amount.sum(), "desc")); on an aggregate projection every sort key must be a group key or an aggregate — anything else raises at build time, as do count()/exists() (ambiguous between rows and groups) and aggregate predicates in where() (post-aggregation filtering is having(), #291). See Aggregations & Grouped Queries for worked examples.

Query

Bases: Generic[T]

Build and execute fluent ORM queries.

Attributes:

Name Type Description
model_cls

Model class used to hydrate results.

where_clause list[QueryNode]

Accumulated filter nodes for the query.

order_by_clause list[OrderByEntry]

Sort definitions sent to the Rust core.

Source code in src/ferro/query/builder.py
 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
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
class Query(Generic[T]):
    """Build and execute fluent ORM queries.

    Attributes:
        model_cls: Model class used to hydrate results.
        where_clause: Accumulated filter nodes for the query.
        order_by_clause: Sort definitions sent to the Rust core.
    """

    def __init__(
        self, model_cls: Type[T], using: str | None = None, session: Any | None = None
    ):
        """Initialize a query for a model class.

        Args:
            model_cls: Model class that defines the target table.

        Examples:
            >>> query = Query(User)
            >>> query.model_cls is User
            True
        """
        self.model_cls = model_cls
        self._using = using
        self._session = session
        self.where_clause: list["QueryNode"] = []
        self.order_by_clause: list[OrderByEntry] = []
        self._limit: int | None = None
        self._offset: int | None = None
        self._m2m_context: M2mContext | None = None
        # Relation paths that must render a join, insertion-ordered (full path
        # tuple -> registered join_type). Populated by where()/order_by()
        # traversal ("inner") and by the explicit join()/left_join() chainers
        # ("inner"/"left"). Serialized into the QueryIR ``joins`` section by
        # all()/count() (#270, #272).
        self._joins: dict[tuple[str, ...], str] = {}
        # Edges (path prefixes, length ≥ 1) whose join type was fixed by an
        # explicit chainer, insertion-ordered (edge tuple -> "inner"|"left").
        # ``.left_join`` marks every edge of its path "left" (whole-path rule,
        # ADR-0006); ``.join`` marks them "inner". The single source of truth
        # for LEFT on the wire — implicit where()/order_by() traversal never
        # touches this, so explicit always beats implicit (#272).
        self._explicit_edges: dict[tuple[str, ...], str] = {}
        # Relation paths to populate (#286, ADR-0008), insertion-ordered and
        # deduped by path identity (dict-as-ordered-set). CRITICAL: include
        # paths never enter ``_joins``/the wire ``joins`` section — they ride the
        # ``instances`` materialization plan, so the ``joins`` wire section
        # keeps its stage-1 semantics untouched and include contributes no
        # join-type opinion on any edge another clause references.
        self._includes: dict[tuple[str, ...], None] = {}

    async def _transaction_or_using(self) -> "RouteHandle":
        from .. import _ensure_rust_registration_synced_for_operation
        from ..state import resolve_operation_scope

        await _ensure_rust_registration_synced_for_operation()
        return resolve_operation_scope(using=self._using, session=self._session)

    def _clone(self) -> Self:
        """Return a copy of this query with no shared mutable state.

        ``copy.copy`` preserves the concrete class (``Relation`` stays
        ``Relation``); the mutable containers are then replaced so chained
        queries never alias the originals (FF-F F-1).
        """
        new = copy.copy(self)
        new.where_clause = list(self.where_clause)
        new.order_by_clause = list(self.order_by_clause)
        # M2mContext is frozen, so clones can share it safely.
        new._m2m_context = self._m2m_context
        new._joins = dict(self._joins)
        new._explicit_edges = dict(self._explicit_edges)
        new._includes = dict(self._includes)
        return new

    def _m2m(
        self, join_table: str, source_col: str, target_col: str, source_id: Any
    ) -> Self:
        """Store many-to-many linkage context for relationship operations.

        A new ``Query`` with the m2m context set; ``self`` is unchanged.
        """
        new = self._clone()
        new._m2m_context = M2mContext(
            join_table=join_table,
            source_col=source_col,
            target_col=target_col,
            source_id=source_id,
        )
        return new

    def where(self, predicate: "Predicate[T]") -> Self:
        """Add a filter condition to the query.

        ``predicate`` is a lambda of shape ``Callable[[QueryProxy[T]], QueryNode]``.
        The lambda receives a fresh :class:`QueryProxy` whose attributes
        return :class:`FieldProxy` instances, so
        ``lambda user: user.archived == False`` builds a comparison. Column
        names are validated at build time against the model's declared
        fields (plus shadow ``{fk}_id`` columns): a misspelled column raises
        ``AttributeError`` naming the closest valid match, before any query
        is sent to the database.

        Attribute access on a declared forward-FK field traverses the relation
        (``lambda t: t.account.ledger_id == lid``): each hop resolves against
        the related model, and every distinct traversed path renders one INNER
        join (ADR-0006) when the query runs. Every hop is validated at build
        time with the same did-you-mean naming the hop's model.

        Args:
            predicate: A callable that takes a :class:`QueryProxy` and
                returns a :class:`QueryNode`.

        Returns:
            A new ``Query`` with the clause added; ``self`` is unchanged.

        Raises:
            TypeError: If ``predicate`` is not callable, or if it does not
                return a ``QueryNode``.

        Examples:
            >>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
            >>> q2 = User.where(lambda user: user.id == 1)
            >>> isinstance(q1, Query) and isinstance(q2, Query)
            True
        """
        new = self._clone()
        node = _resolve_where_node(predicate, self.model_cls)
        new.where_clause.append(node)
        _register_join_paths(node, new._joins)
        return new

    @overload
    def select(self) -> Self: ...

    @overload
    def select(self, selector: "RowSelector[T]") -> "ProjectedQuery[T]": ...

    @overload
    def select(self, *columns: str) -> "ProjectedQuery[T]": ...

    def select(
        self, *selectors: "RowSelector[T] | str"
    ) -> "Self | ProjectedQuery[T]":
        """Project the query to a column subset, or pass through unchanged.

        Bare ``select()`` keeps meaning "full query": it returns an equivalent
        query of complete model instances. With a lambda selector — the
        documented style — the query becomes a projection: its results are
        :class:`Row` records in the list-like :class:`Rows` container, never
        model instances (the complete-instance invariant, ADR-0007). The
        selector returns one field (``select(lambda t: t.amount)``), a tuple
        (``select(lambda t: (t.id, t.amount))``), or a dict whose keys name
        the output fields (``select(lambda t: {"account_name":
        t.account.name})`` — output aliases). Fields may traverse declared
        forward-FK relations at any depth (``t.account.name`` — traversed
        projection); traversal narrows exactly like a ``where()`` predicate
        on the same path (INNER, one join per path, ADR-0006) and
        ``left_join()`` is the opt-out — kept rows decode the traversed
        fields as ``None``. Unaliased traversed fields take the bare leaf
        column name; two fields resolving to the same output name raise at
        build time. Column-name strings (``select("id", "amount")``) follow
        ``order_by``'s string contract exactly: root columns only, never
        traversal, never mixed with a lambda in one call. All forms validate
        at build time with did-you-mean.

        A projected query composes like any other query: ``where()``
        (relation traversal included), ``order_by()`` (by unselected columns
        too), ``limit()``/``offset()``, ``first()``, ``count()``, and
        ``exists()`` all work; ``update()``/``delete()`` and a second
        ``select()`` raise at build time.

        Args:
            *selectors: Nothing (full query), one lambda selector, or
                column-name strings.

        Returns:
            A new query; ``self`` is unchanged. Projected when a selector is
            given.

        Raises:
            TypeError: If the selector is not callable/strings, selects
                non-fields (a bare relation, a comparison), nests shapes
                (a dict inside a tuple, a tuple inside a dict), uses a
                non-string dict key, or mixes strings with a lambda in one
                call.
            ValueError: If the selection is empty, two fields resolve to the
                same output name, a string is a dotted path (strings never
                traverse), or the query already carries an ``include()``
                (one materialization plan per query — record results are
                flat, permanently).

        Examples:
            >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
            >>> rows[0].amount  # doctest: +SKIP
            Decimal('12.50')
            >>> named = await Transaction.select(  # doctest: +SKIP
            ...     lambda t: {"id": t.id, "account_name": t.account.name}
            ... ).all()
        """
        if not selectors:
            return self._clone()
        if self._includes:
            raise ValueError(
                "select() with a projection cannot be combined with "
                "include(): a query carries exactly one materialization plan "
                "— projected records or populated instances, never both, and "
                "record results are flat (ADR-0009). Reach across the "
                "relation with traversed projection instead (e.g. "
                'select(lambda t: {"account_name": t.account.name})), or '
                "populate instances with include() on an unprojected query."
            )
        if any(isinstance(selector, str) for selector in selectors):
            if not all(isinstance(selector, str) for selector in selectors):
                raise TypeError(
                    "select() cannot mix column-name strings and a lambda "
                    "selector in one call; use one form "
                    '(select("id", "amount") or '
                    "select(lambda t: (t.id, t.amount)))."
                )
            names: tuple[str, ...] = selectors  # type: ignore[assignment]  # ty: ignore[invalid-assignment]
            columns = _resolve_projection_strings(names, self.model_cls)
            fields = tuple(
                _ProjectedField(name=column, column=column, path=())
                for column in columns
            )
            return ProjectedQuery(self, fields)
        if len(selectors) > 1:
            raise TypeError(
                "select() takes a single lambda selector naming the columns "
                "(e.g. `select(lambda t: (t.id, t.amount))`), got "
                f"{len(selectors)} arguments"
            )
        selector = selectors[0]
        if not callable(selector):
            raise TypeError(
                "select() expected a selector callable or column-name strings "
                f"(e.g. `lambda t: (t.id, t.amount)`), got {type(selector).__name__}"
            )
        # Strings were dispatched above, so the lone selector is the lambda
        # form; the tuple element type is too wide for the checker to see it.
        fields = _resolve_projection_selector(
            cast("RowSelector[T]", selector), self.model_cls
        )
        return ProjectedQuery(self, fields)

    def order_by(
        self,
        field: "str | Callable[[QueryProxy[T]], FieldProxy[Any]]",
        direction: str = "asc",
    ) -> Self:
        """Add an ordering clause and return a new query.

        Accepts a lambda naming the column (``order_by(lambda u: u.created_at,
        "desc")`` — the documented style, matching ``where`` predicates) or a
        column-name string (``order_by("created_at", "desc")``). Both forms are
        validated against the model's queryable columns at build time.

        A lambda selector may traverse a declared forward-FK relation
        (``order_by(lambda t: t.account.name)``) exactly like ``where()``: each
        hop resolves against the related model, and the traversed path renders
        one INNER join (ADR-0006), shared with any ``where()`` traversal of the
        same path in the same query — the same path referenced in both yields
        exactly one join. String selectors do not traverse: ``"account.name"``
        is looked up as a literal (unqualified) column name on the queried
        model.

        Args:
            field: Column selector — lambda receiving a :class:`QueryProxy`,
                or a column-name string.
            direction: ``"asc"`` (default) or ``"desc"``.

        Returns:
            A new ``Query`` with the ordering added; ``self`` is unchanged.

        Raises:
            AttributeError: If the column is not a queryable column.
            TypeError: If a lambda selector returns something other than a
                single column reference (a bare relation, e.g.
                ``lambda t: t.account``, is meaningless as a sort key).
            ValueError: If ``direction`` is not ``"asc"`` or ``"desc"``.

        Examples:
            >>> newest = await Post.select().order_by(lambda p: p.created_at, "desc").all()
        """
        if direction.lower() not in ("asc", "desc"):
            raise ValueError("direction must be 'asc' or 'desc'")

        path: tuple[str, ...] = ()
        if isinstance(field, str):
            col_name = validate_query_column(self.model_cls, field)
        elif callable(field):
            selected = field(QueryProxy(self.model_cls))
            # Ordering by a bare relation (no column selected) is meaningless —
            # reject it loudly rather than emitting an order_by the SELECT
            # walker cannot render. Ordering by a related COLUMN (a FieldProxy
            # with a non-empty path) is valid traversal (#271).
            if isinstance(selected, RelationProxy):
                relation = selected._path[-1]
                raise TypeError(
                    f"order_by() selector returned the bare relation {relation!r}, "
                    "not a column; order by a column on it instead "
                    f"(e.g. t.{relation}.<column>)."
                )
            if not isinstance(selected, FieldProxy):
                raise TypeError(
                    "order_by() selector must return a FieldProxy "
                    f"(e.g. `lambda u: u.created_at`), got {type(selected).__name__}"
                )
            col_name = selected.column
            path = selected.path
        else:
            raise TypeError(
                "order_by() expected a column-name string or a lambda selector, "
                f"got {type(field).__name__}"
            )

        new = self._clone()
        new.order_by_clause.append(
            OrderByEntry(column=col_name, direction=direction.lower(), path=path)
        )
        if path:
            new._joins.setdefault(path, "inner")
        return new

    def join(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
        """Force an INNER join on a relation path and return a new query.

        ``selector`` is a lambda naming a RELATION path
        (``lambda t: t.account``, ``lambda t: t.account.owner``) — the same
        traversal syntax as ``where()``, but resolving to the relation itself,
        not a column on it. A bare ``.join(lambda t: t.account)`` with no
        predicate is a meaningful **existence filter** on a nullable relation:
        it narrows the result to rows where the relation exists (ADR-0006).

        Every edge of the path is marked explicit-INNER; combining it with an
        explicit ``.left_join`` on the same edge is a build-time error (see
        :meth:`left_join`).

        Args:
            selector: A lambda receiving a :class:`QueryProxy` and returning a
                relation path (a ``RelationProxy``).

        Returns:
            A new ``Query`` with the join registered; ``self`` is unchanged.

        Raises:
            TypeError: If ``selector`` is not callable or does not resolve to a
                relation path (a column selector is rejected — join names a
                relation, not a column).
            ValueError: If an edge of the path is already marked explicit-LEFT.

        Examples:
            >>> with_account = QJTransaction.select().join(lambda t: t.account)
        """
        return self._add_explicit_join(selector, "inner")

    def left_join(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
        """Mark a relation path LEFT (whole-path) and return a new query.

        ``selector`` names a RELATION path exactly like :meth:`join`. Every edge
        of the path is marked LEFT (the **whole-path rule**, ADR-0006), so a
        left-marked 2-hop path retains rows missing the relation at either hop.
        Relation-less rows are retained (NULL retention), observable both in
        ordered results and in traversal predicates on related columns (e.g.
        ``where(lambda t: t.account.name == None)``).

        Conflict rules: an explicit LEFT beats implicit ``where()``/``order_by()``
        traversal on a shared edge (the path renders LEFT); an explicit ``.join``
        plus an explicit ``.left_join`` on the same edge is a build-time error.

        Args:
            selector: A lambda receiving a :class:`QueryProxy` and returning a
                relation path (a ``RelationProxy``).

        Returns:
            A new ``Query`` with the LEFT join registered; ``self`` is unchanged.

        Raises:
            TypeError: If ``selector`` is not callable or does not resolve to a
                relation path.
            ValueError: If an edge of the path is already marked explicit-INNER.

        Examples:
            >>> keep_orphans = QJNote.select().left_join(lambda n: n.account)
        """
        return self._add_explicit_join(selector, "left")

    def include(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
        """Populate a relation path on every result and return a new query.

        ``selector`` is a lambda naming a forward-FK RELATION path
        (``lambda t: t.account``, ``lambda t: t.account.owner``) — the same
        traversal syntax as :meth:`join`. Results are the same ``list[T]``
        the query always returned, in one SQL statement, with each included
        relation **populated**: access is a plain attribute
        (``txn.account.label`` — no await, no query) returning the complete
        related instance, exactly as the field's declared annotation claims
        (ADR-0008). Unpopulated relations keep the awaitable contract
        unchanged; a nullable FK with no target populates as ``None`` with
        the root row retained.

        Include decides attached data only — joins decide membership,
        projection decides shape. Adding ``.include(...)`` to any query
        returns exactly the rows that query returned without it: include
        contributes no join-type opinion on any edge a predicate or explicit
        chainer references, and renders LEFT only on edges nothing else
        touches. ``count()``/``exists()`` are unaffected.

        Cumulative, order-free, and idempotent: chained includes accumulate,
        shared prefixes dedup by path identity, and including a path
        populates every hop along it.

        Args:
            selector: A lambda receiving a :class:`QueryProxy` and returning
                a relation path (a ``RelationProxy``).

        Returns:
            A new ``Query`` with the population registered; ``self`` is
            unchanged.

        Raises:
            TypeError: If ``selector`` is a string or not callable, resolves
                to a column rather than a relation, or names a BackRef/M2M
                relation (reverse population is a separate future mechanism).

        Examples:
            >>> txns = await Transaction.select().include(lambda t: t.account).all()  # doctest: +SKIP
            >>> txns[0].account.label  # doctest: +SKIP
            'checking'
        """
        path = _resolve_include_selector(selector, self.model_cls)
        new = self._clone()
        new._includes.setdefault(path, None)
        return new

    def _add_explicit_join(
        self, selector: "Callable[[QueryProxy[T]], Any]", join_type: str
    ) -> Self:
        """Shared body of :meth:`join`/:meth:`left_join` (#272).

        Resolves the selector to a relation path, checks every edge for a
        contradictory explicit mark (INNER vs LEFT), then records the marks and
        registers the full path so it renders. Re-marking an edge the same
        direction is idempotent.
        """
        path = _resolve_join_selector(selector, self.model_cls)
        edges = path_edges(path)
        new = self._clone()
        for edge in edges:
            existing = new._explicit_edges.get(edge)
            if existing is not None and existing != join_type:
                relation_path = ".".join(edge)
                raise ValueError(
                    f"conflicting explicit join types on relation edge "
                    f"{relation_path!r}: already marked {existing!r} by a prior "
                    f"join()/left_join(), cannot re-mark {join_type!r}. Use one "
                    "join type per edge."
                )
        for edge in edges:
            new._explicit_edges[edge] = join_type
        new._joins.setdefault(path, join_type)
        return new

    def limit(self, value: int) -> Self:
        """Limit the number of records returned

        Args:
            value: The maximum number of records to return.

        Returns:
            A new ``Query`` with the clause added; ``self`` is unchanged.

        Examples:
            >>> query = User.select().limit(10)
            >>> query._limit
            10
        """
        new = self._clone()
        new._limit = value
        return new

    def offset(self, value: int) -> Self:
        """Skip a specific number of records

        Args:
            value: The number of records to skip.

        Returns:
            A new ``Query`` with the clause added; ``self`` is unchanged.

        Examples:
            >>> query = User.select().offset(20)
            >>> query._offset
            20
        """
        new = self._clone()
        new._offset = value
        return new

    async def all(self) -> list[T]:
        """Return all model instances that match the current query

        Returns:
            A list of model instances.

        Examples:
            >>> users = await User.where(lambda user: user.active == True).all()  # noqa: E712
            >>> isinstance(users, list)
            True
        """
        compiled = compile_query(self, "fetch")
        route = await self._transaction_or_using()
        return await fetch_filtered(
            self.model_cls,
            compiled.wire_json,
            route,
            hop_classes=compiled.hop_classes,
        )

    async def count(self) -> int:
        """Return the number of records that match the current query

        Returns:
            The count of matching records.

        Examples:
            >>> total = await User.where(lambda user: user.active == True).count()  # noqa: E712
            >>> isinstance(total, int)
            True
        """
        compiled = compile_query(self, "count")
        route = await self._transaction_or_using()
        return await count_filtered(
            model_identity(self.model_cls),
            compiled.wire_json,
            route,
        )

    async def update(self, **fields) -> int:
        """Update all records matching the current query

        Args:
            **fields: Field names and values to update.

        Returns:
            The number of records updated.

        Raises:
            ValueError: If ``limit()`` or ``offset()`` was set on this query, or
                if the query traverses a relation (a ``where()`` predicate on a
                related column, or an explicit ``join()``/``left_join()``) —
                multi-table mutation has no portable SQL. Filter by a column on
                the target model, or resolve the related primary keys first and
                update by primary-key set.

        Examples:
            >>> updated = await User.where(lambda user: user.id == 1).update(name="Taylor")
            >>> isinstance(updated, int)
            True
        """
        compiled = compile_query(self, "update")
        route = await self._transaction_or_using()
        return await update_filtered(
            model_identity(self.model_cls),
            compiled.wire_json,
            update_bind_payload(fields),
            route,
        )

    async def first(self) -> T | None:
        """Return the first matching record, or None

        Returns:
            A model instance or None.

        Examples:
            >>> user = await User.select().order_by("id").first()
            >>> user is None or isinstance(user, User)
            True
        """
        results = await self.limit(1).all()
        return results[0] if results else None

    async def delete(self) -> int:
        """Delete all records matching the current query

        Returns:
            The number of records deleted.

        Raises:
            ValueError: If ``limit()`` or ``offset()`` was set on this query, or
                if the query traverses a relation (a ``where()`` predicate on a
                related column, or an explicit ``join()``/``left_join()``) —
                multi-table mutation has no portable SQL. Filter by a column on
                the target model, or resolve the related primary keys first and
                delete by primary-key set.

        Examples:
            >>> deleted = await User.where(lambda user: user.disabled == True).delete()  # noqa: E712
            >>> isinstance(deleted, int)
            True
        """
        compiled = compile_query(self, "delete")
        route = await self._transaction_or_using()
        return await delete_filtered(
            model_identity(self.model_cls),
            compiled.wire_json,
            route,
        )

    async def exists(self) -> bool:
        """Return whether at least one record matches the current query

        Returns:
            True if records exist, otherwise False.

        Examples:
            >>> found = await User.where(lambda user: user.email == "[email protected]").exists()
            >>> isinstance(found, bool)
            True
        """
        return await self.count() > 0

    async def add(self, *instances: Any) -> None:
        """Add links to a many-to-many relationship

        Args:
            *instances: Target model instances that provide an ``id`` attribute.

        Raises:
            RuntimeError: If the query is not bound to a many-to-many context.

        Examples:
            >>> user = await User.create(email="[email protected]")
            >>> admin = await Group.create(name="admin")
            >>> staff = await Group.create(name="staff")
            >>> await user.groups.add(admin, staff)
        """
        if not self._m2m_context:
            raise RuntimeError(
                "'.add()' can only be used on Many-to-Many relationships"
            )

        ids = []
        for inst in instances:
            # Assume 'id' for now
            ids.append(getattr(inst, "id"))

        route = await self._transaction_or_using()
        await add_m2m_links(
            self._m2m_context.join_table,
            self._m2m_context.source_col,
            self._m2m_context.target_col,
            self._m2m_context.source_id,
            ids,
            route,
        )

    async def remove(self, *instances: Any) -> None:
        """Remove links from a many-to-many relationship

        Args:
            *instances: Target model instances that provide an ``id`` attribute.

        Raises:
            RuntimeError: If the query is not bound to a many-to-many context.

        Examples:
            >>> user = await User.create(email="[email protected]")
            >>> admin = await Group.create(name="admin")
            >>> await user.groups.remove(admin)
        """
        if not self._m2m_context:
            raise RuntimeError(
                "'.remove()' can only be used on Many-to-Many relationships"
            )

        ids = []
        for inst in instances:
            ids.append(getattr(inst, "id"))

        route = await self._transaction_or_using()
        await remove_m2m_links(
            self._m2m_context.join_table,
            self._m2m_context.source_col,
            self._m2m_context.target_col,
            self._m2m_context.source_id,
            ids,
            route,
        )

    async def clear(self) -> None:
        """Clear all links in a many-to-many relationship

        Raises:
            RuntimeError: If the query is not bound to a many-to-many context.

        Examples:
            >>> user = await User.create(email="[email protected]")
            >>> await user.groups.clear()
        """
        if not self._m2m_context:
            raise RuntimeError(
                "'.clear()' can only be used on Many-to-Many relationships"
            )

        route = await self._transaction_or_using()
        await clear_m2m_links(
            self._m2m_context.join_table,
            self._m2m_context.source_col,
            self._m2m_context.source_id,
            route,
        )

    def __repr__(self):
        """Return a developer-friendly representation of the query"""
        return f"<Query model={self.model_cls.__name__} where={self.where_clause}>"

Attributes

model_cls = model_cls instance-attribute

where_clause = [] instance-attribute

order_by_clause = [] instance-attribute

Functions

__init__(model_cls, using=None, session=None)

Initialize a query for a model class.

Parameters:

Name Type Description Default
model_cls Type[T]

Model class that defines the target table.

required

Examples:

>>> query = Query(User)
>>> query.model_cls is User
True
Source code in src/ferro/query/builder.py
def __init__(
    self, model_cls: Type[T], using: str | None = None, session: Any | None = None
):
    """Initialize a query for a model class.

    Args:
        model_cls: Model class that defines the target table.

    Examples:
        >>> query = Query(User)
        >>> query.model_cls is User
        True
    """
    self.model_cls = model_cls
    self._using = using
    self._session = session
    self.where_clause: list["QueryNode"] = []
    self.order_by_clause: list[OrderByEntry] = []
    self._limit: int | None = None
    self._offset: int | None = None
    self._m2m_context: M2mContext | None = None
    # Relation paths that must render a join, insertion-ordered (full path
    # tuple -> registered join_type). Populated by where()/order_by()
    # traversal ("inner") and by the explicit join()/left_join() chainers
    # ("inner"/"left"). Serialized into the QueryIR ``joins`` section by
    # all()/count() (#270, #272).
    self._joins: dict[tuple[str, ...], str] = {}
    # Edges (path prefixes, length ≥ 1) whose join type was fixed by an
    # explicit chainer, insertion-ordered (edge tuple -> "inner"|"left").
    # ``.left_join`` marks every edge of its path "left" (whole-path rule,
    # ADR-0006); ``.join`` marks them "inner". The single source of truth
    # for LEFT on the wire — implicit where()/order_by() traversal never
    # touches this, so explicit always beats implicit (#272).
    self._explicit_edges: dict[tuple[str, ...], str] = {}
    # Relation paths to populate (#286, ADR-0008), insertion-ordered and
    # deduped by path identity (dict-as-ordered-set). CRITICAL: include
    # paths never enter ``_joins``/the wire ``joins`` section — they ride the
    # ``instances`` materialization plan, so the ``joins`` wire section
    # keeps its stage-1 semantics untouched and include contributes no
    # join-type opinion on any edge another clause references.
    self._includes: dict[tuple[str, ...], None] = {}

where(predicate)

Add a filter condition to the query.

predicate is a lambda of shape Callable[[QueryProxy[T]], QueryNode]. The lambda receives a fresh :class:QueryProxy whose attributes return :class:FieldProxy instances, so lambda user: user.archived == False builds a comparison. Column names are validated at build time against the model's declared fields (plus shadow {fk}_id columns): a misspelled column raises AttributeError naming the closest valid match, before any query is sent to the database.

Attribute access on a declared forward-FK field traverses the relation (lambda t: t.account.ledger_id == lid): each hop resolves against the related model, and every distinct traversed path renders one INNER join (ADR-0006) when the query runs. Every hop is validated at build time with the same did-you-mean naming the hop's model.

Parameters:

Name Type Description Default
predicate Predicate[T]

A callable that takes a :class:QueryProxy and returns a :class:QueryNode.

required

Returns:

Type Description
Self

A new Query with the clause added; self is unchanged.

Raises:

Type Description
TypeError

If predicate is not callable, or if it does not return a QueryNode.

Examples:

>>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
>>> q2 = User.where(lambda user: user.id == 1)
>>> isinstance(q1, Query) and isinstance(q2, Query)
True
Source code in src/ferro/query/builder.py
def where(self, predicate: "Predicate[T]") -> Self:
    """Add a filter condition to the query.

    ``predicate`` is a lambda of shape ``Callable[[QueryProxy[T]], QueryNode]``.
    The lambda receives a fresh :class:`QueryProxy` whose attributes
    return :class:`FieldProxy` instances, so
    ``lambda user: user.archived == False`` builds a comparison. Column
    names are validated at build time against the model's declared
    fields (plus shadow ``{fk}_id`` columns): a misspelled column raises
    ``AttributeError`` naming the closest valid match, before any query
    is sent to the database.

    Attribute access on a declared forward-FK field traverses the relation
    (``lambda t: t.account.ledger_id == lid``): each hop resolves against
    the related model, and every distinct traversed path renders one INNER
    join (ADR-0006) when the query runs. Every hop is validated at build
    time with the same did-you-mean naming the hop's model.

    Args:
        predicate: A callable that takes a :class:`QueryProxy` and
            returns a :class:`QueryNode`.

    Returns:
        A new ``Query`` with the clause added; ``self`` is unchanged.

    Raises:
        TypeError: If ``predicate`` is not callable, or if it does not
            return a ``QueryNode``.

    Examples:
        >>> q1 = User.where(lambda user: user.archived == False)  # noqa: E712
        >>> q2 = User.where(lambda user: user.id == 1)
        >>> isinstance(q1, Query) and isinstance(q2, Query)
        True
    """
    new = self._clone()
    node = _resolve_where_node(predicate, self.model_cls)
    new.where_clause.append(node)
    _register_join_paths(node, new._joins)
    return new

select(*selectors)

select() -> Self
select(selector: RowSelector[T]) -> ProjectedQuery[T]
select(*columns: str) -> ProjectedQuery[T]

Project the query to a column subset, or pass through unchanged.

Bare select() keeps meaning "full query": it returns an equivalent query of complete model instances. With a lambda selector — the documented style — the query becomes a projection: its results are :class:Row records in the list-like :class:Rows container, never model instances (the complete-instance invariant, ADR-0007). The selector returns one field (select(lambda t: t.amount)), a tuple (select(lambda t: (t.id, t.amount))), or a dict whose keys name the output fields (select(lambda t: {"account_name": t.account.name}) — output aliases). Fields may traverse declared forward-FK relations at any depth (t.account.name — traversed projection); traversal narrows exactly like a where() predicate on the same path (INNER, one join per path, ADR-0006) and left_join() is the opt-out — kept rows decode the traversed fields as None. Unaliased traversed fields take the bare leaf column name; two fields resolving to the same output name raise at build time. Column-name strings (select("id", "amount")) follow order_by's string contract exactly: root columns only, never traversal, never mixed with a lambda in one call. All forms validate at build time with did-you-mean.

A projected query composes like any other query: where() (relation traversal included), order_by() (by unselected columns too), limit()/offset(), first(), count(), and exists() all work; update()/delete() and a second select() raise at build time.

Parameters:

Name Type Description Default
*selectors RowSelector[T] | str

Nothing (full query), one lambda selector, or column-name strings.

()

Returns:

Type Description
Self | ProjectedQuery[T]

A new query; self is unchanged. Projected when a selector is

Self | ProjectedQuery[T]

given.

Raises:

Type Description
TypeError

If the selector is not callable/strings, selects non-fields (a bare relation, a comparison), nests shapes (a dict inside a tuple, a tuple inside a dict), uses a non-string dict key, or mixes strings with a lambda in one call.

ValueError

If the selection is empty, two fields resolve to the same output name, a string is a dotted path (strings never traverse), or the query already carries an include() (one materialization plan per query — record results are flat, permanently).

Examples:

>>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()
>>> rows[0].amount
Decimal('12.50')
>>> named = await Transaction.select(
...     lambda t: {"id": t.id, "account_name": t.account.name}
... ).all()
Source code in src/ferro/query/builder.py
def select(
    self, *selectors: "RowSelector[T] | str"
) -> "Self | ProjectedQuery[T]":
    """Project the query to a column subset, or pass through unchanged.

    Bare ``select()`` keeps meaning "full query": it returns an equivalent
    query of complete model instances. With a lambda selector — the
    documented style — the query becomes a projection: its results are
    :class:`Row` records in the list-like :class:`Rows` container, never
    model instances (the complete-instance invariant, ADR-0007). The
    selector returns one field (``select(lambda t: t.amount)``), a tuple
    (``select(lambda t: (t.id, t.amount))``), or a dict whose keys name
    the output fields (``select(lambda t: {"account_name":
    t.account.name})`` — output aliases). Fields may traverse declared
    forward-FK relations at any depth (``t.account.name`` — traversed
    projection); traversal narrows exactly like a ``where()`` predicate
    on the same path (INNER, one join per path, ADR-0006) and
    ``left_join()`` is the opt-out — kept rows decode the traversed
    fields as ``None``. Unaliased traversed fields take the bare leaf
    column name; two fields resolving to the same output name raise at
    build time. Column-name strings (``select("id", "amount")``) follow
    ``order_by``'s string contract exactly: root columns only, never
    traversal, never mixed with a lambda in one call. All forms validate
    at build time with did-you-mean.

    A projected query composes like any other query: ``where()``
    (relation traversal included), ``order_by()`` (by unselected columns
    too), ``limit()``/``offset()``, ``first()``, ``count()``, and
    ``exists()`` all work; ``update()``/``delete()`` and a second
    ``select()`` raise at build time.

    Args:
        *selectors: Nothing (full query), one lambda selector, or
            column-name strings.

    Returns:
        A new query; ``self`` is unchanged. Projected when a selector is
        given.

    Raises:
        TypeError: If the selector is not callable/strings, selects
            non-fields (a bare relation, a comparison), nests shapes
            (a dict inside a tuple, a tuple inside a dict), uses a
            non-string dict key, or mixes strings with a lambda in one
            call.
        ValueError: If the selection is empty, two fields resolve to the
            same output name, a string is a dotted path (strings never
            traverse), or the query already carries an ``include()``
            (one materialization plan per query — record results are
            flat, permanently).

    Examples:
        >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
        >>> rows[0].amount  # doctest: +SKIP
        Decimal('12.50')
        >>> named = await Transaction.select(  # doctest: +SKIP
        ...     lambda t: {"id": t.id, "account_name": t.account.name}
        ... ).all()
    """
    if not selectors:
        return self._clone()
    if self._includes:
        raise ValueError(
            "select() with a projection cannot be combined with "
            "include(): a query carries exactly one materialization plan "
            "— projected records or populated instances, never both, and "
            "record results are flat (ADR-0009). Reach across the "
            "relation with traversed projection instead (e.g. "
            'select(lambda t: {"account_name": t.account.name})), or '
            "populate instances with include() on an unprojected query."
        )
    if any(isinstance(selector, str) for selector in selectors):
        if not all(isinstance(selector, str) for selector in selectors):
            raise TypeError(
                "select() cannot mix column-name strings and a lambda "
                "selector in one call; use one form "
                '(select("id", "amount") or '
                "select(lambda t: (t.id, t.amount)))."
            )
        names: tuple[str, ...] = selectors  # type: ignore[assignment]  # ty: ignore[invalid-assignment]
        columns = _resolve_projection_strings(names, self.model_cls)
        fields = tuple(
            _ProjectedField(name=column, column=column, path=())
            for column in columns
        )
        return ProjectedQuery(self, fields)
    if len(selectors) > 1:
        raise TypeError(
            "select() takes a single lambda selector naming the columns "
            "(e.g. `select(lambda t: (t.id, t.amount))`), got "
            f"{len(selectors)} arguments"
        )
    selector = selectors[0]
    if not callable(selector):
        raise TypeError(
            "select() expected a selector callable or column-name strings "
            f"(e.g. `lambda t: (t.id, t.amount)`), got {type(selector).__name__}"
        )
    # Strings were dispatched above, so the lone selector is the lambda
    # form; the tuple element type is too wide for the checker to see it.
    fields = _resolve_projection_selector(
        cast("RowSelector[T]", selector), self.model_cls
    )
    return ProjectedQuery(self, fields)

order_by(field, direction='asc')

Add an ordering clause and return a new query.

Accepts a lambda naming the column (order_by(lambda u: u.created_at, "desc") — the documented style, matching where predicates) or a column-name string (order_by("created_at", "desc")). Both forms are validated against the model's queryable columns at build time.

A lambda selector may traverse a declared forward-FK relation (order_by(lambda t: t.account.name)) exactly like where(): each hop resolves against the related model, and the traversed path renders one INNER join (ADR-0006), shared with any where() traversal of the same path in the same query — the same path referenced in both yields exactly one join. String selectors do not traverse: "account.name" is looked up as a literal (unqualified) column name on the queried model.

Parameters:

Name Type Description Default
field str | Callable[[QueryProxy[T]], FieldProxy[Any]]

Column selector — lambda receiving a :class:QueryProxy, or a column-name string.

required
direction str

"asc" (default) or "desc".

'asc'

Returns:

Type Description
Self

A new Query with the ordering added; self is unchanged.

Raises:

Type Description
AttributeError

If the column is not a queryable column.

TypeError

If a lambda selector returns something other than a single column reference (a bare relation, e.g. lambda t: t.account, is meaningless as a sort key).

ValueError

If direction is not "asc" or "desc".

Examples:

>>> newest = await Post.select().order_by(lambda p: p.created_at, "desc").all()
Source code in src/ferro/query/builder.py
def order_by(
    self,
    field: "str | Callable[[QueryProxy[T]], FieldProxy[Any]]",
    direction: str = "asc",
) -> Self:
    """Add an ordering clause and return a new query.

    Accepts a lambda naming the column (``order_by(lambda u: u.created_at,
    "desc")`` — the documented style, matching ``where`` predicates) or a
    column-name string (``order_by("created_at", "desc")``). Both forms are
    validated against the model's queryable columns at build time.

    A lambda selector may traverse a declared forward-FK relation
    (``order_by(lambda t: t.account.name)``) exactly like ``where()``: each
    hop resolves against the related model, and the traversed path renders
    one INNER join (ADR-0006), shared with any ``where()`` traversal of the
    same path in the same query — the same path referenced in both yields
    exactly one join. String selectors do not traverse: ``"account.name"``
    is looked up as a literal (unqualified) column name on the queried
    model.

    Args:
        field: Column selector — lambda receiving a :class:`QueryProxy`,
            or a column-name string.
        direction: ``"asc"`` (default) or ``"desc"``.

    Returns:
        A new ``Query`` with the ordering added; ``self`` is unchanged.

    Raises:
        AttributeError: If the column is not a queryable column.
        TypeError: If a lambda selector returns something other than a
            single column reference (a bare relation, e.g.
            ``lambda t: t.account``, is meaningless as a sort key).
        ValueError: If ``direction`` is not ``"asc"`` or ``"desc"``.

    Examples:
        >>> newest = await Post.select().order_by(lambda p: p.created_at, "desc").all()
    """
    if direction.lower() not in ("asc", "desc"):
        raise ValueError("direction must be 'asc' or 'desc'")

    path: tuple[str, ...] = ()
    if isinstance(field, str):
        col_name = validate_query_column(self.model_cls, field)
    elif callable(field):
        selected = field(QueryProxy(self.model_cls))
        # Ordering by a bare relation (no column selected) is meaningless —
        # reject it loudly rather than emitting an order_by the SELECT
        # walker cannot render. Ordering by a related COLUMN (a FieldProxy
        # with a non-empty path) is valid traversal (#271).
        if isinstance(selected, RelationProxy):
            relation = selected._path[-1]
            raise TypeError(
                f"order_by() selector returned the bare relation {relation!r}, "
                "not a column; order by a column on it instead "
                f"(e.g. t.{relation}.<column>)."
            )
        if not isinstance(selected, FieldProxy):
            raise TypeError(
                "order_by() selector must return a FieldProxy "
                f"(e.g. `lambda u: u.created_at`), got {type(selected).__name__}"
            )
        col_name = selected.column
        path = selected.path
    else:
        raise TypeError(
            "order_by() expected a column-name string or a lambda selector, "
            f"got {type(field).__name__}"
        )

    new = self._clone()
    new.order_by_clause.append(
        OrderByEntry(column=col_name, direction=direction.lower(), path=path)
    )
    if path:
        new._joins.setdefault(path, "inner")
    return new

join(selector)

Force an INNER join on a relation path and return a new query.

selector is a lambda naming a RELATION path (lambda t: t.account, lambda t: t.account.owner) — the same traversal syntax as where(), but resolving to the relation itself, not a column on it. A bare .join(lambda t: t.account) with no predicate is a meaningful existence filter on a nullable relation: it narrows the result to rows where the relation exists (ADR-0006).

Every edge of the path is marked explicit-INNER; combining it with an explicit .left_join on the same edge is a build-time error (see :meth:left_join).

Parameters:

Name Type Description Default
selector Callable[[QueryProxy[T]], Any]

A lambda receiving a :class:QueryProxy and returning a relation path (a RelationProxy).

required

Returns:

Type Description
Self

A new Query with the join registered; self is unchanged.

Raises:

Type Description
TypeError

If selector is not callable or does not resolve to a relation path (a column selector is rejected — join names a relation, not a column).

ValueError

If an edge of the path is already marked explicit-LEFT.

Examples:

>>> with_account = QJTransaction.select().join(lambda t: t.account)
Source code in src/ferro/query/builder.py
def join(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
    """Force an INNER join on a relation path and return a new query.

    ``selector`` is a lambda naming a RELATION path
    (``lambda t: t.account``, ``lambda t: t.account.owner``) — the same
    traversal syntax as ``where()``, but resolving to the relation itself,
    not a column on it. A bare ``.join(lambda t: t.account)`` with no
    predicate is a meaningful **existence filter** on a nullable relation:
    it narrows the result to rows where the relation exists (ADR-0006).

    Every edge of the path is marked explicit-INNER; combining it with an
    explicit ``.left_join`` on the same edge is a build-time error (see
    :meth:`left_join`).

    Args:
        selector: A lambda receiving a :class:`QueryProxy` and returning a
            relation path (a ``RelationProxy``).

    Returns:
        A new ``Query`` with the join registered; ``self`` is unchanged.

    Raises:
        TypeError: If ``selector`` is not callable or does not resolve to a
            relation path (a column selector is rejected — join names a
            relation, not a column).
        ValueError: If an edge of the path is already marked explicit-LEFT.

    Examples:
        >>> with_account = QJTransaction.select().join(lambda t: t.account)
    """
    return self._add_explicit_join(selector, "inner")

left_join(selector)

Mark a relation path LEFT (whole-path) and return a new query.

selector names a RELATION path exactly like :meth:join. Every edge of the path is marked LEFT (the whole-path rule, ADR-0006), so a left-marked 2-hop path retains rows missing the relation at either hop. Relation-less rows are retained (NULL retention), observable both in ordered results and in traversal predicates on related columns (e.g. where(lambda t: t.account.name == None)).

Conflict rules: an explicit LEFT beats implicit where()/order_by() traversal on a shared edge (the path renders LEFT); an explicit .join plus an explicit .left_join on the same edge is a build-time error.

Parameters:

Name Type Description Default
selector Callable[[QueryProxy[T]], Any]

A lambda receiving a :class:QueryProxy and returning a relation path (a RelationProxy).

required

Returns:

Type Description
Self

A new Query with the LEFT join registered; self is unchanged.

Raises:

Type Description
TypeError

If selector is not callable or does not resolve to a relation path.

ValueError

If an edge of the path is already marked explicit-INNER.

Examples:

>>> keep_orphans = QJNote.select().left_join(lambda n: n.account)
Source code in src/ferro/query/builder.py
def left_join(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
    """Mark a relation path LEFT (whole-path) and return a new query.

    ``selector`` names a RELATION path exactly like :meth:`join`. Every edge
    of the path is marked LEFT (the **whole-path rule**, ADR-0006), so a
    left-marked 2-hop path retains rows missing the relation at either hop.
    Relation-less rows are retained (NULL retention), observable both in
    ordered results and in traversal predicates on related columns (e.g.
    ``where(lambda t: t.account.name == None)``).

    Conflict rules: an explicit LEFT beats implicit ``where()``/``order_by()``
    traversal on a shared edge (the path renders LEFT); an explicit ``.join``
    plus an explicit ``.left_join`` on the same edge is a build-time error.

    Args:
        selector: A lambda receiving a :class:`QueryProxy` and returning a
            relation path (a ``RelationProxy``).

    Returns:
        A new ``Query`` with the LEFT join registered; ``self`` is unchanged.

    Raises:
        TypeError: If ``selector`` is not callable or does not resolve to a
            relation path.
        ValueError: If an edge of the path is already marked explicit-INNER.

    Examples:
        >>> keep_orphans = QJNote.select().left_join(lambda n: n.account)
    """
    return self._add_explicit_join(selector, "left")

include(selector)

Populate a relation path on every result and return a new query.

selector is a lambda naming a forward-FK RELATION path (lambda t: t.account, lambda t: t.account.owner) — the same traversal syntax as :meth:join. Results are the same list[T] the query always returned, in one SQL statement, with each included relation populated: access is a plain attribute (txn.account.label — no await, no query) returning the complete related instance, exactly as the field's declared annotation claims (ADR-0008). Unpopulated relations keep the awaitable contract unchanged; a nullable FK with no target populates as None with the root row retained.

Include decides attached data only — joins decide membership, projection decides shape. Adding .include(...) to any query returns exactly the rows that query returned without it: include contributes no join-type opinion on any edge a predicate or explicit chainer references, and renders LEFT only on edges nothing else touches. count()/exists() are unaffected.

Cumulative, order-free, and idempotent: chained includes accumulate, shared prefixes dedup by path identity, and including a path populates every hop along it.

Parameters:

Name Type Description Default
selector Callable[[QueryProxy[T]], Any]

A lambda receiving a :class:QueryProxy and returning a relation path (a RelationProxy).

required

Returns:

Type Description
Self

A new Query with the population registered; self is

Self

unchanged.

Raises:

Type Description
TypeError

If selector is a string or not callable, resolves to a column rather than a relation, or names a BackRef/M2M relation (reverse population is a separate future mechanism).

Examples:

>>> txns = await Transaction.select().include(lambda t: t.account).all()
>>> txns[0].account.label
'checking'
Source code in src/ferro/query/builder.py
def include(self, selector: "Callable[[QueryProxy[T]], Any]") -> Self:
    """Populate a relation path on every result and return a new query.

    ``selector`` is a lambda naming a forward-FK RELATION path
    (``lambda t: t.account``, ``lambda t: t.account.owner``) — the same
    traversal syntax as :meth:`join`. Results are the same ``list[T]``
    the query always returned, in one SQL statement, with each included
    relation **populated**: access is a plain attribute
    (``txn.account.label`` — no await, no query) returning the complete
    related instance, exactly as the field's declared annotation claims
    (ADR-0008). Unpopulated relations keep the awaitable contract
    unchanged; a nullable FK with no target populates as ``None`` with
    the root row retained.

    Include decides attached data only — joins decide membership,
    projection decides shape. Adding ``.include(...)`` to any query
    returns exactly the rows that query returned without it: include
    contributes no join-type opinion on any edge a predicate or explicit
    chainer references, and renders LEFT only on edges nothing else
    touches. ``count()``/``exists()`` are unaffected.

    Cumulative, order-free, and idempotent: chained includes accumulate,
    shared prefixes dedup by path identity, and including a path
    populates every hop along it.

    Args:
        selector: A lambda receiving a :class:`QueryProxy` and returning
            a relation path (a ``RelationProxy``).

    Returns:
        A new ``Query`` with the population registered; ``self`` is
        unchanged.

    Raises:
        TypeError: If ``selector`` is a string or not callable, resolves
            to a column rather than a relation, or names a BackRef/M2M
            relation (reverse population is a separate future mechanism).

    Examples:
        >>> txns = await Transaction.select().include(lambda t: t.account).all()  # doctest: +SKIP
        >>> txns[0].account.label  # doctest: +SKIP
        'checking'
    """
    path = _resolve_include_selector(selector, self.model_cls)
    new = self._clone()
    new._includes.setdefault(path, None)
    return new

limit(value)

Limit the number of records returned

Parameters:

Name Type Description Default
value int

The maximum number of records to return.

required

Returns:

Type Description
Self

A new Query with the clause added; self is unchanged.

Examples:

>>> query = User.select().limit(10)
>>> query._limit
10
Source code in src/ferro/query/builder.py
def limit(self, value: int) -> Self:
    """Limit the number of records returned

    Args:
        value: The maximum number of records to return.

    Returns:
        A new ``Query`` with the clause added; ``self`` is unchanged.

    Examples:
        >>> query = User.select().limit(10)
        >>> query._limit
        10
    """
    new = self._clone()
    new._limit = value
    return new

offset(value)

Skip a specific number of records

Parameters:

Name Type Description Default
value int

The number of records to skip.

required

Returns:

Type Description
Self

A new Query with the clause added; self is unchanged.

Examples:

>>> query = User.select().offset(20)
>>> query._offset
20
Source code in src/ferro/query/builder.py
def offset(self, value: int) -> Self:
    """Skip a specific number of records

    Args:
        value: The number of records to skip.

    Returns:
        A new ``Query`` with the clause added; ``self`` is unchanged.

    Examples:
        >>> query = User.select().offset(20)
        >>> query._offset
        20
    """
    new = self._clone()
    new._offset = value
    return new

all() async

Return all model instances that match the current query

Returns:

Type Description
list[T]

A list of model instances.

Examples:

>>> users = await User.where(lambda user: user.active == True).all()  # noqa: E712
>>> isinstance(users, list)
True
Source code in src/ferro/query/builder.py
async def all(self) -> list[T]:
    """Return all model instances that match the current query

    Returns:
        A list of model instances.

    Examples:
        >>> users = await User.where(lambda user: user.active == True).all()  # noqa: E712
        >>> isinstance(users, list)
        True
    """
    compiled = compile_query(self, "fetch")
    route = await self._transaction_or_using()
    return await fetch_filtered(
        self.model_cls,
        compiled.wire_json,
        route,
        hop_classes=compiled.hop_classes,
    )

count() async

Return the number of records that match the current query

Returns:

Type Description
int

The count of matching records.

Examples:

>>> total = await User.where(lambda user: user.active == True).count()  # noqa: E712
>>> isinstance(total, int)
True
Source code in src/ferro/query/builder.py
async def count(self) -> int:
    """Return the number of records that match the current query

    Returns:
        The count of matching records.

    Examples:
        >>> total = await User.where(lambda user: user.active == True).count()  # noqa: E712
        >>> isinstance(total, int)
        True
    """
    compiled = compile_query(self, "count")
    route = await self._transaction_or_using()
    return await count_filtered(
        model_identity(self.model_cls),
        compiled.wire_json,
        route,
    )

update(**fields) async

Update all records matching the current query

Parameters:

Name Type Description Default
**fields

Field names and values to update.

{}

Returns:

Type Description
int

The number of records updated.

Raises:

Type Description
ValueError

If limit() or offset() was set on this query, or if the query traverses a relation (a where() predicate on a related column, or an explicit join()/left_join()) — multi-table mutation has no portable SQL. Filter by a column on the target model, or resolve the related primary keys first and update by primary-key set.

Examples:

>>> updated = await User.where(lambda user: user.id == 1).update(name="Taylor")
>>> isinstance(updated, int)
True
Source code in src/ferro/query/builder.py
async def update(self, **fields) -> int:
    """Update all records matching the current query

    Args:
        **fields: Field names and values to update.

    Returns:
        The number of records updated.

    Raises:
        ValueError: If ``limit()`` or ``offset()`` was set on this query, or
            if the query traverses a relation (a ``where()`` predicate on a
            related column, or an explicit ``join()``/``left_join()``) —
            multi-table mutation has no portable SQL. Filter by a column on
            the target model, or resolve the related primary keys first and
            update by primary-key set.

    Examples:
        >>> updated = await User.where(lambda user: user.id == 1).update(name="Taylor")
        >>> isinstance(updated, int)
        True
    """
    compiled = compile_query(self, "update")
    route = await self._transaction_or_using()
    return await update_filtered(
        model_identity(self.model_cls),
        compiled.wire_json,
        update_bind_payload(fields),
        route,
    )

first() async

Return the first matching record, or None

Returns:

Type Description
T | None

A model instance or None.

Examples:

>>> user = await User.select().order_by("id").first()
>>> user is None or isinstance(user, User)
True
Source code in src/ferro/query/builder.py
async def first(self) -> T | None:
    """Return the first matching record, or None

    Returns:
        A model instance or None.

    Examples:
        >>> user = await User.select().order_by("id").first()
        >>> user is None or isinstance(user, User)
        True
    """
    results = await self.limit(1).all()
    return results[0] if results else None

delete() async

Delete all records matching the current query

Returns:

Type Description
int

The number of records deleted.

Raises:

Type Description
ValueError

If limit() or offset() was set on this query, or if the query traverses a relation (a where() predicate on a related column, or an explicit join()/left_join()) — multi-table mutation has no portable SQL. Filter by a column on the target model, or resolve the related primary keys first and delete by primary-key set.

Examples:

>>> deleted = await User.where(lambda user: user.disabled == True).delete()  # noqa: E712
>>> isinstance(deleted, int)
True
Source code in src/ferro/query/builder.py
async def delete(self) -> int:
    """Delete all records matching the current query

    Returns:
        The number of records deleted.

    Raises:
        ValueError: If ``limit()`` or ``offset()`` was set on this query, or
            if the query traverses a relation (a ``where()`` predicate on a
            related column, or an explicit ``join()``/``left_join()``) —
            multi-table mutation has no portable SQL. Filter by a column on
            the target model, or resolve the related primary keys first and
            delete by primary-key set.

    Examples:
        >>> deleted = await User.where(lambda user: user.disabled == True).delete()  # noqa: E712
        >>> isinstance(deleted, int)
        True
    """
    compiled = compile_query(self, "delete")
    route = await self._transaction_or_using()
    return await delete_filtered(
        model_identity(self.model_cls),
        compiled.wire_json,
        route,
    )

exists() async

Return whether at least one record matches the current query

Returns:

Type Description
bool

True if records exist, otherwise False.

Examples:

>>> found = await User.where(lambda user: user.email == "[email protected]").exists()
>>> isinstance(found, bool)
True
Source code in src/ferro/query/builder.py
async def exists(self) -> bool:
    """Return whether at least one record matches the current query

    Returns:
        True if records exist, otherwise False.

    Examples:
        >>> found = await User.where(lambda user: user.email == "[email protected]").exists()
        >>> isinstance(found, bool)
        True
    """
    return await self.count() > 0

add(*instances) async

Add links to a many-to-many relationship

Parameters:

Name Type Description Default
*instances Any

Target model instances that provide an id attribute.

()

Raises:

Type Description
RuntimeError

If the query is not bound to a many-to-many context.

Examples:

>>> user = await User.create(email="[email protected]")
>>> admin = await Group.create(name="admin")
>>> staff = await Group.create(name="staff")
>>> await user.groups.add(admin, staff)
Source code in src/ferro/query/builder.py
async def add(self, *instances: Any) -> None:
    """Add links to a many-to-many relationship

    Args:
        *instances: Target model instances that provide an ``id`` attribute.

    Raises:
        RuntimeError: If the query is not bound to a many-to-many context.

    Examples:
        >>> user = await User.create(email="[email protected]")
        >>> admin = await Group.create(name="admin")
        >>> staff = await Group.create(name="staff")
        >>> await user.groups.add(admin, staff)
    """
    if not self._m2m_context:
        raise RuntimeError(
            "'.add()' can only be used on Many-to-Many relationships"
        )

    ids = []
    for inst in instances:
        # Assume 'id' for now
        ids.append(getattr(inst, "id"))

    route = await self._transaction_or_using()
    await add_m2m_links(
        self._m2m_context.join_table,
        self._m2m_context.source_col,
        self._m2m_context.target_col,
        self._m2m_context.source_id,
        ids,
        route,
    )

remove(*instances) async

Remove links from a many-to-many relationship

Parameters:

Name Type Description Default
*instances Any

Target model instances that provide an id attribute.

()

Raises:

Type Description
RuntimeError

If the query is not bound to a many-to-many context.

Examples:

>>> user = await User.create(email="[email protected]")
>>> admin = await Group.create(name="admin")
>>> await user.groups.remove(admin)
Source code in src/ferro/query/builder.py
async def remove(self, *instances: Any) -> None:
    """Remove links from a many-to-many relationship

    Args:
        *instances: Target model instances that provide an ``id`` attribute.

    Raises:
        RuntimeError: If the query is not bound to a many-to-many context.

    Examples:
        >>> user = await User.create(email="[email protected]")
        >>> admin = await Group.create(name="admin")
        >>> await user.groups.remove(admin)
    """
    if not self._m2m_context:
        raise RuntimeError(
            "'.remove()' can only be used on Many-to-Many relationships"
        )

    ids = []
    for inst in instances:
        ids.append(getattr(inst, "id"))

    route = await self._transaction_or_using()
    await remove_m2m_links(
        self._m2m_context.join_table,
        self._m2m_context.source_col,
        self._m2m_context.target_col,
        self._m2m_context.source_id,
        ids,
        route,
    )

clear() async

Clear all links in a many-to-many relationship

Raises:

Type Description
RuntimeError

If the query is not bound to a many-to-many context.

Examples:

>>> user = await User.create(email="[email protected]")
>>> await user.groups.clear()
Source code in src/ferro/query/builder.py
async def clear(self) -> None:
    """Clear all links in a many-to-many relationship

    Raises:
        RuntimeError: If the query is not bound to a many-to-many context.

    Examples:
        >>> user = await User.create(email="[email protected]")
        >>> await user.groups.clear()
    """
    if not self._m2m_context:
        raise RuntimeError(
            "'.clear()' can only be used on Many-to-Many relationships"
        )

    route = await self._transaction_or_using()
    await clear_m2m_links(
        self._m2m_context.join_table,
        self._m2m_context.source_col,
        self._m2m_context.source_id,
        route,
    )

__repr__()

Return a developer-friendly representation of the query

Source code in src/ferro/query/builder.py
def __repr__(self):
    """Return a developer-friendly representation of the query"""
    return f"<Query model={self.model_cls.__name__} where={self.where_clause}>"

ProjectedQuery

Bases: Query[T]

A query projected to a column subset: results are records, not models.

Created by select() with a selector; carries a record materialization plan (ADR-0007), so all() delivers :class:Row records in the list-like :class:Rows container and first() a Row | None — never model instances (the complete-instance invariant). Projected records bypass the identity map and carry no persistence identity.

Filtering, ordering, and pagination compose exactly like on :class:Query; count()/exists() are unaffected by the projection.

Source code in src/ferro/query/builder.py
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
class ProjectedQuery(Query[T]):
    """A query projected to a column subset: results are records, not models.

    Created by ``select()`` with a selector; carries a ``record``
    materialization plan (ADR-0007), so ``all()`` delivers :class:`Row`
    records in the list-like :class:`Rows` container and ``first()`` a
    ``Row | None`` — never model instances (the complete-instance invariant).
    Projected records bypass the identity map and carry no persistence
    identity.

    Filtering, ordering, and pagination compose exactly like on
    :class:`Query`; ``count()``/``exists()`` are unaffected by the
    projection.
    """

    def __init__(self, source: Query[T], fields: tuple[_ProjectedField, ...]) -> None:
        """Project ``source`` to ``fields`` (selection order preserved).

        Copies the source query's state through ``_clone()`` (fresh mutable
        containers, FF-F F-1); ``source`` is unchanged. Every traversed
        field's relation path registers a join (#293): projection traversal
        narrows exactly like ``where()``/``order_by()`` traversal, sharing
        join identity per relation path (ADR-0006) — ``setdefault`` keeps an
        already-registered path's entry, and an explicit ``left_join()``
        still wins at serialization via the edge marks.
        """
        self.__dict__.update(source._clone().__dict__)
        # Immutable, so chained clones share it safely.
        self._projection: tuple[_ProjectedField, ...] = fields
        for field in fields:
            if field.path:
                self._joins.setdefault(field.path, "inner")
        # Rule 3 (#295) covers sort keys added BEFORE the projection too:
        # an order_by() chained ahead of an aggregate select() must already
        # name a group key (or coincide with an output field) — checked here
        # so the error stays at build time, not at the render boundary.
        if self._has_aggregate():
            output_names = {field.name for field in fields}
            for order in self.order_by_clause:
                column, path = order.column, order.path
                if not path and column in output_names:
                    continue
                if self._is_group_key_source(column, path):
                    continue
                dotted = "t." + ".".join((*path, column))
                raise ValueError(
                    f"order_by() key {dotted} (added before this select()) "
                    "is not a group key of the aggregate projection: each "
                    "group would answer with an arbitrary row's value. "
                    "Project it as a group key, or sort by an output field "
                    "after the select()."
                )

    def _has_aggregate(self) -> bool:
        """True when this is an AGGREGATE PROJECTION (CONTEXT.md): at least
        one aggregate field, so every plain field is a group key (#295)."""
        return any(field.agg for field in self._projection)

    def _append_order_by(
        self, column: str, direction: str, path: tuple[str, ...]
    ) -> Self:
        """Clone with one resolved ORDER BY entry appended (#295)."""
        new = self._clone()
        new.order_by_clause.append(
            OrderByEntry(column=column, direction=direction.lower(), path=path)
        )
        if path:
            new._joins.setdefault(path, "inner")
        return new

    def order_by(
        self,
        field: "str | Callable[[QueryProxy[T]], Any]",
        direction: str = "asc",
    ) -> Self:
        """Add an ordering clause to a projected query (#295's three rules).

        Strings resolve OUTPUT field names first, then root columns — SQL's
        own ORDER BY scoping (``order_by("total")`` sorts by the projected
        aggregate, even if a root column shares the name). The lambda form
        spells SOURCE expressions: a column (traversal included), or an
        aggregate (``order_by(lambda t: t.amount.sum(), "desc")``) which must
        match a projected aggregate field and sorts by its output name.

        On an AGGREGATE projection every sort key must be a group key or an
        aggregate — anything else raises at build time (each group would
        answer with an arbitrary row's value; SQLite would even permit it).
        On a plain projection unselected root columns stay sortable,
        unchanged.

        Args:
            field: Output field name or root column-name string, or a lambda
                naming a source column / aggregate expression.
            direction: ``"asc"`` (default) or ``"desc"``.

        Returns:
            A new query with the ordering added; ``self`` is unchanged.

        Raises:
            ValueError: If ``direction`` is invalid, or the sort key is not a
                group key or aggregate on an aggregate projection (including
                an aggregate expression matching no projected field).
            AttributeError: If a string names neither an output field nor a
                queryable root column.
            TypeError: If a lambda resolves to a bare relation or any other
                non-sortable value.
        """
        if direction.lower() not in ("asc", "desc"):
            raise ValueError("direction must be 'asc' or 'desc'")
        has_aggregate = self._has_aggregate()
        output_names = {f.name for f in self._projection}

        if isinstance(field, str):
            # Rule 1: output field names first (group keys and aggregates
            # both sort by name), then root columns.
            if field in output_names:
                return self._append_order_by(field, direction, ())
            try:
                validate_query_column(self.model_cls, field)
            except AttributeError as exc:
                raise AttributeError(
                    f"{exc.args[0]} On this projected query, output field "
                    f"names also sort: {', '.join(sorted(output_names))}.",
                    name=getattr(exc, "name", None),
                    obj=getattr(exc, "obj", None),
                ) from None
            if has_aggregate and not self._is_group_key_source(field, ()):
                raise ValueError(
                    f"order_by({field!r}) on an aggregate projection must "
                    "name a group key or an aggregate: it is neither an "
                    "output field nor a group key's source column, so each "
                    "group would answer with an arbitrary row's value. Sort "
                    "by an output field name, or project the column as a "
                    "group key."
                )
            return self._append_order_by(field, direction, ())

        if not callable(field):
            raise TypeError(
                "order_by() expected a column-name string or a lambda "
                f"selector, got {type(field).__name__}"
            )
        selected = field(QueryProxy(self.model_cls))
        # Rule 2: the lambda form spells source expressions — aggregates
        # resolve to the projected field carrying the same expression and
        # sort by its output name.
        if isinstance(selected, AggregateExpr):
            for proj in self._projection:
                if (
                    proj.agg == selected.fn
                    and proj.column == selected.column
                    and proj.path == selected.path
                ):
                    return self._append_order_by(proj.name, direction, ())
            raise ValueError(
                f"order_by() aggregate {selected._dotted()} matches no "
                "projected field; project it to sort by it (e.g. "
                f'select(lambda t: {{..., "key": {selected._dotted()}}}) '
                'with order_by("key")).'
            )
        if isinstance(selected, RelationProxy):
            relation = selected._path[-1]
            raise TypeError(
                f"order_by() selector returned the bare relation {relation!r}, "
                "not a column; order by a column on it instead "
                f"(e.g. t.{relation}.<column>)."
            )
        if not isinstance(selected, FieldProxy):
            raise TypeError(
                "order_by() selector must return a FieldProxy "
                f"(e.g. `lambda u: u.created_at`), got {type(selected).__name__}"
            )
        # Rule 3: on an aggregate projection a source column must be a group
        # key; on a plain projection unselected columns stay sortable.
        if has_aggregate and not self._is_group_key_source(
            selected.column, selected.path
        ):
            dotted = "t." + ".".join((*selected.path, selected.column))
            raise ValueError(
                f"order_by() key {dotted} on an aggregate projection must be "
                "a group key or an aggregate: it is not a group key's source "
                "column, so each group would answer with an arbitrary row's "
                "value. Project it as a group key, or sort by an aggregate."
            )
        return self._append_order_by(selected.column, direction, selected.path)

    def _is_group_key_source(self, column: str, path: tuple[str, ...]) -> bool:
        """True when ``(column, path)`` is some plain field's source (#295)."""
        return any(
            field.agg is None and field.column == column and field.path == path
            for field in self._projection
        )

    def count(self):  # type: ignore[override]
        """Count matching rows — or raise on an aggregate projection (#295).

        On a plain projection ``count()`` stays projection-blind (#279's
        verb contract). On an AGGREGATE projection "count" is ambiguous
        between rows and groups, so it raises synchronously at the call,
        before any coroutine or SQL exists, with both spellings.

        Raises:
            ValueError: On an aggregate projection — count rows with an
                unprojected query, count groups with ``len(await q.all())``.
        """
        if self._has_aggregate():
            raise ValueError(
                "count() on an aggregate projection is ambiguous: count the "
                "matching rows with an unprojected query "
                "(Model.where(...).count()), or count the groups with "
                "len(await q.all())."
            )
        return super().count()

    def exists(self):  # type: ignore[override]
        """Existence check — or raise on an aggregate projection (#295).

        Raises:
            ValueError: On an aggregate projection — a global aggregate
                always yields one record, and "a group exists" is
                ``len(await q.all()) > 0``; test the unprojected query
                instead (``Model.where(...).exists()``).
        """
        if self._has_aggregate():
            raise ValueError(
                "exists() on an aggregate projection is ambiguous: an "
                "aggregate-only projection always yields exactly one record. "
                "Test row existence with an unprojected query "
                "(Model.where(...).exists()), or group existence with "
                "len(await q.all()) > 0."
            )
        return super().exists()

    async def all(self) -> Rows[Row]:  # type: ignore[override]  # ty: ignore[invalid-method-override]
        """Return the projected records for every matching row.

        Returns:
            A :class:`Rows` of :class:`Row` records, fields in selection
            order, wrapped without validation.

        Examples:
            >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
            >>> [r.amount for r in rows]  # doctest: +SKIP
            [Decimal('12.50'), Decimal('7.00')]
        """
        compiled = compile_query(self, "fetch")
        route = await self._transaction_or_using()
        records = await fetch_filtered(
            self.model_cls,
            compiled.wire_json,
            route,
            record_cls=Row,
            hop_classes=compiled.hop_classes,
        )
        return _ROWS_OF_ROW._wrap(records)

    async def first(self) -> Row | None:  # type: ignore[override]  # ty: ignore[invalid-method-override]
        """Return the first matching projected record, or None.

        Examples:
            >>> row = await Transaction.select(lambda t: t.amount).order_by("id").first()  # doctest: +SKIP
            >>> row is None or isinstance(row, Row)  # doctest: +SKIP
            True
        """
        results = await self.limit(1).all()
        return results[0] if results else None

    def select(self, *selectors: "RowSelector[T] | str") -> NoReturn:  # type: ignore[override]  # ty: ignore[invalid-method-override]
        """Reject a second projection: it would change the result type
        mid-chain (#280).

        Raises:
            ValueError: Always — build the projection in one ``select()``
                call, or start a new query from the model.
        """
        raise ValueError(
            "select() was already applied to this query; replacing a "
            "projection changes the result type mid-chain. Name every "
            "projected column in one select() call, or start a new query "
            "from the model."
        )

    def include(self: Never, selector: Any) -> NoReturn:  # type: ignore[override]
        """Reject ``include()`` on a projected query (#287, final at #293).

        One materialization plan per query (ADR-0007/ADR-0008), and record
        results are flat, permanently (ADR-0009: flattened-as-final) — a
        record reaches across a relation with traversed projection, never by
        nesting an instance. The ``self: Never`` pin makes this a STATIC
        error at the call site (tests/static_fixtures/bad_includes.py),
        mirroring the mutation pins.

        Raises:
            ValueError: Always — reach across the relation with traversed
                projection, or populate instances through an unprojected
                query.
        """
        raise ValueError(
            "include() cannot be combined with a projection: a query carries "
            "exactly one materialization plan — projected records or "
            "populated instances, never both, and record results are flat "
            "(ADR-0009). Reach across the relation with traversed projection "
            'instead (e.g. select(lambda t: {"account_name": '
            "t.account.name})), or populate instances with include() on an "
            "unprojected query."
        )

    # `self: Never` makes mutating a projected query a STATIC error at the
    # call site (pinned by tests/static_fixtures/bad_projections.py) while the
    # runtime bodies raise at build time — synchronously, at the call, before
    # any coroutine or SQL exists (#280). A projection is a read shape;
    # silently ignoring it would make select(...) a no-op on mutations.

    def update(self: Never, **fields: Any) -> NoReturn:  # type: ignore[override]
        """Reject ``update()`` on a projected query (#280).

        Raises:
            ValueError: Always — mutate through an unprojected query
                (``Model.where(...).update(...)``).
        """
        raise ValueError(
            "update() is not supported on a projected query: a projection is "
            "a read shape. Build the mutation from the model instead "
            "(e.g. Model.where(...).update(...))."
        )

    def delete(self: Never) -> NoReturn:  # type: ignore[override]
        """Reject ``delete()`` on a projected query (#280).

        Raises:
            ValueError: Always — mutate through an unprojected query
                (``Model.where(...).delete()``).
        """
        raise ValueError(
            "delete() is not supported on a projected query: a projection is "
            "a read shape. Build the mutation from the model instead "
            "(e.g. Model.where(...).delete())."
        )

    def __repr__(self):
        """Return a developer-friendly representation of the projection"""
        return (
            f"<ProjectedQuery model={self.model_cls.__name__} "
            f"fields={[field.name for field in self._projection]} "
            f"where={self.where_clause}>"
        )

Functions

__init__(source, fields)

Project source to fields (selection order preserved).

Copies the source query's state through _clone() (fresh mutable containers, FF-F F-1); source is unchanged. Every traversed field's relation path registers a join (#293): projection traversal narrows exactly like where()/order_by() traversal, sharing join identity per relation path (ADR-0006) — setdefault keeps an already-registered path's entry, and an explicit left_join() still wins at serialization via the edge marks.

Source code in src/ferro/query/builder.py
def __init__(self, source: Query[T], fields: tuple[_ProjectedField, ...]) -> None:
    """Project ``source`` to ``fields`` (selection order preserved).

    Copies the source query's state through ``_clone()`` (fresh mutable
    containers, FF-F F-1); ``source`` is unchanged. Every traversed
    field's relation path registers a join (#293): projection traversal
    narrows exactly like ``where()``/``order_by()`` traversal, sharing
    join identity per relation path (ADR-0006) — ``setdefault`` keeps an
    already-registered path's entry, and an explicit ``left_join()``
    still wins at serialization via the edge marks.
    """
    self.__dict__.update(source._clone().__dict__)
    # Immutable, so chained clones share it safely.
    self._projection: tuple[_ProjectedField, ...] = fields
    for field in fields:
        if field.path:
            self._joins.setdefault(field.path, "inner")
    # Rule 3 (#295) covers sort keys added BEFORE the projection too:
    # an order_by() chained ahead of an aggregate select() must already
    # name a group key (or coincide with an output field) — checked here
    # so the error stays at build time, not at the render boundary.
    if self._has_aggregate():
        output_names = {field.name for field in fields}
        for order in self.order_by_clause:
            column, path = order.column, order.path
            if not path and column in output_names:
                continue
            if self._is_group_key_source(column, path):
                continue
            dotted = "t." + ".".join((*path, column))
            raise ValueError(
                f"order_by() key {dotted} (added before this select()) "
                "is not a group key of the aggregate projection: each "
                "group would answer with an arbitrary row's value. "
                "Project it as a group key, or sort by an output field "
                "after the select()."
            )

order_by(field, direction='asc')

Add an ordering clause to a projected query (#295's three rules).

Strings resolve OUTPUT field names first, then root columns — SQL's own ORDER BY scoping (order_by("total") sorts by the projected aggregate, even if a root column shares the name). The lambda form spells SOURCE expressions: a column (traversal included), or an aggregate (order_by(lambda t: t.amount.sum(), "desc")) which must match a projected aggregate field and sorts by its output name.

On an AGGREGATE projection every sort key must be a group key or an aggregate — anything else raises at build time (each group would answer with an arbitrary row's value; SQLite would even permit it). On a plain projection unselected root columns stay sortable, unchanged.

Parameters:

Name Type Description Default
field str | Callable[[QueryProxy[T]], Any]

Output field name or root column-name string, or a lambda naming a source column / aggregate expression.

required
direction str

"asc" (default) or "desc".

'asc'

Returns:

Type Description
Self

A new query with the ordering added; self is unchanged.

Raises:

Type Description
ValueError

If direction is invalid, or the sort key is not a group key or aggregate on an aggregate projection (including an aggregate expression matching no projected field).

AttributeError

If a string names neither an output field nor a queryable root column.

TypeError

If a lambda resolves to a bare relation or any other non-sortable value.

Source code in src/ferro/query/builder.py
def order_by(
    self,
    field: "str | Callable[[QueryProxy[T]], Any]",
    direction: str = "asc",
) -> Self:
    """Add an ordering clause to a projected query (#295's three rules).

    Strings resolve OUTPUT field names first, then root columns — SQL's
    own ORDER BY scoping (``order_by("total")`` sorts by the projected
    aggregate, even if a root column shares the name). The lambda form
    spells SOURCE expressions: a column (traversal included), or an
    aggregate (``order_by(lambda t: t.amount.sum(), "desc")``) which must
    match a projected aggregate field and sorts by its output name.

    On an AGGREGATE projection every sort key must be a group key or an
    aggregate — anything else raises at build time (each group would
    answer with an arbitrary row's value; SQLite would even permit it).
    On a plain projection unselected root columns stay sortable,
    unchanged.

    Args:
        field: Output field name or root column-name string, or a lambda
            naming a source column / aggregate expression.
        direction: ``"asc"`` (default) or ``"desc"``.

    Returns:
        A new query with the ordering added; ``self`` is unchanged.

    Raises:
        ValueError: If ``direction`` is invalid, or the sort key is not a
            group key or aggregate on an aggregate projection (including
            an aggregate expression matching no projected field).
        AttributeError: If a string names neither an output field nor a
            queryable root column.
        TypeError: If a lambda resolves to a bare relation or any other
            non-sortable value.
    """
    if direction.lower() not in ("asc", "desc"):
        raise ValueError("direction must be 'asc' or 'desc'")
    has_aggregate = self._has_aggregate()
    output_names = {f.name for f in self._projection}

    if isinstance(field, str):
        # Rule 1: output field names first (group keys and aggregates
        # both sort by name), then root columns.
        if field in output_names:
            return self._append_order_by(field, direction, ())
        try:
            validate_query_column(self.model_cls, field)
        except AttributeError as exc:
            raise AttributeError(
                f"{exc.args[0]} On this projected query, output field "
                f"names also sort: {', '.join(sorted(output_names))}.",
                name=getattr(exc, "name", None),
                obj=getattr(exc, "obj", None),
            ) from None
        if has_aggregate and not self._is_group_key_source(field, ()):
            raise ValueError(
                f"order_by({field!r}) on an aggregate projection must "
                "name a group key or an aggregate: it is neither an "
                "output field nor a group key's source column, so each "
                "group would answer with an arbitrary row's value. Sort "
                "by an output field name, or project the column as a "
                "group key."
            )
        return self._append_order_by(field, direction, ())

    if not callable(field):
        raise TypeError(
            "order_by() expected a column-name string or a lambda "
            f"selector, got {type(field).__name__}"
        )
    selected = field(QueryProxy(self.model_cls))
    # Rule 2: the lambda form spells source expressions — aggregates
    # resolve to the projected field carrying the same expression and
    # sort by its output name.
    if isinstance(selected, AggregateExpr):
        for proj in self._projection:
            if (
                proj.agg == selected.fn
                and proj.column == selected.column
                and proj.path == selected.path
            ):
                return self._append_order_by(proj.name, direction, ())
        raise ValueError(
            f"order_by() aggregate {selected._dotted()} matches no "
            "projected field; project it to sort by it (e.g. "
            f'select(lambda t: {{..., "key": {selected._dotted()}}}) '
            'with order_by("key")).'
        )
    if isinstance(selected, RelationProxy):
        relation = selected._path[-1]
        raise TypeError(
            f"order_by() selector returned the bare relation {relation!r}, "
            "not a column; order by a column on it instead "
            f"(e.g. t.{relation}.<column>)."
        )
    if not isinstance(selected, FieldProxy):
        raise TypeError(
            "order_by() selector must return a FieldProxy "
            f"(e.g. `lambda u: u.created_at`), got {type(selected).__name__}"
        )
    # Rule 3: on an aggregate projection a source column must be a group
    # key; on a plain projection unselected columns stay sortable.
    if has_aggregate and not self._is_group_key_source(
        selected.column, selected.path
    ):
        dotted = "t." + ".".join((*selected.path, selected.column))
        raise ValueError(
            f"order_by() key {dotted} on an aggregate projection must be "
            "a group key or an aggregate: it is not a group key's source "
            "column, so each group would answer with an arbitrary row's "
            "value. Project it as a group key, or sort by an aggregate."
        )
    return self._append_order_by(selected.column, direction, selected.path)

count()

Count matching rows — or raise on an aggregate projection (#295).

On a plain projection count() stays projection-blind (#279's verb contract). On an AGGREGATE projection "count" is ambiguous between rows and groups, so it raises synchronously at the call, before any coroutine or SQL exists, with both spellings.

Raises:

Type Description
ValueError

On an aggregate projection — count rows with an unprojected query, count groups with len(await q.all()).

Source code in src/ferro/query/builder.py
def count(self):  # type: ignore[override]
    """Count matching rows — or raise on an aggregate projection (#295).

    On a plain projection ``count()`` stays projection-blind (#279's
    verb contract). On an AGGREGATE projection "count" is ambiguous
    between rows and groups, so it raises synchronously at the call,
    before any coroutine or SQL exists, with both spellings.

    Raises:
        ValueError: On an aggregate projection — count rows with an
            unprojected query, count groups with ``len(await q.all())``.
    """
    if self._has_aggregate():
        raise ValueError(
            "count() on an aggregate projection is ambiguous: count the "
            "matching rows with an unprojected query "
            "(Model.where(...).count()), or count the groups with "
            "len(await q.all())."
        )
    return super().count()

exists()

Existence check — or raise on an aggregate projection (#295).

Raises:

Type Description
ValueError

On an aggregate projection — a global aggregate always yields one record, and "a group exists" is len(await q.all()) > 0; test the unprojected query instead (Model.where(...).exists()).

Source code in src/ferro/query/builder.py
def exists(self):  # type: ignore[override]
    """Existence check — or raise on an aggregate projection (#295).

    Raises:
        ValueError: On an aggregate projection — a global aggregate
            always yields one record, and "a group exists" is
            ``len(await q.all()) > 0``; test the unprojected query
            instead (``Model.where(...).exists()``).
    """
    if self._has_aggregate():
        raise ValueError(
            "exists() on an aggregate projection is ambiguous: an "
            "aggregate-only projection always yields exactly one record. "
            "Test row existence with an unprojected query "
            "(Model.where(...).exists()), or group existence with "
            "len(await q.all()) > 0."
        )
    return super().exists()

all() async

Return the projected records for every matching row.

Returns:

Name Type Description
A Rows[Row]

class:Rows of :class:Row records, fields in selection

Rows[Row]

order, wrapped without validation.

Examples:

>>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()
>>> [r.amount for r in rows]
[Decimal('12.50'), Decimal('7.00')]
Source code in src/ferro/query/builder.py
async def all(self) -> Rows[Row]:  # type: ignore[override]  # ty: ignore[invalid-method-override]
    """Return the projected records for every matching row.

    Returns:
        A :class:`Rows` of :class:`Row` records, fields in selection
        order, wrapped without validation.

    Examples:
        >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
        >>> [r.amount for r in rows]  # doctest: +SKIP
        [Decimal('12.50'), Decimal('7.00')]
    """
    compiled = compile_query(self, "fetch")
    route = await self._transaction_or_using()
    records = await fetch_filtered(
        self.model_cls,
        compiled.wire_json,
        route,
        record_cls=Row,
        hop_classes=compiled.hop_classes,
    )
    return _ROWS_OF_ROW._wrap(records)

first() async

Return the first matching projected record, or None.

Examples:

>>> row = await Transaction.select(lambda t: t.amount).order_by("id").first()
>>> row is None or isinstance(row, Row)
True
Source code in src/ferro/query/builder.py
async def first(self) -> Row | None:  # type: ignore[override]  # ty: ignore[invalid-method-override]
    """Return the first matching projected record, or None.

    Examples:
        >>> row = await Transaction.select(lambda t: t.amount).order_by("id").first()  # doctest: +SKIP
        >>> row is None or isinstance(row, Row)  # doctest: +SKIP
        True
    """
    results = await self.limit(1).all()
    return results[0] if results else None

select(*selectors)

Reject a second projection: it would change the result type mid-chain (#280).

Raises:

Type Description
ValueError

Always — build the projection in one select() call, or start a new query from the model.

Source code in src/ferro/query/builder.py
def select(self, *selectors: "RowSelector[T] | str") -> NoReturn:  # type: ignore[override]  # ty: ignore[invalid-method-override]
    """Reject a second projection: it would change the result type
    mid-chain (#280).

    Raises:
        ValueError: Always — build the projection in one ``select()``
            call, or start a new query from the model.
    """
    raise ValueError(
        "select() was already applied to this query; replacing a "
        "projection changes the result type mid-chain. Name every "
        "projected column in one select() call, or start a new query "
        "from the model."
    )

include(selector)

Reject include() on a projected query (#287, final at #293).

One materialization plan per query (ADR-0007/ADR-0008), and record results are flat, permanently (ADR-0009: flattened-as-final) — a record reaches across a relation with traversed projection, never by nesting an instance. The self: Never pin makes this a STATIC error at the call site (tests/static_fixtures/bad_includes.py), mirroring the mutation pins.

Raises:

Type Description
ValueError

Always — reach across the relation with traversed projection, or populate instances through an unprojected query.

Source code in src/ferro/query/builder.py
def include(self: Never, selector: Any) -> NoReturn:  # type: ignore[override]
    """Reject ``include()`` on a projected query (#287, final at #293).

    One materialization plan per query (ADR-0007/ADR-0008), and record
    results are flat, permanently (ADR-0009: flattened-as-final) — a
    record reaches across a relation with traversed projection, never by
    nesting an instance. The ``self: Never`` pin makes this a STATIC
    error at the call site (tests/static_fixtures/bad_includes.py),
    mirroring the mutation pins.

    Raises:
        ValueError: Always — reach across the relation with traversed
            projection, or populate instances through an unprojected
            query.
    """
    raise ValueError(
        "include() cannot be combined with a projection: a query carries "
        "exactly one materialization plan — projected records or "
        "populated instances, never both, and record results are flat "
        "(ADR-0009). Reach across the relation with traversed projection "
        'instead (e.g. select(lambda t: {"account_name": '
        "t.account.name})), or populate instances with include() on an "
        "unprojected query."
    )

update(**fields)

Reject update() on a projected query (#280).

Raises:

Type Description
ValueError

Always — mutate through an unprojected query (Model.where(...).update(...)).

Source code in src/ferro/query/builder.py
def update(self: Never, **fields: Any) -> NoReturn:  # type: ignore[override]
    """Reject ``update()`` on a projected query (#280).

    Raises:
        ValueError: Always — mutate through an unprojected query
            (``Model.where(...).update(...)``).
    """
    raise ValueError(
        "update() is not supported on a projected query: a projection is "
        "a read shape. Build the mutation from the model instead "
        "(e.g. Model.where(...).update(...))."
    )

delete()

Reject delete() on a projected query (#280).

Raises:

Type Description
ValueError

Always — mutate through an unprojected query (Model.where(...).delete()).

Source code in src/ferro/query/builder.py
def delete(self: Never) -> NoReturn:  # type: ignore[override]
    """Reject ``delete()`` on a projected query (#280).

    Raises:
        ValueError: Always — mutate through an unprojected query
            (``Model.where(...).delete()``).
    """
    raise ValueError(
        "delete() is not supported on a projected query: a projection is "
        "a read shape. Build the mutation from the model instead "
        "(e.g. Model.where(...).delete())."
    )

__repr__()

Return a developer-friendly representation of the projection

Source code in src/ferro/query/builder.py
def __repr__(self):
    """Return a developer-friendly representation of the projection"""
    return (
        f"<ProjectedQuery model={self.model_cls.__name__} "
        f"fields={[field.name for field in self._projection]} "
        f"where={self.where_clause}>"
    )

Row

Bases: BaseModel

One projected record: named values from an explicit projection.

A Row is not a model instance. It carries no persistence identity — no save(), no refresh, no identity-map participation — and its field set is whatever the projection selected, in selection order.

Fields live in pydantic's extra storage (model_config is extra="allow"); the Rust hydration path populates them directly (I-2), so construction never runs pydantic validation. Attribute access, model_dump(), model_dump_json(), equality, and repr all work as on any pydantic model:

Examples:

>>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()
>>> rows[0].amount
Decimal('12.50')
>>> rows[0].model_dump()
{'id': 1, 'amount': Decimal('12.50')}
Source code in src/ferro/query/rows.py
class Row(BaseModel):
    """One projected record: named values from an explicit projection.

    A ``Row`` is not a model instance. It carries no persistence identity —
    no ``save()``, no refresh, no identity-map participation — and its field
    set is whatever the projection selected, in selection order.

    Fields live in pydantic's extra storage (``model_config`` is
    ``extra="allow"``); the Rust hydration path populates them directly
    (I-2), so construction never runs pydantic validation. Attribute access,
    ``model_dump()``, ``model_dump_json()``, equality, and ``repr`` all work
    as on any pydantic model:

    Examples:
        >>> rows = await Transaction.select(lambda t: (t.id, t.amount)).all()  # doctest: +SKIP
        >>> rows[0].amount  # doctest: +SKIP
        Decimal('12.50')
        >>> rows[0].model_dump()  # doctest: +SKIP
        {'id': 1, 'amount': Decimal('12.50')}
    """

    model_config = ConfigDict(extra="allow")

Attributes

model_config = ConfigDict(extra='allow') class-attribute instance-attribute

Rows

Bases: RootModel[list[R]]

List-like pydantic container for projected records.

Delivered by a projected query's all() as Rows[Row]. Indexing, slicing, iteration, len(), and truthiness work like a list; a slice returns a Rows of the same record type. As a pydantic RootModel it drops straight into an API response: model_dump() yields list[dict] and Rows[Row] works as a FastAPI response_model.

Examples:

>>> len(rows), rows[0].id, [r.amount for r in rows]
(3, 1, [Decimal('12.50'), Decimal('7.00'), Decimal('99.99')])
>>> rows[:2].model_dump()
[{'id': 1, 'amount': Decimal('12.50')}, {'id': 2, 'amount': Decimal('7.00')}]
Source code in src/ferro/query/rows.py
class Rows(RootModel[list[R]]):
    """List-like pydantic container for projected records.

    Delivered by a projected query's ``all()`` as ``Rows[Row]``. Indexing,
    slicing, iteration, ``len()``, and truthiness work like a list; a slice
    returns a ``Rows`` of the same record type. As a pydantic ``RootModel``
    it drops straight into an API response: ``model_dump()`` yields
    ``list[dict]`` and ``Rows[Row]`` works as a FastAPI ``response_model``.

    Examples:
        >>> len(rows), rows[0].id, [r.amount for r in rows]  # doctest: +SKIP
        (3, 1, [Decimal('12.50'), Decimal('7.00'), Decimal('99.99')])
        >>> rows[:2].model_dump()  # doctest: +SKIP
        [{'id': 1, 'amount': Decimal('12.50')}, {'id': 2, 'amount': Decimal('7.00')}]
    """

    def __len__(self) -> int:
        return len(self.root)

    def __iter__(self) -> Iterator[R]:  # type: ignore[override]  # ty: ignore[invalid-method-override]
        # BaseModel.__iter__ yields (field, value) pairs; a Rows iterates its
        # records, like the list it stands in for.
        return iter(self.root)

    @overload
    def __getitem__(self, index: int) -> R: ...

    @overload
    def __getitem__(self, index: slice) -> "Rows[R]": ...

    def __getitem__(self, index: int | slice) -> "R | Rows[R]":
        if isinstance(index, slice):
            return self.__class__.model_construct(root=self.root[index])
        return self.root[index]

    @classmethod
    def _wrap(cls, records: list[R]) -> "Rows[R]":
        """Wrap already-hydrated records WITHOUT validation (ADR-0007).

        The records come off the Rust hydration path; re-validating them here
        would put pydantic back on the hot path that direct-to-dict
        construction exists to avoid (I-2).
        """
        return cls.model_construct(root=records)

Functions

__len__()

Source code in src/ferro/query/rows.py
def __len__(self) -> int:
    return len(self.root)

__iter__()

Source code in src/ferro/query/rows.py
def __iter__(self) -> Iterator[R]:  # type: ignore[override]  # ty: ignore[invalid-method-override]
    # BaseModel.__iter__ yields (field, value) pairs; a Rows iterates its
    # records, like the list it stands in for.
    return iter(self.root)

__getitem__(index)

__getitem__(index: int) -> R
__getitem__(index: slice) -> Rows[R]
Source code in src/ferro/query/rows.py
def __getitem__(self, index: int | slice) -> "R | Rows[R]":
    if isinstance(index, slice):
        return self.__class__.model_construct(root=self.root[index])
    return self.root[index]

QueryProxy

Bases: Generic[TModel]

Validating attribute proxy passed to lambda predicates (FF-F F-2).

A fresh QueryProxy is constructed for the queried model each time a lambda predicate is evaluated. Attribute access validates the name against the model's queryable columns (declared fields plus shadow {fk}_id columns) and returns a :class:FieldProxy — so lambda user: user.archived == False builds a :class:QueryNode, while lambda user: user.archievd == False raises AttributeError at build time naming the valid columns.

Attribute types are FieldProxy[Any]: per-field static types for bare lambda parameters require TypeScript-style mapped types, proposed for Python in PEP 827 (draft, targeting 3.16) — adopted here when type checkers support it.

Examples:

>>> rows = await User.where(lambda user: user.archived == False).all()  # noqa: E712
Source code in src/ferro/query/nodes.py
class QueryProxy(Generic[TModel]):
    """Validating attribute proxy passed to lambda predicates (FF-F F-2).

    A fresh ``QueryProxy`` is constructed for the queried model each time a
    lambda predicate is evaluated. Attribute access validates the name
    against the model's queryable columns (declared fields plus shadow
    ``{fk}_id`` columns) and returns a :class:`FieldProxy` — so
    ``lambda user: user.archived == False`` builds a :class:`QueryNode`,
    while ``lambda user: user.archievd == False`` raises ``AttributeError``
    at build time naming the valid columns.

    Attribute types are ``FieldProxy[Any]``: per-field static types for bare
    lambda parameters require TypeScript-style mapped types, proposed for
    Python in PEP 827 (draft, targeting 3.16) — adopted here when type
    checkers support it.

    Examples:
        >>> rows = await User.where(lambda user: user.archived == False).all()  # noqa: E712
    """

    __slots__ = ("_model_cls",)

    def __init__(self, model_cls: type) -> None:
        self._model_cls = model_cls

    def __getattr__(self, name: str) -> "FieldProxy[Any]":
        """Resolve ``name`` on the root model.

        Relation specs are consulted FIRST (#270): a declared forward-FK field
        name yields a :class:`RelationProxy` for traversal (``t.account`` →
        proxy → ``.ledger_id``). A reverse (BackRef) relation name yields a
        :class:`ReverseRelationProxy` exposing the existence test and nothing
        else (#314, ADR-0007). Every other name falls through to column
        validation and returns a :class:`FieldProxy`.
        """
        relations = getattr(self._model_cls, "__ferro_relation_specs__", None) or {}
        spec = relations.get(name)
        if spec is not None:
            # Statically typed as FieldProxy[Any] (chainable shape) even though a
            # relation resolves to a RelationProxy at runtime — a bare relation
            # proxy is intentionally not a QueryNode, so predicate typing still
            # rejects `lambda t: t.account` (design pin, PRD #267).
            return RelationProxy(  # ty: ignore[invalid-return-type]
                self._model_cls, (name,), spec.target
            )
        reverse = getattr(self._model_cls, "__ferro_reverse_specs__", None) or {}
        rspec = reverse.get(name)
        if rspec is not None:
            return ReverseRelationProxy(  # ty: ignore[invalid-return-type]
                self._model_cls, name, rspec
            )
        validate_query_column(self._model_cls, name)
        return FieldProxy(name, owner=self._model_cls)

Attributes

__slots__ = ('_model_cls',) class-attribute instance-attribute

Functions

__init__(model_cls)

Source code in src/ferro/query/nodes.py
def __init__(self, model_cls: type) -> None:
    self._model_cls = model_cls

__getattr__(name)

Resolve name on the root model.

Relation specs are consulted FIRST (#270): a declared forward-FK field name yields a :class:RelationProxy for traversal (t.account → proxy → .ledger_id). A reverse (BackRef) relation name yields a :class:ReverseRelationProxy exposing the existence test and nothing else (#314, ADR-0007). Every other name falls through to column validation and returns a :class:FieldProxy.

Source code in src/ferro/query/nodes.py
def __getattr__(self, name: str) -> "FieldProxy[Any]":
    """Resolve ``name`` on the root model.

    Relation specs are consulted FIRST (#270): a declared forward-FK field
    name yields a :class:`RelationProxy` for traversal (``t.account`` →
    proxy → ``.ledger_id``). A reverse (BackRef) relation name yields a
    :class:`ReverseRelationProxy` exposing the existence test and nothing
    else (#314, ADR-0007). Every other name falls through to column
    validation and returns a :class:`FieldProxy`.
    """
    relations = getattr(self._model_cls, "__ferro_relation_specs__", None) or {}
    spec = relations.get(name)
    if spec is not None:
        # Statically typed as FieldProxy[Any] (chainable shape) even though a
        # relation resolves to a RelationProxy at runtime — a bare relation
        # proxy is intentionally not a QueryNode, so predicate typing still
        # rejects `lambda t: t.account` (design pin, PRD #267).
        return RelationProxy(  # ty: ignore[invalid-return-type]
            self._model_cls, (name,), spec.target
        )
    reverse = getattr(self._model_cls, "__ferro_reverse_specs__", None) or {}
    rspec = reverse.get(name)
    if rspec is not None:
        return ReverseRelationProxy(  # ty: ignore[invalid-return-type]
            self._model_cls, name, rspec
        )
    validate_query_column(self._model_cls, name)
    return FieldProxy(name, owner=self._model_cls)

Predicate = Callable[[QueryProxy[TModel]], QueryNode] module-attribute

Type alias for lambda predicates accepted by :meth:Query.where.

RowSelector = Callable[[QueryProxy[TModel]], FieldProxy[Any] | tuple[FieldProxy[Any], ...] | list[FieldProxy[Any]] | dict[str, 'FieldProxy[Any] | AggregateExpr']] module-attribute

Type alias for lambda selectors accepted by select() projections.

A selector names fields — one (lambda t: t.amount), several (lambda t: (t.id, t.amount)), or a dict whose string keys name the output fields (lambda t: {"account_name": t.account.name} — output aliases, #293). Fields may traverse forward-FK relations at any depth, and dict values may be aggregate expressions ({"total": t.amount.sum()}

294; aggregates are user-named, so the dict form is their only home).

Returning a comparison (a :class:QueryNode), nesting shapes, or using a non-string dict key fails the static gate: a projection selects fields, a predicate belongs in where().

AggregateExpr

A build-time aggregate expression over one column (#294, ADR-0009).

Built by the five aggregate methods on :class:FieldProxy (t.amount.sum(), traversal included: t.account.balance.avg()). Carries the aggregate fn (the closed count/sum/avg/min/max set), the source column, and the source's relation path — the data the select() resolver turns into a v5 expr record field.

Deliberately opaque: an aggregate expression is a projection source, not a value — comparing one raises pointedly at build time (post-aggregation filtering is having(), #291), and it is not a predicate, a column, or an iterable.

Source code in src/ferro/query/nodes.py
class AggregateExpr:
    """A build-time aggregate expression over one column (#294, ADR-0009).

    Built by the five aggregate methods on :class:`FieldProxy`
    (``t.amount.sum()``, traversal included: ``t.account.balance.avg()``).
    Carries the aggregate ``fn`` (the closed ``count/sum/avg/min/max`` set),
    the source ``column``, and the source's relation ``path`` — the data the
    ``select()`` resolver turns into a v5 ``expr`` record field.

    Deliberately opaque: an aggregate expression is a projection source, not
    a value — comparing one raises pointedly at build time (post-aggregation
    filtering is ``having()``, #291), and it is not a predicate, a column, or
    an iterable.
    """

    __slots__ = ("fn", "column", "path")

    def __init__(self, fn: str, column: str, path: tuple[str, ...]) -> None:
        self.fn = fn
        self.column = column
        self.path = path

    def _dotted(self) -> str:
        """The selector expression this aggregate came from (errors)."""
        return "t." + ".".join((*self.path, self.column)) + f".{self.fn}()"

    def _reject_comparison(self, symbol: str) -> NoReturn:
        """Reject a comparison on an aggregate (#294 → having(), #291)."""
        raise TypeError(
            f"{self._dotted()} {symbol} ... is not a where() predicate: "
            "WHERE filters rows before aggregation, so an aggregate cannot "
            "appear in it. Post-aggregation filtering is having() (#291); "
            "until it lands, filter rows with where() and compare the "
            "aggregated result in Python."
        )

    def __eq__(self, other: object) -> NoReturn:  # type: ignore[override]
        self._reject_comparison("==")

    def __ne__(self, other: object) -> NoReturn:  # type: ignore[override]
        self._reject_comparison("!=")

    def __lt__(self, other: object) -> NoReturn:
        self._reject_comparison("<")

    def __le__(self, other: object) -> NoReturn:
        self._reject_comparison("<=")

    def __gt__(self, other: object) -> NoReturn:
        self._reject_comparison(">")

    def __ge__(self, other: object) -> NoReturn:
        self._reject_comparison(">=")

    def __bool__(self) -> NoReturn:
        raise TypeError(
            f"{self._dotted()} has no truth value; an aggregate expression "
            "is a projection source (select(lambda t: {\"total\": "
            f"{self._dotted()}}})), not a predicate."
        )

    def __repr__(self) -> str:
        return f"AggregateExpr(fn={self.fn!r}, column={self.column!r}, path={self.path!r})"

Attributes

__slots__ = ('fn', 'column', 'path') class-attribute instance-attribute

fn = fn instance-attribute

column = column instance-attribute

path = path instance-attribute

Functions

__init__(fn, column, path)

Source code in src/ferro/query/nodes.py
def __init__(self, fn: str, column: str, path: tuple[str, ...]) -> None:
    self.fn = fn
    self.column = column
    self.path = path

__eq__(other)

Source code in src/ferro/query/nodes.py
def __eq__(self, other: object) -> NoReturn:  # type: ignore[override]
    self._reject_comparison("==")

__ne__(other)

Source code in src/ferro/query/nodes.py
def __ne__(self, other: object) -> NoReturn:  # type: ignore[override]
    self._reject_comparison("!=")

__lt__(other)

Source code in src/ferro/query/nodes.py
def __lt__(self, other: object) -> NoReturn:
    self._reject_comparison("<")

__le__(other)

Source code in src/ferro/query/nodes.py
def __le__(self, other: object) -> NoReturn:
    self._reject_comparison("<=")

__gt__(other)

Source code in src/ferro/query/nodes.py
def __gt__(self, other: object) -> NoReturn:
    self._reject_comparison(">")

__ge__(other)

Source code in src/ferro/query/nodes.py
def __ge__(self, other: object) -> NoReturn:
    self._reject_comparison(">=")

__bool__()

Source code in src/ferro/query/nodes.py
def __bool__(self) -> NoReturn:
    raise TypeError(
        f"{self._dotted()} has no truth value; an aggregate expression "
        "is a projection source (select(lambda t: {\"total\": "
        f"{self._dotted()}}})), not a predicate."
    )

__repr__()

Source code in src/ferro/query/nodes.py
def __repr__(self) -> str:
    return f"AggregateExpr(fn={self.fn!r}, column={self.column!r}, path={self.path!r})"