v0.166.0
  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
 13from typing import TYPE_CHECKING, Any
 14
 15from plain.utils.hashable import make_hashable
 16
 17from .mixins import FieldCacheMixin
 18
 19if TYPE_CHECKING:
 20    from plain.postgres.base import Model
 21    from plain.postgres.deletion import OnDelete
 22    from plain.postgres.fields import Field
 23    from plain.postgres.fields.related import (
 24        ForeignKeyField,
 25        ManyToManyField,
 26        RelatedField,
 27    )
 28    from plain.postgres.lookups import Lookup
 29    from plain.postgres.query_utils import PathInfo
 30
 31
 32class ForeignObjectRel(FieldCacheMixin):
 33    """
 34    Used by ForeignKeyField to store information about the relation.
 35
 36    ``_model_meta.get_fields()`` returns this class to provide access to the field
 37    flags for the reverse relation.
 38    """
 39
 40    # Field flags
 41    auto_created = True
 42
 43    # Reverse relations are always nullable (Plain can't enforce that a
 44    # foreign key on the related model points to this model).
 45    allow_null = True
 46    empty_strings_allowed = False
 47
 48    # Type annotations for instance attributes
 49    on_delete: OnDelete | None
 50    # The target model as given to the field: a "package.Model" string (or
 51    # "self") until lazy_related_operation resolves it to the class. Read
 52    # `model` for the resolved class.
 53    model_ref: str | type[Model]
 54
 55    def __init__(
 56        self,
 57        *,
 58        field: RelatedField,
 59        to: str | type[Model],
 60        related_query_name: str | None = None,
 61        on_delete: OnDelete | None = None,
 62    ):
 63        # Annotated here rather than as a class-body attribute: RelatedField
 64        # is itself a descriptor, so a class-body `field: RelatedField` would
 65        # route every `self.field` read through its __get__. ForeignKeyRel
 66        # and ManyToManyRel below narrow this further, the same way.
 67        self.field: RelatedField = field
 68        self.model_ref = to
 69        self.related_query_name = related_query_name
 70        self.on_delete = on_delete
 71
 72        self.symmetrical = False
 73
 74    # Some of the following cached_properties can't be initialized in
 75    # __init__ as the field doesn't have its model yet. Calling these methods
 76    # before field.contribute_to_class() has been called will result in
 77    # AttributeError
 78    @property
 79    def model(self) -> type[Model]:
 80        """The resolved target model class."""
 81        # Deliberately not AttributeError: getattr(rel, "model", None) and
 82        # hasattr() would swallow that and silently report "no model".
 83        if isinstance(self.model_ref, str):
 84            raise TypeError(
 85                f"Related model {self.model_ref!r} for {self.field!r} has not been "
 86                "resolved yet; use model_ref to read the unresolved reference."
 87            )
 88        return self.model_ref
 89
 90    @cached_property
 91    def name(self) -> str:
 92        return self.field.related_query_name()
 93
 94    @property
 95    def remote_field(self) -> RelatedField:
 96        return self.field
 97
 98    @property
 99    def target_field(self) -> Field:
