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