plain.postgres 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import re
5
+ from collections.abc import Generator
6
+ from typing import TYPE_CHECKING, Any, NamedTuple
7
+
8
+ from plain.postgres.fields.related import (
9
+ RECURSIVE_RELATIONSHIP_CONSTANT,
10
+ RelatedField,
11
+ )
12
+
13
+ if TYPE_CHECKING:
14
+ from plain.postgres.fields import Field
15
+ from plain.postgres.fields.reverse_related import ForeignObjectRel
16
+
17
+
18
+ class FieldReference(NamedTuple):
19
+ """Reference to a field in migrations, tracking direct and through relationships."""
20
+
21
+ to: tuple[ForeignObjectRel, list[str]] | None
22
+ through: tuple[ForeignObjectRel, tuple[str, ...] | None] | None
23
+
24
+
25
+ COMPILED_REGEX_TYPE = type(re.compile(""))
26
+
27
+
28
+ class RegexObject:
29
+ def __init__(self, obj: Any) -> None:
30
+ self.pattern = obj.pattern
31
+ self.flags = obj.flags
32
+
33
+ def __eq__(self, other: Any) -> bool:
34
+ if not isinstance(other, RegexObject):
35
+ return NotImplemented
36
+ return self.pattern == other.pattern and self.flags == other.flags
37
+
38
+
39
+ def get_migration_name_timestamp() -> str:
40
+ return datetime.datetime.now().strftime("%Y%m%d_%H%M")
41
+
42
+
43
+ def resolve_relation(
44
+ model: str | Any, package_label: str | None = None, model_name: str | None = None
45
+ ) -> tuple[str, str]:
46
+ """
47
+ Turn a model class or model reference string and return a model tuple.
48
+
49
+ package_label and model_name are used to resolve the scope of recursive and
50
+ unscoped model relationship.
51
+ """
52
+ if isinstance(model, str):
53
+ if model == RECURSIVE_RELATIONSHIP_CONSTANT:
54
+ if package_label is None or model_name is None:
55
+ raise TypeError(
56
+ "package_label and model_name must be provided to resolve "
57
+ "recursive relationships."
58
+ )
59
+ return package_label, model_name
60
+ if "." in model:
61
+ package_label, model_name = model.split(".", 1)
62
+ return package_label, model_name.lower()
63
+ if package_label is None:
64
+ raise TypeError(
65
+ "package_label must be provided to resolve unscoped model relationships."
66
+ )
67
+ return package_label, model.lower()
68
+ return model.model_options.package_label, model.model_options.model_name
69
+
70
+
71
+ def field_references(
72
+ model_tuple: tuple[str, str],
73
+ field: Field,
74
+ reference_model_tuple: tuple[str, str],
75
+ reference_field_name: str | None = None,
76
+ reference_field: Field | None = None,
77
+ ) -> FieldReference | bool:
78
+ """
79
+ Return either False or a FieldReference if `field` references provided
80
+ context.
81
+
82
+ False positives can be returned if `reference_field_name` is provided
83
+ without `reference_field` because of the introspection limitation it
84
+ incurs. This should not be an issue when this function is used to determine
85
+ whether or not an optimization can take place.
86
+ """
87
+ # Only RelatedFields have remote_field attribute
88
+ if not isinstance(field, RelatedField):
89
+ return False
90
+ remote_field = field.remote_field
91
+ if not remote_field:
92
+ return False
93
+ references_to = None
94
+ references_through = None
95
+ if resolve_relation(remote_field.model, *model_tuple) == reference_model_tuple:
96
+ # ForeignObject always references 'id'
97
+ if (
98
+ reference_field_name is None
99
+ or reference_field_name == "id"
100
+ or (reference_field is None or reference_field.primary_key)
101
+ ):
102
+ references_to = (remote_field, ["id"])
103
+ through = getattr(remote_field, "through", None)
104
+ if through and resolve_relation(through, *model_tuple) == reference_model_tuple:
105
+ through_fields = getattr(remote_field, "through_fields", None)
106
+ if (
107
+ reference_field_name is None
108
+ or
109
+ # Unspecified through_fields.
110
+ through_fields is None
111
+ or
112
+ # Reference to field.
113
+ reference_field_name in through_fields
114
+ ):
115
+ references_through = (remote_field, through_fields)
116
+ if not (references_to or references_through):
117
+ return False
118
+ return FieldReference(references_to, references_through)
119
+
120
+
121
+ def get_references(
122
+ state: Any, model_tuple: tuple[str, str], field_tuple: tuple[Any, ...] = ()
123
+ ) -> Generator[tuple[Any, str, Field, FieldReference]]:
124
+ """
125
+ Generator of (model_state, name, field, reference) referencing
126
+ provided context.
127
+
128
+ If field_tuple is provided only references to this particular field of
129
+ model_tuple will be generated.
130
+ """
131
+ for state_model_tuple, model_state in state.models.items():
132
+ for name, field in model_state.fields.items():
133
+ reference = field_references(
134
+ state_model_tuple,
135
+ field,
136
+ model_tuple,
137
+ *field_tuple,
138
+ )
139
+ if reference:
140
+ yield model_state, name, field, reference
141
+
142
+
143
+ def field_is_referenced(
144
+ state: Any, model_tuple: tuple[str, str], field_tuple: tuple[Any, ...]
145
+ ) -> bool:
146
+ """Return whether `field_tuple` is referenced by any state models."""
147
+ return next(get_references(state, model_tuple, field_tuple), None) is not None
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from importlib import import_module
6
+ from typing import Any
7
+
8
+ from plain.packages import packages_registry
9
+ from plain.postgres import migrations
10
+ from plain.postgres.migrations.loader import MigrationLoader
11
+ from plain.postgres.migrations.migration import SettingsTuple
12
+ from plain.postgres.migrations.serializer import serializer_factory
13
+ from plain.runtime import __version__
14
+ from plain.utils.inspect import get_func_args
15
+ from plain.utils.module_loading import module_dir
16
+ from plain.utils.timezone import now
17
+
18
+
19
+ class OperationWriter:
20
+ def __init__(self, operation: Any, indentation: int = 2) -> None:
21
+ self.operation = operation
22
+ self.buff: list[str] = []
23
+ self.indentation = indentation
24
+
25
+ def serialize(self) -> tuple[str, set[str]]:
26
+ def _write(_arg_name: str, _arg_value: Any) -> None:
27
+ if _arg_name in self.operation.serialization_expand_args and isinstance(
28
+ _arg_value, list | tuple | dict
29
+ ):
30
+ if isinstance(_arg_value, dict):
31
+ self.feed(f"{_arg_name}={{")
32
+ self.indent()
33
+ for key, value in _arg_value.items():
34
+ key_string, key_imports = MigrationWriter.serialize(key)
35
+ arg_string, arg_imports = MigrationWriter.serialize(value)
36
+ args = arg_string.splitlines()
37
+ if len(args) > 1:
38
+ self.feed(f"{key_string}: {args[0]}")
39
+ for arg in args[1:-1]:
40
+ self.feed(arg)
41
+ self.feed(f"{args[-1]},")
42
+ else:
43
+ self.feed(f"{key_string}: {arg_string},")
44
+ imports.update(key_imports)
45
+ imports.update(arg_imports)
46
+ self.unindent()
47
+ self.feed("},")
48
+ else:
49
+ self.feed(f"{_arg_name}=[")
50
+ self.indent()
51
+ for item in _arg_value:
52
+ arg_string, arg_imports = MigrationWriter.serialize(item)
53
+ args = arg_string.splitlines()
54
+ if len(args) > 1:
55
+ for arg in args[:-1]:
56
+ self.feed(arg)
57
+ self.feed(f"{args[-1]},")
58
+ else:
59
+ self.feed(f"{arg_string},")
60
+ imports.update(arg_imports)
61
+ self.unindent()
62
+ self.feed("],")
63
+ else:
64
+ arg_string, arg_imports = MigrationWriter.serialize(_arg_value)
65
+ args = arg_string.splitlines()
66
+ if len(args) > 1:
67
+ self.feed(f"{_arg_name}={args[0]}")
68
+ for arg in args[1:-1]:
69
+ self.feed(arg)
70
+ self.feed(f"{args[-1]},")
71
+ else:
72
+ self.feed(f"{_arg_name}={arg_string},")
73
+ imports.update(arg_imports)
74
+
75
+ imports = set()
76
+ name, args, kwargs = self.operation.deconstruct()
77
+ operation_args = get_func_args(self.operation.__init__)
78
+
79
+ # See if this operation is in plain.postgres.migrations. If it is,
80
+ # We can just use the fact we already have that imported,
81
+ # otherwise, we need to add an import for the operation class.
82
+ if getattr(migrations, name, None) == self.operation.__class__:
83
+ self.feed(f"migrations.{name}(")
84
+ else:
85
+ imports.add(f"import {self.operation.__class__.__module__}")
86
+ self.feed(f"{self.operation.__class__.__module__}.{name}(")
87
+
88
+ self.indent()
89
+
90
+ for i, arg in enumerate(args):
91
+ arg_value = arg
92
+ arg_name = operation_args[i]
93
+ _write(arg_name, arg_value)
94
+
95
+ i = len(args)
96
+ # Only iterate over remaining arguments
97
+ for arg_name in operation_args[i:]:
98
+ if arg_name in kwargs: # Don't sort to maintain signature order
99
+ arg_value = kwargs[arg_name]
100
+ _write(arg_name, arg_value)
101
+
102
+ self.unindent()
103
+ self.feed("),")
104
+ return self.render(), imports
105
+
106
+ def indent(self) -> None:
107
+ self.indentation += 1
108
+
109
+ def unindent(self) -> None:
110
+ self.indentation -= 1
111
+
112
+ def feed(self, line: str) -> None:
113
+ self.buff.append(" " * (self.indentation * 4) + line)
114
+
115
+ def render(self) -> str:
116
+ return "\n".join(self.buff)
117
+
118
+
119
+ class MigrationWriter:
120
+ """
121
+ Take a Migration instance and is able to produce the contents
122
+ of the migration file from it.
123
+ """
124
+
125
+ def __init__(self, migration: Any, include_header: bool = True) -> None:
126
+ self.migration = migration
127
+ self.include_header = include_header
128
+ self.needs_manual_porting = False
129
+
130
+ def as_string(self) -> str:
131
+ """Return a string of the file contents."""
132
+ items = {
133
+ "replaces_str": "",
134
+ "initial_str": "",
135
+ }
136
+
137
+ imports = set()
138
+
139
+ # Deconstruct operations
140
+ operations = []
141
+ for operation in self.migration.operations:
142
+ operation_string, operation_imports = OperationWriter(operation).serialize()
143
+ imports.update(operation_imports)
144
+ operations.append(operation_string)
145
+ items["operations"] = "\n".join(operations) + "\n" if operations else ""
146
+
147
+ # Format dependencies and write out settings dependencies right
148
+ dependencies = []
149
+ for dependency in self.migration.dependencies:
150
+ if isinstance(dependency, SettingsTuple):
151
+ dependencies.append(
152
+ f" migrations.settings_dependency(settings.{dependency[1]}),"
153
+ )
154
+ imports.add("from plain.runtime import settings")
155
+ else:
156
+ dependencies.append(f" {self.serialize(dependency)[0]},")
157
+ items["dependencies"] = "\n".join(dependencies) + "\n" if dependencies else ""
158
+
159
+ # Format imports nicely, swapping imports of functions from migration files
160
+ # for comments
161
+ migration_imports = set()
162
+ for line in list(imports):
163
+ if re.match(r"^import (.*)\.\d+[^\s]*$", line):
164
+ migration_imports.add(line.split("import")[1].strip())
165
+ imports.remove(line)
166
+ self.needs_manual_porting = True
167
+
168
+ imports.add("from plain.postgres import migrations")
169
+
170
+ # Sort imports by the package / module to be imported (the part after
171
+ # "from" in "from ... import ..." or after "import" in "import ...").
172
+ # First group the "import" statements, then "from ... import ...".
173
+ sorted_imports = sorted(
174
+ imports, key=lambda i: (i.split()[0] == "from", i.split()[1])
175
+ )
176
+ items["imports"] = "\n".join(sorted_imports) + "\n" if imports else ""
177
+ if migration_imports:
178
+ items["imports"] += (
179
+ "\n\n# Functions from the following migrations need manual "
180
+ "copying.\n# Move them and any dependencies into this file, "
181
+ "then update the\n# RunPython operations to refer to the local "
182
+ "versions:\n# {}"
183
+ ).format("\n# ".join(sorted(migration_imports)))
184
+ # If there's a replaces, make a string for it
185
+ if self.migration.replaces:
186
+ items["replaces_str"] = (
187
+ f"\n replaces = {self.serialize(self.migration.replaces)[0]}\n"
188
+ )
189
+ # Hinting that goes into comment
190
+ if self.include_header:
191
+ items["migration_header"] = MIGRATION_HEADER_TEMPLATE % {
192
+ "version": __version__,
193
+ "timestamp": now().strftime("%Y-%m-%d %H:%M"),
194
+ }
195
+ else:
196
+ items["migration_header"] = ""
197
+
198
+ if self.migration.initial:
199
+ items["initial_str"] = "\n initial = True\n"
200
+
201
+ return MIGRATION_TEMPLATE % items
202
+
203
+ @property
204
+ def basedir(self) -> str:
205
+ migrations_package_name, _ = MigrationLoader.migrations_module(
206
+ self.migration.package_label
207
+ )
208
+
209
+ if migrations_package_name is None:
210
+ raise ValueError(
211
+ f"Plain can't create migrations for app '{self.migration.package_label}' because "
212
+ "migrations have been disabled via the MIGRATION_MODULES "
213
+ "setting."
214
+ )
215
+
216
+ # See if we can import the migrations module directly
217
+ try:
218
+ migrations_module = import_module(migrations_package_name)
219
+ except ImportError:
220
+ pass
221
+ else:
222
+ try:
223
+ return module_dir(migrations_module)
224
+ except ValueError:
225
+ pass
226
+
227
+ # Alright, see if it's a direct submodule of the app
228
+ package_config = packages_registry.get_package_config(
229
+ self.migration.package_label
230
+ )
231
+ (
232
+ maybe_package_name,
233
+ _,
234
+ migrations_package_basename,
235
+ ) = migrations_package_name.rpartition(".")
236
+ if package_config.name == maybe_package_name:
237
+ return os.path.join(package_config.path, migrations_package_basename)
238
+
239
+ # In case of using MIGRATION_MODULES setting and the custom package
240
+ # doesn't exist, create one, starting from an existing package
241
+ existing_dirs, missing_dirs = migrations_package_name.split("."), []
242
+ while existing_dirs:
243
+ missing_dirs.insert(0, existing_dirs.pop(-1))
244
+ try:
245
+ base_module = import_module(".".join(existing_dirs))
246
+ except (ImportError, ValueError):
247
+ continue
248
+ else:
249
+ try:
250
+ base_dir = module_dir(base_module)
251
+ except ValueError:
252
+ continue
253
+ else:
254
+ break
255
+ else:
256
+ raise ValueError(
257
+ "Could not locate an appropriate location to create "
258
+ f"migrations package {migrations_package_name}. Make sure the toplevel "
259
+ "package exists and can be imported."
260
+ )
261
+
262
+ final_dir = os.path.join(base_dir, *missing_dirs)
263
+ os.makedirs(final_dir, exist_ok=True)
264
+ for missing_dir in missing_dirs:
265
+ base_dir = os.path.join(base_dir, missing_dir)
266
+ with open(os.path.join(base_dir, "__init__.py"), "w"):
267
+ pass
268
+
269
+ return final_dir
270
+
271
+ @property
272
+ def filename(self) -> str:
273
+ return f"{self.migration.name}.py"
274
+
275
+ @property
276
+ def path(self) -> str:
277
+ return os.path.join(self.basedir, self.filename)
278
+
279
+ @classmethod
280
+ def serialize(cls, value: Any) -> tuple[str, set[str]]:
281
+ return serializer_factory(value).serialize()
282
+
283
+
284
+ MIGRATION_HEADER_TEMPLATE = """\
285
+ # Generated by Plain %(version)s on %(timestamp)s
286
+
287
+ """
288
+
289
+
290
+ MIGRATION_TEMPLATE = """\
291
+ %(migration_header)s%(imports)s
292
+
293
+ class Migration(migrations.Migration):
294
+ %(replaces_str)s%(initial_str)s
295
+ dependencies = [
296
+ %(dependencies)s\
297
+ ]
298
+
299
+ operations = [
300
+ %(operations)s\
301
+ ]
302
+ """
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from plain.packages import packages_registry
7
+ from plain.postgres.dialect import MAX_NAME_LENGTH
8
+ from plain.postgres.utils import truncate_name
9
+
10
+ if 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
+
16
+ class 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.postgres.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.postgres.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()}"