plain.models 0.51.1__py3-none-any.whl → 0.53.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/models/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.53.0](https://github.com/dropseed/plain/releases/plain-models@0.53.0) (2025-10-12)
4
+
5
+ ### What's changed
6
+
7
+ - Added new `plain models prune-migrations` command to identify and remove stale migration records from the database ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
8
+ - The `--prune` option has been removed from `plain migrate` command in favor of the dedicated `prune-migrations` command ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
9
+ - Added new preflight check `models.prunable_migrations` that warns about stale migration records in the database ([9b43617](https://github.com/dropseed/plain/commit/9b4361765c))
10
+ - The `show-migrations` command no longer displays prunable migrations in its output ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace any usage of `plain migrate --prune` with the new `plain models prune-migrations` command
15
+
16
+ ## [0.52.0](https://github.com/dropseed/plain/releases/plain-models@0.52.0) (2025-10-10)
17
+
18
+ ### What's changed
19
+
20
+ - The `plain migrate` command now shows detailed operation descriptions and SQL statements for each migration step, replacing the previous verbosity levels with a cleaner `--quiet` flag ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
21
+ - Migration output format has been improved to display each operation's description and the actual SQL being executed, making it easier to understand what changes are being made to the database ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
22
+ - The `-v/--verbosity` option has been removed from `plain migrate` in favor of the simpler `--quiet` flag for suppressing output ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
23
+
24
+ ### Upgrade instructions
25
+
26
+ - Replace any usage of `-v` or `--verbosity` flags in `plain migrate` commands with `--quiet` if you want to suppress migration output
27
+
3
28
  ## [0.51.1](https://github.com/dropseed/plain/releases/plain-models@0.51.1) (2025-10-08)
4
29
 
5
30
  ### What's changed
@@ -52,9 +52,6 @@ class BaseDatabaseCreation:
52
52
  settings.DATABASE["NAME"] = test_database_name
53
53
  self.connection.settings_dict["NAME"] = test_database_name
54
54
 
55
- # We report migrate messages at one level lower than that
56
- # requested. This ensures we don't get flooded with messages during
57
- # testing (unless you really ask to be flooded).
58
55
  migrate.callback(
59
56
  package_label=None,
60
57
  migration_name=None,
@@ -62,10 +59,9 @@ class BaseDatabaseCreation:
62
59
  plan=False,
63
60
  check_unapplied=False,
64
61
  backup=False,
65
- prune=False,
66
62
  no_input=True,
67
- verbosity=max(verbosity - 1, 0),
68
63
  atomic_batch=False, # No need for atomic batch when creating test database
64
+ quiet=verbosity < 2, # Show migration output when verbosity is 2+
69
65
  )
70
66
 
71
67
  # Ensure a connection for the side effect of initializing the test database.
@@ -159,19 +159,16 @@ class BaseDatabaseSchemaEditor:
159
159
  def __init__(
160
160
  self,
161
161
  connection: BaseDatabaseWrapper,
162
- collect_sql: bool = False,
163
162
  atomic: bool = True,
164
163
  ):
165
164
  self.connection = connection
166
- self.collect_sql = collect_sql
167
- if self.collect_sql:
168
- self.collected_sql: list[str] = []
169
165
  self.atomic_migration = self.connection.features.can_rollback_ddl and atomic
170
166
 
171
167
  # State-managing methods
172
168
 
173
169
  def __enter__(self) -> BaseDatabaseSchemaEditor:
174
170
  self.deferred_sql: list[Any] = []
171
+ self.executed_sql: list[str] = []
175
172
  if self.atomic_migration:
176
173
  self.atomic = atomic()
177
174
  self.atomic.__enter__()
@@ -190,11 +187,8 @@ class BaseDatabaseSchemaEditor:
190
187
  self, sql: str | Statement, params: tuple[Any, ...] | list[Any] | None = ()
191
188
  ) -> None:
192
189
  """Execute the given SQL statement, with optional parameters."""
193
- # Don't perform the transactional DDL check if SQL is being collected
194
- # as it's not going to be executed anyway.
195
190
  if (
196
- not self.collect_sql
197
- and self.connection.in_atomic_block
191
+ self.connection.in_atomic_block
198
192
  and not self.connection.features.can_rollback_ddl
199
193
  ):
200
194
  raise TransactionManagementError(
@@ -207,17 +201,16 @@ class BaseDatabaseSchemaEditor:
207
201
  logger.debug(
208
202
  "%s; (params %r)", sql, params, extra={"params": params, "sql": sql}
209
203
  )
210
- if self.collect_sql:
211
- ending = "" if sql.rstrip().endswith(";") else ";"
212
- if params is not None:
213
- self.collected_sql.append(
214
- (sql % tuple(map(self.quote_value, params))) + ending
215
- )
216
- else:
217
- self.collected_sql.append(sql + ending)
204
+
205
+ # Track executed SQL for display in migration output
206
+ # Store the SQL for display (interpolate params for readability)
207
+ if params:
208
+ self.executed_sql.append(sql % tuple(map(self.quote_value, params)))
218
209
  else:
219
- with self.connection.cursor() as cursor:
220
- cursor.execute(sql, params)
210
+ self.executed_sql.append(sql)
211
+
212
+ with self.connection.cursor() as cursor:
213
+ cursor.execute(sql, params)
221
214
 
222
215
  def quote_name(self, name: str) -> str:
223
216
  return self.connection.ops.quote_name(name)
plain/models/cli.py CHANGED
@@ -15,7 +15,7 @@ from plain.utils.text import Truncator
15
15
 
16
16
  from . import migrations
17
17
  from .backups.cli import cli as backups_cli
18
- from .backups.cli import create_backup
18
+ from .backups.core import DatabaseBackups
19
19
  from .db import OperationalError
20
20
  from .db import db_connection as _db_connection
21
21
  from .migrations.autodetector import MigrationAutodetector
@@ -34,6 +34,7 @@ from .registry import models_registry
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  from .backends.base.base import BaseDatabaseWrapper
37
+ from .migrations.operations.base import Operation
37
38
 
38
39
  db_connection = cast("BaseDatabaseWrapper", _db_connection)
39
40
  else:
@@ -365,11 +366,6 @@ def makemigrations(
365
366
  default=None,
366
367
  help="Explicitly enable/disable pre-migration backups.",
367
368
  )
368
- @click.option(
369
- "--prune",
370
- is_flag=True,
371
- help="Delete nonexistent migrations from the plainmigrations table.",
372
- )
373
369
  @click.option(
374
370
  "--no-input",
375
371
  "--noinput",
@@ -377,18 +373,16 @@ def makemigrations(
377
373
  is_flag=True,
378
374
  help="Tells Plain to NOT prompt the user for input of any kind.",
379
375
  )
380
- @click.option(
381
- "-v",
382
- "--verbosity",
383
- type=int,
384
- default=1,
385
- help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
386
- )
387
376
  @click.option(
388
377
  "--atomic-batch/--no-atomic-batch",
389
378
  default=None,
390
379
  help="Run migrations in a single transaction (auto-detected by default)",
391
380
  )
381
+ @click.option(
382
+ "--quiet",
383
+ is_flag=True,
384
+ help="Suppress migration output (used for test database creation).",
385
+ )
392
386
  def migrate(
393
387
  package_label: str | None,
394
388
  migration_name: str | None,
@@ -396,28 +390,43 @@ def migrate(
396
390
  plan: bool,
397
391
  check_unapplied: bool,
398
392
  backup: bool | None,
399
- prune: bool,
400
393
  no_input: bool,
401
- verbosity: int,
402
394
  atomic_batch: bool | None,
395
+ quiet: bool,
403
396
  ) -> None:
404
397
  """Updates database schema. Manages both packages with migrations and those without."""
405
398
 
406
399
  def migration_progress_callback(
407
- action: str, migration: Migration | None = None, fake: bool = False
400
+ action: str,
401
+ *,
402
+ migration: Migration | None = None,
403
+ fake: bool = False,
404
+ operation: Operation | None = None,
405
+ sql_statements: list[str] | None = None,
408
406
  ) -> None:
409
- if verbosity >= 1:
410
- if action == "apply_start":
411
- click.echo(f" Applying {migration}...", nl=False)
412
- elif action == "apply_success":
413
- if fake:
414
- click.echo(click.style(" FAKED", fg="green"))
415
- else:
416
- click.echo(click.style(" OK", fg="green"))
417
- elif action == "render_start":
418
- click.echo(" Rendering model states...", nl=False)
419
- elif action == "render_success":
420
- click.echo(click.style(" DONE", fg="green"))
407
+ if quiet:
408
+ return
409
+
410
+ if action == "apply_start":
411
+ click.echo() # Always add newline between migrations
412
+ if fake:
413
+ click.secho(f"{migration} (faked)", fg="cyan")
414
+ else:
415
+ click.secho(f"{migration}", fg="cyan")
416
+ elif action == "apply_success":
417
+ pass # Already shown via operations
418
+ elif action == "operation_start":
419
+ click.echo(f" {operation.describe()}", nl=False)
420
+ click.secho("... ", dim=True, nl=False)
421
+ elif action == "operation_success":
422
+ # Show SQL statements (no OK needed, SQL implies success)
423
+ if sql_statements:
424
+ click.echo() # newline after "..."
425
+ for sql in sql_statements:
426
+ click.secho(f" {sql}", dim=True)
427
+ else:
428
+ # No SQL: just add a newline
429
+ click.echo()
421
430
 
422
431
  def describe_operation(operation: Any) -> tuple[str, bool]:
423
432
  """Return a string that describes a migration operation for --plan."""
@@ -504,74 +513,22 @@ def migrate(
504
513
  else:
505
514
  targets = list(executor.loader.graph.leaf_nodes())
506
515
 
507
- if prune:
508
- if not package_label:
509
- raise click.ClickException(
510
- "Migrations can be pruned only when a package is specified."
511
- )
512
- if verbosity > 0:
513
- click.secho("Pruning migrations:", fg="cyan")
514
- to_prune = set(executor.loader.applied_migrations) - set( # type: ignore[arg-type]
515
- executor.loader.disk_migrations # type: ignore[arg-type]
516
- )
517
- squashed_migrations_with_deleted_replaced_migrations = [
518
- migration_key
519
- for migration_key, migration_obj in executor.loader.replacements.items()
520
- if any(replaced in to_prune for replaced in migration_obj.replaces)
521
- ]
522
- if squashed_migrations_with_deleted_replaced_migrations:
523
- click.echo(
524
- click.style(
525
- " Cannot use --prune because the following squashed "
526
- "migrations have their 'replaces' attributes and may not "
527
- "be recorded as applied:",
528
- fg="yellow",
529
- )
530
- )
531
- for migration in squashed_migrations_with_deleted_replaced_migrations:
532
- package, name = migration
533
- click.echo(f" {package}.{name}")
534
- click.echo(
535
- click.style(
536
- " Re-run `plain migrate` if they are not marked as "
537
- "applied, and remove 'replaces' attributes in their "
538
- "Migration classes.",
539
- fg="yellow",
540
- )
541
- )
542
- else:
543
- to_prune = sorted(
544
- migration for migration in to_prune if migration[0] == package_label
545
- )
546
- if to_prune:
547
- for migration in to_prune:
548
- package, name = migration
549
- if verbosity > 0:
550
- click.echo(
551
- click.style(f" Pruning {package}.{name}", fg="yellow"),
552
- nl=False,
553
- )
554
- executor.recorder.record_unapplied(package, name)
555
- if verbosity > 0:
556
- click.echo(click.style(" OK", fg="green"))
557
- elif verbosity > 0:
558
- click.echo(" No migrations to prune.")
559
-
560
516
  migration_plan = executor.migration_plan(targets)
561
517
 
562
518
  if plan:
563
- click.secho("Planned operations:", fg="cyan")
564
- if not migration_plan:
565
- click.echo(" No planned migration operations.")
566
- else:
567
- for migration in migration_plan:
568
- click.secho(str(migration), fg="cyan")
569
- for operation in migration.operations:
570
- message, is_error = describe_operation(operation)
571
- if is_error:
572
- click.secho(" " + message, fg="yellow")
573
- else:
574
- click.echo(" " + message)
519
+ if not quiet:
520
+ click.secho("Planned operations:", fg="cyan")
521
+ if not migration_plan:
522
+ click.echo(" No planned migration operations.")
523
+ else:
524
+ for migration in migration_plan:
525
+ click.secho(str(migration), fg="cyan")
526
+ for operation in migration.operations:
527
+ message, is_error = describe_operation(operation)
528
+ if is_error:
529
+ click.secho(" " + message, fg="yellow")
530
+ else:
531
+ click.echo(" " + message)
575
532
  if check_unapplied:
576
533
  sys.exit(1)
577
534
  return
@@ -581,33 +538,24 @@ def migrate(
581
538
  sys.exit(1)
582
539
  return
583
540
 
584
- if prune:
585
- return
586
-
587
541
  # Print some useful info
588
- if verbosity >= 1:
589
- click.secho("Operations to perform:", fg="cyan")
590
-
542
+ if not quiet:
591
543
  if target_package_labels_only:
592
- click.secho(
593
- " Apply all migrations: "
594
- + (", ".join(sorted({a for a, n in targets})) or "(none)"),
595
- fg="yellow",
596
- )
544
+ packages = ", ".join(sorted({a for a, n in targets})) or "(none)"
545
+ click.secho("Packages: ", bold=True, nl=False)
546
+ click.secho(packages, dim=True)
547
+ click.echo() # Add newline after packages
597
548
  else:
598
- click.secho(
599
- f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
600
- fg="yellow",
601
- )
549
+ click.secho("Target: ", bold=True, nl=False)
550
+ click.secho(f"{targets[0][1]} from {targets[0][0]}", dim=True)
551
+ click.echo() # Add newline after target
602
552
 
603
553
  pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
604
554
 
605
- # sql = executor.loader.collect_sql(migration_plan)
606
- # pprint(sql)
607
-
608
555
  if migration_plan:
609
556
  # Determine whether to use atomic batch
610
557
  use_atomic_batch = False
558
+ atomic_batch_message = None
611
559
  if len(migration_plan) > 1:
612
560
  # Check database capabilities
613
561
  can_rollback_ddl = db_connection.features.can_rollback_ddl
@@ -632,58 +580,62 @@ def migrate(
632
580
  f"--atomic-batch requested but these migrations have atomic=False: {names}"
633
581
  )
634
582
  use_atomic_batch = True
635
- if verbosity >= 1:
636
- click.echo(
637
- f" Running {len(migration_plan)} migrations in atomic batch (all-or-nothing)"
638
- )
583
+ atomic_batch_message = (
584
+ f"Running {len(migration_plan)} migrations in atomic batch"
585
+ )
639
586
  elif atomic_batch is False:
640
587
  # User explicitly disabled atomic batch
641
588
  use_atomic_batch = False
642
- if verbosity >= 1:
643
- click.echo(f" Running {len(migration_plan)} migrations separately")
589
+ if len(migration_plan) > 1:
590
+ atomic_batch_message = (
591
+ f"Running {len(migration_plan)} migrations separately"
592
+ )
644
593
  else:
645
594
  # Auto-detect (atomic_batch is None)
646
595
  if can_rollback_ddl and not non_atomic_migrations:
647
596
  use_atomic_batch = True
648
- if verbosity >= 1:
649
- click.echo(
650
- f" Running {len(migration_plan)} migrations in atomic batch (all-or-nothing)"
651
- )
597
+ atomic_batch_message = (
598
+ f"Running {len(migration_plan)} migrations in atomic batch"
599
+ )
652
600
  else:
653
601
  use_atomic_batch = False
654
- if verbosity >= 1:
602
+ if len(migration_plan) > 1:
655
603
  if not can_rollback_ddl:
656
- click.echo(
657
- f" Running {len(migration_plan)} migrations separately ({db_connection.vendor} doesn't support batch transactions)"
658
- )
604
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately ({db_connection.vendor} doesn't support batch)"
659
605
  elif non_atomic_migrations:
660
- click.echo(
661
- f" Running {len(migration_plan)} migrations separately (some migrations have atomic=False)"
662
- )
606
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
663
607
  else:
664
- click.echo(
665
- f" Running {len(migration_plan)} migrations separately"
608
+ atomic_batch_message = (
609
+ f"Running {len(migration_plan)} migrations separately"
666
610
  )
667
611
 
668
612
  if backup or (backup is None and settings.DEBUG):
669
613
  backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
670
- click.secho(
671
- f"Creating backup before applying migrations: {backup_name}",
672
- bold=True,
673
- )
674
- # Can't use ctx.invoke because this is called by the test db creation currently,
675
- # which doesn't have a context.
676
- create_backup.callback(
677
- backup_name=backup_name,
678
- pg_dump=os.environ.get(
679
- "PG_DUMP", "pg_dump"
680
- ), # Have to pass this in manually
614
+ if not quiet:
615
+ click.secho("Creating backup: ", bold=True, nl=False)
616
+ click.secho(f"{backup_name}", dim=True, nl=False)
617
+ click.secho("... ", dim=True, nl=False)
618
+
619
+ backups_handler = DatabaseBackups()
620
+ backups_handler.create(
621
+ backup_name,
622
+ pg_dump=os.environ.get("PG_DUMP", "pg_dump"),
681
623
  )
682
- print()
683
624
 
684
- if verbosity >= 1:
685
- click.secho("Running migrations:", fg="cyan")
625
+ if not quiet:
626
+ click.echo(click.style("OK", fg="green"))
627
+ click.echo() # Add blank line after backup output
628
+ else:
629
+ if not quiet:
630
+ click.echo() # Add blank line after packages/target info
686
631
 
632
+ if not quiet:
633
+ if atomic_batch_message:
634
+ click.secho(
635
+ f"Applying migrations ({atomic_batch_message.lower()}):", bold=True
636
+ )
637
+ else:
638
+ click.secho("Applying migrations:", bold=True)
687
639
  post_migrate_state = executor.migrate(
688
640
  targets,
689
641
  plan=migration_plan,
@@ -712,31 +664,24 @@ def migrate(
712
664
  ]
713
665
  )
714
666
 
715
- elif verbosity >= 1:
716
- click.echo(" No migrations to apply.")
717
- # If there's changes that aren't in migrations yet, tell them
718
- # how to fix it.
719
- autodetector = MigrationAutodetector(
720
- executor.loader.project_state(),
721
- ProjectState.from_models_registry(models_registry),
722
- )
723
- changes = autodetector.changes(graph=executor.loader.graph)
724
- if changes:
725
- click.echo(
726
- click.style(
727
- f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "
728
- "have changes that are not yet reflected in a migration, and so won't be applied.",
729
- fg="yellow",
730
- )
667
+ else:
668
+ if not quiet:
669
+ click.echo("No migrations to apply.")
670
+ # If there's changes that aren't in migrations yet, tell them
671
+ # how to fix it.
672
+ autodetector = MigrationAutodetector(
673
+ executor.loader.project_state(),
674
+ ProjectState.from_models_registry(models_registry),
731
675
  )
732
- click.echo(
733
- click.style(
734
- " Run `plain makemigrations` to make new "
735
- "migrations, and then re-run `plain migrate` to "
736
- "apply them.",
737
- fg="yellow",
676
+ changes = autodetector.changes(graph=executor.loader.graph)
677
+ if changes:
678
+ packages = ", ".join(sorted(changes))
679
+ click.echo(
680
+ f"Your models have changes that are not yet reflected in migrations ({packages})."
681
+ )
682
+ click.echo(
683
+ "Run 'plain makemigrations' to create migrations for these changes."
738
684
  )
739
- )
740
685
 
741
686
 
742
687
  @cli.command()
@@ -819,35 +764,6 @@ def show_migrations(
819
764
  if not shown:
820
765
  click.secho(" (no migrations)", fg="red")
821
766
 
822
- # Find recorded migrations that aren't in the graph (prunable)
823
- prunable_migrations = [
824
- migration
825
- for migration in recorded_migrations
826
- if (
827
- migration not in loader.disk_migrations # type: ignore[operator]
828
- and (not package_names_list or migration[0] in package_names_list)
829
- )
830
- ]
831
-
832
- if prunable_migrations:
833
- click.echo()
834
- click.secho(
835
- "Recorded migrations not in migration files (candidates for pruning):",
836
- fg="yellow",
837
- bold=True,
838
- )
839
- prunable_by_package = {}
840
- for migration in prunable_migrations:
841
- package, name = migration
842
- if package not in prunable_by_package:
843
- prunable_by_package[package] = []
844
- prunable_by_package[package].append(name)
845
-
846
- for package in sorted(prunable_by_package.keys()):
847
- click.secho(f" {package}:", fg="yellow")
848
- for name in sorted(prunable_by_package[package]):
849
- click.echo(f" - {name}")
850
-
851
767
  def show_plan(db_connection: Any, package_names: tuple[str, ...]) -> None:
852
768
  """
853
769
  Show all known migrations (or only those of the specified package_names)
@@ -900,6 +816,108 @@ def show_migrations(
900
816
  show_list(db_connection, package_labels)
901
817
 
902
818
 
819
+ @cli.command()
820
+ @click.option(
821
+ "--yes",
822
+ is_flag=True,
823
+ help="Skip confirmation prompt (for non-interactive use).",
824
+ )
825
+ def prune_migrations(yes: bool) -> None:
826
+ """Show and optionally remove stale migration records from the database."""
827
+ # Load migrations from disk and database
828
+ loader = MigrationLoader(db_connection, ignore_no_migrations=True)
829
+ recorder = MigrationRecorder(db_connection)
830
+ recorded_migrations = recorder.applied_migrations()
831
+
832
+ # Find all prunable migrations (recorded but not on disk)
833
+ all_prunable = [
834
+ migration
835
+ for migration in recorded_migrations
836
+ if migration not in loader.disk_migrations # type: ignore[operator]
837
+ ]
838
+
839
+ if not all_prunable:
840
+ click.echo("No stale migration records found.")
841
+ return
842
+
843
+ # Separate into existing packages vs orphaned packages
844
+ existing_packages = set(loader.migrated_packages)
845
+ prunable_existing: dict[str, list[str]] = {}
846
+ prunable_orphaned: dict[str, list[str]] = {}
847
+
848
+ for migration in all_prunable:
849
+ package, name = migration
850
+ if package in existing_packages:
851
+ if package not in prunable_existing:
852
+ prunable_existing[package] = []
853
+ prunable_existing[package].append(name)
854
+ else:
855
+ if package not in prunable_orphaned:
856
+ prunable_orphaned[package] = []
857
+ prunable_orphaned[package].append(name)
858
+
859
+ # Display what was found
860
+ if prunable_existing:
861
+ click.secho(
862
+ "Stale migration records (from existing packages):",
863
+ fg="yellow",
864
+ bold=True,
865
+ )
866
+ for package in sorted(prunable_existing.keys()):
867
+ click.secho(f" {package}:", fg="yellow")
868
+ for name in sorted(prunable_existing[package]):
869
+ click.echo(f" - {name}")
870
+ click.echo()
871
+
872
+ if prunable_orphaned:
873
+ click.secho(
874
+ "Orphaned migration records (from removed packages):",
875
+ fg="red",
876
+ bold=True,
877
+ )
878
+ for package in sorted(prunable_orphaned.keys()):
879
+ click.secho(f" {package}:", fg="red")
880
+ for name in sorted(prunable_orphaned[package]):
881
+ click.echo(f" - {name}")
882
+ click.echo()
883
+
884
+ total_count = sum(len(migs) for migs in prunable_existing.values()) + sum(
885
+ len(migs) for migs in prunable_orphaned.values()
886
+ )
887
+
888
+ if not yes:
889
+ click.echo(
890
+ f"Found {total_count} stale migration record{'s' if total_count != 1 else ''}."
891
+ )
892
+ click.echo()
893
+
894
+ # Prompt for confirmation if interactive
895
+ if not click.confirm(
896
+ "Do you want to remove these migrations from the database?"
897
+ ):
898
+ return
899
+
900
+ # Actually prune the migrations
901
+ click.secho("Pruning migrations...", bold=True)
902
+
903
+ for package, migration_names in prunable_existing.items():
904
+ for name in sorted(migration_names):
905
+ click.echo(f" Pruning {package}.{name}...", nl=False)
906
+ recorder.record_unapplied(package, name)
907
+ click.echo(" OK")
908
+
909
+ for package, migration_names in prunable_orphaned.items():
910
+ for name in sorted(migration_names):
911
+ click.echo(f" Pruning {package}.{name} (orphaned)...", nl=False)
912
+ recorder.record_unapplied(package, name)
913
+ click.echo(" OK")
914
+
915
+ click.secho(
916
+ f"✓ Removed {total_count} stale migration record{'s' if total_count != 1 else ''}.",
917
+ fg="green",
918
+ )
919
+
920
+
903
921
  @cli.command()
904
922
  @click.argument("package_label")
905
923
  @click.argument("start_migration_name", required=False)
@@ -4,6 +4,8 @@ from collections.abc import Callable
4
4
  from contextlib import nullcontext
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
+ from plain.models.connections import DatabaseConnection
8
+
7
9
  from ..transaction import atomic
8
10
  from .loader import MigrationLoader
9
11
  from .migration import Migration
@@ -22,7 +24,7 @@ class MigrationExecutor:
22
24
 
23
25
  def __init__(
24
26
  self,
25
- connection: BaseDatabaseWrapper,
27
+ connection: BaseDatabaseWrapper | DatabaseConnection,
26
28
  progress_callback: Callable[..., Any] | None = None,
27
29
  ) -> None:
28
30
  self.connection = connection
@@ -126,11 +128,7 @@ class MigrationExecutor:
126
128
  break
127
129
  if migration in migrations_to_run:
128
130
  if "models_registry" not in state.__dict__:
129
- if self.progress_callback:
130
- self.progress_callback("render_start")
131
131
  state.models_registry # Render all -- performance critical
132
- if self.progress_callback:
133
- self.progress_callback("render_success")
134
132
  state = self.apply_migration(state, migration, fake=fake)
135
133
  migrations_to_run.remove(migration)
136
134
 
@@ -145,13 +143,15 @@ class MigrationExecutor:
145
143
  """Run a migration forwards."""
146
144
  migration_recorded = False
147
145
  if self.progress_callback:
148
- self.progress_callback("apply_start", migration, fake)
146
+ self.progress_callback("apply_start", migration=migration, fake=fake)
149
147
  if not fake:
150
148
  # Alright, do it normally
151
149
  with self.connection.schema_editor(
152
150
  atomic=migration.atomic
153
151
  ) as schema_editor:
154
- state = migration.apply(state, schema_editor)
152
+ state = migration.apply(
153
+ state, schema_editor, operation_callback=self.progress_callback
154
+ )
155
155
  if not schema_editor.deferred_sql:
156
156
  self.record_migration(migration)
157
157
  migration_recorded = True
@@ -159,7 +159,7 @@ class MigrationExecutor:
159
159
  self.record_migration(migration)
160
160
  # Report progress
161
161
  if self.progress_callback:
162
- self.progress_callback("apply_success", migration, fake)
162
+ self.progress_callback("apply_success", migration=migration, fake=fake)
163
163
  return state
164
164
 
165
165
  def record_migration(self, migration: Migration) -> None:
@@ -5,6 +5,7 @@ import sys
5
5
  from importlib import import_module, reload
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ from plain.models.connections import DatabaseConnection
8
9
  from plain.models.migrations.graph import MigrationGraph
9
10
  from plain.models.migrations.recorder import MigrationRecorder
10
11
  from plain.packages import packages_registry
@@ -50,7 +51,7 @@ class MigrationLoader:
50
51
 
51
52
  def __init__(
52
53
  self,
53
- connection: BaseDatabaseWrapper | None,
54
+ connection: BaseDatabaseWrapper | DatabaseConnection | None,
54
55
  load: bool = True,
55
56
  ignore_no_migrations: bool = False,
56
57
  replace_migrations: bool = True,
@@ -316,7 +317,9 @@ class MigrationLoader:
316
317
  raise
317
318
  self.graph.ensure_not_cyclic()
318
319
 
319
- def check_consistent_history(self, connection: BaseDatabaseWrapper) -> None:
320
+ def check_consistent_history(
321
+ self, connection: BaseDatabaseWrapper | DatabaseConnection
322
+ ) -> None:
320
323
  """
321
324
  Raise InconsistentMigrationHistory if any applied migrations have
322
325
  unapplied dependencies.
@@ -370,23 +373,3 @@ class MigrationLoader:
370
373
  return self.graph.make_state(
371
374
  nodes=nodes, at_end=at_end, real_packages=self.unmigrated_packages
372
375
  )
373
-
374
- def collect_sql(self, plan: list[Migration]) -> list[str]:
375
- """
376
- Take a migration plan and return a list of collected SQL statements
377
- that represent the best-efforts version of that plan.
378
- """
379
- statements = []
380
- state = None
381
- for migration in plan:
382
- with self.connection.schema_editor(
383
- collect_sql=True, atomic=migration.atomic
384
- ) as schema_editor:
385
- if state is None:
386
- state = self.project_state(
387
- (migration.package_label, migration.name), at_end=False
388
- )
389
-
390
- state = migration.apply(state, schema_editor, collect_sql=True)
391
- statements.extend(schema_editor.collected_sql)
392
- return statements
@@ -1,11 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- from typing import Any
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
5
6
 
6
7
  from plain.models.migrations.utils import get_migration_name_timestamp
7
8
  from plain.models.transaction import atomic
8
9
 
10
+ if TYPE_CHECKING:
11
+ from plain.models.backends.base.schema import BaseDatabaseSchemaEditor
12
+ from plain.models.migrations.state import ProjectState
13
+
9
14
 
10
15
  class Migration:
11
16
  """
@@ -86,8 +91,11 @@ class Migration:
86
91
  return new_state
87
92
 
88
93
  def apply(
89
- self, project_state: Any, schema_editor: Any, collect_sql: bool = False
90
- ) -> Any:
94
+ self,
95
+ project_state: ProjectState,
96
+ schema_editor: BaseDatabaseSchemaEditor,
97
+ operation_callback: Callable[..., Any] | None = None,
98
+ ) -> ProjectState:
91
99
  """
92
100
  Take a project_state representing all migrations prior to this one
93
101
  and a schema_editor for a live database and apply the migration
@@ -97,18 +105,11 @@ class Migration:
97
105
  Migrations.
98
106
  """
99
107
  for operation in self.operations:
100
- # If this operation cannot be represented as SQL, place a comment
101
- # there instead
102
- if collect_sql:
103
- schema_editor.collected_sql.append("--")
104
- schema_editor.collected_sql.append(f"-- {operation.describe()}")
105
- schema_editor.collected_sql.append("--")
106
- if not operation.reduces_to_sql:
107
- schema_editor.collected_sql.append(
108
- "-- THIS OPERATION CANNOT BE WRITTEN AS SQL"
109
- )
110
- continue
111
- collected_sql_before = len(schema_editor.collected_sql)
108
+ # Clear any previous SQL statements before starting this operation
109
+ schema_editor.executed_sql = []
110
+
111
+ if operation_callback:
112
+ operation_callback("operation_start", operation=operation)
112
113
  # Save the state before the operation has run
113
114
  old_state = project_state.clone()
114
115
  operation.state_forwards(self.package_label, project_state)
@@ -128,8 +129,13 @@ class Migration:
128
129
  operation.database_forwards(
129
130
  self.package_label, schema_editor, old_state, project_state
130
131
  )
131
- if collect_sql and collected_sql_before == len(schema_editor.collected_sql):
132
- schema_editor.collected_sql.append("-- (no-op)")
132
+ if operation_callback:
133
+ # Pass the accumulated SQL statements for this operation
134
+ operation_callback(
135
+ "operation_success",
136
+ operation=operation,
137
+ sql_statements=schema_editor.executed_sql,
138
+ )
133
139
  return project_state
134
140
 
135
141
  def suggest_name(self) -> str:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from plain import models
6
+ from plain.models.connections import DatabaseConnection
6
7
  from plain.models.db import DatabaseError
7
8
  from plain.models.meta import Meta
8
9
  from plain.models.registry import ModelsRegistry
@@ -59,7 +60,7 @@ class MigrationRecorder:
59
60
  cls._migration_class = Migration
60
61
  return cls._migration_class
61
62
 
62
- def __init__(self, connection: BaseDatabaseWrapper) -> None:
63
+ def __init__(self, connection: BaseDatabaseWrapper | DatabaseConnection) -> None:
63
64
  self.connection = connection
64
65
 
65
66
  @property
plain/models/preflight.py CHANGED
@@ -244,3 +244,83 @@ class CheckDatabaseTables(PreflightCheck):
244
244
  )
245
245
 
246
246
  return errors
247
+
248
+
249
+ @register_check("models.prunable_migrations")
250
+ class CheckPrunableMigrations(PreflightCheck):
251
+ """Warns about stale migration records in the database."""
252
+
253
+ def run(self) -> list[PreflightResult]:
254
+ # Import here to avoid circular import issues
255
+ from plain.models.migrations.loader import MigrationLoader
256
+ from plain.models.migrations.recorder import MigrationRecorder
257
+
258
+ errors = []
259
+
260
+ # Load migrations from disk and database
261
+ loader = MigrationLoader(db_connection, ignore_no_migrations=True)
262
+ recorder = MigrationRecorder(db_connection)
263
+ recorded_migrations = recorder.applied_migrations()
264
+
265
+ # disk_migrations should not be None after MigrationLoader initialization,
266
+ # but check to satisfy type checker
267
+ if loader.disk_migrations is None:
268
+ return errors
269
+
270
+ # Find all prunable migrations (recorded but not on disk)
271
+ all_prunable = [
272
+ migration
273
+ for migration in recorded_migrations
274
+ if migration not in loader.disk_migrations
275
+ ]
276
+
277
+ if not all_prunable:
278
+ return errors
279
+
280
+ # Separate into existing packages vs orphaned packages
281
+ existing_packages = set(loader.migrated_packages)
282
+ prunable_existing: list[tuple[str, str]] = []
283
+ prunable_orphaned: list[tuple[str, str]] = []
284
+
285
+ for migration in all_prunable:
286
+ package, name = migration
287
+ if package in existing_packages:
288
+ prunable_existing.append(migration)
289
+ else:
290
+ prunable_orphaned.append(migration)
291
+
292
+ # Build the warning message
293
+ total_count = len(all_prunable)
294
+ message_parts = [
295
+ f"Found {total_count} stale migration record{'s' if total_count != 1 else ''} in the database."
296
+ ]
297
+
298
+ if prunable_existing:
299
+ existing_list = ", ".join(
300
+ f"{pkg}.{name}" for pkg, name in prunable_existing[:3]
301
+ )
302
+ if len(prunable_existing) > 3:
303
+ existing_list += f" (and {len(prunable_existing) - 3} more)"
304
+ message_parts.append(f"From existing packages: {existing_list}.")
305
+
306
+ if prunable_orphaned:
307
+ orphaned_list = ", ".join(
308
+ f"{pkg}.{name}" for pkg, name in prunable_orphaned[:3]
309
+ )
310
+ if len(prunable_orphaned) > 3:
311
+ orphaned_list += f" (and {len(prunable_orphaned) - 3} more)"
312
+ message_parts.append(f"From removed packages: {orphaned_list}.")
313
+
314
+ message_parts.append(
315
+ "Run 'plain models prune-migrations' to review and remove them."
316
+ )
317
+
318
+ errors.append(
319
+ PreflightResult(
320
+ fix=" ".join(message_parts),
321
+ id="models.prunable_migrations",
322
+ warning=True,
323
+ )
324
+ )
325
+
326
+ return errors
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.51.1
3
+ Version: 0.53.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,10 +1,10 @@
1
1
  plain/models/AGENTS.md,sha256=xQQW-z-DehnCUyjiGSBfLqUjoSUdo_W1b0JmwYmWieA,209
2
- plain/models/CHANGELOG.md,sha256=Wr9ULasjvH3Shkr1jN75_KeNkRd_ZR8gCzR63-TWvps,23160
2
+ plain/models/CHANGELOG.md,sha256=opeamIL3UOaX7RoG3reojlYb9W0O9k6Yf6SWCuj0cwc,25083
3
3
  plain/models/README.md,sha256=BW4a56bKkf2r-fkfK4SIU92th8h1geBNZ6j-XCv9yE4,8190
4
4
  plain/models/__init__.py,sha256=S0HNxIS4PQ0mSNpo3PNOXExVnHXFmODQvdPhTCVrW-E,2903
5
5
  plain/models/aggregates.py,sha256=z6AjlPlMI-henws9DYPwylL91sfrBPPHR7f9MNb2cjw,8043
6
6
  plain/models/base.py,sha256=uB1OHfyd9hDDqEHck481MCUeXsiwuYmyM7lkS2uQ7ts,64448
7
- plain/models/cli.py,sha256=cI323H4xEKLl1Q62zZQtl_UZBfuE6JMXsq5kSr7Ai0c,41031
7
+ plain/models/cli.py,sha256=SUcDnRL6QT9nCduK_F6cdD4wgoe3TVcCpw8XK5mVGCc,41376
8
8
  plain/models/config.py,sha256=vrrGZnmT0TCR_B-YF0VWZgG3iQ5syijaab2BW7Xfr7A,390
9
9
  plain/models/connections.py,sha256=8DVH6Rl47wbfY_4wO6bGSoTuyJKoKz3tMB8fpe1cpqE,2937
10
10
  plain/models/constants.py,sha256=ndnj9TOTKW0p4YcIPLOLEbsH6mOgFi6B1-rIzr_iwwU,210
@@ -23,7 +23,7 @@ plain/models/lookups.py,sha256=A-3rs3a2Obb-gQPs5RsQiCq-Shj21tznk2LwOeD4RXs,29447
23
23
  plain/models/meta.py,sha256=alEKxzgwxmCRddq9Wonl1bjglDk1-x3CtEwWL3cGy6A,19942
24
24
  plain/models/options.py,sha256=gTso-rRXxV6qbuAprsA1qflkQfsRqmcoETEQJmP0dGk,8593
25
25
  plain/models/otel.py,sha256=6xsu5BhNhGXWRPNQVj0yzhsn1SryOhtCq_qzP8XL-qo,8010
26
- plain/models/preflight.py,sha256=VIiNdWady7uHAEB_XT4_Vkt4M--vGVYL7QEdNMW8Tko,9818
26
+ plain/models/preflight.py,sha256=QmLr51J2GNtvm5y45SQ7oxdqq585VoifgHGgsm1unSM,12695
27
27
  plain/models/query.py,sha256=-ZDJlt7aM5d3Sms_1Eu53h1UGnQvtKinFIhhLBgnfkY,98481
28
28
  plain/models/query_utils.py,sha256=m_3FCbFZJjqkN6tcDuLrgA2n63RxUHOaXb_bJyQsS9k,16171
29
29
  plain/models/registry.py,sha256=dIsgZolh7senbPil6xW_qLFIeIXkCtzysPcrpWdGAAY,8903
@@ -35,11 +35,11 @@ plain/models/backends/utils.py,sha256=wn4U_qrzGQxecJtcXS5VFds2Tg6VoJrs1TB_l8LN9A
35
35
  plain/models/backends/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  plain/models/backends/base/base.py,sha256=465KAc8J2R8eIy3NmJSnrgMPK0K2DwDsylGKEzUkpsI,29099
37
37
  plain/models/backends/base/client.py,sha256=xuMk4iXQjDju5cUspmOSpv6w2JnsB-gJa3RDvoP5I9I,1230
38
- plain/models/backends/base/creation.py,sha256=A3cBPzxjmzEUIrsiuxoI4M3mi-Y9sGcVHoCa-TpQRtM,9857
38
+ plain/models/backends/base/creation.py,sha256=xNrYqxheUiK1-CaKEQa-CGFcVzme0CpApqU--SY9a-4,9667
39
39
  plain/models/backends/base/features.py,sha256=iRfB346Z9Ng3LIMvJAwr-8KxQthlSuvcdo9zBklE1-8,8067
40
40
  plain/models/backends/base/introspection.py,sha256=3yeluBm6UHOlKikgVzGlKnDqBhyaqP-1FoSO4y_c-Hc,7613
41
41
  plain/models/backends/base/operations.py,sha256=8m5XDZ5LtALaXbUX10QPSGDFKce17V5UeJDMkbSZdCs,29324
42
- plain/models/backends/base/schema.py,sha256=t5U45noXOGxQkadh-EBz7BLIdPDg-uai3qUxXYHAkiU,70546
42
+ plain/models/backends/base/schema.py,sha256=H8Fdl4Gqyhc6aLifwYa5PsuPK3i-4oN6NTCR_61tBRs,70206
43
43
  plain/models/backends/base/validation.py,sha256=Ok-TbVVi84zdPprKI--tUxYgoDl2PaxfNDiuYqZQBLM,1341
44
44
  plain/models/backends/mysql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
45
  plain/models/backends/mysql/base.py,sha256=6yNw8m4LpnNOOBVGzGZDPZGDAZKDnDbAWBO62Xg4yJY,14786
@@ -90,13 +90,13 @@ plain/models/functions/window.py,sha256=7rzVWpRMZ-1FptAWN_xPt-onh-tErL5sGfss63IL
90
90
  plain/models/migrations/__init__.py,sha256=ZAQUGrfr_OxYMIO7vUBIHLs_M3oZ4iQSjDzCHRFUdtI,96
91
91
  plain/models/migrations/autodetector.py,sha256=Aack1d0nGPx8UPRea0ZcSouMwE9hsXltOK6LIdlPAVs,62565
92
92
  plain/models/migrations/exceptions.py,sha256=yfimKFZJpyw4IpH2LqZR7hL3J41PBSmXd1HbuWWayWY,1143
93
- plain/models/migrations/executor.py,sha256=zfqQ0cNtoo-UEBAmenuO1_Mfyinj3fSELfKJsduC35Y,7611
93
+ plain/models/migrations/executor.py,sha256=6_BzYIxSf2ayN7ITFo5Yxj0xdoiLAu1kv8TpE-dTttw,7546
94
94
  plain/models/migrations/graph.py,sha256=SFSk2QxG8ni9LxtnfuMHeN2qQeI3lv4jD-R-y_i2VL0,14101
95
- plain/models/migrations/loader.py,sha256=PzbEL_9XxU3XcQAb5O9mH22nuBjvJhr_bkOkYJfglbA,17515
96
- plain/models/migrations/migration.py,sha256=2lLS9XFPPktpwhCUWcWx6eFkPHZQE_KYnIMRSm0CvWo,6896
95
+ plain/models/migrations/loader.py,sha256=vr717xlnahSzmEkAGnIJp9_FFoOIilS_2nwH7Ww-vd4,16837
96
+ plain/models/migrations/migration.py,sha256=I2Nsu-8YlTKXZBGCufzK50XeCIsEMSz-I5Vg3r6Hf9s,6953
97
97
  plain/models/migrations/optimizer.py,sha256=Kk8-KBB51mYiR96hp8F7ZhFvdVg7DYyujpKmp8b9T6A,3385
98
98
  plain/models/migrations/questioner.py,sha256=1uhQqPooxAaJuXe_W_B9X5df8MOoO_G7IFcubQxd6lE,12980
99
- plain/models/migrations/recorder.py,sha256=4rxOUmwJRkyKt5WVTQtnfsXnesV5Ue--1MpXYFltbto,4175
99
+ plain/models/migrations/recorder.py,sha256=e268sBTptmYVqRitlkza_mTdmWklUk-jjpMCMrn6zIY,4252
100
100
  plain/models/migrations/serializer.py,sha256=QWz5PvDSWKgHjae1McEIPwahQTm17ZES2Kf6aVebrr0,13410
101
101
  plain/models/migrations/state.py,sha256=3wmARJZ8XgCnkW-xvg2r2VWrhl1GoLRQmBFIh2EFa3w,35795
102
102
  plain/models/migrations/utils.py,sha256=MkrO_ZHCLQVlhgJPVxIlkZ7tqpQ_BDa6i07T-OCORnE,4796
@@ -116,8 +116,8 @@ plain/models/sql/where.py,sha256=GeTopzVmvZTqm2NTS32ok0rHbNgoEREUVtsD7usrlCA,138
116
116
  plain/models/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
117
117
  plain/models/test/pytest.py,sha256=sZtHzmNoqIFb7csZ8fqbRwljQ0vWrcMcDm6Wk_0g-uk,3924
118
118
  plain/models/test/utils.py,sha256=eduH039cMVixWORfsUr7qkk0YDkTHPXFZklm9lzY474,540
119
- plain_models-0.51.1.dist-info/METADATA,sha256=KnNRGQxkypz9IZdzymjZYxCqAeKAfnNJfI7bU886uRc,8502
120
- plain_models-0.51.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
121
- plain_models-0.51.1.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
122
- plain_models-0.51.1.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
123
- plain_models-0.51.1.dist-info/RECORD,,
119
+ plain_models-0.53.0.dist-info/METADATA,sha256=EiyfdHYM2rkhwORfwuTzZQBmq7js5m7vqWmITs43UHA,8502
120
+ plain_models-0.53.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
121
+ plain_models-0.53.0.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
122
+ plain_models-0.53.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
123
+ plain_models-0.53.0.dist-info/RECORD,,