plain.models 0.52.0__tar.gz → 0.53.0__tar.gz

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.
Files changed (138) hide show
  1. {plain_models-0.52.0 → plain_models-0.53.0}/PKG-INFO +1 -1
  2. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/CHANGELOG.md +13 -0
  3. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/creation.py +0 -1
  4. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/cli.py +102 -90
  5. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/executor.py +3 -1
  6. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/loader.py +5 -2
  7. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/recorder.py +2 -1
  8. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/preflight.py +80 -0
  9. {plain_models-0.52.0 → plain_models-0.53.0}/pyproject.toml +1 -1
  10. {plain_models-0.52.0 → plain_models-0.53.0}/.gitignore +0 -0
  11. {plain_models-0.52.0 → plain_models-0.53.0}/LICENSE +0 -0
  12. {plain_models-0.52.0 → plain_models-0.53.0}/README.md +0 -0
  13. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/AGENTS.md +0 -0
  14. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/README.md +0 -0
  15. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/__init__.py +0 -0
  16. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/aggregates.py +0 -0
  17. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/__init__.py +0 -0
  18. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/__init__.py +0 -0
  19. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/base.py +0 -0
  20. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/client.py +0 -0
  21. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/features.py +0 -0
  22. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/introspection.py +0 -0
  23. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/operations.py +0 -0
  24. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/schema.py +0 -0
  25. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/base/validation.py +0 -0
  26. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/ddl_references.py +0 -0
  27. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/__init__.py +0 -0
  28. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/base.py +0 -0
  29. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/client.py +0 -0
  30. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/compiler.py +0 -0
  31. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/creation.py +0 -0
  32. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/features.py +0 -0
  33. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/introspection.py +0 -0
  34. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/operations.py +0 -0
  35. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/schema.py +0 -0
  36. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/mysql/validation.py +0 -0
  37. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/__init__.py +0 -0
  38. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/base.py +0 -0
  39. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/client.py +0 -0
  40. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/creation.py +0 -0
  41. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/features.py +0 -0
  42. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/introspection.py +0 -0
  43. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/operations.py +0 -0
  44. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/postgresql/schema.py +0 -0
  45. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/__init__.py +0 -0
  46. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/_functions.py +0 -0
  47. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/base.py +0 -0
  48. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/client.py +0 -0
  49. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/creation.py +0 -0
  50. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/features.py +0 -0
  51. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/introspection.py +0 -0
  52. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/operations.py +0 -0
  53. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/sqlite3/schema.py +0 -0
  54. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backends/utils.py +0 -0
  55. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backups/__init__.py +0 -0
  56. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backups/cli.py +0 -0
  57. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backups/clients.py +0 -0
  58. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/backups/core.py +0 -0
  59. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/base.py +0 -0
  60. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/config.py +0 -0
  61. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/connections.py +0 -0
  62. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/constants.py +0 -0
  63. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/constraints.py +0 -0
  64. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/database_url.py +0 -0
  65. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/db.py +0 -0
  66. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/default_settings.py +0 -0
  67. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/deletion.py +0 -0
  68. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/entrypoints.py +0 -0
  69. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/enums.py +0 -0
  70. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/exceptions.py +0 -0
  71. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/expressions.py +0 -0
  72. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/__init__.py +0 -0
  73. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/json.py +0 -0
  74. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/mixins.py +0 -0
  75. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/related.py +0 -0
  76. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/related_descriptors.py +0 -0
  77. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/related_lookups.py +0 -0
  78. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/related_managers.py +0 -0
  79. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/fields/reverse_related.py +0 -0
  80. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/forms.py +0 -0
  81. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/__init__.py +0 -0
  82. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/comparison.py +0 -0
  83. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/datetime.py +0 -0
  84. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/math.py +0 -0
  85. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/mixins.py +0 -0
  86. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/text.py +0 -0
  87. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/functions/window.py +0 -0
  88. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/indexes.py +0 -0
  89. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/lookups.py +0 -0
  90. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/meta.py +0 -0
  91. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/__init__.py +0 -0
  92. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/autodetector.py +0 -0
  93. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/exceptions.py +0 -0
  94. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/graph.py +0 -0
  95. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/migration.py +0 -0
  96. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/operations/__init__.py +0 -0
  97. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/operations/base.py +0 -0
  98. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/operations/fields.py +0 -0
  99. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/operations/models.py +0 -0
  100. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/operations/special.py +0 -0
  101. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/optimizer.py +0 -0
  102. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/questioner.py +0 -0
  103. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/serializer.py +0 -0
  104. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/state.py +0 -0
  105. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/utils.py +0 -0
  106. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/migrations/writer.py +0 -0
  107. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/options.py +0 -0
  108. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/otel.py +0 -0
  109. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/query.py +0 -0
  110. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/query_utils.py +0 -0
  111. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/registry.py +0 -0
  112. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/__init__.py +0 -0
  113. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/compiler.py +0 -0
  114. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/constants.py +0 -0
  115. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/datastructures.py +0 -0
  116. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/query.py +0 -0
  117. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/subqueries.py +0 -0
  118. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/sql/where.py +0 -0
  119. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/test/__init__.py +0 -0
  120. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/test/pytest.py +0 -0
  121. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/test/utils.py +0 -0
  122. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/transaction.py +0 -0
  123. {plain_models-0.52.0 → plain_models-0.53.0}/plain/models/utils.py +0 -0
  124. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  125. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  126. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  127. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  128. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/migrations/__init__.py +0 -0
  129. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/examples/models.py +0 -0
  130. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/settings.py +0 -0
  131. {plain_models-0.52.0 → plain_models-0.53.0}/tests/app/urls.py +0 -0
  132. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_database_url.py +0 -0
  133. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_delete_behaviors.py +0 -0
  134. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_exceptions.py +0 -0
  135. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_manager_assignment.py +0 -0
  136. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_models.py +0 -0
  137. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_related_descriptors.py +0 -0
  138. {plain_models-0.52.0 → plain_models-0.53.0}/tests/test_related_manager_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.52.0
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,5 +1,18 @@
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
+
3
16
  ## [0.52.0](https://github.com/dropseed/plain/releases/plain-models@0.52.0) (2025-10-10)