100        """
101        The remote field this relation filters against. Read dynamically by the
102        related-lookup prep path (`output_field.target_field`) when the relation
103        itself is the lookup target, e.g. ``Parent.query.filter(child=obj)``.
104        """
105        return self.path_infos[-1].target_field
106
107    @cached_property
108    def related_model(self) -> type[Model]:
109        if not self.field.model:
110            raise AttributeError(
111                "This property can't be accessed before self.field.contribute_to_class "
112                "has been called."
113            )
114        return self.field.model
115
116    def get_lookup(self, lookup: str) -> type[Lookup] | None:
117        return self.field.get_lookup(lookup)
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_ref,
127            self.related_query_name,
128            self.on_delete,
129            self.symmetrical,
130        )
131
132    def __eq__(self, other: object) -> bool:
133        if not isinstance(other, self.__class__):
134            return NotImplemented
135        return self.identity == other.identity
136
137    def __hash__(self) -> int:
138        return hash(self.identity)
139
140    def __getstate__(self) -> dict[str, Any]:
141        state = self.__dict__.copy()
142        # Delete the path_infos cached property because it can be recalculated
143        # at first invocation after deserialization. The attribute must be
144        # removed because subclasses like ForeignKeyRel may have a PathInfo
145        # which contains an intermediate M2M table that's been dynamically
146        # created and doesn't exist in the .models module.
147        # This is a reverse relation, so there is no reverse_path_infos to
148        # delete.
149        state.pop("path_infos", None)
150        return state
151
152    @cached_property
153    def path_infos(self) -> list[PathInfo]:
154        return self.field.reverse_path_infos
155
156    def get_cache_name(self) -> str:
157        """
158        Return the name of the cache key to use for storing an instance of the
159        forward model on the reverse model.
160
161        Uses the related_query_name for caching, which provides a stable name
162        for prefetch() operations.
163        """
164        return self.field.related_query_name()
165
166
167class ForeignKeyRel(ForeignObjectRel):
168    """
169    Used by the ForeignKeyField field to store information about the relation.
170
171    ``_model_meta.get_fields()`` returns this class to provide access to the
172    reverse relation. Use ``isinstance(rel, ForeignKeyRel)`` to identify
173    one-to-many reverse relations.
174    """
175
176    # Type annotations for instance attributes
177    on_delete: OnDelete  # narrowed: FK rel always has a concrete action
178
179    def __init__(
180        self,
181        *,
182        field: ForeignKeyField,
183        to: str | type[Model],
184        on_delete: OnDelete,
185        related_query_name: str | None = None,
186    ):
187        super().__init__(
188            field=field,
189            to=to,
190            related_query_name=related_query_name,
191            on_delete=on_delete,
192        )
193        # Narrows self.field from RelatedField to ForeignKeyField (see
194        # ForeignObjectRel.__init__ for why this can't be a class-body
195        # annotation).
196        self.field: ForeignKeyField
197
198    def __getstate__(self) -> dict[str, Any]:
199        state = super().__getstate__()
200        state.pop("related_model", None)
201        return state
202
203    def get_joining_columns(self) -> tuple[str, str]:
204        return self.field.get_joining_columns(reverse_join=True)
205
206
207class ManyToManyRel(ForeignObjectRel):
208    """
209    Used by ManyToManyField to store information about the relation.
210
211    ``_model_meta.get_fields()`` returns this class to provide access to the field
212    flags for the reverse relation.
213    """
214
215    # Type annotations for instance attributes
216    through_fields: tuple[str, str] | None
217    # The intermediary model as given to the field; a string until
218    # lazy_related_operation resolves it. Read `through` for the class.
219    through_ref: str | type[Model]
220
221    def __init__(
222        self,
223        *,
224        field: ManyToManyField,
225        to: str | type[Model],
226        through: str | type[Model],
227        through_fields: tuple[str, str] | None = None,
228        related_query_name: str | None = None,
229        symmetrical: bool = True,
230    ):
231        super().__init__(
232            field=field,
233            to=to,
234            related_query_name=related_query_name,
235        )
236        # Narrows self.field from RelatedField to ManyToManyField (see
237        # ForeignObjectRel.__init__ for why this can't be a class-body
238        # annotation).
239        self.field: ManyToManyField
240
241        self.through_ref = through
242        self.through_fields = through_fields
243
244        self.symmetrical = symmetrical
245
246    @property
247    def identity(self) -> tuple[Any, ...]:
248        return super().identity + (
249            self.through_ref,
250            make_hashable(self.through_fields),
251        )
252
253    @property
254    def through(self) -> type[Model]:
255        """The resolved intermediary model class."""
256        # Deliberately not AttributeError, for the same reason as `model`.
257        if isinstance(self.through_ref, str):
258            raise TypeError(
259                f"Through model {self.through_ref!r} for {self.field!r} has not been "
260                "resolved yet; use through_ref to read the unresolved reference."
261            )
262        return self.through_ref