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.
- plain/postgres/CHANGELOG.md +1028 -0
- plain/postgres/README.md +925 -0
- plain/postgres/__init__.py +120 -0
- plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
- plain/postgres/aggregates.py +236 -0
- plain/postgres/backups/__init__.py +0 -0
- plain/postgres/backups/cli.py +148 -0
- plain/postgres/backups/clients.py +94 -0
- plain/postgres/backups/core.py +172 -0
- plain/postgres/base.py +1415 -0
- plain/postgres/cli/__init__.py +3 -0
- plain/postgres/cli/db.py +142 -0
- plain/postgres/cli/migrations.py +1085 -0
- plain/postgres/config.py +18 -0
- plain/postgres/connection.py +1331 -0
- plain/postgres/connections.py +77 -0
- plain/postgres/constants.py +13 -0
- plain/postgres/constraints.py +495 -0
- plain/postgres/database_url.py +94 -0
- plain/postgres/db.py +59 -0
- plain/postgres/default_settings.py +38 -0
- plain/postgres/deletion.py +475 -0
- plain/postgres/dialect.py +640 -0
- plain/postgres/entrypoints.py +4 -0
- plain/postgres/enums.py +103 -0
- plain/postgres/exceptions.py +217 -0
- plain/postgres/expressions.py +1912 -0
- plain/postgres/fields/__init__.py +2118 -0
- plain/postgres/fields/encrypted.py +354 -0
- plain/postgres/fields/json.py +413 -0
- plain/postgres/fields/mixins.py +30 -0
- plain/postgres/fields/related.py +1192 -0
- plain/postgres/fields/related_descriptors.py +290 -0
- plain/postgres/fields/related_lookups.py +223 -0
- plain/postgres/fields/related_managers.py +661 -0
- plain/postgres/fields/reverse_descriptors.py +229 -0
- plain/postgres/fields/reverse_related.py +328 -0
- plain/postgres/fields/timezones.py +143 -0
- plain/postgres/forms.py +773 -0
- plain/postgres/functions/__init__.py +189 -0
- plain/postgres/functions/comparison.py +127 -0
- plain/postgres/functions/datetime.py +454 -0
- plain/postgres/functions/math.py +140 -0
- plain/postgres/functions/mixins.py +59 -0
- plain/postgres/functions/text.py +282 -0
- plain/postgres/functions/window.py +125 -0
- plain/postgres/indexes.py +286 -0
- plain/postgres/lookups.py +758 -0
- plain/postgres/meta.py +584 -0
- plain/postgres/migrations/__init__.py +53 -0
- plain/postgres/migrations/autodetector.py +1379 -0
- plain/postgres/migrations/exceptions.py +54 -0
- plain/postgres/migrations/executor.py +188 -0
- plain/postgres/migrations/graph.py +364 -0
- plain/postgres/migrations/loader.py +377 -0
- plain/postgres/migrations/migration.py +180 -0
- plain/postgres/migrations/operations/__init__.py +34 -0
- plain/postgres/migrations/operations/base.py +139 -0
- plain/postgres/migrations/operations/fields.py +373 -0
- plain/postgres/migrations/operations/models.py +798 -0
- plain/postgres/migrations/operations/special.py +184 -0
- plain/postgres/migrations/optimizer.py +74 -0
- plain/postgres/migrations/questioner.py +340 -0
- plain/postgres/migrations/recorder.py +119 -0
- plain/postgres/migrations/serializer.py +378 -0
- plain/postgres/migrations/state.py +882 -0
- plain/postgres/migrations/utils.py +147 -0
- plain/postgres/migrations/writer.py +302 -0
- plain/postgres/options.py +207 -0
- plain/postgres/otel.py +231 -0
- plain/postgres/preflight.py +336 -0
- plain/postgres/query.py +2242 -0
- plain/postgres/query_utils.py +456 -0
- plain/postgres/registry.py +217 -0
- plain/postgres/schema.py +1885 -0
- plain/postgres/sql/__init__.py +40 -0
- plain/postgres/sql/compiler.py +1869 -0
- plain/postgres/sql/constants.py +22 -0
- plain/postgres/sql/datastructures.py +222 -0
- plain/postgres/sql/query.py +2947 -0
- plain/postgres/sql/where.py +374 -0
- plain/postgres/test/__init__.py +0 -0
- plain/postgres/test/pytest.py +117 -0
- plain/postgres/test/utils.py +18 -0
- plain/postgres/transaction.py +222 -0
- plain/postgres/types.py +92 -0
- plain/postgres/types.pyi +751 -0
- plain/postgres/utils.py +345 -0
- plain_postgres-0.84.0.dist-info/METADATA +937 -0
- plain_postgres-0.84.0.dist-info/RECORD +93 -0
- plain_postgres-0.84.0.dist-info/WHEEL +4 -0
- plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
- 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()}"
|