v0.165.0
  1"""Typed FK traversal for the where() query API.
  2
  3When `Order.user` is a ForeignKey, accessing `.email` at the class level (as in
  4`where(Order.user.email.equals("x"))`) needs to produce `Q(user__email="x")` so
  5the existing SQL builder's join machinery resolves the right column.
  6
  7The whole mechanism is `Field.with_lookup_prefix`: walking into the related
  8model hands back that model's own field, renamed to carry the relation path, so
  9its own condition methods build the right keys. Nothing here re-implements or
 10rewrites a field's surface -- which is why a traversed field offers exactly
 11what direct access offers, down to an encrypted field's blocks.
 12"""
 13
 14from typing import TYPE_CHECKING, Any
 15
 16from plain.postgres.constants import LOOKUP_SEP
 17from plain.postgres.exceptions import FieldDoesNotExist
 18from plain.postgres.fields.base import CONDITION_METHODS
 19from plain.postgres.fields.related import RelatedField
 20from plain.postgres.fields.reverse_descriptors import (
 21    ReverseForeignKey,
 22    ReverseManyToMany,
 23)
 24
 25if TYPE_CHECKING:
 26    from plain.postgres.base import Model
 27
 28
 29class UnresolvedRelationError(AttributeError):
 30    """A traversal reached a relation whose target model isn't resolved yet.
 31
 32    An AttributeError subclass so `hasattr` and `getattr(..., default)` keep
 33    working, but a distinct type so the timing problem is greppable rather
 34    than looking like a typo.
 35    """
 36
 37
 38class RelatedFieldRef:
 39    """Class-level proxy that walks attribute access into the related model and
 40    accumulates the lookup-path prefix as it goes.
 41
 42    Chained traversal (`Order.user.profile.city`) builds nested
 43    `RelatedFieldRef` instances until a concrete field is reached, which comes
 44    back as a prefixed copy of that field. Every relation is a hop -- many-to-
 45    many included, since `widget__tags__name` is as valid a lookup path as
 46    `widget__author__name`.
 47
 48    Names resolve through the related model's metadata (`get_forward_field`),
 49    not attribute lookup, so a related field keeps resolving to the field.
 50
 51    A relation is not itself a field, so it carries no condition methods.
 52    `Child.parent.equals(obj)` is spelled `Child.parent.id.equals(obj.id)` --
 53    traversal to the key the relation targets, which compiles to the same
 54    `parent__id=` lookup `filter(parent=obj)` produces. This can't be smoothed
 55    over by adding the methods here: to the type checker `Child.parent` is
 56    `type[Parent]` (see `Field.__get__`'s model-valued overloads), which is
 57    what makes chained traversal type-check, and a runtime method the checker
 58    rejects would be worse than no method at all. `__getattr__` raises an
 59    AttributeError pointing at the right spelling instead.
 60    """
 61
 62    def __init__(
 63        self,
 64        model: type[Model],
 65        prefix: str,
 66        target_name: str,
 67        source_model: type[Model],
 68    ) -> None:
 69        if isinstance(model, str):
 70            raise unresolved_relation_error(prefix, model)
 71        self._model = model
 72        self._prefix = prefix
 73        # The field on the related model that this relation targets -- the hop
 74        # a condition on the relation has to go through.
 75        self._target_name = target_name
 76        # The model the traversal started from, carried unchanged through every
 77        # hop: a condition on `Order.user.profile.city` belongs to `Order`.
 78        self._source_model = source_model
 79
 80    def __repr__(self) -> str:
 81        return f"<RelatedFieldRef {self._prefix} → {self._model.__name__}>"
 82
 83    def _is_reverse_relation(self, name: str) -> bool:
 84        """Whether `name` names a reverse accessor on the related model.
 85
 86        Reverse accessors live on the class as descriptors, not in the field
 87        metadata, so both places are checked.
 88        """
 89        try:
 90            self._model._model_meta.get_reverse_relation(name)
 91        except FieldDoesNotExist:
 92            return isinstance(
 93                getattr(self._model, name, None),
 94                (ReverseForeignKey, ReverseManyToMany),
 95            )
 96        return True
 97
 98    @property
 99    def _attribute_path(self) -> str:
100        """The prefix spelled the way it was written -- `widget.tags`, not
101        `widget__tags` -- so error messages can be pasted back into code."""
102        return self._prefix.replace(LOOKUP_SEP, ".")
103
104    def __getattr__(self, name: str) -> Any:
105        if name.startswith("_"):
106            # Avoid infinite recursion on internals and let pickling/hasattr
107            # checks fail cleanly.
108            raise AttributeError(name)
109
110        try:
111            field = self._model._model_meta.get_forward_field(name)
112        except FieldDoesNotExist:
113            # The field lookup comes first so a related model that really does
114            # have a column named `equals` (or `contains`, …) still traverses
115            # to it. Every failure below is an AttributeError, not a TypeError,
116            # so `hasattr` and `getattr(..., default)` keep behaving.
117            if name in CONDITION_METHODS:
118                raise AttributeError(
119                    f"{self._attribute_path}.{name}() is not available: "
120                    f"{self._prefix!r} is a relation, not a field. Build the "
121                    f"condition on the key it points at instead -- "
122                    f"{self._attribute_path}.{self._target_name}.{name}(...), "
123                    f"which compiles to the same SQL."
124                ) from None
125            if self._is_reverse_relation(name):
126                raise AttributeError(
127                    f"{self._attribute_path}.{name} is a reverse relation, "
128                    f"which the typed API cannot traverse: a reverse accessor "
129                    f"is a ClassVar, so there is nothing for "
130                    f"`{self._model.__name__}` to offer the type checker here. "
131                    f"Use the string path instead -- "
132                    f"filter({self._prefix}{LOOKUP_SEP}{name}{LOOKUP_SEP}...=...)."
133                ) from None
134            raise AttributeError(
135                f"{self._attribute_path}.{name} is not a traversable field or relation"
136            ) from None
137
138        if isinstance(field, RelatedField):
139            # Any relation is another hop, foreign key or many-to-many alike --
140            # `widget__tags__name` is as valid a lookup path as
141            # `widget__author__name`. Handing back the relation field itself
142            # would rename it to "widget__tags" and then let `.name` resolve to
143            # that string.
144            return RelatedFieldRef(
145                model=field.remote_field.model,
146                prefix=f"{self._prefix}{LOOKUP_SEP}{name}",
147                target_name=field.target_field.name,
148                source_model=self._source_model,
149            )
150        return field.with_lookup_prefix(self._prefix, self._source_model)
151
152
153def unresolved_relation_error(prefix: str, target: str) -> UnresolvedRelationError:
154    """The error for a traversal that outran model registration.
155
156    Relation targets are replaced with the resolved class when the model
157    registers, so a traversal evaluated at import time -- at module level, or
158    in a default argument -- can run before the registry is populated.
159    """
160    return UnresolvedRelationError(
161        f"Cannot traverse {prefix!r}: its target model {target!r} hasn't been "
162        f"resolved yet. Relation targets are resolved when the model is "
163        f"registered, so this traversal is running too early -- move it inside "
164        f"the function or method that needs it."
165    )