4
17
 
5
18
  ### What's changed
@@ -59,7 +59,6 @@ class BaseDatabaseCreation:
59
59
  plan=False,
60
60
  check_unapplied=False,
61
61
  backup=False,
62
- prune=False,
63
62
  no_input=True,
64
63
  atomic_batch=False, # No need for atomic batch when creating test database
65
64
  quiet=verbosity < 2, # Show migration output when verbosity is 2+
@@ -366,11 +366,6 @@ def makemigrations(
366
366
  default=None,
367
367
  help="Explicitly enable/disable pre-migration backups.",
368
368
  )
369
- @click.option(
370
- "--prune",
371
- is_flag=True,
372
- help="Delete nonexistent migrations from the plainmigrations table.",
373
- )
374
369
  @click.option(
375
370
  "--no-input",
376
371
  "--noinput",
@@ -395,7 +390,6 @@ def migrate(
395
390
  plan: bool,
396
391
  check_unapplied: bool,
397
392
  backup: bool | None,
398
- prune: bool,
399
393
  no_input: bool,
400
394
  atomic_batch: bool | None,
401
395
  quiet: bool,
@@ -519,58 +513,6 @@ def migrate(
519
513
  else:
520
514
  targets = list(executor.loader.graph.leaf_nodes())
521
515
 
522
- if prune:
523
- if not package_label:
524
- raise click.ClickException(
525
- "Migrations can be pruned only when a package is specified."
526
- )
527
- if not quiet:
528
- click.secho("Pruning migrations:", bold=True)
529
- to_prune = set(executor.loader.applied_migrations) - set( # type: ignore[arg-type]
530
- executor.loader.disk_migrations # type: ignore[arg-type]
531
- )
532
- squashed_migrations_with_deleted_replaced_migrations = [
533
- migration_key
534
- for migration_key, migration_obj in executor.loader.replacements.items()
535
- if any(replaced in to_prune for replaced in migration_obj.replaces)
536
- ]
537
- if squashed_migrations_with_deleted_replaced_migrations:
538
- if not quiet:
539
- click.echo(
540
- click.style(
541
- " Cannot use --prune because the following squashed "
542
- "migrations have their 'replaces' attributes and may not "
543
- "be recorded as applied:",
544
- fg="yellow",
545
- )
546
- )
547
- for migration in squashed_migrations_with_deleted_replaced_migrations:
548
- package, name = migration
549
- click.echo(f" {package}.{name}")
550
- click.echo(
551
- click.style(
552
- " Re-run `plain migrate` if they are not marked as "
553
- "applied, and remove 'replaces' attributes in their "
554
- "Migration classes.",
555
- fg="yellow",
556
- )
557
- )
558
- else:
559
- to_prune = sorted(
560
- migration for migration in to_prune if migration[0] == package_label
561
- )
562
- if to_prune:
563
- for migration in to_prune:
564
- package, name = migration
565
- if not quiet:
566
- click.echo(f" Pruning {package}.{name}...", nl=False)
567
- executor.recorder.record_unapplied(package, name)
568
- if not quiet:
569
- click.echo(" OK")
570
- else:
571
- if not quiet:
572
- click.echo(" No migrations to prune.")
573
-
574
516
  migration_plan = executor.migration_plan(targets)
575
517
 
576
518
  if plan:
@@ -596,9 +538,6 @@ def migrate(
596
538
  sys.exit(1)
597
539
  return
598
540
 
599
- if prune:
600
- return
601
-
602
541
  # Print some useful info
603
542
  if not quiet:
604
543
  if target_package_labels_only:
@@ -825,35 +764,6 @@ def show_migrations(
825
764
  if not shown:
826
765
  click.secho(" (no migrations)", fg="red")
827
766
 
828
- # Find recorded migrations that aren't in the graph (prunable)
829
- prunable_migrations = [
830
- migration
831
- for migration in recorded_migrations
832
- if (
833
- migration not in loader.disk_migrations # type: ignore[operator]
834
- and (not package_names_list or migration[0] in package_names_list)
835
- )
836
- ]
837
-
838
- if prunable_migrations:
839
- click.echo()
840
- click.secho(
841
- "Recorded migrations not in migration files (candidates for pruning):",
842
- fg="yellow",
843
- bold=True,
844
- )
845
- prunable_by_package = {}
846
- for migration in prunable_migrations:
847
- package, name = migration
848
- if package not in prunable_by_package:
849
- prunable_by_package[package] = []
850
- prunable_by_package[package].append(name)
851
-
852
- for package in sorted(prunable_by_package.keys()):
853
- click.secho(f" {package}:", fg="yellow")
854
- for name in sorted(prunable_by_package[package]):
855
- click.echo(f" - {name}")
856
-
857
767
  def show_plan(db_connection: Any, package_names: tuple[str, ...]) -> None:
858
768
  """
859
769
  Show all known migrations (or only those of the specified package_names)
@@ -906,6 +816,108 @@ def show_migrations(
906
816
  show_list(db_connection, package_labels)
907
817
 
908
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
+
909
921
  @cli.command()
910
922
  @click.argument("package_label")
911
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
@@ -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.
@@ -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
@@ -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
  [project]
2
2
  name = "plain.models"
3
- version = "0.52.0"
3
+ version = "0.53.0"
4
4
  description = "Model your data and store it in a database."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
File without changes
File without changes
File without changes