1from __future__ import annotations
  2
  3import sys
  4
  5import click
  6
  7from plain.runtime import settings
  8
  9from ..convergence import execute_plan, plan_convergence
 10
 11
 12@click.command()
 13@click.option(
 14    "--check",
 15    is_flag=True,
 16    help="Exit with non-zero status if sync would make any database changes.",
 17)
 18@click.option(
 19    "--drop-undeclared",
 20    is_flag=True,
 21    help="Drop indexes and constraints not declared on any model.",
 22)
 23def sync(check: bool, drop_undeclared: bool) -> None:
 24    """Sync the database schema with models.
 25
 26    In DEBUG mode: generates migrations, applies them, then converges constraints.
 27    In production: applies migrations, then converges constraints.
 28
 29    With --drop-undeclared, also drops indexes and constraints that exist in the
 30    database but are not declared on any model.
 31
 32    Without --drop-undeclared, exits non-zero if undeclared constraints remain
 33    (constraints affect database behavior). Undeclared indexes are reported but
 34    do not block success.
 35    """
 36    if check:
 37        _check(drop_undeclared=drop_undeclared)
 38        return
 39
 40    if settings.DEBUG:
 41        _create_migrations()
 42
 43    _migrate()
 44    _converge(drop_undeclared=drop_undeclared)
 45
 46
 47def _check(*, drop_undeclared: bool) -> None:
 48    """Exit non-zero if sync would make any database changes."""
 49    from .migrations import apply, create
 50
 51    has_changes = False
 52
 53    # Check if migrations would be created (DEBUG only)
 54    if settings.DEBUG:
 55        try:
 56            create.callback(
 57                package_labels=(),
 58                dry_run=False,
 59                empty=False,
 60                no_input=True,
 61                name=None,
 62                check=True,
 63                verbosity=0,
 64            )
 65        except SystemExit:
 66            has_changes = True
 67
 68    # Check for unapplied migrations
 69    try:
 70        apply.callback(
 71            package_label=None,
 72            migration_name=None,
 73            fake=False,
 74            plan=False,
 75            check_unapplied=True,
 76            no_input=True,
 77            atomic_batch=None,
 78            quiet=True,
 79        )
 80    except SystemExit:
 81        has_changes = True
 82
 83    # Check for convergence
 84    plan = plan_convergence()
 85    if plan.has_work(drop_undeclared=drop_undeclared):
 86        has_changes = True
 87    if not drop_undeclared and plan.blocking_cleanup:
 88        has_changes = True
 89    if plan.blocked:
 90        has_changes = True
 91
 92    if has_changes:
 93        sys.exit(1)
 94
 95
 96def _create_migrations() -> None:
 97    from .migrations import create
 98
 99    click.secho("Checking for model changes...", bold=True)
100    create.callback(
101        package_labels=(),
102        dry_run=False,
103        empty=False,
104        no_input=False,
105        name=None,
106        check=False,
107        verbosity=1,
108    )
109
110
111def _migrate() -> None:
112    from ..db import get_connection
113    from ..migrations.executor import MigrationExecutor
114
115    click.secho("Applying migrations...", bold=True)
116
117    conn = get_connection()
118    conn.ensure_connection()
119    executor = MigrationExecutor(conn)
120    targets = executor.loader.graph.leaf_nodes()
121    migration_plan = executor.migration_plan(targets)
122
123    if not migration_plan:
124        click.echo("  No migrations to apply.")
125        return
126
127    click.echo(f"  Applying {len(migration_plan)} migration(s)...")
128    executor.migrate(targets, plan=migration_plan)
129    click.echo(f"  Applied {len(migration_plan)} migration(s).")
130
131
132def _converge(*, drop_undeclared: bool) -> None:
133    click.secho("Converging schema...", bold=True)
134
135    plan = plan_convergence()
136    items = plan.executable(drop_undeclared=drop_undeclared)
137    success = True
138
139    if items:
140        result = execute_plan(items)
141
142        for r in result.results:
143            if r.ok:
144                click.echo(f"    {r.sql}")
145            else:
146                click.secho(f"    FAILED: {r.item.describe()}{r.error}", fg="red")
147
148        click.secho(f"  {result.summary}", fg="green" if result.ok else "yellow")
149        if not result.ok_for_sync:
150            success = False
151
152    if plan.blocked:
153        click.echo()
154        click.secho("  Schema changes require a staged rollout:", fg="red", bold=True)
155        for item in plan.blocked:
156            click.secho(f"    {item.drift.describe()}", fg="red")
157            if item.guidance:
158                click.secho(f"      {item.guidance}", fg="red", dim=True)
159        success = False
160
161    if not drop_undeclared and plan.blocking_cleanup:
162        click.echo()
163        click.secho("  Undeclared constraints still in database:", fg="red", bold=True)
164        for item in plan.blocking_cleanup:
165            click.secho(f"    {item.describe()}", fg="red")
166        click.secho("  Rerun with --drop-undeclared to remove them.", fg="red")
167        success = False
168
169    if not drop_undeclared and plan.optional_cleanup:
170        click.echo()
171        for item in plan.optional_cleanup:
172            click.echo(f"    {item.describe()}")
173        click.echo("  Run with --drop-undeclared to remove undeclared indexes.")
174
175    if not success:
176        sys.exit(1)
177    elif not items:
178        click.echo("  Schema is converged.")