1from __future__ import annotations
2
3from collections.abc import Sequence
4from typing import TYPE_CHECKING, Any
5
6from plain.models.postgres.sql import MAX_NAME_LENGTH
7from plain.models.postgres.utils import truncate_name
8from plain.packages import packages_registry
9
10if TYPE_CHECKING:
11 from plain.models.base import Model
12 from plain.models.constraints import BaseConstraint
13 from plain.models.indexes import Index
14
15
16class Options:
17 """
18 Model options descriptor and container.
19
20 Acts as both a descriptor (for lazy initialization and access control)
21 and the actual options instance (cached per model class).
22 """
23
24 # Type annotations for attributes set in _create_and_cache
25 # These exist on cached instances, not on the descriptor itself
26 model: type[Model]
27 package_label: str
28 db_table: str
29 ordering: Sequence[str]
30 indexes: Sequence[Index]
31 constraints: Sequence[BaseConstraint]
32 _provided_options: set[str]
33
34 def __init__(
35 self,
36 *,
37 db_table: str | None = None,
38 ordering: Sequence[str] | None = None,
39 indexes: Sequence[Index] | None = None,
40 constraints: Sequence[BaseConstraint] | None = None,
41 package_label: str | None = None,
42 ):
43 """
44 Initialize the descriptor with optional configuration.
45
46 This is called ONCE when defining the base Model class, or when
47 a user explicitly sets model_options = Options(...) on their model.
48 The descriptor then creates cached instances per model subclass.
49 """
50 self._config = {
51 "db_table": db_table,
52 "ordering": ordering,
53 "indexes": indexes,
54 "constraints": constraints,
55 "package_label": package_label,
56 }
57 self._cache: dict[type[Model], Options] = {}
58
59 def __get__(self, instance: Any, owner: type[Model]) -> Options:
60 """
61 Descriptor protocol - returns cached Options for the model class.
62
63 This is called when accessing Model.model_options and returns a per-class
64 cached instance created by _create_and_cache().
65
66 Can be accessed from both class and instances:
67 - MyModel.model_options (class access)
68 - my_instance.model_options (instance access - returns class's options)
69 """
70 # Allow instance access - just return the class's options
71 if instance is not None:
72 owner = instance.__class__
73
74 # Skip for the base Model class - return descriptor
75 if owner.__name__ == "Model" and owner.__module__ == "plain.models.base":
76 return self
77
78 # Return cached instance or create new one
79 if owner not in self._cache:
80 return self._create_and_cache(owner)
81
82 return self._cache[owner]
83
84 def _create_and_cache(self, model: type[Model]) -> Options:
85 """Create Options and cache it."""
86 # Create instance without calling __init__
87 instance = Options.__new__(Options)
88
89 # Track which options were explicitly provided by user
90 # Note: package_label is excluded because it's passed separately in migrations
91 instance._provided_options = {
92 k for k, v in self._config.items() if v is not None and k != "package_label"
93 }
94
95 instance.model = model
96
97 # Resolve package_label
98 package_label = self._config.get("package_label")
99 if package_label is None:
100 module = model.__module__
101 package_config = packages_registry.get_containing_package_config(module)
102 if package_config is None:
103 raise RuntimeError(
104 f"Model class {module}.{model.__name__} doesn't declare an explicit "
105 "package_label and isn't in an application in INSTALLED_PACKAGES."
106 )
107 instance.package_label = package_config.package_label
108 else:
109 instance.package_label = package_label
110
111 # Set db_table
112 db_table = self._config.get("db_table")
113 if db_table is None:
114 instance.db_table = truncate_name(
115 f"{instance.package_label}_{model.__name__.lower()}",
116 MAX_NAME_LENGTH,
117 )
118 else:
119 instance.db_table = db_table
120
121 instance.ordering = self._config.get("ordering") or []
122 instance.indexes = self._config.get("indexes") or []
123 instance.constraints = self._config.get("constraints") or []
124
125 # Format names with class interpolation
126 instance.constraints = instance._format_names_with_class(instance.constraints)
127 instance.indexes = instance._format_names_with_class(instance.indexes)
128
129 # Cache early to prevent recursion if needed
130 self._cache[model] = instance
131
132 return instance
133
134 @property
135 def object_name(self) -> str:
136 """The model class name."""
137 return self.model.__name__
138
139 @property
140 def model_name(self) -> str:
141 """The model class name in lowercase."""
142 return self.object_name.lower()
143
144 @property
145 def label(self) -> str:
146 """The model label: package_label.ClassName"""
147 return f"{self.package_label}.{self.object_name}"
148
149 @property
150 def label_lower(self) -> str:
151 """The model label in lowercase: package_label.classname"""
152 return f"{self.package_label}.{self.model_name}"
153
154 def _format_names_with_class(self, objs: list[Any]) -> list[Any]:
155 """Package label/class name interpolation for object names."""
156 new_objs = []
157 for obj in objs:
158 obj = obj.clone()
159 obj.name = obj.name % {
160 "package_label": self.package_label.lower(),
161 "class": self.model.__name__.lower(),
162 }
163 new_objs.append(obj)
164 return new_objs
165
166 def export_for_migrations(self) -> dict[str, Any]:
167 """Export user-provided options for migrations."""
168 options = {}
169 for name in self._provided_options:
170 if name == "indexes":
171 # Clone indexes and ensure names are set
172 indexes = [idx.clone() for idx in self.indexes]
173 for index in indexes:
174 if not index.name:
175 index.set_name_with_model(self.model)
176 options["indexes"] = indexes
177 elif name == "constraints":
178 # Clone constraints
179 options["constraints"] = [con.clone() for con in self.constraints]
180 else:
181 # Use current attribute value
182 options[name] = getattr(self, name)
183 return options
184
185 @property
186 def total_unique_constraints(self) -> list[Any]:
187 """
188 Return a list of total unique constraints. Useful for determining set
189 of fields guaranteed to be unique for all rows.
190 """
191 from plain.models.constraints import UniqueConstraint
192
193 return [
194 constraint
195 for constraint in self.constraints
196 if (
197 isinstance(constraint, UniqueConstraint)
198 and constraint.condition is None
199 and not constraint.contains_expressions
200 )
201 ]
202
203 def __repr__(self) -> str:
204 return f"<Options for {self.model.__name__}>"
205
206 def __str__(self) -> str:
207 return f"{self.package_label}.{self.model.__name__.lower()}"