plain.postgres 0.94.1__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.1 → plain_postgres-0.95.0}/.gitignore +1 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/PKG-INFO +91 -24
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/CHANGELOG.md +39 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/README.md +89 -22
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/__init__.py +2 -5
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +1 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/aggregates.py +2 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/base.py +21 -8
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/migrations.py +2 -4
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/connection.py +7 -4
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/constraints.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/convergence/analysis.py +50 -6
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/convergence/fixes.py +57 -0
- {plain_postgres-0.94.1 → 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.1 → plain_postgres-0.95.0}/plain/postgres/enums.py +5 -5
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/expressions.py +5 -5
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/encrypted.py +5 -5
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/related.py +32 -27
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/related_managers.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_related.py +15 -17
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/forms.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/indexes.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/introspection/schema.py +2 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/lookups.py +2 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/meta.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/__init__.py +1 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/autodetector.py +8 -39
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/graph.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/migration.py +0 -17
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/recorder.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/serializer.py +12 -10
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/state.py +2 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/utils.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/writer.py +2 -9
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/preflight.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/query.py +22 -23
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/query_utils.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/compiler.py +11 -11
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/query.py +6 -6
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/test/pytest.py +4 -4
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/types.pyi +3 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/utils.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/pyproject.toml +2 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +2 -29
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -9
- {plain_postgres-0.94.1 → 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.1/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.1 → plain_postgres-0.95.0}/tests/conftest_convergence.py +15 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_connection_isolation.py +2 -2
- {plain_postgres-0.94.1 → 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.1 → plain_postgres-0.95.0}/tests/test_encrypted_fields.py +1 -1
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_exceptions.py +2 -2
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_models.py +3 -3
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_related_descriptors.py +3 -3
- plain_postgres-0.94.1/plain/postgres/deletion.py +0 -476
- plain_postgres-0.94.1/tests/test_delete_behaviors.py +0 -70
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/CLAUDE.md +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/LICENSE +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/README.md +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/introspection/health.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_related_manager_api.py +0 -0
- {plain_postgres-0.94.1 → plain_postgres-0.95.0}/tests/test_schema_normalize_type.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
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
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.132.0
|
|
10
10
|
Requires-Dist: sqlparse>=0.3.1
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
|
|
@@ -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,44 @@
|
|
|
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
|
+
|
|
31
|
+
## [0.94.2](https://github.com/dropseed/plain/releases/plain-postgres@0.94.2) (2026-04-13)
|
|
32
|
+
|
|
33
|
+
### What's changed
|
|
34
|
+
|
|
35
|
+
- Updated internal references to use the fixed `app.users.models.User` convention. ([0861c9915cb6](https://github.com/dropseed/plain/commit/0861c9915cb6))
|
|
36
|
+
- Migrated type suppression comments to `ty: ignore` for the new ty checker version. ([4ec631a7ef51](https://github.com/dropseed/plain/commit/4ec631a7ef51))
|
|
37
|
+
|
|
38
|
+
### Upgrade instructions
|
|
39
|
+
|
|
40
|
+
- No changes required.
|
|
41
|
+
|
|
3
42
|
## [0.94.1](https://github.com/dropseed/plain/releases/plain-postgres@0.94.1) (2026-04-05)
|
|
4
43
|
|
|
5
44
|
### 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.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plain-postgres-doctor
|
|
3
3
|
description: Check overall database health — schema correctness and operational health. Use when asked to check the database, validate schema, optimize indexes, or diagnose Postgres problems.
|
|
4
|
+
context: fork
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# Database Doctor
|
|
@@ -75,7 +75,7 @@ class Aggregate(Func):
|
|
|
75
75
|
self.filter = self.filter and exprs_list.pop()
|
|
76
76
|
super().set_source_expressions(exprs_list)
|
|
77
77
|
|
|
78
|
-
def resolve_expression( #
|
|
78
|
+
def resolve_expression( # ty: ignore[invalid-method-override]
|
|
79
79
|
self,
|
|
80
80
|
query: Any = None,
|
|
81
81
|
allow_joins: bool = True,
|
|
@@ -140,7 +140,7 @@ class Aggregate(Func):
|
|
|
140
140
|
if self.filter is not None:
|
|
141
141
|
# Use FILTER clause for aggregates when filter is specified
|
|
142
142
|
try:
|
|
143
|
-
filter_sql, filter_params = self.filter.as_sql(compiler, connection) #
|
|
143
|
+
filter_sql, filter_params = self.filter.as_sql(compiler, connection) # ty: ignore[unresolved-attribute]
|
|
144
144
|
except FullResultSet:
|
|
145
145
|
pass
|
|
146
146
|
else:
|
|
@@ -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."""
|
|
@@ -744,7 +757,7 @@ class Model(metaclass=ModelBase):
|
|
|
744
757
|
|
|
745
758
|
if len(unique_check) == 1:
|
|
746
759
|
field = meta.get_forward_field(unique_check[0])
|
|
747
|
-
params["field_label"] = field.name #
|
|
760
|
+
params["field_label"] = field.name # ty: ignore[invalid-assignment]
|
|
748
761
|
return ValidationError(
|
|
749
762
|
message=field.error_messages["unique"],
|
|
750
763
|
code="unique",
|
|
@@ -1215,7 +1228,7 @@ class Model(metaclass=ModelBase):
|
|
|
1215
1228
|
fld = None
|
|
1216
1229
|
for part in field.split(LOOKUP_SEP):
|
|
1217
1230
|
try:
|
|
1218
|
-
fld = _cls._model_meta.get_field(part) #
|
|
1231
|
+
fld = _cls._model_meta.get_field(part) # ty: ignore[unresolved-attribute]
|
|
1219
1232
|
if isinstance(fld, RelatedField):
|
|
1220
1233
|
_cls = fld.path_infos[-1].to_meta.model
|
|
1221
1234
|
else:
|
|
@@ -1413,4 +1426,4 @@ def model_unpickle(model_id: tuple[str, str] | type[Model]) -> Model:
|
|
|
1413
1426
|
|
|
1414
1427
|
|
|
1415
1428
|
# Pickle protocol marker - functions don't normally have this attribute
|
|
1416
|
-
model_unpickle.__safe_for_unpickle__ = True #
|
|
1429
|
+
model_unpickle.__safe_for_unpickle__ = True # ty: ignore[unresolved-attribute]
|
|
@@ -16,7 +16,7 @@ from ..db import get_connection
|
|
|
16
16
|
from ..migrations.autodetector import MigrationAutodetector
|
|
17
17
|
from ..migrations.executor import MigrationExecutor
|
|
18
18
|
from ..migrations.loader import AmbiguityError, MigrationLoader
|
|
19
|
-
from ..migrations.migration import Migration
|
|
19
|
+
from ..migrations.migration import Migration
|
|
20
20
|
from ..migrations.optimizer import MigrationOptimizer
|
|
21
21
|
from ..migrations.questioner import (
|
|
22
22
|
InteractiveMigrationQuestioner,
|
|
@@ -971,9 +971,7 @@ def squash(
|
|
|
971
971
|
)
|
|
972
972
|
operations.extend(smigration.operations)
|
|
973
973
|
for dependency in smigration.dependencies:
|
|
974
|
-
if
|
|
975
|
-
dependencies.add(dependency)
|
|
976
|
-
elif dependency[0] != smigration.package_label or first_migration:
|
|
974
|
+
if dependency[0] != smigration.package_label or first_migration:
|
|
977
975
|
dependencies.add(dependency)
|
|
978
976
|
first_migration = False
|
|
979
977
|
|
|
@@ -354,7 +354,7 @@ class DatabaseConnection:
|
|
|
354
354
|
return False
|
|
355
355
|
if new_role := self.settings_dict.get("OPTIONS", {}).get("assume_role"):
|
|
356
356
|
sql_str = self.compose_sql("SET ROLE %s", [new_role])
|
|
357
|
-
self.connection.execute(sql_str) #
|
|
357
|
+
self.connection.execute(sql_str) # ty: ignore[invalid-argument-type]
|
|
358
358
|
return True
|
|
359
359
|
return False
|
|
360
360
|
|
|
@@ -373,7 +373,7 @@ class DatabaseConnection:
|
|
|
373
373
|
|
|
374
374
|
# Register the cursor timezone only if the connection disagrees, to avoid copying the adapter map.
|
|
375
375
|
tzloader = self.connection.adapters.get_loader(TIMESTAMPTZ_OID, Format.TEXT)
|
|
376
|
-
if self.timezone != tzloader.timezone: #
|
|
376
|
+
if self.timezone != tzloader.timezone: # ty: ignore[unresolved-attribute]
|
|
377
377
|
register_tzloader(self.timezone, cursor)
|
|
378
378
|
return cursor
|
|
379
379
|
|
|
@@ -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(
|
|
@@ -1309,7 +1312,7 @@ class CursorMixin:
|
|
|
1309
1312
|
|
|
1310
1313
|
qparts.append(psycopg_sql.SQL(")"))
|
|
1311
1314
|
stmt = psycopg_sql.Composed(qparts)
|
|
1312
|
-
self.execute(stmt) #
|
|
1315
|
+
self.execute(stmt) # ty: ignore[unresolved-attribute]
|
|
1313
1316
|
return args
|
|
1314
1317
|
|
|
1315
1318
|
|
|
@@ -372,7 +372,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
372
372
|
if exclude:
|
|
373
373
|
for expression in self.expressions:
|
|
374
374
|
if hasattr(expression, "flatten"):
|
|
375
|
-
for expr in expression.flatten(): #
|
|
375
|
+
for expr in expression.flatten(): # ty: ignore[call-non-callable]
|
|
376
376
|
if isinstance(expr, F) and expr.name in exclude:
|
|
377
377
|
return
|
|
378
378
|
elif isinstance(expression, F) and expression.name in exclude:
|