1"""
2"Rel objects" for related fields.
3
4"Rel objects" (for lack of a better name) carry information about the relation
5modeled by a related field and provide some utility functions. They're stored
6in the ``remote_field`` attribute of the field.
7
8They also act as reverse fields for the purposes of the Meta API because
9they're the closest concept currently available.
10"""
11
12from functools import cached_property
13from typing import TYPE_CHECKING, Any
14
15from plain.utils.hashable import make_hashable
16
17from .mixins import FieldCacheMixin
18
19if TYPE_CHECKING:
20 from plain.postgres.base import Model
21 from plain.postgres.deletion import OnDelete
22 from plain.postgres.fields import Field
23 from plain.postgres.fields.related import (
24 ForeignKeyField,
25 ManyToManyField,
26 RelatedField,
27 )
28 from plain.postgres.lookups import Lookup
29 from plain.postgres.query_utils import PathInfo
30
31
32class ForeignObjectRel(FieldCacheMixin):
33 """
34 Used by ForeignKeyField to store information about the relation.
35
36 ``_model_meta.get_fields()`` returns this class to provide access to the field
37 flags for the reverse relation.
38 """
39
40 # Field flags
41 auto_created = True
42
43 # Reverse relations are always nullable (Plain can't enforce that a
44 # foreign key on the related model points to this model).
45 allow_null = True
46 empty_strings_allowed = False
47
48 # Type annotations for instance attributes
49 on_delete: OnDelete | None
50 # The target model as given to the field: a "package.Model" string (or
51 # "self") until lazy_related_operation resolves it to the class. Read
52 # `model` for the resolved class.
53 model_ref: str | type[Model]
54
55 def __init__(
56 self,
57 *,
58 field: RelatedField,
59 to: str | type[Model],
60 related_query_name: str | None = None,
61 on_delete: OnDelete | None = None,
62 ):
63 # Annotated here rather than as a class-body attribute: RelatedField
64 # is itself a descriptor, so a class-body `field: RelatedField` would
65 # route every `self.field` read through its __get__. ForeignKeyRel
66 # and ManyToManyRel below narrow this further, the same way.
67 self.field: RelatedField = field
68 self.model_ref = to
69 self.related_query_name = related_query_name
70 self.on_delete = on_delete
71
72 self.symmetrical = False
73
74 # Some of the following cached_properties can't be initialized in
75 # __init__ as the field doesn't have its model yet. Calling these methods
76 # before field.contribute_to_class() has been called will result in
77 # AttributeError
78 @property
79 def model(self) -> type[Model]:
80 """The resolved target model class."""
81 # Deliberately not AttributeError: getattr(rel, "model", None) and
82 # hasattr() would swallow that and silently report "no model".
83 if isinstance(self.model_ref, str):
84 raise TypeError(
85 f"Related model {self.model_ref!r} for {self.field!r} has not been "
86 "resolved yet; use model_ref to read the unresolved reference."
87 )
88 return self.model_ref
89
90 @cached_property
91 def name(self) -> str:
92 return self.field.related_query_name()
93
94 @property
95 def remote_field(self) -> RelatedField:
96 return self.field
97
98 @property
99 def target_field(self) -> Field:
100 """
101 The remote field this relation filters against. Read dynamically by the
102 related-lookup prep path (`output_field.target_field`) when the relation
103 itself is the lookup target, e.g. ``Parent.query.filter(child=obj)``.
104 """
105 return self.path_infos[-1].target_field
106
107 @cached_property
108 def related_model(self) -> type[Model]:
109 if not self.field.model:
110 raise AttributeError(
111 "This property can't be accessed before self.field.contribute_to_class "
112 "has been called."
113 )
114 return self.field.model
115
116 def get_lookup(self, lookup: str) -> type[Lookup] | None:
117 return self.field.get_lookup(lookup)
118
119 def __repr__(self) -> str:
120 return f"<{type(self).__name__}: {self.related_model.model_options.package_label}.{self.related_model.model_options.model_name}>"
121
122 @property
123 def identity(self) -> tuple[Any, ...]:
124 return (
125 self.field,
126 self.model_ref,
127 self.related_query_name,
128 self.on_delete,
129 self.symmetrical,
130 )
131
132 def __eq__(self, other: object) -> bool:
133 if not isinstance(other, self.__class__):
134 return NotImplemented
135 return self.identity == other.identity
136
137 def __hash__(self) -> int:
138 return hash(self.identity)
139
140 def __getstate__(self) -> dict[str, Any]:
141 state = self.__dict__.copy()
142 # Delete the path_infos cached property because it can be recalculated
143 # at first invocation after deserialization. The attribute must be
144 # removed because subclasses like ForeignKeyRel may have a PathInfo
145 # which contains an intermediate M2M table that's been dynamically
146 # created and doesn't exist in the .models module.
147 # This is a reverse relation, so there is no reverse_path_infos to
148 # delete.
149 state.pop("path_infos", None)
150 return state
151
152 @cached_property
153 def path_infos(self) -> list[PathInfo]:
154 return self.field.reverse_path_infos
155
156 def get_cache_name(self) -> str:
157 """
158 Return the name of the cache key to use for storing an instance of the
159 forward model on the reverse model.
160
161 Uses the related_query_name for caching, which provides a stable name
162 for prefetch() operations.
163 """
164 return self.field.related_query_name()
165
166
167class ForeignKeyRel(ForeignObjectRel):
168 """
169 Used by the ForeignKeyField field to store information about the relation.
170
171 ``_model_meta.get_fields()`` returns this class to provide access to the
172 reverse relation. Use ``isinstance(rel, ForeignKeyRel)`` to identify
173 one-to-many reverse relations.
174 """
175
176 # Type annotations for instance attributes
177 on_delete: OnDelete # narrowed: FK rel always has a concrete action
178
179 def __init__(
180 self,
181 *,
182 field: ForeignKeyField,
183 to: str | type[Model],
184 on_delete: OnDelete,
185 related_query_name: str | None = None,
186 ):
187 super().__init__(
188 field=field,
189 to=to,
190 related_query_name=related_query_name,
191 on_delete=on_delete,
192 )
193 # Narrows self.field from RelatedField to ForeignKeyField (see
194 # ForeignObjectRel.__init__ for why this can't be a class-body
195 # annotation).
196 self.field: ForeignKeyField
197
198 def __getstate__(self) -> dict[str, Any]:
199 state = super().__getstate__()
200 state.pop("related_model", None)
201 return state
202
203 def get_joining_columns(self) -> tuple[str, str]:
204 return self.field.get_joining_columns(reverse_join=True)
205
206
207class ManyToManyRel(ForeignObjectRel):
208 """
209 Used by ManyToManyField to store information about the relation.
210
211 ``_model_meta.get_fields()`` returns this class to provide access to the field
212 flags for the reverse relation.
213 """
214
215 # Type annotations for instance attributes
216 through_fields: tuple[str, str] | None
217 # The intermediary model as given to the field; a string until
218 # lazy_related_operation resolves it. Read `through` for the class.
219 through_ref: str | type[Model]
220
221 def __init__(
222 self,
223 *,
224 field: ManyToManyField,
225 to: str | type[Model],
226 through: str | type[Model],
227 through_fields: tuple[str, str] | None = None,
228 related_query_name: str | None = None,
229 symmetrical: bool = True,
230 ):
231 super().__init__(
232 field=field,
233 to=to,
234 related_query_name=related_query_name,
235 )
236 # Narrows self.field from RelatedField to ManyToManyField (see
237 # ForeignObjectRel.__init__ for why this can't be a class-body
238 # annotation).
239 self.field: ManyToManyField
240
241 self.through_ref = through
242 self.through_fields = through_fields
243
244 self.symmetrical = symmetrical
245
246 @property
247 def identity(self) -> tuple[Any, ...]:
248 return super().identity + (
249 self.through_ref,
250 make_hashable(self.through_fields),
251 )
252
253 @property
254 def through(self) -> type[Model]:
255 """The resolved intermediary model class."""
256 # Deliberately not AttributeError, for the same reason as `model`.
257 if isinstance(self.through_ref, str):
258 raise TypeError(
259 f"Through model {self.through_ref!r} for {self.field!r} has not been "
260 "resolved yet; use through_ref to read the unresolved reference."
261 )
262 return self.through_ref