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