Plain is headed towards 1.0! Subscribe for development updates →

  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]