v0.146.0
  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()}"