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.
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/PKG-INFO +90 -23
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/CHANGELOG.md +28 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/README.md +89 -22
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/__init__.py +2 -5
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/base.py +18 -5
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/connection.py +4 -1
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/analysis.py +50 -6
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/fixes.py +57 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/planning.py +17 -1
- plain_postgres-0.95.0/plain/postgres/deletion.py +48 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related.py +28 -20
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_related.py +12 -14
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/schema.py +2 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/serializer.py +11 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/query.py +13 -14
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/types.pyi +3 -2
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/pyproject.toml +1 -1
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +2 -29
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -9
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +1 -1
- plain_postgres-0.95.0/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +81 -0
- plain_postgres-0.95.0/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +47 -0
- plain_postgres-0.95.0/tests/app/examples/migrations/0010_hideableitem.py +20 -0
- plain_postgres-0.94.2/tests/app/examples/models.py → plain_postgres-0.95.0/tests/app/examples/models/__init__.py +2 -86
- plain_postgres-0.95.0/tests/app/examples/models/delete.py +194 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/conftest_convergence.py +15 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_fk.py +142 -1
- plain_postgres-0.95.0/tests/test_delete_behaviors.py +563 -0
- plain_postgres-0.94.2/plain/postgres/deletion.py +0 -476
- plain_postgres-0.94.2/tests/test_delete_behaviors.py +0 -70
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/.gitignore +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/CLAUDE.md +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/LICENSE +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/README.md +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_models.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_related_descriptors.py +0 -0
- {plain_postgres-0.94.2 → plain_postgres-0.95.0}/tests/test_related_manager_api.py +0 -0
- {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.
|
|
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
|
-
- [
|
|
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
|
-
|
|
449
|
+
Schema changes fall into three categories, each with a different author and apply model:
|
|
449
450
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
518
|
+
### Structural migrations
|
|
496
519
|
|
|
497
|
-
|
|
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
|
-
|
|
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,
|
|
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 —
|
|
949
|
-
company: Company = types.ForeignKeyField("Company", on_delete=postgres.
|
|
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
|
-
- [
|
|
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
|
-
|
|
437
|
+
Schema changes fall into three categories, each with a different author and apply model:
|
|
437
438
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
506
|
+
### Structural migrations
|
|
484
507
|
|
|
485
|
-
|
|
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
|
-
|
|
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,
|
|
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 —
|
|
937
|
-
company: Company = types.ForeignKeyField("Company", on_delete=postgres.
|
|
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,
|
|
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
|
-
"
|
|
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,
|
|
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) ->
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
827
|
-
|
|
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
|
-
|
|
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, (
|
|
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
|
-
|
|
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
|
)
|