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.")