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 )