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