plain.postgres 0.94.2__tar.gz → 0.95.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 (141) hide show
  1. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/PKG-INFO +90 -23
  2. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/CHANGELOG.md +28 -0
  3. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/README.md +89 -22
  4. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/__init__.py +2 -5
  5. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
  6. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/base.py +18 -5
  7. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/connection.py +4 -1
  8. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/analysis.py +50 -6
  9. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/fixes.py +57 -0
  10. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/planning.py +17 -1
  11. plain_postgres-0.95.0/plain/postgres/deletion.py +48 -0
  12. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related.py +28 -20
  13. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_related.py +12 -14
  14. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/schema.py +2 -0
  15. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/serializer.py +11 -0
  16. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/query.py +13 -14
  17. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/types.pyi +3 -2
  18. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/pyproject.toml +1 -1
  19. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +2 -29
  20. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -9
  21. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +1 -1
  22. plain_postgres-0.95.0/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +81 -0
  23. plain_postgres-0.95.0/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +47 -0
  24. plain_postgres-0.95.0/tests/app/examples/migrations/0010_hideableitem.py +20 -0
  25. plain_postgres-0.94.2/tests/app/examples/models.py → plain_postgres-0.95.0/tests/app/examples/models/__init__.py +2 -86
  26. plain_postgres-0.95.0/tests/app/examples/models/delete.py +194 -0
  27. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/conftest_convergence.py +15 -0
  28. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_fk.py +142 -1
  29. plain_postgres-0.95.0/tests/test_delete_behaviors.py +563 -0
  30. plain_postgres-0.94.2/plain/postgres/deletion.py +0 -476
  31. plain_postgres-0.94.2/tests/test_delete_behaviors.py +0 -70
  32. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/.gitignore +0 -0
  33. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/CLAUDE.md +0 -0
  34. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/LICENSE +0 -0
  35. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/README.md +0 -0
  36. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  37. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/aggregates.py +0 -0
  38. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/__init__.py +0 -0
  39. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/converge.py +0 -0
  40. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/core.py +0 -0
  41. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/diagnose.py +0 -0
  42. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/migrations.py +0 -0
  43. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/schema.py +0 -0
  44. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/sync.py +0 -0
  45. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/config.py +0 -0
  46. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/connections.py +0 -0
  47. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/constants.py +0 -0
  48. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/constraints.py +0 -0
  49. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/__init__.py +0 -0
  50. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/database_url.py +0 -0
  51. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/db.py +0 -0
  52. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/ddl.py +0 -0
  53. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/default_settings.py +0 -0
  54. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/dialect.py +0 -0
  55. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/entrypoints.py +0 -0
  56. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/enums.py +0 -0
  57. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/exceptions.py +0 -0
  58. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/expressions.py +0 -0
  59. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/__init__.py +0 -0
  60. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/encrypted.py +0 -0
  61. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/json.py +0 -0
  62. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/mixins.py +0 -0
  63. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_descriptors.py +0 -0
  64. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_lookups.py +0 -0
  65. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_managers.py +0 -0
  66. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  67. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/timezones.py +0 -0
  68. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/forms.py +0 -0
  69. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/__init__.py +0 -0
  70. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/comparison.py +0 -0
  71. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/datetime.py +0 -0
  72. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/math.py +0 -0
  73. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/mixins.py +0 -0
  74. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/text.py +0 -0
  75. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/window.py +0 -0
  76. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/indexes.py +0 -0
  77. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/__init__.py +0 -0
  78. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/health.py +0 -0
  79. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/lookups.py +0 -0
  80. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/meta.py +0 -0
  81. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/__init__.py +0 -0
  82. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/autodetector.py +0 -0
  83. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/exceptions.py +0 -0
  84. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/executor.py +0 -0
  85. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/graph.py +0 -0
  86. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/loader.py +0 -0
  87. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/migration.py +0 -0
  88. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  89. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/base.py +0 -0
  90. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/fields.py +0 -0
  91. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/models.py +0 -0
  92. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/special.py +0 -0
  93. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/optimizer.py +0 -0
  94. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/questioner.py +0 -0
  95. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/recorder.py +0 -0
  96. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/state.py +0 -0
  97. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/utils.py +0 -0
  98. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/writer.py +0 -0
  99. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/options.py +0 -0
  100. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/otel.py +0 -0
  101. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/preflight.py +0 -0
  102. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/query_utils.py +0 -0
  103. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/registry.py +0 -0
  104. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/schema.py +0 -0
  105. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/__init__.py +0 -0
  106. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/compiler.py +0 -0
  107. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/constants.py +0 -0
  108. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/datastructures.py +0 -0
  109. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/query.py +0 -0
  110. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/where.py +0 -0
  111. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/__init__.py +0 -0
  112. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/pytest.py +0 -0
  113. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/utils.py +0 -0
  114. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/transaction.py +0 -0
  115. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/types.py +0 -0
  116. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/utils.py +0 -0
  117. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  118. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  119. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  120. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  121. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/__init__.py +0 -0
  122. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/settings.py +0 -0
  123. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/urls.py +0 -0
  124. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_connection_isolation.py +0 -0
  125. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_connection_lifecycle.py +0 -0
  126. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence.py +0 -0
  127. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_constraints.py +0 -0
  128. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_indexes.py +0 -0
  129. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_nullability.py +0 -0
  130. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_database_url.py +0 -0
  131. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_encrypted_fields.py +0 -0
  132. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_exceptions.py +0 -0
  133. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_introspection.py +0 -0
  134. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_iterator.py +0 -0
  135. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_manager_assignment.py +0 -0
  136. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_migration_executor.py +0 -0
  137. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_models.py +0 -0
  138. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_read_only_transactions.py +0 -0
  139. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_related_descriptors.py +0 -0
  140. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_related_manager_api.py +0 -0
  141. {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_schema_normalize_type.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.94.2
3
+ Version: 0.95.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-Expression: BSD-3-Clause
@@ -19,7 +19,8 @@ Description-Content-Type: text/markdown
19
19
  - [Querying](#querying)
20
20
  - [Schema management](#schema-management)
21
21
  - [Syncing](#syncing)
22
- - [Migrations](#migrations)
22
+ - [Structural migrations](#structural-migrations)
23
+ - [Data migrations](#data-migrations)
23
24
  - [Convergence](#convergence)
24
25
  - [Fields](#fields)
25
26
  - [Relationships](#relationships)
@@ -445,20 +446,42 @@ Read-only mode must be set outside a transaction — calling it inside `atomic()
445
446
 
446
447
  ## Schema management
447
448
 
448
- Your database schema is managed by two complementary systems: **migrations** and **convergence**. Migrations handle structural changes creating tables, adding columns, renaming things. Convergence handles everything declarative — indexes, constraints, foreign keys, and NOT NULL enforcement. You declare these on your models and convergence makes the database match.
449
+ Schema changes fall into three categories, each with a different author and apply model:
449
450
 
450
- ```
451
- Migrations Convergence
452
- (imperative, ordered) (declarative, idempotent)
453
- ───────────────────────────── ─────────────────────────────
454
- Create / drop tables Indexes
455
- Add / remove / rename columns Check constraints
456
- Change column types Unique constraints
457
- Data migrations (RunPython) Foreign key constraints
458
- Custom SQL (RunSQL) NOT NULL enforcement
459
- ```
451
+ - **Convergence** — declarative properties like indexes, constraints, NOT NULL, and FK `on_delete`. Derived from model definitions and applied automatically using online-safe DDL (`CREATE INDEX CONCURRENTLY`, `NOT VALID` + `VALIDATE`, etc.). The framework owns the safe apply pattern.
452
+ - **Structural migrations** — tables, columns, renames, column type changes. Framework-generated from the model diff, but you review them and decide when to deploy (a column type change can rewrite the table; a column drop is destructive).
453
+ - **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
454
+
455
+ | Change | Category | Safe apply pattern |
456
+ | ------------------------------------- | -------------------- | ----------------------------------------------- |
457
+ | Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
458
+ | Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
459
+ | Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
460
+ | Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
461
+ | Create / drop table | Structural migration | framework-generated, you review |
462
+ | Add / drop / rename column | Structural migration | framework-generated, you review |
463
+ | Column type change | Structural migration | may rewrite the table |
464
+ | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
465
+ | One-time cleanup, seeding | Data migration | you author |
466
+
467
+ **The principle: who authors the change, and can the framework guarantee safety?** If the framework can derive both the change and a universally-safe apply pattern from model definitions, it belongs to convergence. If the framework can generate the DDL but safety depends on context (table size, deploy timing, destructiveness), it's a structural migration — you review it before deploying. If only you know what to do, it's a data migration.
468
+
469
+ Many convergence-managed changes produce DB-enforced behavior — cascading deletes (`ON DELETE`), validation (`CHECK`, `NOT NULL`), default generation. Whether a change is "behavioral" doesn't determine the category; whether the framework can guarantee a safe apply does.
470
+
471
+ | Property | Convergence | Migrations |
472
+ | ---------------- | --------------------------------------------------------------- | --------------------------------------- |
473
+ | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
474
+ | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
475
+ | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
476
+ | Reversible | Implicit (roll back code, re-sync re-derives) | No — forward-only, fix-forward |
477
+ | Files on disk | None — derived from models live | `.py` files in `migrations/` |
478
+ | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
479
+
480
+ **Drift correction is a convergence-only behavior.** Convergence re-runs on every `sync` and compares models against the database. An index created manually outside a model declaration will be dropped on the next run because models are the source of truth. Migrations don't behave this way — once applied, they're recorded and never re-applied.
481
+
482
+ **Caveats.** The safety promise isn't absolute. Structural migrations aren't lint-checked yet: adding a column with a volatile default (`gen_random_uuid()`, `now()`) on a large table will rewrite it without warning. Review structural migrations before deploying to production.
460
483
 
461
- This split exists because structural changes (adding a column) must happen in a specific order and can't be retried, while declarative objects (indexes, constraints) can be compared against model definitions and fixed automatically even if a previous attempt failed.
484
+ **Out of scope for convergence.** Triggers, views, stored procedures, and other non-standard DDL stay outside convergence — it won't create them from models, and it won't drop them if they exist. Manage them with `RunSQL` data migrations.
462
485
 
463
486
  ### Syncing
464
487
 
@@ -492,9 +515,9 @@ In development (`DEBUG=True`), sync auto-generates migrations before applying th
492
515
  | `plain postgres schema --json` | Machine-readable schema output |
493
516
  | `plain postgres converge` | Run convergence alone (advanced) |
494
517
 
495
- ### Migrations
518
+ ### Structural migrations
496
519
 
497
- Migrations track structural changes to your models. They are Python files stored in your app's `migrations/` directory.
520
+ Structural migrations are framework-generated from model changes tables, columns, renames, column type changes. They are Python files stored in your app's `migrations/` directory. You don't author them by hand; you edit models and run `plain migrations create`.
498
521
 
499
522
  ```bash
500
523
  plain migrations create
@@ -504,12 +527,9 @@ Key flags:
504
527
 
505
528
  - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
506
529
  - `--check` — Exit non-zero if migrations are needed (for CI)
507
- - `--empty <package>` — Create an empty migration for custom data migrations
508
530
  - `--name <name>` — Set the migration filename
509
531
 
510
- Only write migrations by hand if they are custom data migrations.
511
-
512
- Other migration commands:
532
+ Shared commands (apply equally to structural and data migrations):
513
533
 
514
534
  | Command | Purpose |
515
535
  | ------------------------------------------- | ---------------------------------------------------- |
@@ -572,6 +592,53 @@ Over time a package can accumulate dozens of migrations. Once **every environmen
572
592
  - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
573
593
  - If CI runs `migrations create --check` or `migrations apply --check`, the reset PR must be merged and deployed before those checks pass in other branches.
574
594
 
595
+ ### Data migrations
596
+
597
+ Data migrations are user-authored operations — backfills, transformations, seeding, cleanup. The framework has no way to derive these from models; you write the logic and it gets sequenced in timestamp order alongside structural migrations.
598
+
599
+ Create an empty migration to author one:
600
+
601
+ ```bash
602
+ plain migrations create --empty <package>
603
+ ```
604
+
605
+ Add a `RunPython` or `RunSQL` operation inside:
606
+
607
+ ```python
608
+ def forwards(models, schema_editor):
609
+ User = models.get_model("users", "User")
610
+ # Use .update() for batch SQL — a row-by-row save loop can lock a large table.
611
+ User.query.filter(full_name="").update(full_name="pending")
612
+ ```
613
+
614
+ For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
615
+
616
+ See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `squash`, `prune`).
617
+
618
+ #### Cascading deletes inside data migrations
619
+
620
+ `Model.delete()` and `QuerySet.delete()` rely on Postgres `ON DELETE` clauses (`CASCADE`, `SET NULL`, `RESTRICT`) — Plain does not walk relationships in Python.
621
+
622
+ Foreign key constraints are added by **convergence** (step 3 of `postgres sync`), not by migrations. During a fresh `migrations apply` (before convergence has run), FK constraints don't exist yet. A `RunPython` data migration that calls `.delete()` on a model with cascading children will:
623
+
624
+ - Delete only the parent row — children become orphans
625
+ - Cause convergence's later `VALIDATE CONSTRAINT` step to fail because of the orphans
626
+
627
+ This only affects fresh applies. Existing databases keep their FK constraints across syncs, so incremental data migrations are unaffected.
628
+
629
+ **If your data migration needs to delete rows that have cascading children, handle the cascade explicitly:**
630
+
631
+ ```python
632
+ def forwards(models, schema_editor):
633
+ Parent = models.get_model("myapp", "Parent")
634
+ Child = models.get_model("myapp", "Child")
635
+ # Delete children first, then parent — explicit, no constraint reliance
636
+ Child.query.filter(parent__name="old").delete()
637
+ Parent.query.filter(name="old").delete()
638
+ ```
639
+
640
+ Or use `RunSQL` with explicit cascade if the relationship is large.
641
+
575
642
  ### Convergence
576
643
 
577
644
  Convergence compares the indexes, constraints, foreign keys, and nullability declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
@@ -939,14 +1006,14 @@ model_options = postgres.Options(
939
1006
 
940
1007
  #### Choose `on_delete` deliberately
941
1008
 
942
- CASCADE for owned children, PROTECT for referenced data, SET_NULL for optional references.
1009
+ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references.
943
1010
 
944
1011
  ```python
945
1012
  # Bad — blindly using CASCADE everywhere
946
1013
  company: Company = types.ForeignKeyField("Company", on_delete=postgres.CASCADE) # deleting company deletes invoices!
947
1014
 
948
- # Good — protect referenced data
949
- company: Company = types.ForeignKeyField("Company", on_delete=postgres.PROTECT)
1015
+ # Good — block the delete while invoices reference the company
1016
+ company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
950
1017
  ```
951
1018
 
952
1019
  #### No `allow_null` on string fields
@@ -1,5 +1,33 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.95.0](https://github.com/dropseed/plain/releases/plain-postgres@0.95.0) (2026-04-14)
4
+
5
+ ### What's changed
6
+
7
+ - **Deletes now run as a single DELETE statement and cascade through Postgres `ON DELETE` clauses.** The Python `Collector` (which walked relationships in Python to fire per-table DELETEs) has been removed. `Model.delete()` and `QuerySet.delete()` issue one statement and let Postgres do the cascading via the FK actions installed by convergence. The old Collector path required N queries per cascade; the new path requires exactly one. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
8
+ - **`Model.delete()` and `QuerySet.delete()` now return `int`** (the directly-deleted row count). They previously returned a `(count, {label: count})` tuple — Postgres does not report cascaded counts, and the per-label dict was Collector-only bookkeeping. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
9
+ - **`on_delete` constants are now `OnDelete` instances, not bare functions.** `ForeignKeyField` rejects any non-`OnDelete` value at construction, and the declared action is emitted as the FK's Postgres `ON DELETE` clause. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
10
+ - **Removed `PROTECT`, `SET()`, `SET_DEFAULT`, `ProtectedError`, and `RestrictedError`.** `PROTECT` and `SET(callable)` had no Postgres equivalent (prefer `RESTRICT`). `SET_DEFAULT` was removed because Plain does not currently persist Python model defaults as DB-level column defaults — emitting `ON DELETE SET DEFAULT` would set children to `NULL` on bypass-the-ORM deletes, contradicting the model's intent. `SET_DEFAULT` will return once DB-level defaults are supported. `RESTRICT` now surfaces as `psycopg.errors.IntegrityError` directly. ([670dab428ad2](https://github.com/dropseed/plain/commit/670dab428ad2), [29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
11
+ - **Renamed `DO_NOTHING` to `NO_ACTION`** to match Postgres's SQL term. No behavior change. ([5fcf8aa9ced3](https://github.com/dropseed/plain/commit/5fcf8aa9ced3))
12
+ - **Convergence now owns FK `on_delete` drift.** `plain postgres sync` introspects `pg_constraint.confdeltype`, compares it to the declared `on_delete`, and replaces the constraint when they drift. Replacement uses `ADD CONSTRAINT … NOT VALID` + `VALIDATE` to minimize lock time. Existing databases auto-upgrade on their next sync. ([211840197e1e](https://github.com/dropseed/plain/commit/211840197e1e))
13
+ - **Preflight rejects `db_constraint=False` with a non-`NO_ACTION` `on_delete`.** Without a constraint there is no place to attach a deletion action. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
14
+ - **Tightened types.** `on_delete` is now typed as `OnDelete` everywhere (was `Any`). `ForeignKeyField.remote_field` narrows to `ForeignKeyRel` so `remote_field.on_delete` is non-optional. `ForeignObjectRel`, `ForeignKeyRel`, and `ManyToManyRel` `__init__` are kwarg-only. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
15
+ - **Known limitation: data migrations + cascading deletes.** On a fresh `migrations apply`, FK constraints don't exist yet (they're added by convergence in step 3 of `postgres sync`). A `RunPython` data migration that calls `.delete()` on a parent with cascading children will orphan the children, and the subsequent convergence `VALIDATE` will fail. Existing databases are unaffected. Documented with a workaround in the Postgres README (delete children explicitly first, or use `RunSQL`). ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
16
+ - **Rewrote the Schema management docs** to distinguish convergence, structural migrations, and data migrations by who authors the change and whether the framework can guarantee a safe apply. Added a per-change-type table covering safe apply patterns (`CREATE INDEX CONCURRENTLY`, `NOT VALID` + `VALIDATE`, etc.) and split the Migrations section into “Structural migrations” and “Data migrations.” ([8ae39e2cef78](https://github.com/dropseed/plain/commit/8ae39e2cef78), [49d2b2452dea](https://github.com/dropseed/plain/commit/49d2b2452dea))
17
+
18
+ ### Upgrade instructions
19
+
20
+ - **Adapt callers of `.delete()`.** `.delete()` now returns an `int`, not a `(count, by_label)` tuple.
21
+ - Before: `count, _ = qs.delete()` or `count = qs.delete()[0]`
22
+ - After: `count = qs.delete()`
23
+ - **Rename `DO_NOTHING` to `NO_ACTION`** at all import and usage sites. Regenerate or hand-edit migration files that reference `DO_NOTHING`.
24
+ - **Replace `PROTECT` with `RESTRICT`.** Catch `psycopg.errors.IntegrityError` instead of `ProtectedError` / `RestrictedError`.
25
+ - **Replace `SET(callable)` usages.** There is no one-line equivalent — the Python-callable path doesn't exist in Postgres. Either switch to a supported action (`SET_NULL`, `RESTRICT`, `CASCADE`, `NO_ACTION`) or handle the affected rows explicitly before deletion.
26
+ - **Replace `SET_DEFAULT` usages.** Pick a different `on_delete`, or set the default explicitly in application code before deletion. `SET_DEFAULT` will return once Plain persists column defaults.
27
+ - **Run `plain postgres sync`** after upgrading. Convergence will install the correct `ON DELETE` clauses on existing FKs — no migration file, no manual step.
28
+ - **If you set `db_constraint=False` on a FK with a non-`NO_ACTION` `on_delete`**, change the action to `NO_ACTION` — preflight will now fail otherwise.
29
+ - **Review `RunPython` migrations that call `.delete()` on parents with cascading children.** On a fresh `migrations apply` before convergence runs, children become orphans and break the subsequent `VALIDATE`. Delete children explicitly first, or use `RunSQL`.
30
+
3
31
  ## [0.94.2](https://github.com/dropseed/plain/releases/plain-postgres@0.94.2) (2026-04-13)
4
32
 
5
33
  ### What's changed
@@ -7,7 +7,8 @@
7
7
  - [Querying](#querying)
8
8
  - [Schema management](#schema-management)
9
9
  - [Syncing](#syncing)
10
- - [Migrations](#migrations)
10
+ - [Structural migrations](#structural-migrations)
11
+ - [Data migrations](#data-migrations)
11
12
  - [Convergence](#convergence)
12
13
  - [Fields](#fields)
13
14
  - [Relationships](#relationships)
@@ -433,20 +434,42 @@ Read-only mode must be set outside a transaction — calling it inside `atomic()
433
434
 
434
435
  ## Schema management
435
436
 
436
- Your database schema is managed by two complementary systems: **migrations** and **convergence**. Migrations handle structural changes creating tables, adding columns, renaming things. Convergence handles everything declarative — indexes, constraints, foreign keys, and NOT NULL enforcement. You declare these on your models and convergence makes the database match.
437
+ Schema changes fall into three categories, each with a different author and apply model:
437
438
 
438
- ```
439
- Migrations Convergence
440
- (imperative, ordered) (declarative, idempotent)
441
- ───────────────────────────── ─────────────────────────────
442
- Create / drop tables Indexes
443
- Add / remove / rename columns Check constraints
444
- Change column types Unique constraints
445
- Data migrations (RunPython) Foreign key constraints
446
- Custom SQL (RunSQL) NOT NULL enforcement
447
- ```
439
+ - **Convergence** — declarative properties like indexes, constraints, NOT NULL, and FK `on_delete`. Derived from model definitions and applied automatically using online-safe DDL (`CREATE INDEX CONCURRENTLY`, `NOT VALID` + `VALIDATE`, etc.). The framework owns the safe apply pattern.
440
+ - **Structural migrations** — tables, columns, renames, column type changes. Framework-generated from the model diff, but you review them and decide when to deploy (a column type change can rewrite the table; a column drop is destructive).
441
+ - **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
442
+
443
+ | Change | Category | Safe apply pattern |
444
+ | ------------------------------------- | -------------------- | ----------------------------------------------- |
445
+ | Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
446
+ | Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
447
+ | Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
448
+ | Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
449
+ | Create / drop table | Structural migration | framework-generated, you review |
450
+ | Add / drop / rename column | Structural migration | framework-generated, you review |
451
+ | Column type change | Structural migration | may rewrite the table |
452
+ | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
453
+ | One-time cleanup, seeding | Data migration | you author |
454
+
455
+ **The principle: who authors the change, and can the framework guarantee safety?** If the framework can derive both the change and a universally-safe apply pattern from model definitions, it belongs to convergence. If the framework can generate the DDL but safety depends on context (table size, deploy timing, destructiveness), it's a structural migration — you review it before deploying. If only you know what to do, it's a data migration.
456
+
457
+ Many convergence-managed changes produce DB-enforced behavior — cascading deletes (`ON DELETE`), validation (`CHECK`, `NOT NULL`), default generation. Whether a change is "behavioral" doesn't determine the category; whether the framework can guarantee a safe apply does.
458
+
459
+ | Property | Convergence | Migrations |
460
+ | ---------------- | --------------------------------------------------------------- | --------------------------------------- |
461
+ | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
462
+ | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
463
+ | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
464
+ | Reversible | Implicit (roll back code, re-sync re-derives) | No — forward-only, fix-forward |
465
+ | Files on disk | None — derived from models live | `.py` files in `migrations/` |
466
+ | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
467
+
468
+ **Drift correction is a convergence-only behavior.** Convergence re-runs on every `sync` and compares models against the database. An index created manually outside a model declaration will be dropped on the next run because models are the source of truth. Migrations don't behave this way — once applied, they're recorded and never re-applied.
469
+
470
+ **Caveats.** The safety promise isn't absolute. Structural migrations aren't lint-checked yet: adding a column with a volatile default (`gen_random_uuid()`, `now()`) on a large table will rewrite it without warning. Review structural migrations before deploying to production.
448
471
 
449
- This split exists because structural changes (adding a column) must happen in a specific order and can't be retried, while declarative objects (indexes, constraints) can be compared against model definitions and fixed automatically even if a previous attempt failed.
472
+ **Out of scope for convergence.** Triggers, views, stored procedures, and other non-standard DDL stay outside convergence — it won't create them from models, and it won't drop them if they exist. Manage them with `RunSQL` data migrations.
450
473
 
451
474
  ### Syncing
452
475
 
@@ -480,9 +503,9 @@ In development (`DEBUG=True`), sync auto-generates migrations before applying th
480
503
  | `plain postgres schema --json` | Machine-readable schema output |
481
504
  | `plain postgres converge` | Run convergence alone (advanced) |
482
505
 
483
- ### Migrations
506
+ ### Structural migrations
484
507
 
485
- Migrations track structural changes to your models. They are Python files stored in your app's `migrations/` directory.
508
+ Structural migrations are framework-generated from model changes tables, columns, renames, column type changes. They are Python files stored in your app's `migrations/` directory. You don't author them by hand; you edit models and run `plain migrations create`.
486
509
 
487
510
  ```bash
488
511
  plain migrations create
@@ -492,12 +515,9 @@ Key flags:
492
515
 
493
516
  - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
494
517
  - `--check` — Exit non-zero if migrations are needed (for CI)
495
- - `--empty <package>` — Create an empty migration for custom data migrations
496
518
  - `--name <name>` — Set the migration filename
497
519
 
498
- Only write migrations by hand if they are custom data migrations.
499
-
500
- Other migration commands:
520
+ Shared commands (apply equally to structural and data migrations):
501
521
 
502
522
  | Command | Purpose |
503
523
  | ------------------------------------------- | ---------------------------------------------------- |
@@ -560,6 +580,53 @@ Over time a package can accumulate dozens of migrations. Once **every environmen
560
580
  - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
561
581
  - If CI runs `migrations create --check` or `migrations apply --check`, the reset PR must be merged and deployed before those checks pass in other branches.
562
582
 
583
+ ### Data migrations
584
+
585
+ Data migrations are user-authored operations — backfills, transformations, seeding, cleanup. The framework has no way to derive these from models; you write the logic and it gets sequenced in timestamp order alongside structural migrations.
586
+
587
+ Create an empty migration to author one:
588
+
589
+ ```bash
590
+ plain migrations create --empty <package>
591
+ ```
592
+
593
+ Add a `RunPython` or `RunSQL` operation inside:
594
+
595
+ ```python
596
+ def forwards(models, schema_editor):
597
+ User = models.get_model("users", "User")
598
+ # Use .update() for batch SQL — a row-by-row save loop can lock a large table.
599
+ User.query.filter(full_name="").update(full_name="pending")
600
+ ```
601
+
602
+ For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
603
+
604
+ See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `squash`, `prune`).
605
+
606
+ #### Cascading deletes inside data migrations
607
+
608
+ `Model.delete()` and `QuerySet.delete()` rely on Postgres `ON DELETE` clauses (`CASCADE`, `SET NULL`, `RESTRICT`) — Plain does not walk relationships in Python.
609
+
610
+ Foreign key constraints are added by **convergence** (step 3 of `postgres sync`), not by migrations. During a fresh `migrations apply` (before convergence has run), FK constraints don't exist yet. A `RunPython` data migration that calls `.delete()` on a model with cascading children will:
611
+
612
+ - Delete only the parent row — children become orphans
613
+ - Cause convergence's later `VALIDATE CONSTRAINT` step to fail because of the orphans
614
+
615
+ This only affects fresh applies. Existing databases keep their FK constraints across syncs, so incremental data migrations are unaffected.
616
+
617
+ **If your data migration needs to delete rows that have cascading children, handle the cascade explicitly:**
618
+
619
+ ```python
620
+ def forwards(models, schema_editor):
621
+ Parent = models.get_model("myapp", "Parent")
622
+ Child = models.get_model("myapp", "Child")
623
+ # Delete children first, then parent — explicit, no constraint reliance
624
+ Child.query.filter(parent__name="old").delete()
625
+ Parent.query.filter(name="old").delete()
626
+ ```
627
+
628
+ Or use `RunSQL` with explicit cascade if the relationship is large.
629
+
563
630
  ### Convergence
564
631
 
565
632
  Convergence compares the indexes, constraints, foreign keys, and nullability declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
@@ -927,14 +994,14 @@ model_options = postgres.Options(
927
994
 
928
995
  #### Choose `on_delete` deliberately
929
996
 
930
- CASCADE for owned children, PROTECT for referenced data, SET_NULL for optional references.
997
+ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references.
931
998
 
932
999
  ```python
933
1000
  # Bad — blindly using CASCADE everywhere
934
1001
  company: Company = types.ForeignKeyField("Company", on_delete=postgres.CASCADE) # deleting company deletes invoices!
935
1002
 
936
- # Good — protect referenced data
937
- company: Company = types.ForeignKeyField("Company", on_delete=postgres.PROTECT)
1003
+ # Good — block the delete while invoices reference the company
1004
+ company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
938
1005
  ```
939
1006
 
940
1007
  #### No `allow_null` on string fields
@@ -7,7 +7,7 @@ from . import (
7
7
  from .base import Model
8
8
  from .constraints import CheckConstraint, UniqueConstraint
9
9
  from .db import get_connection
10
- from .deletion import CASCADE, DO_NOTHING, PROTECT, RESTRICT, SET, SET_DEFAULT, SET_NULL
10
+ from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
11
11
  from .expressions import F
12
12
  from .enums import IntegerChoices, TextChoices
13
13
  from .fields import (
@@ -82,11 +82,8 @@ __all__ = [
82
82
  "Index",
83
83
  # From deletion
84
84
  "CASCADE",
85
- "DO_NOTHING",
86
- "PROTECT",
85
+ "NO_ACTION",
87
86
  "RESTRICT",
88
- "SET",
89
- "SET_DEFAULT",
90
87
  "SET_NULL",
91
88
  # From options
92
89
  "Options",
@@ -65,7 +65,7 @@ Run `uv run plain docs postgres --section querying` for full patterns with code
65
65
  - Index fields used in `.filter()` and `.order_by()`
66
66
  - Indexes: `{table}_{column(s)}_idx`
67
67
  - Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
68
- - Choose `on_delete` deliberately: CASCADE for children, PROTECT for referenced data
68
+ - Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
69
69
  - No `allow_null` on string fields — use `default=""`
70
70
 
71
71
  Run `uv run plain docs postgres --section constraints` for full patterns with code examples.
@@ -18,7 +18,6 @@ from plain.postgres import models_registry, transaction, types
18
18
  from plain.postgres.constants import LOOKUP_SEP
19
19
  from plain.postgres.constraints import CheckConstraint, UniqueConstraint
20
20
  from plain.postgres.db import PLAIN_VERSION_PICKLE_KEY
21
- from plain.postgres.deletion import Collector
22
21
  from plain.postgres.dialect import MAX_NAME_LENGTH
23
22
  from plain.postgres.exceptions import (
24
23
  DoesNotExistDescriptor,
@@ -592,15 +591,29 @@ class Model(metaclass=ModelBase):
592
591
  ):
593
592
  field.delete_cached_value(self)
594
593
 
595
- def delete(self) -> tuple[int, dict[str, int]]:
594
+ def delete(self) -> int:
595
+ """Delete this row. Returns the number of rows deleted (1 or 0).
596
+
597
+ Cascades are handled entirely by Postgres via the `on_delete`
598
+ clauses declared on related foreign keys.
599
+ """
596
600
  if self.id is None:
597
601
  raise ValueError(
598
602
  f"{self.model_options.object_name} object can't be deleted because its id attribute is set "
599
603
  "to None."
600
604
  )
601
- collector = Collector(origin=self)
602
- collector.collect([self])
603
- return collector.delete()
605
+ # Use base_queryset to bypass any user-defined filters on the public
606
+ # query (e.g. soft-delete scopes). An instance we have a reference to
607
+ # should always be deletable — custom querysets shape reads, not
608
+ # internal row lifecycle operations.
609
+ #
610
+ # mark_for_rollback_on_error: FK errors (RESTRICT / NO_ACTION) leave
611
+ # the DB transaction aborted. Mark the connection so outer atomic()
612
+ # blocks see the abort state even if the caller catches IntegrityError.
613
+ with transaction.mark_for_rollback_on_error():
614
+ count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
615
+ setattr(self, self._model_meta.get_forward_field("id").attname, None)
616
+ return count
604
617
 
605
618
  def get_field_display(self, field_name: str) -> str:
606
619
  """Get the display value for a field, especially useful for fields with choices."""
@@ -990,7 +990,8 @@ class DatabaseConnection:
990
990
  WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]),
991
991
  cl.reloptions,
992
992
  c.convalidated,
993
- pg_get_constraintdef(c.oid)
993
+ pg_get_constraintdef(c.oid),
994
+ c.confdeltype
994
995
  FROM pg_constraint AS c
995
996
  JOIN pg_class AS cl ON c.conrelid = cl.oid
996
997
  WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid)
@@ -1005,6 +1006,7 @@ class DatabaseConnection:
1005
1006
  options,
1006
1007
  validated,
1007
1008
  constraintdef,
1009
+ confdeltype,
1008
1010
  ) in cursor.fetchall():
1009
1011
  constraints[constraint] = {
1010
1012
  "columns": columns,
@@ -1017,6 +1019,7 @@ class DatabaseConnection:
1017
1019
  "definition": constraintdef,
1018
1020
  "options": options,
1019
1021
  "validated": validated,
1022
+ "on_delete_action": confdeltype if kind == "f" else None,
1020
1023
  }
1021
1024
  # Now get indexes
1022
1025
  cursor.execute(
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from ..constraints import CheckConstraint, UniqueConstraint
10
10
  from ..ddl import compile_expression_sql, compile_index_expressions_sql
11
+ from ..deletion import sql_on_delete
11
12
  from ..dialect import quote_name
12
13
  from ..fields.related import ForeignKeyField
13
14
  from ..indexes import Index
@@ -111,6 +112,9 @@ class ForeignKeyDrift:
111
112
  column: str | None = None
112
113
  target_table: str | None = None
113
114
  target_column: str | None = None
115
+ on_delete_clause: str = "" # SQL clause to emit, e.g. " ON DELETE CASCADE"
116
+ actual_action: str | None = None # CHANGED only: current DB confdeltype
117
+ expected_action: str | None = None # CHANGED only: expected confdeltype
114
118
 
115
119
  def describe(self) -> str:
116
120
  match self.kind:
@@ -118,6 +122,11 @@ class ForeignKeyDrift:
118
122
  return f"{self.table}: FK {self.name} missing ({self.column} → {self.target_table}.{self.target_column})"
119
123
  case DriftKind.UNVALIDATED:
120
124
  return f"{self.table}: FK {self.name} NOT VALID"
125
+ case DriftKind.CHANGED:
126
+ return (
127
+ f"{self.table}: FK {self.name} on_delete changed "
128
+ f"({self.actual_action!r} → {self.expected_action!r})"
129
+ )
121
130
  case _:
122
131
  return f"{self.table}: FK {self.name} not declared"
123
132
 
@@ -823,8 +832,10 @@ def _compare_foreign_keys(
823
832
  if v.constraint_type == ConType.FOREIGN_KEY
824
833
  }
825
834
 
826
- # Build expected FKs from model fields: shape → (field_name, constraint_name)
827
- expected_fks: dict[tuple[str, str, str], tuple[str, str]] = {}
835
+ # Build expected FKs from model fields.
836
+ # Key: shape (column, target_table, target_column)
837
+ # Value: (field_name, constraint_name, expected_on_delete_clause, expected_confdeltype)
838
+ expected_fks: dict[tuple[str, str, str], tuple[str, str, str, str]] = {}
828
839
  for f in model._model_meta.local_fields:
829
840
  if isinstance(f, ForeignKeyField) and f.db_constraint:
830
841
  assert f.name is not None
@@ -833,7 +844,13 @@ def _compare_foreign_keys(
833
844
  constraint_name = generate_fk_constraint_name(
834
845
  table, f.column, to_table, to_column
835
846
  )
836
- expected_fks[(f.column, to_table, to_column)] = (f.name, constraint_name)
847
+ on_delete_clause, confdeltype = sql_on_delete(f.remote_field.on_delete)
848
+ expected_fks[(f.column, to_table, to_column)] = (
849
+ f.name,
850
+ constraint_name,
851
+ on_delete_clause,
852
+ confdeltype,
853
+ )
837
854
 
838
855
  # Build actual FKs from DB: shape → (constraint_name, ConstraintState)
839
856
  actual_fk_by_shape: dict[tuple[str, str, str], tuple[str, ConstraintState]] = {}
@@ -845,15 +862,41 @@ def _compare_foreign_keys(
845
862
  )
846
863
 
847
864
  matched_fk_names: set[str] = set()
848
- for key, (field_name, constraint_name) in expected_fks.items():
865
+ for key, (
866
+ field_name,
867
+ constraint_name,
868
+ on_delete_clause,
869
+ expected_action,
870
+ ) in expected_fks.items():
849
871
  if match := actual_fk_by_shape.get(key):
850
872
  actual_name, cs = match
851
873
  matched_fk_names.add(actual_name)
852
874
 
853
- # Check validation state
854
875
  issue: str | None = None
855
876
  drift: ForeignKeyDrift | None = None
856
- if not cs.validated:
877
+
878
+ # on_delete action mismatch — drop + re-add with new clause
879
+ if (
880
+ cs.on_delete_action is not None
881
+ and cs.on_delete_action != expected_action
882
+ ):
883
+ issue = (
884
+ f"on_delete action differs "
885
+ f"({cs.on_delete_action!r} → {expected_action!r})"
886
+ )
887
+ col, to_table, to_column = key
888
+ drift = ForeignKeyDrift(
889
+ kind=DriftKind.CHANGED,
890
+ table=table,
891
+ name=actual_name,
892
+ column=col,
893
+ target_table=to_table,
894
+ target_column=to_column,
895
+ on_delete_clause=on_delete_clause,
896
+ actual_action=cs.on_delete_action,
897
+ expected_action=expected_action,
898
+ )
899
+ elif not cs.validated:
857
900
  issue = "NOT VALID — needs validation"
858
901
  drift = ForeignKeyDrift(
859
902
  kind=DriftKind.UNVALIDATED,
@@ -885,6 +928,7 @@ def _compare_foreign_keys(
885
928
  column=col,
886
929
  target_table=to_table,
887
930
  target_column=to_column,
931
+ on_delete_clause=on_delete_clause,
888
932
  ),
889
933
  )
890
934
  )