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,377 @@
1
+ from __future__ import annotations
2
+
3
+ import pkgutil
4
+ import sys
5
+ from importlib import import_module, reload
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from plain.packages import packages_registry
9
+ from plain.postgres.migrations.graph import MigrationGraph
10
+ from plain.postgres.migrations.recorder import MigrationRecorder
11
+
12
+ from .exceptions import (
13
+ AmbiguityError,
14
+ BadMigrationError,
15
+ InconsistentMigrationHistory,
16
+ NodeNotFoundError,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from plain.postgres.connection import DatabaseConnection
21
+ from plain.postgres.migrations.migration import Migration
22
+
23
+ MIGRATIONS_MODULE_NAME = "migrations"
24
+
25
+
26
+ class MigrationLoader:
27
+ """
28
+ Load migration files from disk and their status from the database.
29
+
30
+ Migration files are expected to live in the "migrations" directory of
31
+ an app. Their names are entirely unimportant from a code perspective,
32
+ but will probably follow the 1234_name.py convention.
33
+
34
+ On initialization, this class will scan those directories, and open and
35
+ read the Python files, looking for a class called Migration, which should
36
+ inherit from plain.postgres.migrations.Migration. See
37
+ plain.postgres.migrations.migration for what that looks like.
38
+
39
+ Some migrations will be marked as "replacing" another set of migrations.
40
+ These are loaded into a separate set of migrations away from the main ones.
41
+ If all the migrations they replace are either unapplied or missing from
42
+ disk, then they are injected into the main set, replacing the named migrations.
43
+ Any dependency pointers to the replaced migrations are re-pointed to the
44
+ new migration.
45
+
46
+ This does mean that this class MUST also talk to the database as well as
47
+ to disk, but this is probably fine. We're already not just operating
48
+ in memory.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ connection: DatabaseConnection | None,
54
+ load: bool = True,
55
+ ignore_no_migrations: bool = False,
56
+ replace_migrations: bool = True,
57
+ ):
58
+ self.connection = connection
59
+ self.disk_migrations: dict[tuple[str, str], Migration] | None = None
60
+ self.applied_migrations: dict[tuple[str, str], Any] | None = None
61
+ self.ignore_no_migrations = ignore_no_migrations
62
+ self.replace_migrations = replace_migrations
63
+ self.unmigrated_packages: set[str]
64
+ self.migrated_packages: set[str]
65
+ self.graph: MigrationGraph
66
+ self.replacements: dict[tuple[str, str], Migration]
67
+ if load:
68
+ self.build_graph()
69
+
70
+ @classmethod
71
+ def migrations_module(cls, package_label: str) -> tuple[str | None, bool]:
72
+ """
73
+ Return the path to the migrations module for the specified package_label
74
+ and a boolean indicating if the module is specified in
75
+ settings.MIGRATION_MODULE.
76
+ """
77
+
78
+ # This package (plain-postgres) has different code under migrations/
79
+ if package_label == "plainpostgres":
80
+ return None, True
81
+
82
+ app = packages_registry.get_package_config(package_label)
83
+ return f"{app.name}.{MIGRATIONS_MODULE_NAME}", False
84
+
85
+ def load_disk(self) -> None:
86
+ """Load the migrations from all INSTALLED_PACKAGES from disk."""
87
+ self.disk_migrations = {}
88
+ self.unmigrated_packages = set()
89
+ self.migrated_packages = set()
90
+ for package_config in packages_registry.get_package_configs():
91
+ # Get the migrations module directory
92
+ module_name, explicit = self.migrations_module(package_config.package_label)
93
+ if module_name is None:
94
+ self.unmigrated_packages.add(package_config.package_label)
95
+ continue
96
+ was_loaded = module_name in sys.modules
97
+ try:
98
+ module = import_module(module_name)
99
+ except ModuleNotFoundError as e:
100
+ if (explicit and self.ignore_no_migrations) or (
101
+ not explicit
102
+ and e.name is not None
103
+ and MIGRATIONS_MODULE_NAME in e.name.split(".")
104
+ ):
105
+ self.unmigrated_packages.add(package_config.package_label)
106
+ continue
107
+ raise
108
+ else:
109
+ # Module is not a package (e.g. migrations.py).
110
+ if not hasattr(module, "__path__"):
111
+ self.unmigrated_packages.add(package_config.package_label)
112
+ continue
113
+ # Empty directories are namespaces. Namespace packages have no
114
+ # __file__ and don't use a list for __path__. See
115
+ # https://docs.python.org/3/reference/import.html#namespace-packages
116
+ if getattr(module, "__file__", None) is None and not isinstance(
117
+ module.__path__, list
118
+ ):
119
+ self.unmigrated_packages.add(package_config.package_label)
120
+ continue
121
+ # Force a reload if it's already loaded (tests need this)
122
+ if was_loaded:
123
+ reload(module)
124
+ self.migrated_packages.add(package_config.package_label)
125
+ migration_names = {
126
+ name
127
+ for _, name, is_pkg in pkgutil.iter_modules(module.__path__)
128
+ if not is_pkg and name[0] not in "_~"
129
+ }
130
+ # Load migrations
131
+ for migration_name in migration_names:
132
+ migration_path = f"{module_name}.{migration_name}"
133
+ try:
134
+ migration_module = import_module(migration_path)
135
+ except ImportError as e:
136
+ if "bad magic number" in str(e):
137
+ raise ImportError(
138
+ f"Couldn't import {migration_path!r} as it appears to be a stale "
139
+ ".pyc file."
140
+ ) from e
141
+ else:
142
+ raise
143
+ if not hasattr(migration_module, "Migration"):
144
+ raise BadMigrationError(
145
+ f"Migration {migration_name} in app {package_config.package_label} has no Migration class"
146
+ )
147
+ self.disk_migrations[package_config.package_label, migration_name] = (
148
+ migration_module.Migration(
149
+ migration_name,
150
+ package_config.package_label,
151
+ )
152
+ )
153
+
154
+ def get_migration(self, package_label: str, name_prefix: str) -> Migration | None:
155
+ """Return the named migration or raise NodeNotFoundError."""
156
+ return self.graph.nodes[package_label, name_prefix]
157
+
158
+ def get_migration_by_prefix(
159
+ self, package_label: str, name_prefix: str
160
+ ) -> Migration:
161
+ """
162
+ Return the migration(s) which match the given app label and name_prefix.
163
+ """
164
+ # Do the search
165
+ assert self.disk_migrations is not None, "load_disk() must be called first"
166
+ results = []
167
+ for migration_package_label, migration_name in self.disk_migrations:
168
+ if migration_package_label == package_label and migration_name.startswith(
169
+ name_prefix
170
+ ):
171
+ results.append((migration_package_label, migration_name))
172
+ if len(results) > 1:
173
+ raise AmbiguityError(
174
+ f"There is more than one migration for '{package_label}' with the prefix '{name_prefix}'"
175
+ )
176
+ elif not results:
177
+ raise KeyError(
178
+ f"There is no migration for '{package_label}' with the prefix "
179
+ f"'{name_prefix}'"
180
+ )
181
+ else:
182
+ return self.disk_migrations[results[0]]
183
+
184
+ def check_key(
185
+ self, key: tuple[str, str], current_package: str
186
+ ) -> tuple[str, str] | None:
187
+ if (key[1] != "__first__" and key[1] != "__latest__") or key in self.graph:
188
+ return key
189
+ # Special-case __first__, which means "the first migration" for
190
+ # migrated packages, and is ignored for unmigrated packages. It allows
191
+ # makemigrations to declare dependencies on packages before they even have
192
+ # migrations.
193
+ if key[0] == current_package:
194
+ # Ignore __first__ references to the same app (#22325)
195
+ return None
196
+ if key[0] in self.unmigrated_packages:
197
+ # This app isn't migrated, but something depends on it.
198
+ # The models will get auto-added into the state, though
199
+ # so we're fine.
200
+ return None
201
+ if key[0] in self.migrated_packages:
202
+ try:
203
+ if key[1] == "__first__":
204
+ return self.graph.root_nodes(key[0])[0]
205
+ else: # "__latest__"
206
+ return self.graph.leaf_nodes(key[0])[0]
207
+ except IndexError:
208
+ if self.ignore_no_migrations:
209
+ return None
210
+ else:
211
+ raise ValueError(f"Dependency on app with no migrations: {key[0]}")
212
+ raise ValueError(f"Dependency on unknown app: {key[0]}")
213
+
214
+ def add_internal_dependencies(
215
+ self, key: tuple[str, str], migration: Migration
216
+ ) -> None:
217
+ """
218
+ Internal dependencies need to be added first to ensure `__first__`
219
+ dependencies find the correct root node.
220
+ """
221
+ for parent in migration.dependencies:
222
+ # Ignore __first__ references to the same app.
223
+ if parent[0] == key[0] and parent[1] != "__first__":
224
+ # Migration object is used only for error messages in add_dependency
225
+ self.graph.add_dependency(migration, key, parent, skip_validation=True)
226
+
227
+ def add_external_dependencies(
228
+ self, key: tuple[str, str], migration: Migration
229
+ ) -> None:
230
+ for parent in migration.dependencies:
231
+ # Skip internal dependencies
232
+ if key[0] == parent[0]:
233
+ continue
234
+ parent = self.check_key(parent, key[0])
235
+ if parent is not None:
236
+ # Migration object is used only for error messages in add_dependency
237
+ self.graph.add_dependency(migration, key, parent, skip_validation=True)
238
+
239
+ def build_graph(self) -> None:
240
+ """
241
+ Build a migration dependency graph using both the disk and database.
242
+ You'll need to rebuild the graph if you apply migrations. This isn't
243
+ usually a problem as generally migration stuff runs in a one-shot process.
244
+ """
245
+ # Load disk data
246
+ self.load_disk()
247
+ assert self.disk_migrations is not None # load_disk() ensures this
248
+ # Load database data
249
+ if self.connection is None:
250
+ self.applied_migrations = {}
251
+ else:
252
+ recorder = MigrationRecorder(self.connection)
253
+ self.applied_migrations = recorder.applied_migrations()
254
+ # To start, populate the migration graph with nodes for ALL migrations
255
+ # and their dependencies. Also make note of replacing migrations at this step.
256
+ self.graph = MigrationGraph()
257
+ self.replacements = {}
258
+ for key, migration in self.disk_migrations.items():
259
+ self.graph.add_node(key, migration)
260
+ # Replacing migrations.
261
+ if migration.replaces:
262
+ self.replacements[key] = migration
263
+ for key, migration in self.disk_migrations.items():
264
+ # Internal (same app) dependencies.
265
+ self.add_internal_dependencies(key, migration)
266
+ # Add external dependencies now that the internal ones have been resolved.
267
+ for key, migration in self.disk_migrations.items():
268
+ self.add_external_dependencies(key, migration)
269
+ # Carry out replacements where possible and if enabled.
270
+ if self.replace_migrations:
271
+ for key, migration in self.replacements.items():
272
+ # Get applied status of each of this migration's replacement
273
+ # targets.
274
+ applied_statuses = [
275
+ (target in self.applied_migrations) for target in migration.replaces
276
+ ]
277
+ # The replacing migration is only marked as applied if all of
278
+ # its replacement targets are.
279
+ if all(applied_statuses):
280
+ self.applied_migrations[key] = migration
281
+ else:
282
+ self.applied_migrations.pop(key, None)
283
+ # A replacing migration can be used if either all or none of
284
+ # its replacement targets have been applied.
285
+ if all(applied_statuses) or (not any(applied_statuses)):
286
+ self.graph.remove_replaced_nodes(key, migration.replaces)
287
+ else:
288
+ # This replacing migration cannot be used because it is
289
+ # partially applied. Remove it from the graph and remap
290
+ # dependencies to it (#25945).
291
+ self.graph.remove_replacement_node(key, migration.replaces)
292
+ # Ensure the graph is consistent.
293
+ try:
294
+ self.graph.validate_consistency()
295
+ except NodeNotFoundError as exc:
296
+ # Check if the missing node could have been replaced by any squash
297
+ # migration but wasn't because the squash migration was partially
298
+ # applied before. In that case raise a more understandable exception
299
+ # (#23556).
300
+ # Get reverse replacements.
301
+ reverse_replacements = {}
302
+ for key, migration in self.replacements.items():
303
+ for replaced in migration.replaces:
304
+ reverse_replacements.setdefault(replaced, set()).add(key)
305
+ # Try to reraise exception with more detail.
306
+ if exc.node in reverse_replacements:
307
+ candidates = reverse_replacements.get(exc.node, set())
308
+ is_replaced = any(
309
+ candidate in self.graph.nodes for candidate in candidates
310
+ )
311
+ if not is_replaced:
312
+ tries = ", ".join("{}.{}".format(*c) for c in candidates)
313
+ raise NodeNotFoundError(
314
+ f"Migration {exc.origin} depends on nonexistent node ('{exc.node[0]}', '{exc.node[1]}'). "
315
+ f"Plain tried to replace migration {exc.node[0]}.{exc.node[1]} with any of [{tries}] "
316
+ "but wasn't able to because some of the replaced migrations "
317
+ "are already applied.",
318
+ exc.node,
319
+ ) from exc
320
+ raise
321
+ self.graph.ensure_not_cyclic()
322
+
323
+ def check_consistent_history(self, connection: DatabaseConnection) -> None:
324
+ """
325
+ Raise InconsistentMigrationHistory if any applied migrations have
326
+ unapplied dependencies.
327
+ """
328
+ recorder = MigrationRecorder(connection)
329
+ applied = recorder.applied_migrations()
330
+ for migration in applied:
331
+ # If the migration is unknown, skip it.
332
+ if migration not in self.graph.nodes:
333
+ continue
334
+ for parent in self.graph.node_map[migration].parents:
335
+ if parent not in applied:
336
+ # Skip unapplied squashed migrations that have all of their
337
+ # `replaces` applied.
338
+ # Use parent.key for dict lookup (Node.__eq__ allows `in` check)
339
+ if parent.key in self.replacements:
340
+ if all(
341
+ m in applied for m in self.replacements[parent.key].replaces
342
+ ):
343
+ continue
344
+ raise InconsistentMigrationHistory(
345
+ f"Migration {migration[0]}.{migration[1]} is applied before its dependency "
346
+ f"{parent[0]}.{parent[1]} on the database."
347
+ )
348
+
349
+ def detect_conflicts(self) -> dict[str, list[str]]:
350
+ """
351
+ Look through the loaded graph and detect any conflicts - packages
352
+ with more than one leaf migration. Return a dict of the app labels
353
+ that conflict with the migration names that conflict.
354
+ """
355
+ seen_packages = {}
356
+ conflicting_packages = set()
357
+ for package_label, migration_name in self.graph.leaf_nodes():
358
+ if package_label in seen_packages:
359
+ conflicting_packages.add(package_label)
360
+ seen_packages.setdefault(package_label, set()).add(migration_name)
361
+ return {
362
+ package_label: sorted(seen_packages[package_label])
363
+ for package_label in conflicting_packages
364
+ }
365
+
366
+ def project_state(
367
+ self, nodes: tuple[str, str] | None = None, at_end: bool = True
368
+ ) -> Any:
369
+ """
370
+ Return a ProjectState object representing the most recent state
371
+ that the loaded migrations represent.
372
+
373
+ See graph.make_state() for the meaning of "nodes" and "at_end".
374
+ """
375
+ return self.graph.make_state(
376
+ nodes=nodes, at_end=at_end, real_packages=self.unmigrated_packages
377
+ )
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from plain.postgres.migrations.utils import get_migration_name_timestamp
8
+ from plain.postgres.transaction import atomic
9
+
10
+ if TYPE_CHECKING:
11
+ from plain.postgres.migrations.state import ProjectState
12
+ from plain.postgres.schema import DatabaseSchemaEditor
13
+
14
+
15
+ class Migration:
16
+ """
17
+ The base class for all migrations.
18
+
19
+ Migration files will import this from plain.postgres.migrations.Migration
20
+ and subclass it as a class called Migration. It will have one or more
21
+ of the following attributes:
22
+
23
+ - operations: A list of Operation instances, probably from
24
+ plain.postgres.migrations.operations
25
+ - dependencies: A list of tuples of (app_path, migration_name)
26
+ - replaces: A list of migration_names
27
+
28
+ Note that all migrations come out of migrations and into the Loader or
29
+ Graph as instances, having been initialized with their app label and name.
30
+ """
31
+
32
+ # Operations to apply during this migration, in order.
33
+ operations: list[Any] = []
34
+
35
+ # Other migrations that should be run before this migration.
36
+ # Should be a list of (app, migration_name).
37
+ dependencies: list[tuple[str, str]] = []
38
+
39
+ # Migration names in this app that this migration replaces. If this is
40
+ # non-empty, this migration will only be applied if all these migrations
41
+ # are not applied.
42
+ # Note: Despite the comment saying "migration names", this is actually a list of tuples
43
+ # (app_label, migration_name) as used throughout the codebase.
44
+ replaces: list[tuple[str, str]] = []
45
+
46
+ # Is this an initial migration? Initial migrations are skipped on
47
+ # --fake-initial if the table or fields already exist. If None, check if
48
+ # the migration has any dependencies to determine if there are dependencies
49
+ # to tell if db introspection needs to be done. If True, always perform
50
+ # introspection. If False, never perform introspection.
51
+ initial: bool | None = None
52
+
53
+ # Whether to wrap the whole migration in a transaction.
54
+ atomic: bool = True
55
+
56
+ def __init__(self, name: str, package_label: str) -> None:
57
+ self.name = name
58
+ self.package_label = package_label
59
+ # Copy dependencies & other attrs as we might mutate them at runtime
60
+ self.operations = list(self.__class__.operations)
61
+ self.dependencies = list(self.__class__.dependencies)
62
+ self.replaces = list(self.__class__.replaces)
63
+
64
+ def __eq__(self, other: object) -> bool:
65
+ return (
66
+ isinstance(other, Migration)
67
+ and self.name == other.name
68
+ and self.package_label == other.package_label
69
+ )
70
+
71
+ def __repr__(self) -> str:
72
+ return f"<Migration {self.package_label}.{self.name}>"
73
+
74
+ def __str__(self) -> str:
75
+ return f"{self.package_label}.{self.name}"
76
+
77
+ def __hash__(self) -> int:
78
+ return hash(f"{self.package_label}.{self.name}")
79
+
80
+ def mutate_state(self, project_state: Any, preserve: bool = True) -> Any:
81
+ """
82
+ Take a ProjectState and return a new one with the migration's
83
+ operations applied to it. Preserve the original object state by
84
+ default and return a mutated state from a copy.
85
+ """
86
+ new_state = project_state
87
+ if preserve:
88
+ new_state = project_state.clone()
89
+
90
+ for operation in self.operations:
91
+ operation.state_forwards(self.package_label, new_state)
92
+ return new_state
93
+
94
+ def apply(
95
+ self,
96
+ project_state: ProjectState,
97
+ schema_editor: DatabaseSchemaEditor,
98
+ operation_callback: Callable[..., Any] | None = None,
99
+ ) -> ProjectState:
100
+ """
101
+ Take a project_state representing all migrations prior to this one
102
+ and a schema_editor for a live database and apply the migration
103
+ in a forwards order.
104
+
105
+ Return the resulting project state for efficient reuse by following
106
+ Migrations.
107
+ """
108
+ for operation in self.operations:
109
+ # Clear any previous SQL statements before starting this operation
110
+ schema_editor.executed_sql = []
111
+
112
+ if operation_callback:
113
+ operation_callback("operation_start", operation=operation)
114
+ # Save the state before the operation has run
115
+ old_state = project_state.clone()
116
+ operation.state_forwards(self.package_label, project_state)
117
+ # Run the operation
118
+ atomic_operation = operation.atomic or (
119
+ self.atomic and operation.atomic is not False
120
+ )
121
+ if not schema_editor.atomic_migration and atomic_operation:
122
+ # Force a transaction for an atomic operation inside a non-atomic migration.
123
+ with atomic():
124
+ operation.database_forwards(
125
+ self.package_label, schema_editor, old_state, project_state
126
+ )
127
+ else:
128
+ # Normal behaviour
129
+ operation.database_forwards(
130
+ self.package_label, schema_editor, old_state, project_state
131
+ )
132
+ if operation_callback:
133
+ # Pass the accumulated SQL statements for this operation
134
+ operation_callback(
135
+ "operation_success",
136
+ operation=operation,
137
+ sql_statements=schema_editor.executed_sql,
138
+ )
139
+ return project_state
140
+
141
+ def suggest_name(self) -> str:
142
+ """
143
+ Suggest a name for the operations this migration might represent. Names
144
+ are not guaranteed to be unique, but put some effort into the fallback
145
+ name to avoid VCS conflicts if possible.
146
+ """
147
+ if self.initial:
148
+ return "initial"
149
+
150
+ raw_fragments = [op.migration_name_fragment for op in self.operations]
151
+ fragments = [re.sub(r"\W+", "_", name) for name in raw_fragments if name]
152
+
153
+ if not fragments or len(fragments) != len(self.operations):
154
+ return f"auto_{get_migration_name_timestamp()}"
155
+
156
+ name = fragments[0]
157
+ for fragment in fragments[1:]:
158
+ new_name = f"{name}_{fragment}"
159
+ if len(new_name) > 52:
160
+ name = f"{name}_and_more"
161
+ break
162
+ name = new_name
163
+ return name
164
+
165
+
166
+ class SettingsTuple(tuple):
167
+ """
168
+ Subclass of tuple so Plain can tell this was originally a settings
169
+ dependency when it reads the migration file.
170
+ """
171
+
172
+ def __new__(cls, value: tuple[str, str], setting: str) -> SettingsTuple:
173
+ self = tuple.__new__(cls, value)
174
+ self.setting = setting
175
+ return self
176
+
177
+
178
+ def settings_dependency(value: str) -> SettingsTuple:
179
+ """Turn a setting value into a dependency."""
180
+ return SettingsTuple((value.split(".", 1)[0], "__first__"), value)
@@ -0,0 +1,34 @@
1
+ from .fields import AddField, AlterField, RemoveField, RenameField
2
+ from .models import (
3
+ AddConstraint,
4
+ AddIndex,
5
+ AlterModelOptions,
6
+ AlterModelTable,
7
+ CreateModel,
8
+ DeleteModel,
9
+ RemoveConstraint,
10
+ RemoveIndex,
11
+ RenameIndex,
12
+ RenameModel,
13
+ )
14
+ from .special import RunPython, RunSQL, SeparateDatabaseAndState
15
+
16
+ __all__ = [
17
+ "CreateModel",
18
+ "DeleteModel",
19
+ "AlterModelTable",
20
+ "RenameModel",
21
+ "AlterModelOptions",
22
+ "AddIndex",
23
+ "RemoveIndex",
24
+ "RenameIndex",
25
+ "AddField",
26
+ "RemoveField",
27
+ "AlterField",
28
+ "RenameField",
29
+ "AddConstraint",
30
+ "RemoveConstraint",
31
+ "SeparateDatabaseAndState",
32
+ "RunSQL",
33
+ "RunPython",
34
+ ]