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