1from __future__ import annotations
2
3import copy
4import inspect
5from collections import defaultdict
6from collections.abc import Iterable
7from functools import cached_property
8from typing import TYPE_CHECKING, Any, Literal, overload
9
10from plain.postgres.exceptions import FieldDoesNotExist
11from plain.postgres.query import QuerySet
12from plain.postgres.registry import models_registry as default_models_registry
13from plain.utils.datastructures import ImmutableList
14
15if TYPE_CHECKING:
16 from plain.postgres.base import Model
17 from plain.postgres.constraints import BaseConstraint
18 from plain.postgres.fields import Field
19 from plain.postgres.fields.related import ManyToManyField, RelatedField
20 from plain.postgres.fields.reverse_related import ForeignObjectRel
21
22EMPTY_RELATION_TREE = ()
23
24IMMUTABLE_WARNING = (
25 "The return type of '%s' should never be mutated. If you want to manipulate this "
26 "list for your own use, make a copy first."
27)
28
29
30def make_immutable_fields_list[T](name: str, data: Iterable[T]) -> ImmutableList[T]:
31 return ImmutableList(data, warning=IMMUTABLE_WARNING % name)
32
33
34class Meta:
35 """
36 Model metadata descriptor and container.
37
38 Acts as both a descriptor (for lazy initialization and access control)
39 and the actual metadata instance (cached per model class).
40 """
41
42 FORWARD_PROPERTIES = {
43 "fields",
44 "many_to_many",
45 "concrete_fields",
46 "local_concrete_fields",
47 "_non_pk_concrete_field_names",
48 "_forward_fields_map",
49 "base_queryset",
50 }
51 REVERSE_PROPERTIES = {"related_objects", "fields_map", "_relation_tree"}
52
53 # Type annotations for attributes set in _create_and_cache
54 # These exist on cached instances, not on the descriptor itself
55 model: type[Model]
56 models_registry: Any
57 _get_fields_cache: dict[Any, Any]
58 local_fields: list[Field]
59 local_many_to_many: list[ManyToManyField]
60
61 def __init__(self, models_registry: Any | None = None):
62 """
63 Initialize the descriptor with optional configuration.
64
65 This is called ONCE when defining the base Model class.
66 The descriptor then creates cached instances per model subclass.
67 """
68 self._models_registry = models_registry
69 self._cache: dict[type[Model], Meta] = {}
70
71 def __get__(self, instance: Any, owner: type[Model]) -> Meta:
72 """
73 Descriptor protocol - returns cached Meta instance for the model class.
74
75 This is called when accessing Model._model_meta and returns a per-class
76 cached instance created by _create_and_cache().
77
78 Can be accessed from both class and instances:
79 - MyModel._model_meta (class access)
80 - my_instance._model_meta (instance access - returns class's metadata)
81 """
82 # Allow instance access - just return the class's metadata
83 if instance is not None:
84 owner = instance.__class__
85
86 # Skip for the base Model class - return descriptor
87 if owner.__name__ == "Model" and owner.__module__ == "plain.postgres.base":
88 return self
89
90 # Return cached instance or create new one
91 if owner not in self._cache:
92 # Create the instance and cache it BEFORE field contribution
93 # to avoid infinite recursion when fields access cls._model_meta
94 return self._create_and_cache(owner)
95
96 return self._cache[owner]
97
98 def _create_and_cache(self, model: type[Model]) -> Meta:
99 """Create Meta instance and cache it before field contribution."""
100 # Create instance without calling __init__
101 instance = Meta.__new__(Meta)
102
103 # Initialize basic model-specific state
104 instance.model = model
105 instance.models_registry = self._models_registry or default_models_registry
106 instance._get_fields_cache = {}
107 instance.local_fields = []
108 instance.local_many_to_many = []
109
110 # Cache the instance BEFORE processing fields to prevent recursion
111 self._cache[model] = instance
112
113 # Now process fields - they can safely access cls._model_meta
114 seen_attrs = set()
115 for klass in model.__mro__:
116 for attr_name in list(klass.__dict__.keys()):
117 if attr_name.startswith("_") or attr_name in seen_attrs:
118 continue
119 seen_attrs.add(attr_name)
120
121 attr_value = klass.__dict__[attr_name]
122
123 if not inspect.isclass(attr_value) and hasattr(
124 attr_value, "contribute_to_class"
125 ):
126 if attr_name not in model.__dict__:
127 field = copy.deepcopy(attr_value)
128 else:
129 field = attr_value
130 field.contribute_to_class(model, attr_name)
131
132 # Sort fields: primary key first, then alphabetically by name
133 instance.local_fields.sort(key=lambda f: (not f.primary_key, f.name or ""))
134 instance.local_many_to_many.sort(key=lambda f: f.name or "")
135
136 return instance
137
138 @property
139 def base_queryset(self) -> QuerySet:
140 """
141 The base queryset is used by Plain's internal operations like cascading
142 deletes, migrations, and related object lookups. It provides access to
143 all objects in the database without any filtering, ensuring Plain can
144 always see the complete dataset when performing framework operations.
145
146 Unlike user-defined querysets which may filter results (e.g. only active
147 objects), the base queryset must never filter out rows to prevent
148 incomplete results in related queries.
149 """
150 return QuerySet.from_model(self.model)
151
152 def add_field(self, field: Field) -> None:
153 from plain.postgres.fields.related import ManyToManyField, RelatedField
154
155 if isinstance(field, ManyToManyField):
156 self.local_many_to_many.append(field)
157 else:
158 self.local_fields.append(field)
159
160 # If the field being added is a relation to another known field,
161 # expire the cache on this field and the forward cache on the field
162 # being referenced, because there will be new relationships in the
163 # cache. Otherwise, expire the cache of references *to* this field.
164 # The mechanism for getting at the related model is slightly odd -
165 # ideally, we'd just ask for field.related_model. However, related_model
166 # is a cached property, and all the models haven't been loaded yet, so
167 # we need to make sure we don't cache a string reference.
168 if isinstance(field, RelatedField) and field.remote_field.model:
169 try:
170 field.remote_field.model._model_meta._expire_cache(forward=False)
171 except AttributeError:
172 pass
173 self._expire_cache()
174 else:
175 self._expire_cache(reverse=False)
176
177 @cached_property
178 def fields(self) -> ImmutableList[Field]:
179 from plain.postgres.fields.related import RelatedField
180
181 """
182 Return a list of all forward fields on the model and its parents,
183 excluding ManyToManyFields.
184
185 Private API intended only to be used by Plain itself; get_fields()
186 combined with filtering of field properties is the public API for
187 obtaining this field list.
188 """
189
190 # For legacy reasons, the fields property should only contain forward
191 # fields that are not private or with a m2m cardinality.
192 def is_not_an_m2m_field(f: Any) -> bool:
193 from plain.postgres.fields.related import ManyToManyField
194
195 return not isinstance(f, ManyToManyField)
196
197 def is_not_a_generic_relation(f: Any) -> bool:
198 from plain.postgres.fields.related import ForeignKeyField, ManyToManyField
199
200 # Only ForeignKeyField and ManyToManyField are valid RelatedFields
201 # Anything else is a generic relation
202 if not isinstance(f, RelatedField):
203 return True
204 return isinstance(f, ForeignKeyField | ManyToManyField)
205
206 return make_immutable_fields_list(
207 "fields",
208 (
209 f
210 for f in self._get_fields(reverse=False)
211 if is_not_an_m2m_field(f) and is_not_a_generic_relation(f)
212 ),
213 )
214
215 @cached_property
216 def concrete_fields(self) -> ImmutableList[Field]:
217 """
218 Return a list of all concrete fields on the model and its parents.
219
220 Private API intended only to be used by Plain itself; get_fields()
221 combined with filtering of field properties is the public API for
222 obtaining this field list.
223 """
224 return make_immutable_fields_list(
225 "concrete_fields", (f for f in self.fields if f.concrete)
226 )
227
228 @cached_property
229 def local_concrete_fields(self) -> ImmutableList[Field]:
230 """
231 Return a list of all concrete fields on the model.
232
233 Private API intended only to be used by Plain itself; get_fields()
234 combined with filtering of field properties is the public API for
235 obtaining this field list.
236 """
237 return make_immutable_fields_list(
238 "local_concrete_fields", (f for f in self.local_fields if f.concrete)
239 )
240
241 @cached_property
242 def many_to_many(self) -> ImmutableList[Field]:
243 """
244 Return a list of all many to many fields on the model and its parents.
245
246 Private API intended only to be used by Plain itself; get_fields()
247 combined with filtering of field properties is the public API for
248 obtaining this list.
249 """
250 from plain.postgres.fields.related import ManyToManyField
251
252 return make_immutable_fields_list(
253 "many_to_many",
254 (
255 f
256 for f in self._get_fields(reverse=False)
257 if isinstance(f, ManyToManyField)
258 ),
259 )
260
261 @cached_property
262 def related_objects(self) -> ImmutableList[ForeignObjectRel]:
263 """
264 Return all related objects pointing to the current model. The related
265 objects can come from a one-to-many or many-to-many field relation type.
266
267 Private API intended only to be used by Plain itself; get_fields()
268 combined with filtering of field properties is the public API for
269 obtaining this field list.
270 """
271 from plain.postgres.fields.reverse_related import ForeignKeyRel, ManyToManyRel
272
273 all_related_fields = self._get_fields(forward=False, reverse=True)
274 return make_immutable_fields_list(
275 "related_objects",
276 (
277 obj
278 for obj in all_related_fields
279 if isinstance(obj, ManyToManyRel | ForeignKeyRel)
280 ),
281 )
282
283 @cached_property
284 def _forward_fields_map(self) -> dict[str, Field]:
285 return {field.name: field for field in self._get_fields(reverse=False)}
286
287 @cached_property
288 def fields_map(self) -> dict[str, Field | ForeignObjectRel]:
289 return {
290 field.name: field for field in self._get_fields(forward=False, reverse=True)
291 }
292
293 def get_field(self, field_name: str) -> Field | ForeignObjectRel:
294 """
295 Return a field instance given the name of a forward or reverse field.
296 """
297 try:
298 # In order to avoid premature loading of the relation tree
299 # (expensive) we prefer checking if the field is a forward field.
300 return self._forward_fields_map[field_name]
301 except KeyError:
302 # If the app registry is not ready, reverse fields are
303 # unavailable, therefore we throw a FieldDoesNotExist exception.
304 if not self.models_registry.ready:
305 raise FieldDoesNotExist(
306 f"{self.model} has no field named '{field_name}'. The app cache isn't ready yet, "
307 "so if this is an auto-created related field, it won't "
308 "be available yet."
309 )
310
311 try:
312 # Retrieve field instance by name from cached or just-computed
313 # field map.
314 return self.fields_map[field_name]
315 except KeyError:
316 raise FieldDoesNotExist(f"{self.model} has no field named '{field_name}'")
317
318 def get_forward_field(self, field_name: str) -> Field:
319 """
320 Return a forward field instance given the field name.
321
322 Raises FieldDoesNotExist if the field doesn't exist or is a reverse relation.
323 """
324 try:
325 return self._forward_fields_map[field_name]
326 except KeyError:
327 raise FieldDoesNotExist(
328 f"{self.model} has no forward field named '{field_name}'"
329 )
330
331 def get_reverse_relation(self, field_name: str) -> ForeignObjectRel:
332 """
333 Return a reverse relation instance given the field name.
334
335 Raises FieldDoesNotExist if the field doesn't exist or is a forward field.
336 """
337 # If the app registry is not ready, reverse fields are unavailable
338 if not self.models_registry.ready:
339 raise FieldDoesNotExist(
340 f"{self.model} has no reverse relation named '{field_name}'. The app cache isn't ready yet."
341 )
342
343 # Check if it's a forward field first
344 if field_name in self._forward_fields_map:
345 raise FieldDoesNotExist(
346 f"'{field_name}' is a forward field, not a reverse relation"
347 )
348
349 try:
350 return self.fields_map[field_name] # ty: ignore[invalid-return-type]
351 except KeyError:
352 raise FieldDoesNotExist(
353 f"{self.model} has no reverse relation named '{field_name}'"
354 )
355
356 def _populate_directed_relation_graph(self) -> list[RelatedField]:
357 from plain.postgres.fields.related import RelatedField
358
359 """
360 This method is used by each model to find its reverse objects. As this
361 method is very expensive and is accessed frequently (it looks up every
362 field in a model, in every app), it is computed on first access and then
363 is set as a property on every model.
364 """
365 related_objects_graph: defaultdict[str, list[Any]] = defaultdict(list)
366
367 all_models = self.models_registry.get_models()
368 for model in all_models:
369 meta = model._model_meta
370
371 fields_with_relations = (
372 f
373 for f in meta._get_fields(reverse=False)
374 if isinstance(f, RelatedField)
375 )
376 for f in fields_with_relations:
377 if not isinstance(f.remote_field.model, str):
378 remote_label = f.remote_field.model.model_options.label
379 related_objects_graph[remote_label].append(f)
380
381 for model in all_models:
382 # Set the relation_tree using the internal __dict__. In this way
383 # we avoid calling the cached property. In attribute lookup,
384 # __dict__ takes precedence over a data descriptor (such as
385 # @cached_property). This means that the _model_meta._relation_tree is
386 # only called if related_objects is not in __dict__.
387 related_objects = related_objects_graph[model.model_options.label]
388 model._model_meta.__dict__["_relation_tree"] = related_objects
389 # It seems it is possible that self is not in all_models, so guard
390 # against that with default for get().
391 return self.__dict__.get("_relation_tree", EMPTY_RELATION_TREE)
392
393 @cached_property
394 def _relation_tree(self) -> list[RelatedField]:
395 return self._populate_directed_relation_graph()
396
397 def _expire_cache(self, forward: bool = True, reverse: bool = True) -> None:
398 # This method is usually called by packages.cache_clear(), when the
399 # registry is finalized, or when a new field is added.
400 if forward:
401 for cache_key in self.FORWARD_PROPERTIES:
402 if cache_key in self.__dict__:
403 delattr(self, cache_key)
404 if reverse:
405 for cache_key in self.REVERSE_PROPERTIES:
406 if cache_key in self.__dict__:
407 delattr(self, cache_key)
408 self._get_fields_cache = {}
409
410 @overload
411 def get_fields(
412 self, include_reverse: Literal[False] = False
413 ) -> ImmutableList[Field]: ...
414
415 @overload
416 def get_fields(
417 self, include_reverse: Literal[True]
418 ) -> ImmutableList[Field | ForeignObjectRel]: ...
419
420 def get_fields(
421 self, include_reverse: bool = False
422 ) -> ImmutableList[Field | ForeignObjectRel]:
423 """
424 Return a list of fields associated to the model.
425
426 By default, returns only forward fields (fields explicitly defined on
427 this model). Set include_reverse=True to also include reverse relations
428 (fields from other models that point to this model).
429
430 Args:
431 include_reverse: Include reverse relation fields (fields from other
432 models pointing to this model). Needed for framework
433 operations like migrations and deletion cascading.
434 """
435 return self._get_fields(reverse=include_reverse)
436
437 @overload
438 def _get_fields(
439 self,
440 *,
441 forward: Literal[True] = True,
442 reverse: Literal[False],
443 seen_models: set[type[Any]] | None = None,
444 ) -> ImmutableList[Field]: ...
445
446 @overload
447 def _get_fields(
448 self,
449 *,
450 forward: Literal[False],
451 reverse: Literal[True] = True,
452 seen_models: set[type[Any]] | None = None,
453 ) -> ImmutableList[ForeignObjectRel]: ...
454
455 @overload
456 def _get_fields(
457 self,
458 *,
459 forward: bool = True,
460 reverse: bool = True,
461 seen_models: set[type[Any]] | None = None,
462 ) -> ImmutableList[Field | ForeignObjectRel]: ...
463
464 def _get_fields(
465 self,
466 *,
467 forward: bool = True,
468 reverse: bool = True,
469 seen_models: set[type[Any]] | None = None,
470 ) -> ImmutableList[Field | ForeignObjectRel]:
471 """
472 Internal helper function to return fields of the model.
473
474 Args:
475 forward: If True, fields defined on this model are returned.
476 reverse: If True, reverse relations (fields from other models
477 pointing to this model) are returned.
478 seen_models: Track visited models to prevent duplicates in recursion.
479 """
480
481 # This helper function is used to allow recursion in ``get_fields()``
482 # implementation and to provide a fast way for Plain's internals to
483 # access specific subsets of fields.
484
485 # We must keep track of which models we have already seen. Otherwise we
486 # could include the same field multiple times from different models.
487 topmost_call = seen_models is None
488 if seen_models is None:
489 seen_models = set()
490 seen_models.add(self.model)
491
492 # Creates a cache key composed of all arguments
493 cache_key = (forward, reverse, topmost_call)
494
495 try:
496 # In order to avoid list manipulation. Always return a shallow copy
497 # of the results.
498 return self._get_fields_cache[cache_key]
499 except KeyError:
500 pass
501
502 fields = []
503
504 if reverse:
505 # Tree is computed once and cached until the app cache is expired.
506 # It is composed of a list of fields from other models pointing to
507 # the current model (reverse relations).
508 all_fields = self._relation_tree
509 for field in all_fields:
510 fields.append(field.remote_field)
511
512 if forward:
513 # get_fields() intentionally returns a heterogeneous list of field types.
514 fields += self.local_fields # ty: ignore[unsupported-operator]
515 fields += self.local_many_to_many # ty: ignore[unsupported-operator]
516
517 # In order to avoid list manipulation. Always
518 # return a shallow copy of the results
519 fields = make_immutable_fields_list("get_fields()", fields)
520
521 # Store result into cache for later access
522 self._get_fields_cache[cache_key] = fields
523 return fields
524
525 @cached_property
526 def _property_names(self) -> frozenset[str]:
527 """Return a set of the names of the properties defined on the model."""
528 names = []
529 for name in dir(self.model):
530 attr = inspect.getattr_static(self.model, name)
531 if isinstance(attr, property):
532 names.append(name)
533 return frozenset(names)
534
535 @cached_property
536 def _non_pk_concrete_field_names(self) -> frozenset[str]:
537 """
538 Return a set of the non-primary key concrete field names defined on the model.
539 """
540 names = []
541 for field in self.concrete_fields:
542 if not field.primary_key:
543 names.append(field.name)
544 return frozenset(names)
545
546 @cached_property
547 def db_returning_fields(self) -> list[Field]:
548 """
549 Private API intended only to be used by Plain itself.
550 Fields to be returned after a database insert.
551 """
552 return [
553 field
554 for field in self._get_fields(forward=True, reverse=False)
555 if field.db_returning
556 ]
557
558 @property
559 def constraints_by_name(self) -> dict[str, BaseConstraint]:
560 """
561 Map each named constraint to its definition, keyed by the name
562 Postgres reports in ``err.diag.constraint_name`` — used on the write
563 path to translate an IntegrityError back to the constraint that raised
564 it.
565
566 A plain ``property``, not ``cached_property``: only read on the error
567 path, so recomputing is free, and it can never serve a stale map if
568 ``model_options.constraints`` is mutated.
569 """
570 return {
571 constraint.name: constraint
572 for constraint in self.model.model_options.constraints
573 }