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 __future__ import annotations
13
14from functools import cached_property
15from typing import TYPE_CHECKING, Any
16
17from plain.models.exceptions import FieldDoesNotExist, FieldError
18from plain.utils.hashable import make_hashable
19
20from . import BLANK_CHOICE_DASH
21from .mixins import FieldCacheMixin
22
23if TYPE_CHECKING:
24 from plain.models.lookups import Lookup
25
26
27class ForeignObjectRel(FieldCacheMixin):
28 """
29 Used by ForeignKey to store information about the relation.
30
31 ``_model_meta.get_fields()`` returns this class to provide access to the field
32 flags for the reverse relation.
33 """
34
35 # Field flags
36 auto_created = True
37 concrete = False
38 is_relation = True
39
40 # Reverse relations are always nullable (Plain can't enforce that a
41 # foreign key on the related model points to this model).
42 allow_null = True
43 empty_strings_allowed = False
44
45 def __init__(
46 self,
47 field: Any,
48 to: Any,
49 related_query_name: str | None = None,
50 limit_choices_to: Any = None,
51 on_delete: Any = None,
52 ):
53 self.field = field
54 self.model = to
55 self.related_query_name = related_query_name
56 self.limit_choices_to = {} if limit_choices_to is None else limit_choices_to
57 self.on_delete = on_delete
58
59 self.symmetrical = False
60 self.multiple = True
61
62 # Some of the following cached_properties can't be initialized in
63 # __init__ as the field doesn't have its model yet. Calling these methods
64 # before field.contribute_to_class() has been called will result in
65 # AttributeError
66 @cached_property
67 def name(self) -> str:
68 return self.field.related_query_name()
69
70 @property
71 def remote_field(self) -> Any:
72 return self.field
73
74 @property
75 def target_field(self) -> Any:
76 """
77 When filtering against this relation, return the field on the remote
78 model against which the filtering should happen.
79 """
80 target_fields = self.path_infos[-1].target_fields
81 if len(target_fields) > 1:
82 raise FieldError("Can't use target_field for multicolumn relations.")
83 return target_fields[0]
84
85 @cached_property
86 def related_model(self) -> Any:
87 if not self.field.model:
88 raise AttributeError(
89 "This property can't be accessed before self.field.contribute_to_class "
90 "has been called."
91 )
92 return self.field.model
93
94 @cached_property
95 def many_to_many(self) -> bool:
96 return self.field.many_to_many
97
98 @cached_property
99 def many_to_one(self) -> bool:
100 return self.field.one_to_many
101
102 @cached_property
103 def one_to_many(self) -> bool:
104 return self.field.many_to_one
105
106 def get_lookup(self, lookup_name: str) -> type[Lookup] | None:
107 return self.field.get_lookup(lookup_name)
108
109 def get_internal_type(self) -> str:
110 return self.field.get_internal_type()
111
112 @property
113 def db_type(self) -> Any:
114 return self.field.db_type
115
116 def __repr__(self) -> str:
117 return f"<{type(self).__name__}: {self.related_model.model_options.package_label}.{self.related_model.model_options.model_name}>"
118
119 @property
120 def identity(self) -> tuple[Any, ...]:
121 return (
122 self.field,
123 self.model,
124 self.related_query_name,
125 make_hashable(self.limit_choices_to),
126 self.on_delete,
127 self.symmetrical,
128 self.multiple,
129 )
130
131 def __eq__(self, other: object) -> bool:
132 if not isinstance(other, self.__class__):
133 return NotImplemented
134 return self.identity == other.identity
135
136 def __hash__(self) -> int:
137 return hash(self.identity)
138
139 def __getstate__(self) -> dict[str, Any]:
140 state = self.__dict__.copy()
141 # Delete the path_infos cached property because it can be recalculated
142 # at first invocation after deserialization. The attribute must be
143 # removed because subclasses like ForeignKeyRel may have a PathInfo
144 # which contains an intermediate M2M table that's been dynamically
145 # created and doesn't exist in the .models module.
146 # This is a reverse relation, so there is no reverse_path_infos to
147 # delete.
148 state.pop("path_infos", None)
149 return state
150
151 def get_choices(
152 self,
153 include_blank: bool = True,
154 blank_choice: list[tuple[str, str]] = BLANK_CHOICE_DASH,
155 limit_choices_to: Any = None,
156 ordering: tuple[str, ...] = (),
157 ) -> list[tuple[Any, str]]:
158 """
159 Return choices with a default blank choices included, for use
160 as <select> choices for this field.
161
162 Analog of plain.models.fields.Field.get_choices(), provided
163 initially for utilization by RelatedFieldListFilter.
164 """
165 limit_choices_to = limit_choices_to or self.limit_choices_to
166 qs = self.related_model.query.complex_filter(limit_choices_to)
167 if ordering:
168 qs = qs.order_by(*ordering)
169 return (blank_choice if include_blank else []) + [(x.id, str(x)) for x in qs]
170
171 def get_joining_columns(self) -> Any:
172 return self.field.get_reverse_joining_columns()
173
174 def get_extra_restriction(self, alias: str, related_alias: str) -> Any:
175 return self.field.get_extra_restriction(related_alias, alias)
176
177 def set_field_name(self) -> None:
178 """
179 Set the related field's name, this is not available until later stages
180 of app loading, so set_field_name is called from
181 set_attributes_from_rel()
182 """
183 # By default foreign object doesn't relate to any remote field (for
184 # example custom multicolumn joins currently have no remote field).
185 self.field_name = None
186
187 def get_path_info(self, filtered_relation: Any = None) -> Any:
188 if filtered_relation:
189 return self.field.get_reverse_path_info(filtered_relation)
190 else:
191 return self.field.reverse_path_infos
192
193 @cached_property
194 def path_infos(self) -> Any:
195 return self.get_path_info()
196
197 def get_cache_name(self) -> str | None:
198 """
199 Return the name of the cache key to use for storing an instance of the
200 forward model on the reverse model.
201
202 Uses the related_query_name for caching, which provides a stable name
203 for prefetch_related operations.
204 """
205 return self.field.related_query_name()
206
207
208class ForeignKeyRel(ForeignObjectRel):
209 """
210 Used by the ForeignKey field to store information about the relation.
211
212 ``_model_meta.get_fields()`` returns this class to provide access to the field
213 flags for the reverse relation.
214
215 Note: Because we somewhat abuse the Rel objects by using them as reverse
216 fields we get the funny situation where
217 ``ForeignKeyRel.many_to_one == False`` and
218 ``ForeignKeyRel.one_to_many == True``. This is unfortunate but the actual
219 ForeignKeyRel class is a private API and there is work underway to turn
220 reverse relations into actual fields.
221 """
222
223 def __init__(
224 self,
225 field: Any,
226 to: Any,
227 related_query_name: str | None = None,
228 limit_choices_to: Any = None,
229 on_delete: Any = None,
230 ):
231 super().__init__(
232 field,
233 to,
234 related_query_name=related_query_name,
235 limit_choices_to=limit_choices_to,
236 on_delete=on_delete,
237 )
238
239 self.field_name = "id"
240
241 def __getstate__(self) -> dict[str, Any]:
242 state = super().__getstate__()
243 state.pop("related_model", None)
244 return state
245
246 @property
247 def identity(self) -> tuple[Any, ...]:
248 return super().identity + (self.field_name,)
249
250 def get_related_field(self) -> Any:
251 """
252 Return the Field in the 'to' object to which this relationship is tied.
253 """
254 field = self.model._model_meta.get_field("id")
255 if not field.concrete:
256 raise FieldDoesNotExist("No related field named 'id'")
257 return field
258
259 def set_field_name(self) -> None:
260 pass
261
262
263class ManyToManyRel(ForeignObjectRel):
264 """
265 Used by ManyToManyField to store information about the relation.
266
267 ``_model_meta.get_fields()`` returns this class to provide access to the field
268 flags for the reverse relation.
269 """
270
271 def __init__(
272 self,
273 field: Any,
274 to: Any,
275 *,
276 through: Any,
277 through_fields: tuple[str, str] | None = None,
278 related_query_name: str | None = None,
279 limit_choices_to: Any = None,
280 symmetrical: bool = True,
281 ):
282 super().__init__(
283 field,
284 to,
285 related_query_name=related_query_name,
286 limit_choices_to=limit_choices_to,
287 )
288
289 self.through = through
290 self.through_fields = through_fields
291
292 self.symmetrical = symmetrical
293 self.db_constraint = True
294
295 @property
296 def identity(self) -> tuple[Any, ...]:
297 return super().identity + (
298 self.through,
299 make_hashable(self.through_fields),
300 self.db_constraint,
301 )
302
303 def get_related_field(self) -> Any:
304 """
305 Return the field in the 'to' object to which this relationship is tied.
306 Provided for symmetry with ForeignKeyRel.
307 """
308 meta = self.through._model_meta
309 if self.through_fields:
310 field = meta.get_field(self.through_fields[0])
311 else:
312 for field in meta.fields:
313 rel = getattr(field, "remote_field", None)
314 if rel and rel.model == self.model:
315 break
316 return field.foreign_related_fields[0]