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