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 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]