plain.models
Model your data and store it in a database.
# app/users/models.py
from plain import models
from plain.passwords.models import PasswordField
class User(models.Model):
email = models.EmailField(unique=True)
password = PasswordField()
is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.email
Create, update, and delete instances of your models:
from .models import User
# Create a new user
user = User.objects.create(
email="[email protected]",
password="password",
)
# Update a user
user.email = "[email protected]"
user.save()
# Delete a user
user.delete()
# Query for users
staff_users = User.objects.filter(is_staff=True)
Installation
# app/settings.py
INSTALLED_PACKAGES = [
...
"plain.models",
]
To connect to a database, you can provide a DATABASE_URL
environment variable.
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
Or you can manually define the DATABASES
setting.
# app/settings.py
DATABASES = {
"default": {
"ENGINE": "plain.models.backends.postgresql",
"NAME": "dbname",
"USER": "user",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "5432",
}
}
Multiple backends are supported, including Postgres, MySQL, and SQLite.
Querying
Migrations
Fields
Validation
Indexes and constraints
Managers
Forms
1import os
2import subprocess
3import sys
4import time
5from importlib import import_module
6from itertools import takewhile
7
8import click
9
10from plain.models import migrations
11from plain.models.db import DEFAULT_DB_ALIAS, OperationalError, connections, router
12from plain.models.migrations.autodetector import MigrationAutodetector
13from plain.models.migrations.executor import MigrationExecutor
14from plain.models.migrations.loader import AmbiguityError, MigrationLoader
15from plain.models.migrations.migration import Migration, SwappableTuple
16from plain.models.migrations.optimizer import MigrationOptimizer
17from plain.models.migrations.questioner import (
18 InteractiveMigrationQuestioner,
19 MigrationQuestioner,
20 NonInteractiveMigrationQuestioner,
21)
22from plain.models.migrations.recorder import MigrationRecorder
23from plain.models.migrations.state import ModelState, ProjectState
24from plain.models.migrations.utils import get_migration_name_timestamp
25from plain.models.migrations.writer import MigrationWriter
26from plain.packages import packages
27from plain.runtime import settings
28from plain.utils.module_loading import module_has_submodule
29from plain.utils.text import Truncator
30
31
32@click.group()
33def cli():
34 pass
35
36
37@cli.command()
38@click.option(
39 "--database",
40 default=DEFAULT_DB_ALIAS,
41 help=(
42 "Nominates a database onto which to open a shell. Defaults to the "
43 '"default" database.'
44 ),
45)
46@click.argument("parameters", nargs=-1)
47def db_shell(database, parameters):
48 """Runs the command-line client for specified database, or the default database if none is provided."""
49 connection = connections[database]
50 try:
51 connection.client.runshell(parameters)
52 except FileNotFoundError:
53 # Note that we're assuming the FileNotFoundError relates to the
54 # command missing. It could be raised for some other reason, in
55 # which case this error message would be inaccurate. Still, this
56 # message catches the common case.
57 click.secho(
58 "You appear not to have the %r program installed or on your path."
59 % connection.client.executable_name,
60 fg="red",
61 err=True,
62 )
63 sys.exit(1)
64 except subprocess.CalledProcessError as e:
65 click.secho(
66 '"{}" returned non-zero exit status {}.'.format(
67 " ".join(e.cmd),
68 e.returncode,
69 ),
70 fg="red",
71 err=True,
72 )
73 sys.exit(e.returncode)
74
75
76@cli.command()
77def db_wait():
78 """Wait for the database to be ready"""
79 attempts = 0
80 while True:
81 attempts += 1
82 waiting_for = []
83
84 for conn in connections.all():
85 try:
86 conn.ensure_connection()
87 except OperationalError:
88 waiting_for.append(conn.alias)
89
90 if waiting_for:
91 click.secho(
92 f"Waiting for database (attempt {attempts}): {', '.join(waiting_for)}",
93 fg="yellow",
94 )
95 time.sleep(1.5)
96 else:
97 click.secho(f"Database ready: {', '.join(connections)}", fg="green")
98 break
99
100
101@cli.command()
102@click.argument("package_labels", nargs=-1)
103@click.option(
104 "--dry-run",
105 is_flag=True,
106 help="Just show what migrations would be made; don't actually write them.",
107)
108@click.option("--merge", is_flag=True, help="Enable fixing of migration conflicts.")
109@click.option("--empty", is_flag=True, help="Create an empty migration.")
110@click.option(
111 "--noinput",
112 "--no-input",
113 "no_input",
114 is_flag=True,
115 help="Tells Plain to NOT prompt the user for input of any kind.",
116)
117@click.option("-n", "--name", help="Use this name for migration file(s).")
118@click.option(
119 "--check",
120 is_flag=True,
121 help="Exit with a non-zero status if model changes are missing migrations and don't actually write them.",
122)
123@click.option(
124 "--update",
125 is_flag=True,
126 help="Merge model changes into the latest migration and optimize the resulting operations.",
127)
128@click.option(
129 "-v",
130 "--verbosity",
131 type=int,
132 default=1,
133 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
134)
135def makemigrations(
136 package_labels, dry_run, merge, empty, no_input, name, check, update, verbosity
137):
138 """Creates new migration(s) for packages."""
139
140 written_files = []
141 interactive = not no_input
142 migration_name = name
143 check_changes = check
144
145 def log(msg, level=1):
146 if verbosity >= level:
147 click.echo(msg)
148
149 def write_migration_files(changes, update_previous_migration_paths=None):
150 """Take a changes dict and write them out as migration files."""
151 directory_created = {}
152 for package_label, package_migrations in changes.items():
153 log(
154 click.style(f"Migrations for '{package_label}':", fg="cyan", bold=True),
155 level=1,
156 )
157 for migration in package_migrations:
158 writer = MigrationWriter(migration)
159 migration_string = os.path.relpath(writer.path)
160 log(f" {click.style(migration_string, fg='yellow')}\n", level=1)
161 for operation in migration.operations:
162 log(f" - {operation.describe()}", level=1)
163
164 if not dry_run:
165 migrations_directory = os.path.dirname(writer.path)
166 if not directory_created.get(package_label):
167 os.makedirs(migrations_directory, exist_ok=True)
168 init_path = os.path.join(migrations_directory, "__init__.py")
169 if not os.path.isfile(init_path):
170 open(init_path, "w").close()
171 directory_created[package_label] = True
172
173 migration_string = writer.as_string()
174 with open(writer.path, "w", encoding="utf-8") as fh:
175 fh.write(migration_string)
176 written_files.append(writer.path)
177
178 if update_previous_migration_paths:
179 prev_path = update_previous_migration_paths[package_label]
180 if writer.needs_manual_porting:
181 log(
182 click.style(
183 f"Updated migration {migration_string} requires manual porting.\n"
184 f"Previous migration {os.path.relpath(prev_path)} was kept and "
185 f"must be deleted after porting functions manually.",
186 fg="yellow",
187 ),
188 level=1,
189 )
190 else:
191 os.remove(prev_path)
192 log(f"Deleted {os.path.relpath(prev_path)}", level=1)
193 elif verbosity >= 3:
194 log(
195 click.style(
196 f"Full migrations file '{writer.filename}':",
197 fg="cyan",
198 bold=True,
199 ),
200 level=3,
201 )
202 log(writer.as_string(), level=3)
203
204 def write_to_last_migration_files(changes):
205 """Write changes to the last migration file for each package."""
206 loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])
207 new_changes = {}
208 update_previous_migration_paths = {}
209 for package_label, package_migrations in changes.items():
210 leaf_migration_nodes = loader.graph.leaf_nodes(app=package_label)
211 if len(leaf_migration_nodes) == 0:
212 raise click.ClickException(
213 f"Package {package_label} has no migration, cannot update last migration."
214 )
215 leaf_migration_node = leaf_migration_nodes[0]
216 leaf_migration = loader.graph.nodes[leaf_migration_node]
217
218 if leaf_migration.replaces:
219 raise click.ClickException(
220 f"Cannot update squash migration '{leaf_migration}'."
221 )
222 if leaf_migration_node in loader.applied_migrations:
223 raise click.ClickException(
224 f"Cannot update applied migration '{leaf_migration}'."
225 )
226
227 depending_migrations = [
228 migration
229 for migration in loader.disk_migrations.values()
230 if leaf_migration_node in migration.dependencies
231 ]
232 if depending_migrations:
233 formatted_migrations = ", ".join(
234 [f"'{migration}'" for migration in depending_migrations]
235 )
236 raise click.ClickException(
237 f"Cannot update migration '{leaf_migration}' that migrations "
238 f"{formatted_migrations} depend on."
239 )
240
241 for migration in package_migrations:
242 leaf_migration.operations.extend(migration.operations)
243 for dependency in migration.dependencies:
244 if isinstance(dependency, SwappableTuple):
245 if settings.AUTH_USER_MODEL == dependency.setting:
246 leaf_migration.dependencies.append(
247 ("__setting__", "AUTH_USER_MODEL")
248 )
249 else:
250 leaf_migration.dependencies.append(dependency)
251 elif dependency[0] != migration.package_label:
252 leaf_migration.dependencies.append(dependency)
253
254 optimizer = MigrationOptimizer()
255 leaf_migration.operations = optimizer.optimize(
256 leaf_migration.operations, package_label
257 )
258
259 previous_migration_path = MigrationWriter(leaf_migration).path
260 suggested_name = (
261 leaf_migration.name[:4] + "_" + leaf_migration.suggest_name()
262 )
263 new_name = (
264 suggested_name
265 if leaf_migration.name != suggested_name
266 else leaf_migration.name + "_updated"
267 )
268 leaf_migration.name = new_name
269
270 new_changes[package_label] = [leaf_migration]
271 update_previous_migration_paths[package_label] = previous_migration_path
272
273 write_migration_files(new_changes, update_previous_migration_paths)
274
275 def handle_merge(loader, conflicts):
276 """Handle merging conflicting migrations."""
277 if interactive:
278 questioner = InteractiveMigrationQuestioner()
279 else:
280 questioner = MigrationQuestioner(defaults={"ask_merge": True})
281
282 for package_label, migration_names in conflicts.items():
283 log(click.style(f"Merging {package_label}", fg="cyan", bold=True), level=1)
284
285 merge_migrations = []
286 for migration_name in migration_names:
287 migration = loader.get_migration(package_label, migration_name)
288 migration.ancestry = [
289 mig
290 for mig in loader.graph.forwards_plan(
291 (package_label, migration_name)
292 )
293 if mig[0] == migration.package_label
294 ]
295 merge_migrations.append(migration)
296
297 def all_items_equal(seq):
298 return all(item == seq[0] for item in seq[1:])
299
300 merge_migrations_generations = zip(*(m.ancestry for m in merge_migrations))
301 common_ancestor_count = sum(
302 1 for _ in takewhile(all_items_equal, merge_migrations_generations)
303 )
304 if not common_ancestor_count:
305 raise ValueError(f"Could not find common ancestor of {migration_names}")
306
307 for migration in merge_migrations:
308 migration.branch = migration.ancestry[common_ancestor_count:]
309 migrations_ops = (
310 loader.get_migration(node_package, node_name).operations
311 for node_package, node_name in migration.branch
312 )
313 migration.merged_operations = sum(migrations_ops, [])
314
315 for migration in merge_migrations:
316 log(click.style(f" Branch {migration.name}", fg="yellow"), level=1)
317 for operation in migration.merged_operations:
318 log(f" - {operation.describe()}", level=1)
319
320 if questioner.ask_merge(package_label):
321 numbers = [
322 MigrationAutodetector.parse_number(migration.name)
323 for migration in merge_migrations
324 ]
325 biggest_number = (
326 max(x for x in numbers if x is not None) if numbers else 0
327 )
328
329 subclass = type(
330 "Migration",
331 (Migration,),
332 {
333 "dependencies": [
334 (package_label, migration.name)
335 for migration in merge_migrations
336 ],
337 },
338 )
339
340 parts = [f"{biggest_number + 1:04d}"]
341 if migration_name:
342 parts.append(migration_name)
343 else:
344 parts.append("merge")
345 leaf_names = "_".join(
346 sorted(migration.name for migration in merge_migrations)
347 )
348 if len(leaf_names) > 47:
349 parts.append(get_migration_name_timestamp())
350 else:
351 parts.append(leaf_names)
352
353 new_migration_name = "_".join(parts)
354 new_migration = subclass(new_migration_name, package_label)
355 writer = MigrationWriter(new_migration)
356
357 if not dry_run:
358 with open(writer.path, "w", encoding="utf-8") as fh:
359 fh.write(writer.as_string())
360 log(f"\nCreated new merge migration {writer.path}", level=1)
361 elif verbosity == 3:
362 log(
363 click.style(
364 f"Full merge migrations file '{writer.filename}':",
365 fg="cyan",
366 bold=True,
367 ),
368 level=3,
369 )
370 log(writer.as_string(), level=3)
371
372 # Validate package labels
373 package_labels = set(package_labels)
374 has_bad_labels = False
375 for package_label in package_labels:
376 try:
377 packages.get_package_config(package_label)
378 except LookupError as err:
379 click.echo(str(err), err=True)
380 has_bad_labels = True
381 if has_bad_labels:
382 sys.exit(2)
383
384 # Load the current graph state
385 loader = MigrationLoader(None, ignore_no_migrations=True)
386
387 # Raise an error if any migrations are applied before their dependencies.
388 consistency_check_labels = {
389 config.label for config in packages.get_package_configs()
390 }
391 # Non-default databases are only checked if database routers used.
392 aliases_to_check = connections if settings.DATABASE_ROUTERS else [DEFAULT_DB_ALIAS]
393 for alias in sorted(aliases_to_check):
394 connection = connections[alias]
395 if connection.settings_dict["ENGINE"] != "plain.models.backends.dummy" and any(
396 router.allow_migrate(
397 connection.alias, package_label, model_name=model._meta.object_name
398 )
399 for package_label in consistency_check_labels
400 for model in packages.get_package_config(package_label).get_models()
401 ):
402 loader.check_consistent_history(connection)
403
404 # Check for conflicts
405 conflicts = loader.detect_conflicts()
406 if package_labels:
407 conflicts = {
408 package_label: conflict
409 for package_label, conflict in conflicts.items()
410 if package_label in package_labels
411 }
412
413 if conflicts and not merge:
414 name_str = "; ".join(
415 "{} in {}".format(", ".join(names), package)
416 for package, names in conflicts.items()
417 )
418 raise click.ClickException(
419 f"Conflicting migrations detected; multiple leaf nodes in the "
420 f"migration graph: ({name_str}).\nTo fix them run "
421 f"'python manage.py makemigrations --merge'"
422 )
423
424 # Handle merge if requested
425 if merge and conflicts:
426 return handle_merge(loader, conflicts)
427
428 # Set up questioner
429 if interactive:
430 questioner = InteractiveMigrationQuestioner(
431 specified_packages=package_labels,
432 dry_run=dry_run,
433 )
434 else:
435 questioner = NonInteractiveMigrationQuestioner(
436 specified_packages=package_labels,
437 dry_run=dry_run,
438 verbosity=verbosity,
439 )
440
441 # Set up autodetector
442 autodetector = MigrationAutodetector(
443 loader.project_state(),
444 ProjectState.from_packages(packages),
445 questioner,
446 )
447
448 # Handle empty migrations if requested
449 if empty:
450 if not package_labels:
451 raise click.ClickException(
452 "You must supply at least one package label when using --empty."
453 )
454 changes = {
455 package: [Migration("custom", package)] for package in package_labels
456 }
457 changes = autodetector.arrange_for_graph(
458 changes=changes,
459 graph=loader.graph,
460 migration_name=migration_name,
461 )
462 write_migration_files(changes)
463 return
464
465 # Detect changes
466 changes = autodetector.changes(
467 graph=loader.graph,
468 trim_to_packages=package_labels or None,
469 convert_packages=package_labels or None,
470 migration_name=migration_name,
471 )
472
473 if not changes:
474 log(
475 "No changes detected"
476 if not package_labels
477 else f"No changes detected in {'package' if len(package_labels) == 1 else 'packages'} "
478 f"'{', '.join(package_labels)}'",
479 level=1,
480 )
481 else:
482 if check_changes:
483 sys.exit(1)
484 if update:
485 write_to_last_migration_files(changes)
486 else:
487 write_migration_files(changes)
488
489
490@cli.command()
491@click.argument("package_label", required=False)
492@click.argument("migration_name", required=False)
493@click.option(
494 "--noinput",
495 "--no-input",
496 "no_input",
497 is_flag=True,
498 help="Tells Plain to NOT prompt the user for input of any kind.",
499)
500@click.option(
501 "--database",
502 default=DEFAULT_DB_ALIAS,
503 help="Nominates a database to synchronize. Defaults to the 'default' database.",
504)
505@click.option(
506 "--fake", is_flag=True, help="Mark migrations as run without actually running them."
507)
508@click.option(
509 "--fake-initial",
510 is_flag=True,
511 help="Detect if tables already exist and fake-apply initial migrations if so. Make sure that the current database schema matches your initial migration before using this flag. Plain will only check for an existing table name.",
512)
513@click.option(
514 "--plan",
515 is_flag=True,
516 help="Shows a list of the migration actions that will be performed.",
517)
518@click.option(
519 "--check",
520 "check_unapplied",
521 is_flag=True,
522 help="Exits with a non-zero status if unapplied migrations exist and does not actually apply migrations.",
523)
524@click.option(
525 "--run-syncdb", is_flag=True, help="Creates tables for packages without migrations."
526)
527@click.option(
528 "--prune",
529 is_flag=True,
530 help="Delete nonexistent migrations from the plainmigrations table.",
531)
532@click.option(
533 "-v",
534 "--verbosity",
535 type=int,
536 default=1,
537 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
538)
539def migrate(
540 package_label,
541 migration_name,
542 no_input,
543 database,
544 fake,
545 fake_initial,
546 plan,
547 check_unapplied,
548 run_syncdb,
549 prune,
550 verbosity,
551):
552 """Updates database schema. Manages both packages with migrations and those without."""
553
554 def migration_progress_callback(action, migration=None, fake=False):
555 if verbosity >= 1:
556 compute_time = verbosity > 1
557 if action == "apply_start":
558 if compute_time:
559 start = time.monotonic()
560 click.echo(f" Applying {migration}...", nl=False)
561 elif action == "apply_success":
562 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""
563 if fake:
564 click.echo(click.style(f" FAKED{elapsed}", fg="green"))
565 else:
566 click.echo(click.style(f" OK{elapsed}", fg="green"))
567 elif action == "unapply_start":
568 if compute_time:
569 start = time.monotonic()
570 click.echo(f" Unapplying {migration}...", nl=False)
571 elif action == "unapply_success":
572 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""
573 if fake:
574 click.echo(click.style(f" FAKED{elapsed}", fg="green"))
575 else:
576 click.echo(click.style(f" OK{elapsed}", fg="green"))
577 elif action == "render_start":
578 if compute_time:
579 start = time.monotonic()
580 click.echo(" Rendering model states...", nl=False)
581 elif action == "render_success":
582 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""
583 click.echo(click.style(f" DONE{elapsed}", fg="green"))
584
585 def sync_packages(connection, package_labels):
586 """Run the old syncdb-style operation on a list of package_labels."""
587 with connection.cursor() as cursor:
588 tables = connection.introspection.table_names(cursor)
589
590 # Build the manifest of packages and models that are to be synchronized.
591 all_models = [
592 (
593 package_config.label,
594 router.get_migratable_models(
595 package_config, connection.alias, include_auto_created=False
596 ),
597 )
598 for package_config in packages.get_package_configs()
599 if package_config.models_module is not None
600 and package_config.label in package_labels
601 ]
602
603 def model_installed(model):
604 opts = model._meta
605 converter = connection.introspection.identifier_converter
606 return not (
607 (converter(opts.db_table) in tables)
608 or (
609 opts.auto_created
610 and converter(opts.auto_created._meta.db_table) in tables
611 )
612 )
613
614 manifest = {
615 package_name: list(filter(model_installed, model_list))
616 for package_name, model_list in all_models
617 }
618
619 # Create the tables for each model
620 if verbosity >= 1:
621 click.echo(" Creating tables...", color="cyan")
622 with connection.schema_editor() as editor:
623 for package_name, model_list in manifest.items():
624 for model in model_list:
625 # Never install unmanaged models, etc.
626 if not model._meta.can_migrate(connection):
627 continue
628 if verbosity >= 3:
629 click.echo(
630 f" Processing {package_name}.{model._meta.object_name} model"
631 )
632 if verbosity >= 1:
633 click.echo(f" Creating table {model._meta.db_table}")
634 editor.create_model(model)
635
636 # Deferred SQL is executed when exiting the editor's context.
637 if verbosity >= 1:
638 click.echo(" Running deferred SQL...", color="cyan")
639
640 def describe_operation(operation, backwards):
641 """Return a string that describes a migration operation for --plan."""
642 prefix = ""
643 is_error = False
644 if hasattr(operation, "code"):
645 code = operation.reverse_code if backwards else operation.code
646 action = (code.__doc__ or "") if code else None
647 elif hasattr(operation, "sql"):
648 action = operation.reverse_sql if backwards else operation.sql
649 else:
650 action = ""
651 if backwards:
652 prefix = "Undo "
653 if action is not None:
654 action = str(action).replace("\n", "")
655 elif backwards:
656 action = "IRREVERSIBLE"
657 is_error = True
658 if action:
659 action = " -> " + action
660 truncated = Truncator(action)
661 return prefix + operation.describe() + truncated.chars(40), is_error
662
663 # Import the 'management' module within each installed package, to register
664 # dispatcher events.
665 for package_config in packages.get_package_configs():
666 if module_has_submodule(package_config.module, "management"):
667 import_module(".management", package_config.name)
668
669 # Get the database we're operating from
670 connection = connections[database]
671
672 # Hook for backends needing any database preparation
673 connection.prepare_database()
674
675 # Work out which packages have migrations and which do not
676 executor = MigrationExecutor(connection, migration_progress_callback)
677
678 # Raise an error if any migrations are applied before their dependencies.
679 executor.loader.check_consistent_history(connection)
680
681 # Before anything else, see if there's conflicting packages and drop out
682 # hard if there are any
683 conflicts = executor.loader.detect_conflicts()
684 if conflicts:
685 name_str = "; ".join(
686 "{} in {}".format(", ".join(names), package)
687 for package, names in conflicts.items()
688 )
689 raise click.ClickException(
690 "Conflicting migrations detected; multiple leaf nodes in the "
691 "migration graph: (%s).\nTo fix them run "
692 "'python manage.py makemigrations --merge'" % name_str
693 )
694
695 # If they supplied command line arguments, work out what they mean.
696 target_package_labels_only = True
697 if package_label:
698 try:
699 packages.get_package_config(package_label)
700 except LookupError as err:
701 raise click.ClickException(str(err))
702 if run_syncdb:
703 if package_label in executor.loader.migrated_packages:
704 raise click.ClickException(
705 f"Can't use run_syncdb with package '{package_label}' as it has migrations."
706 )
707 elif package_label not in executor.loader.migrated_packages:
708 raise click.ClickException(
709 f"Package '{package_label}' does not have migrations."
710 )
711
712 if package_label and migration_name:
713 if migration_name == "zero":
714 targets = [(package_label, None)]
715 else:
716 try:
717 migration = executor.loader.get_migration_by_prefix(
718 package_label, migration_name
719 )
720 except AmbiguityError:
721 raise click.ClickException(
722 f"More than one migration matches '{migration_name}' in package '{package_label}'. "
723 "Please be more specific."
724 )
725 except KeyError:
726 raise click.ClickException(
727 f"Cannot find a migration matching '{migration_name}' from package '{package_label}'."
728 )
729 target = (package_label, migration.name)
730 if (
731 target not in executor.loader.graph.nodes
732 and target in executor.loader.replacements
733 ):
734 incomplete_migration = executor.loader.replacements[target]
735 target = incomplete_migration.replaces[-1]
736 targets = [target]
737 target_package_labels_only = False
738 elif package_label:
739 targets = [
740 key for key in executor.loader.graph.leaf_nodes() if key[0] == package_label
741 ]
742 else:
743 targets = executor.loader.graph.leaf_nodes()
744
745 if prune:
746 if not package_label:
747 raise click.ClickException(
748 "Migrations can be pruned only when a package is specified."
749 )
750 if verbosity > 0:
751 click.echo("Pruning migrations:", color="cyan")
752 to_prune = set(executor.loader.applied_migrations) - set(
753 executor.loader.disk_migrations
754 )
755 squashed_migrations_with_deleted_replaced_migrations = [
756 migration_key
757 for migration_key, migration_obj in executor.loader.replacements.items()
758 if any(replaced in to_prune for replaced in migration_obj.replaces)
759 ]
760 if squashed_migrations_with_deleted_replaced_migrations:
761 click.echo(
762 click.style(
763 " Cannot use --prune because the following squashed "
764 "migrations have their 'replaces' attributes and may not "
765 "be recorded as applied:",
766 fg="yellow",
767 )
768 )
769 for migration in squashed_migrations_with_deleted_replaced_migrations:
770 package, name = migration
771 click.echo(f" {package}.{name}")
772 click.echo(
773 click.style(
774 " Re-run 'manage.py migrate' if they are not marked as "
775 "applied, and remove 'replaces' attributes in their "
776 "Migration classes.",
777 fg="yellow",
778 )
779 )
780 else:
781 to_prune = sorted(
782 migration for migration in to_prune if migration[0] == package_label
783 )
784 if to_prune:
785 for migration in to_prune:
786 package, name = migration
787 if verbosity > 0:
788 click.echo(
789 click.style(f" Pruning {package}.{name}", fg="yellow"),
790 nl=False,
791 )
792 executor.recorder.record_unapplied(package, name)
793 if verbosity > 0:
794 click.echo(click.style(" OK", fg="green"))
795 elif verbosity > 0:
796 click.echo(" No migrations to prune.")
797
798 migration_plan = executor.migration_plan(targets)
799
800 if plan:
801 click.echo("Planned operations:", color="cyan")
802 if not migration_plan:
803 click.echo(" No planned migration operations.")
804 else:
805 for migration, backwards in migration_plan:
806 click.echo(str(migration), color="cyan")
807 for operation in migration.operations:
808 message, is_error = describe_operation(operation, backwards)
809 if is_error:
810 click.echo(" " + message, fg="yellow")
811 else:
812 click.echo(" " + message)
813 if check_unapplied:
814 sys.exit(1)
815 return
816
817 if check_unapplied:
818 if migration_plan:
819 sys.exit(1)
820 return
821
822 if prune:
823 return
824
825 # At this point, ignore run_syncdb if there aren't any packages to sync.
826 run_syncdb = run_syncdb and executor.loader.unmigrated_packages
827 # Print some useful info
828 if verbosity >= 1:
829 click.echo("Operations to perform:", color="cyan")
830 if run_syncdb:
831 if package_label:
832 click.echo(
833 f" Synchronize unmigrated package: {package_label}", color="yellow"
834 )
835 else:
836 click.echo(
837 " Synchronize unmigrated packages: "
838 + (", ".join(sorted(executor.loader.unmigrated_packages))),
839 color="yellow",
840 )
841 if target_package_labels_only:
842 click.echo(
843 " Apply all migrations: "
844 + (", ".join(sorted({a for a, n in targets})) or "(none)"),
845 color="yellow",
846 )
847 else:
848 if targets[0][1] is None:
849 click.echo(f" Unapply all migrations: {targets[0][0]}", color="yellow")
850 else:
851 click.echo(
852 f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
853 color="yellow",
854 )
855
856 pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
857
858 # Run the syncdb phase.
859 if run_syncdb:
860 if verbosity >= 1:
861 click.echo("Synchronizing packages without migrations:", color="cyan")
862 if package_label:
863 sync_packages(connection, [package_label])
864 else:
865 sync_packages(connection, executor.loader.unmigrated_packages)
866
867 # Migrate!
868 if verbosity >= 1:
869 click.echo("Running migrations:", color="cyan")
870 if not migration_plan:
871 if verbosity >= 1:
872 click.echo(" No migrations to apply.")
873 # If there's changes that aren't in migrations yet, tell them
874 # how to fix it.
875 autodetector = MigrationAutodetector(
876 executor.loader.project_state(),
877 ProjectState.from_packages(packages),
878 )
879 changes = autodetector.changes(graph=executor.loader.graph)
880 if changes:
881 click.echo(
882 click.style(
883 f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "
884 "have changes that are not yet reflected in a migration, and so won't be applied.",
885 fg="yellow",
886 )
887 )
888 click.echo(
889 click.style(
890 " Run 'manage.py makemigrations' to make new "
891 "migrations, and then re-run 'manage.py migrate' to "
892 "apply them.",
893 fg="yellow",
894 )
895 )
896 else:
897 post_migrate_state = executor.migrate(
898 targets,
899 plan=migration_plan,
900 state=pre_migrate_state.clone(),
901 fake=fake,
902 fake_initial=fake_initial,
903 )
904 # post_migrate signals have access to all models. Ensure that all models
905 # are reloaded in case any are delayed.
906 post_migrate_state.clear_delayed_packages_cache()
907 post_migrate_packages = post_migrate_state.packages
908
909 # Re-render models of real packages to include relationships now that
910 # we've got a final state. This wouldn't be necessary if real packages
911 # models were rendered with relationships in the first place.
912 with post_migrate_packages.bulk_update():
913 model_keys = []
914 for model_state in post_migrate_packages.real_models:
915 model_key = model_state.package_label, model_state.name_lower
916 model_keys.append(model_key)
917 post_migrate_packages.unregister_model(*model_key)
918 post_migrate_packages.render_multiple(
919 [ModelState.from_model(packages.get_model(*model)) for model in model_keys]
920 )
921
922
923@cli.command()
924@click.argument("package_label")
925@click.argument("migration_name")
926@click.option(
927 "--check",
928 is_flag=True,
929 help="Exit with a non-zero status if the migration can be optimized.",
930)
931@click.option(
932 "-v",
933 "--verbosity",
934 type=int,
935 default=1,
936 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
937)
938def optimize_migration(package_label, migration_name, check, verbosity):
939 """Optimizes the operations for the named migration."""
940 try:
941 packages.get_package_config(package_label)
942 except LookupError as err:
943 raise click.ClickException(str(err))
944
945 # Load the current graph state.
946 loader = MigrationLoader(None)
947 if package_label not in loader.migrated_packages:
948 raise click.ClickException(
949 f"Package '{package_label}' does not have migrations."
950 )
951
952 # Find a migration.
953 try:
954 migration = loader.get_migration_by_prefix(package_label, migration_name)
955 except AmbiguityError:
956 raise click.ClickException(
957 f"More than one migration matches '{migration_name}' in package "
958 f"'{package_label}'. Please be more specific."
959 )
960 except KeyError:
961 raise click.ClickException(
962 f"Cannot find a migration matching '{migration_name}' from package "
963 f"'{package_label}'."
964 )
965
966 # Optimize the migration.
967 optimizer = MigrationOptimizer()
968 new_operations = optimizer.optimize(migration.operations, migration.package_label)
969 if len(migration.operations) == len(new_operations):
970 if verbosity > 0:
971 click.echo("No optimizations possible.")
972 return
973 else:
974 if verbosity > 0:
975 click.echo(
976 f"Optimizing from {len(migration.operations)} operations to {len(new_operations)} operations."
977 )
978 if check:
979 sys.exit(1)
980
981 # Set the new migration optimizations.
982 migration.operations = new_operations
983
984 # Write out the optimized migration file.
985 writer = MigrationWriter(migration)
986 migration_file_string = writer.as_string()
987 if writer.needs_manual_porting:
988 if migration.replaces:
989 raise click.ClickException(
990 "Migration will require manual porting but is already a squashed "
991 "migration.\nTransition to a normal migration first."
992 )
993 # Make a new migration with those operations.
994 subclass = type(
995 "Migration",
996 (migrations.Migration,),
997 {
998 "dependencies": migration.dependencies,
999 "operations": new_operations,
1000 "replaces": [(migration.package_label, migration.name)],
1001 },
1002 )
1003 optimized_migration_name = f"{migration.name}_optimized"
1004 optimized_migration = subclass(optimized_migration_name, package_label)
1005 writer = MigrationWriter(optimized_migration)
1006 migration_file_string = writer.as_string()
1007 if verbosity > 0:
1008 click.echo(click.style("Manual porting required", fg="yellow", bold=True))
1009 click.echo(
1010 " Your migrations contained functions that must be manually "
1011 "copied over,\n"
1012 " as we could not safely copy their implementation.\n"
1013 " See the comment at the top of the optimized migration for "
1014 "details."
1015 )
1016
1017 with open(writer.path, "w", encoding="utf-8") as fh:
1018 fh.write(migration_file_string)
1019
1020 if verbosity > 0:
1021 click.echo(
1022 click.style(f"Optimized migration {writer.path}", fg="green", bold=True)
1023 )
1024
1025
1026@cli.command()
1027@click.argument("package_labels", nargs=-1)
1028@click.option(
1029 "--database",
1030 default=DEFAULT_DB_ALIAS,
1031 help="Nominates a database to show migrations for. Defaults to the 'default' database.",
1032)
1033@click.option(
1034 "--format",
1035 type=click.Choice(["list", "plan"]),
1036 default="list",
1037 help="Output format.",
1038)
1039@click.option(
1040 "-v",
1041 "--verbosity",
1042 type=int,
1043 default=1,
1044 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
1045)
1046def show_migrations(package_labels, database, format, verbosity):
1047 """Shows all available migrations for the current project"""
1048
1049 def _validate_package_names(package_names):
1050 has_bad_names = False
1051 for package_name in package_names:
1052 try:
1053 packages.get_package_config(package_name)
1054 except LookupError as err:
1055 click.echo(str(err), err=True)
1056 has_bad_names = True
1057 if has_bad_names:
1058 sys.exit(2)
1059
1060 def show_list(connection, package_names):
1061 """
1062 Show a list of all migrations on the system, or only those of
1063 some named packages.
1064 """
1065 # Load migrations from disk/DB
1066 loader = MigrationLoader(connection, ignore_no_migrations=True)
1067 recorder = MigrationRecorder(connection)
1068 recorded_migrations = recorder.applied_migrations()
1069 graph = loader.graph
1070 # If we were passed a list of packages, validate it
1071 if package_names:
1072 _validate_package_names(package_names)
1073 # Otherwise, show all packages in alphabetic order
1074 else:
1075 package_names = sorted(loader.migrated_packages)
1076 # For each app, print its migrations in order from oldest (roots) to
1077 # newest (leaves).
1078 for package_name in package_names:
1079 click.secho(package_name, fg="cyan", bold=True)
1080 shown = set()
1081 for node in graph.leaf_nodes(package_name):
1082 for plan_node in graph.forwards_plan(node):
1083 if plan_node not in shown and plan_node[0] == package_name:
1084 # Give it a nice title if it's a squashed one
1085 title = plan_node[1]
1086 if graph.nodes[plan_node].replaces:
1087 title += f" ({len(graph.nodes[plan_node].replaces)} squashed migrations)"
1088 applied_migration = loader.applied_migrations.get(plan_node)
1089 # Mark it as applied/unapplied
1090 if applied_migration:
1091 if plan_node in recorded_migrations:
1092 output = f" [X] {title}"
1093 else:
1094 title += " Run 'manage.py migrate' to finish recording."
1095 output = f" [-] {title}"
1096 if verbosity >= 2 and hasattr(applied_migration, "applied"):
1097 output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"
1098 click.echo(output)
1099 else:
1100 click.echo(f" [ ] {title}")
1101 shown.add(plan_node)
1102 # If we didn't print anything, then a small message
1103 if not shown:
1104 click.secho(" (no migrations)", fg="red")
1105
1106 def show_plan(connection, package_names):
1107 """
1108 Show all known migrations (or only those of the specified package_names)
1109 in the order they will be applied.
1110 """
1111 # Load migrations from disk/DB
1112 loader = MigrationLoader(connection)
1113 graph = loader.graph
1114 if package_names:
1115 _validate_package_names(package_names)
1116 targets = [key for key in graph.leaf_nodes() if key[0] in package_names]
1117 else:
1118 targets = graph.leaf_nodes()
1119 plan = []
1120 seen = set()
1121
1122 # Generate the plan
1123 for target in targets:
1124 for migration in graph.forwards_plan(target):
1125 if migration not in seen:
1126 node = graph.node_map[migration]
1127 plan.append(node)
1128 seen.add(migration)
1129
1130 # Output
1131 def print_deps(node):
1132 out = []
1133 for parent in sorted(node.parents):
1134 out.append(f"{parent.key[0]}.{parent.key[1]}")
1135 if out:
1136 return f" ... ({', '.join(out)})"
1137 return ""
1138
1139 for node in plan:
1140 deps = ""
1141 if verbosity >= 2:
1142 deps = print_deps(node)
1143 if node.key in loader.applied_migrations:
1144 click.echo(f"[X] {node.key[0]}.{node.key[1]}{deps}")
1145 else:
1146 click.echo(f"[ ] {node.key[0]}.{node.key[1]}{deps}")
1147 if not plan:
1148 click.secho("(no migrations)", fg="red")
1149
1150 # Get the database we're operating from
1151 connection = connections[database]
1152
1153 if format == "plan":
1154 show_plan(connection, package_labels)
1155 else:
1156 show_list(connection, package_labels)
1157
1158
1159@cli.command()
1160@click.argument("package_label")
1161@click.argument("start_migration_name", required=False)
1162@click.argument("migration_name")
1163@click.option(
1164 "--no-optimize",
1165 is_flag=True,
1166 help="Do not try to optimize the squashed operations.",
1167)
1168@click.option(
1169 "--noinput",
1170 "--no-input",
1171 "no_input",
1172 is_flag=True,
1173 help="Tells Plain to NOT prompt the user for input of any kind.",
1174)
1175@click.option("--squashed-name", help="Sets the name of the new squashed migration.")
1176@click.option(
1177 "-v",
1178 "--verbosity",
1179 type=int,
1180 default=1,
1181 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
1182)
1183def squash_migrations(
1184 package_label,
1185 start_migration_name,
1186 migration_name,
1187 no_optimize,
1188 no_input,
1189 squashed_name,
1190 verbosity,
1191):
1192 """
1193 Squashes an existing set of migrations (from first until specified) into a single new one.
1194 """
1195 interactive = not no_input
1196
1197 def find_migration(loader, package_label, name):
1198 try:
1199 return loader.get_migration_by_prefix(package_label, name)
1200 except AmbiguityError:
1201 raise click.ClickException(
1202 f"More than one migration matches '{name}' in package '{package_label}'. Please be more specific."
1203 )
1204 except KeyError:
1205 raise click.ClickException(
1206 f"Cannot find a migration matching '{name}' from package '{package_label}'."
1207 )
1208
1209 # Validate package_label
1210 try:
1211 packages.get_package_config(package_label)
1212 except LookupError as err:
1213 raise click.ClickException(str(err))
1214
1215 # Load the current graph state, check the app and migration they asked for exists
1216 loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])
1217 if package_label not in loader.migrated_packages:
1218 raise click.ClickException(
1219 f"Package '{package_label}' does not have migrations (so squashmigrations on it makes no sense)"
1220 )
1221
1222 migration = find_migration(loader, package_label, migration_name)
1223
1224 # Work out the list of predecessor migrations
1225 migrations_to_squash = [
1226 loader.get_migration(al, mn)
1227 for al, mn in loader.graph.forwards_plan(
1228 (migration.package_label, migration.name)
1229 )
1230 if al == migration.package_label
1231 ]
1232
1233 if start_migration_name:
1234 start_migration = find_migration(loader, package_label, start_migration_name)
1235 start = loader.get_migration(
1236 start_migration.package_label, start_migration.name
1237 )
1238 try:
1239 start_index = migrations_to_squash.index(start)
1240 migrations_to_squash = migrations_to_squash[start_index:]
1241 except ValueError:
1242 raise click.ClickException(
1243 f"The migration '{start_migration}' cannot be found. Maybe it comes after "
1244 f"the migration '{migration}'?\n"
1245 f"Have a look at:\n"
1246 f" python manage.py showmigrations {package_label}\n"
1247 f"to debug this issue."
1248 )
1249
1250 # Tell them what we're doing and optionally ask if we should proceed
1251 if verbosity > 0 or interactive:
1252 click.secho("Will squash the following migrations:", fg="cyan", bold=True)
1253 for migration in migrations_to_squash:
1254 click.echo(f" - {migration.name}")
1255
1256 if interactive:
1257 if not click.confirm("Do you wish to proceed?"):
1258 return
1259
1260 # Load the operations from all those migrations and concat together,
1261 # along with collecting external dependencies and detecting double-squashing
1262 operations = []
1263 dependencies = set()
1264 # We need to take all dependencies from the first migration in the list
1265 # as it may be 0002 depending on 0001
1266 first_migration = True
1267 for smigration in migrations_to_squash:
1268 if smigration.replaces:
1269 raise click.ClickException(
1270 "You cannot squash squashed migrations! Please transition it to a "
1271 "normal migration first"
1272 )
1273 operations.extend(smigration.operations)
1274 for dependency in smigration.dependencies:
1275 if isinstance(dependency, SwappableTuple):
1276 if settings.AUTH_USER_MODEL == dependency.setting:
1277 dependencies.add(("__setting__", "AUTH_USER_MODEL"))
1278 else:
1279 dependencies.add(dependency)
1280 elif dependency[0] != smigration.package_label or first_migration:
1281 dependencies.add(dependency)
1282 first_migration = False
1283
1284 if no_optimize:
1285 if verbosity > 0:
1286 click.secho("(Skipping optimization.)", fg="yellow")
1287 new_operations = operations
1288 else:
1289 if verbosity > 0:
1290 click.secho("Optimizing...", fg="cyan")
1291
1292 optimizer = MigrationOptimizer()
1293 new_operations = optimizer.optimize(operations, migration.package_label)
1294
1295 if verbosity > 0:
1296 if len(new_operations) == len(operations):
1297 click.echo(" No optimizations possible.")
1298 else:
1299 click.echo(
1300 f" Optimized from {len(operations)} operations to {len(new_operations)} operations."
1301 )
1302
1303 # Work out the value of replaces (any squashed ones we're re-squashing)
1304 # need to feed their replaces into ours
1305 replaces = []
1306 for migration in migrations_to_squash:
1307 if migration.replaces:
1308 replaces.extend(migration.replaces)
1309 else:
1310 replaces.append((migration.package_label, migration.name))
1311
1312 # Make a new migration with those operations
1313 subclass = type(
1314 "Migration",
1315 (migrations.Migration,),
1316 {
1317 "dependencies": dependencies,
1318 "operations": new_operations,
1319 "replaces": replaces,
1320 },
1321 )
1322 if start_migration_name:
1323 if squashed_name:
1324 # Use the name from --squashed-name
1325 prefix, _ = start_migration.name.split("_", 1)
1326 name = f"{prefix}_{squashed_name}"
1327 else:
1328 # Generate a name
1329 name = f"{start_migration.name}_squashed_{migration.name}"
1330 new_migration = subclass(name, package_label)
1331 else:
1332 name = f"0001_{'squashed_' + migration.name if not squashed_name else squashed_name}"
1333 new_migration = subclass(name, package_label)
1334 new_migration.initial = True
1335
1336 # Write out the new migration file
1337 writer = MigrationWriter(new_migration)
1338 if os.path.exists(writer.path):
1339 raise click.ClickException(
1340 f"Migration {new_migration.name} already exists. Use a different name."
1341 )
1342 with open(writer.path, "w", encoding="utf-8") as fh:
1343 fh.write(writer.as_string())
1344
1345 if verbosity > 0:
1346 click.secho(
1347 f"Created new squashed migration {writer.path}", fg="green", bold=True
1348 )
1349 click.echo(
1350 " You should commit this migration but leave the old ones in place;\n"
1351 " the new migration will be used for new installs. Once you are sure\n"
1352 " all instances of the codebase have applied the migrations you squashed,\n"
1353 " you can delete them."
1354 )
1355 if writer.needs_manual_porting:
1356 click.secho("Manual porting required", fg="yellow", bold=True)
1357 click.echo(
1358 " Your migrations contained functions that must be manually copied over,\n"
1359 " as we could not safely copy their implementation.\n"
1360 " See the comment at the top of the squashed migration for details."
1361 )