Plain is headed towards 1.0! Subscribe for development updates →

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

Migration docs

Fields

Field docs

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            )