plain.postgres 0.114.0__tar.gz → 0.115.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.114.0/plain/postgres/README.md → plain_postgres-0.115.0/PKG-INFO +23 -4
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/CHANGELOG.md +23 -0
- plain_postgres-0.114.0/PKG-INFO → plain_postgres-0.115.0/plain/postgres/README.md +10 -17
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/__init__.py +1 -2
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +2 -1
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/base.py +16 -14
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/connection.py +4 -10
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/constraints.py +4 -35
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/analysis.py +146 -98
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/corrections.py +65 -25
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/planning.py +8 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/ddl.py +0 -16
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/deletion.py +3 -19
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/dialect.py +0 -3
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related.py +37 -2
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/schema.py +2 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/meta.py +17 -1
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/serializer.py +3 -3
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/query.py +3 -3
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/pytest.py +0 -12
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/utils.py +9 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/pyproject.toml +1 -1
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -13
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/delete.py +2 -9
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_constraints.py +0 -60
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_fk.py +223 -71
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_timeouts.py +1 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_migration_executor.py +34 -1
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_delete_behaviors.py +18 -59
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_integrity_error_mapping.py +27 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/.gitignore +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/CLAUDE.md +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/LICENSE +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/README.md +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/conftest.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_databases.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_only_empty_defaults.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_order_by_expressions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_related.py +0 -0
- {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.115.0
|
|
4
|
+
Summary: Model your data and store it in a database.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.157.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# plain.postgres
|
|
2
15
|
|
|
3
16
|
**Model your data and store it in a database.**
|
|
@@ -983,6 +996,14 @@ The first access to any non-key field loads the whole row in a single query. The
|
|
|
983
996
|
|
|
984
997
|
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
985
998
|
|
|
999
|
+
### Constraints are checked immediately
|
|
1000
|
+
|
|
1001
|
+
Every constraint is checked at the write that violates it, never at commit — the same as Postgres's own default. Inserting a child row that points at a parent that doesn't exist yet fails at that `INSERT`, with a traceback pointing at the offending write, and deleting a parent with `RESTRICT` children fails at that `DELETE`. Create parents before children. A cycle of foreign keys needs a nullable back-reference: create both rows, then `update()` the back-reference. Two required foreign keys pointing at each other can never be inserted.
|
|
1002
|
+
|
|
1003
|
+
To swap two rows' values under a unique constraint (reordering by `position`, say), move one row to a temporary value first — a single `UPDATE` that swaps them fails, because Postgres checks uniqueness per row.
|
|
1004
|
+
|
|
1005
|
+
A migration can add a column, backfill it in `RunPython`, and drop or alter columns on the same table, all in one transaction.
|
|
1006
|
+
|
|
986
1007
|
### Reverse relationships
|
|
987
1008
|
|
|
988
1009
|
When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
|
|
@@ -1087,7 +1108,7 @@ class User(postgres.Model):
|
|
|
1087
1108
|
|
|
1088
1109
|
Field-level validation happens automatically based on field types and constraints.
|
|
1089
1110
|
|
|
1090
|
-
**The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500.
|
|
1111
|
+
**The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. A foreign key pointing at a row that doesn't exist maps the same way, as an error on that field. (`NOT NULL` and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
|
|
1091
1112
|
|
|
1092
1113
|
Because the rejected write reaches the database, it aborts the surrounding transaction. If you catch the `ValidationError` and want to keep using the transaction, wrap the write in `transaction.atomic()` so it rolls back to a savepoint:
|
|
1093
1114
|
|
|
@@ -1101,7 +1122,7 @@ except ValidationError:
|
|
|
1101
1122
|
|
|
1102
1123
|
Forms are the exception: a `ModelForm` pre-checks constraints explicitly (a `validate_constraints()` call in its `_post_clean`) so it can surface every violation at once, then writes via `form.create()`/`form.update()` with validation already done. A direct `create()`/`update()` reports the first violation Postgres hits.
|
|
1103
1124
|
|
|
1104
|
-
This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to. If you retry on a unique conflict, catch both:
|
|
1125
|
+
This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to, and so does a `delete()` blocked by `RESTRICT` children. If you retry on a unique conflict, catch both:
|
|
1105
1126
|
|
|
1106
1127
|
```python
|
|
1107
1128
|
try:
|
|
@@ -1112,8 +1133,6 @@ except (psycopg.IntegrityError, ValidationError):
|
|
|
1112
1133
|
|
|
1113
1134
|
For a plain insert-or-update with no per-row logic, `bulk_create(..., update_conflicts=True, unique_fields=[...])` is an atomic upsert with no race to catch.
|
|
1114
1135
|
|
|
1115
|
-
Two caveats. The mapping covers **immediate** constraints — the default. An explicitly deferred constraint (`UniqueConstraint(deferrable=Deferrable.DEFERRED)`) is checked at commit, _after_ the write returns, so its violation still surfaces as a raw `psycopg.IntegrityError`. And when a row violates several constraints at once, a form's pre-check (or an explicit `validate_constraints()`) reports them all, while a direct `create()`/`update()` gets only the first one the database hits.
|
|
1116
|
-
|
|
1117
1136
|
### Indexes and constraints
|
|
1118
1137
|
|
|
1119
1138
|
You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.115.0](https://github.com/dropseed/plain/releases/plain-postgres@0.115.0) (2026-09-02)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Foreign keys are now `NOT DEFERRABLE`** (Postgres's own default) and checked at each write. Plain inherited Django's `DEFERRABLE INITIALLY DEFERRED` foreign keys, added in 2007 so fixtures could contain forward references. Plain has no fixtures, and the deferral was causing real problems: a migration that adds a column, backfills it in `RunPython`, then alters the same table failed with "cannot ALTER TABLE ... because it has pending trigger events" whenever the backfill touched a row, and FK violations only surfaced at `COMMIT` with a traceback pointing at `atomic.__exit__` instead of the offending write ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
|
|
8
|
+
- Convergence detects an existing `DEFERRABLE` foreign key as drift and fixes it with a catalog-only `ALTER CONSTRAINT ... NOT DEFERRABLE` — no revalidation scan, just a brief `ACCESS EXCLUSIVE` lock bounded by the usual `lock_timeout` — so the first `postgres sync` after upgrading rewrites every FK quickly. A legacy `DEFERRABLE` + `NOT VALID` foreign key gets both corrections in one pass ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
|
|
9
|
+
- `NO_ACTION` is removed. Its only meaning was deferred checking — use `RESTRICT` ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
|
|
10
|
+
- A foreign key violation from `create()`/`update()` now surfaces as a `ValidationError` routed to the `ForeignKeyField` — "Author with id 5 does not exist." — with code `invalid_choice` so forms can override it. It lands in the same `IntegrityError` mapping that unique and check constraints already used, so a missing related row reports like any other field error instead of a 500. The message is `ForeignKeyField.does_not_exist_error_message`. Set-based writes (`QuerySet.update()`, `bulk_create()`) and a `delete()` blocked by `RESTRICT` still raise the raw `psycopg` error, since there's no instance to attribute it to ([31df77e665](https://github.com/dropseed/plain/commit/31df77e665))
|
|
11
|
+
- That mapping keys on the foreign key's generated constraint name, so convergence now treats a stale name — left behind by `RenameField`/`RenameModel`, which only rename the column or table — as drift and renames the constraint. Because the write-path mapping depends on those corrections having run, FK renames and the `NOT DEFERRABLE` flip block sync on failure. `generate_fk_constraint_name()` moved to `plain.postgres.utils` and is now the single source of that name for both convergence and the write path ([31df77e665](https://github.com/dropseed/plain/commit/31df77e665))
|
|
12
|
+
- **`UniqueConstraint(deferrable=...)` and the `Deferrable` enum are removed.** With foreign keys immediate, this was the last deferred-constraint concept in Plain, nothing in Plain's own packages used it, and it forced a caveat onto every "constraints are checked at the write" statement while keeping the pending-trigger-events failure alive for one case. Rails (pre-7.1), Ecto, and Laravel never had it for uniques. Every constraint is now checked at the write that violates it, never at commit ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
|
|
13
|
+
- `DatabaseConnection.check_constraints()` is gone, and the pytest `db` fixture no longer forces deferred constraints to immediate at teardown — there is nothing left to defer ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
|
|
14
|
+
- Constraint renames now run in convergence pass 1, matching index renames, so anything else planned for the same constraint in that pass addresses it by its new name ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
|
|
15
|
+
- New README section, "Constraints are checked immediately", covering the patterns this changes: create parents before children, use a nullable back-reference for a foreign key cycle (two required FKs pointing at each other can never be inserted), and move through a temporary value when swapping two rows under a unique constraint, since Postgres checks uniqueness per row even within a single `UPDATE` ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40), [fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
|
|
16
|
+
|
|
17
|
+
### Upgrade instructions
|
|
18
|
+
|
|
19
|
+
- Replace `NO_ACTION` with `RESTRICT` in your models **and in any historical migration file that references it** — `postgres.NO_ACTION` no longer exists, so a migration importing it fails to load.
|
|
20
|
+
- Remove `deferrable=` from every `UniqueConstraint(...)` and drop any `Deferrable` import. An existing deferred unique constraint in the database shows as CHANGED drift with staged-rollout guidance — nothing is rewritten silently.
|
|
21
|
+
- Run `plain postgres sync` after upgrading. It flips every existing foreign key to `NOT DEFERRABLE` (catalog-only, no table scan) and renames foreign key constraints left stale by past `RenameField`/`RenameModel`.
|
|
22
|
+
- Code that inserted a child row before its parent, or deleted a parent and re-pointed its children later in the same transaction, now raises `ForeignKeyViolation` at that statement rather than succeeding by commit time. Create parents before children; for a foreign key cycle, create both rows and then `update()` the nullable back-reference.
|
|
23
|
+
- If you catch `psycopg.IntegrityError` around `create()`/`update()` to handle a missing related row, catch `ValidationError` instead — or both, if the same block also covers set-based writes.
|
|
24
|
+
- If you called `connection.check_constraints()`, remove the call.
|
|
25
|
+
|
|
3
26
|
## [0.114.0](https://github.com/dropseed/plain/releases/plain-postgres@0.114.0) (2026-08-12)
|
|
4
27
|
|
|
5
28
|
### What's changed
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.114.0
|
|
4
|
-
Summary: Model your data and store it in a database.
|
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
-
License-Expression: BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.157.0
|
|
10
|
-
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
-
Requires-Dist: psycopg>=3.2
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# plain.postgres
|
|
15
2
|
|
|
16
3
|
**Model your data and store it in a database.**
|
|
@@ -996,6 +983,14 @@ The first access to any non-key field loads the whole row in a single query. The
|
|
|
996
983
|
|
|
997
984
|
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
998
985
|
|
|
986
|
+
### Constraints are checked immediately
|
|
987
|
+
|
|
988
|
+
Every constraint is checked at the write that violates it, never at commit — the same as Postgres's own default. Inserting a child row that points at a parent that doesn't exist yet fails at that `INSERT`, with a traceback pointing at the offending write, and deleting a parent with `RESTRICT` children fails at that `DELETE`. Create parents before children. A cycle of foreign keys needs a nullable back-reference: create both rows, then `update()` the back-reference. Two required foreign keys pointing at each other can never be inserted.
|
|
989
|
+
|
|
990
|
+
To swap two rows' values under a unique constraint (reordering by `position`, say), move one row to a temporary value first — a single `UPDATE` that swaps them fails, because Postgres checks uniqueness per row.
|
|
991
|
+
|
|
992
|
+
A migration can add a column, backfill it in `RunPython`, and drop or alter columns on the same table, all in one transaction.
|
|
993
|
+
|
|
999
994
|
### Reverse relationships
|
|
1000
995
|
|
|
1001
996
|
When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
|
|
@@ -1100,7 +1095,7 @@ class User(postgres.Model):
|
|
|
1100
1095
|
|
|
1101
1096
|
Field-level validation happens automatically based on field types and constraints.
|
|
1102
1097
|
|
|
1103
|
-
**The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500.
|
|
1098
|
+
**The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. A foreign key pointing at a row that doesn't exist maps the same way, as an error on that field. (`NOT NULL` and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
|
|
1104
1099
|
|
|
1105
1100
|
Because the rejected write reaches the database, it aborts the surrounding transaction. If you catch the `ValidationError` and want to keep using the transaction, wrap the write in `transaction.atomic()` so it rolls back to a savepoint:
|
|
1106
1101
|
|
|
@@ -1114,7 +1109,7 @@ except ValidationError:
|
|
|
1114
1109
|
|
|
1115
1110
|
Forms are the exception: a `ModelForm` pre-checks constraints explicitly (a `validate_constraints()` call in its `_post_clean`) so it can surface every violation at once, then writes via `form.create()`/`form.update()` with validation already done. A direct `create()`/`update()` reports the first violation Postgres hits.
|
|
1116
1111
|
|
|
1117
|
-
This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to. If you retry on a unique conflict, catch both:
|
|
1112
|
+
This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to, and so does a `delete()` blocked by `RESTRICT` children. If you retry on a unique conflict, catch both:
|
|
1118
1113
|
|
|
1119
1114
|
```python
|
|
1120
1115
|
try:
|
|
@@ -1125,8 +1120,6 @@ except (psycopg.IntegrityError, ValidationError):
|
|
|
1125
1120
|
|
|
1126
1121
|
For a plain insert-or-update with no per-row logic, `bulk_create(..., update_conflicts=True, unique_fields=[...])` is an atomic upsert with no race to catch.
|
|
1127
1122
|
|
|
1128
|
-
Two caveats. The mapping covers **immediate** constraints — the default. An explicitly deferred constraint (`UniqueConstraint(deferrable=Deferrable.DEFERRED)`) is checked at commit, _after_ the write returns, so its violation still surfaces as a raw `psycopg.IntegrityError`. And when a row violates several constraints at once, a form's pre-check (or an explicit `validate_constraints()`) reports them all, while a direct `create()`/`update()` gets only the first one the database hits.
|
|
1129
|
-
|
|
1130
1123
|
### Indexes and constraints
|
|
1131
1124
|
|
|
1132
1125
|
You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
|
|
@@ -12,7 +12,7 @@ from .base import Model
|
|
|
12
12
|
from .constraints import CheckConstraint, UniqueConstraint
|
|
13
13
|
from .db import get_connection, use_management_connection
|
|
14
14
|
from .middleware import DatabaseConnectionMiddleware
|
|
15
|
-
from .deletion import CASCADE,
|
|
15
|
+
from .deletion import CASCADE, RESTRICT, SET_NULL
|
|
16
16
|
from .expressions import F
|
|
17
17
|
from .enums import TextChoices
|
|
18
18
|
from .fields import (
|
|
@@ -57,7 +57,6 @@ from . import types
|
|
|
57
57
|
# imported from their specific modules (e.g., plain.postgres.aggregates).
|
|
58
58
|
__all__ = [
|
|
59
59
|
"CASCADE",
|
|
60
|
-
"NO_ACTION",
|
|
61
60
|
"RESTRICT",
|
|
62
61
|
"SET_NULL",
|
|
63
62
|
"BigIntegerField",
|
|
@@ -69,7 +69,7 @@ Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`)
|
|
|
69
69
|
- Use `.values_list()` when you only need specific columns
|
|
70
70
|
- Wrap multi-step writes in `transaction.atomic()`
|
|
71
71
|
- Instance writes are `obj.create()` (always INSERT) and `obj.update()` (always UPDATE; `update(fields=[...])` limits the columns) — there is no `save()`, `force_insert`, or `force_update`. Constructing an instance then `create()`-ing it inserts; a hand-set `id` that collides raises `IntegrityError`.
|
|
72
|
-
- `create()`/`update()` raise `ValidationError` (not raw `psycopg.IntegrityError`) on a declared unique/check constraint violation, even a raced one — the DB enforces it, so inside an open `transaction.atomic()` the violation aborts the transaction (wrap the write in its own `atomic()` to catch and keep using the transaction). Set-based writes (`QuerySet.update()`/`bulk_create()`) raise raw `psycopg.IntegrityError`. Retrying on conflict? `except (psycopg.IntegrityError, ValidationError)`, or `bulk_create(..., update_conflicts=True)`
|
|
72
|
+
- `create()`/`update()` raise `ValidationError` (not raw `psycopg.IntegrityError`) on a declared unique/check constraint violation or a foreign key pointing at a missing row, even a raced one — the DB enforces it, so inside an open `transaction.atomic()` the violation aborts the transaction (wrap the write in its own `atomic()` to catch and keep using the transaction). Set-based writes (`QuerySet.update()`/`bulk_create()`) and `delete()` blocked by `RESTRICT` raise raw `psycopg.IntegrityError`. Retrying on conflict? `except (psycopg.IntegrityError, ValidationError)`, or `bulk_create(..., update_conflicts=True)`
|
|
73
73
|
- Always paginate list queries — unbounded querysets get slower as data grows
|
|
74
74
|
|
|
75
75
|
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
@@ -81,6 +81,7 @@ Run `uv run plain docs postgres` for full patterns with code examples.
|
|
|
81
81
|
- Indexes: `{table}_{column(s)}_idx`
|
|
82
82
|
- Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
|
|
83
83
|
- Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
|
|
84
|
+
- Foreign keys are checked at each write, not at commit — create parents before children. A backfill followed by DDL on the same table in one migration is fine.
|
|
84
85
|
- No `allow_null` on string fields — use `default=""`. Optional string fields are `required=False, default=""` — the declared default is what lets the column be added to a populated table (`required=False` alone only affects Python-side validation)
|
|
85
86
|
|
|
86
87
|
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
@@ -438,16 +438,20 @@ class Model(metaclass=ModelBase):
|
|
|
438
438
|
"""
|
|
439
439
|
Map a Postgres constraint violation back to the constraint that raised
|
|
440
440
|
it and return the ValidationError the in-Python check produces for that
|
|
441
|
-
constraint, or None when the violation doesn't
|
|
442
|
-
constraint that can describe it (PK
|
|
443
|
-
NULL — which carries no constraint name —
|
|
444
|
-
re-raise as the original IntegrityError). A PK
|
|
445
|
-
when create() inserts a hand-set id that's
|
|
441
|
+
constraint or foreign key, or None when the violation doesn't
|
|
442
|
+
correspond to a declared constraint that can describe it (PK
|
|
443
|
+
collisions and NOT NULL — which carries no constraint name — fall
|
|
444
|
+
through to None and re-raise as the original IntegrityError). A PK
|
|
445
|
+
collision reaches here when create() inserts a hand-set id that's
|
|
446
|
+
already taken.
|
|
446
447
|
"""
|
|
447
448
|
constraint_name = exc.diag.constraint_name
|
|
448
449
|
if not constraint_name:
|
|
449
450
|
return None
|
|
450
|
-
|
|
451
|
+
meta = self._model_meta
|
|
452
|
+
constraint = meta.constraints_by_name.get(
|
|
453
|
+
constraint_name
|
|
454
|
+
) or meta.foreign_keys_by_constraint_name.get(constraint_name)
|
|
451
455
|
if constraint is None:
|
|
452
456
|
return None
|
|
453
457
|
error = constraint._db_violation_error(self, self.__class__)
|
|
@@ -531,11 +535,9 @@ class Model(metaclass=ModelBase):
|
|
|
531
535
|
if not obj:
|
|
532
536
|
continue
|
|
533
537
|
# A pk may have been assigned manually to a model instance not
|
|
534
|
-
# saved to the database
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
# constraints aren't supported by the database, there's the
|
|
538
|
-
# unavoidable risk of data corruption.
|
|
538
|
+
# saved to the database, but we allow the write to proceed and
|
|
539
|
+
# rely on the database's foreign key check (mapped to a
|
|
540
|
+
# ValidationError on the field) if the row doesn't exist.
|
|
539
541
|
if obj.id is None:
|
|
540
542
|
raise ValueError(
|
|
541
543
|
f"{operation_name}() prohibited to prevent data loss due to unsaved "
|
|
@@ -570,9 +572,9 @@ class Model(metaclass=ModelBase):
|
|
|
570
572
|
# should always be deletable — custom querysets shape reads, not
|
|
571
573
|
# internal row lifecycle operations.
|
|
572
574
|
#
|
|
573
|
-
# mark_for_rollback_on_error:
|
|
574
|
-
#
|
|
575
|
-
#
|
|
575
|
+
# mark_for_rollback_on_error: RESTRICT violations leave the DB
|
|
576
|
+
# transaction aborted. Mark the connection so outer atomic() blocks
|
|
577
|
+
# see the abort state even if the caller catches IntegrityError.
|
|
576
578
|
with transaction.mark_for_rollback_on_error():
|
|
577
579
|
count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
|
|
578
580
|
id_field = self._model_meta.get_forward_field("id")
|
|
@@ -148,15 +148,6 @@ class DatabaseConnection:
|
|
|
148
148
|
assert self.connection is not None
|
|
149
149
|
self.connection.autocommit = autocommit
|
|
150
150
|
|
|
151
|
-
def check_constraints(self, table_names: list[str] | None = None) -> None:
|
|
152
|
-
"""
|
|
153
|
-
Check constraints by setting them to immediate. Return them to deferred
|
|
154
|
-
afterward.
|
|
155
|
-
"""
|
|
156
|
-
with self.cursor() as cursor:
|
|
157
|
-
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
|
158
|
-
cursor.execute("SET CONSTRAINTS ALL DEFERRED")
|
|
159
|
-
|
|
160
151
|
def make_debug_cursor(self, cursor: psycopg.Cursor[Any]) -> CursorDebugWrapper:
|
|
161
152
|
return CursorDebugWrapper(cursor, self)
|
|
162
153
|
|
|
@@ -615,7 +606,8 @@ class DatabaseConnection:
|
|
|
615
606
|
WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]),
|
|
616
607
|
c.convalidated,
|
|
617
608
|
pg_get_constraintdef(c.oid),
|
|
618
|
-
c.confdeltype
|
|
609
|
+
c.confdeltype,
|
|
610
|
+
c.condeferrable
|
|
619
611
|
FROM pg_constraint AS c
|
|
620
612
|
JOIN pg_class AS cl ON c.conrelid = cl.oid
|
|
621
613
|
WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid)
|
|
@@ -630,6 +622,7 @@ class DatabaseConnection:
|
|
|
630
622
|
validated,
|
|
631
623
|
constraintdef,
|
|
632
624
|
confdeltype,
|
|
625
|
+
condeferrable,
|
|
633
626
|
) in cursor.fetchall():
|
|
634
627
|
constraints[constraint] = {
|
|
635
628
|
"columns": columns,
|
|
@@ -639,6 +632,7 @@ class DatabaseConnection:
|
|
|
639
632
|
"definition": constraintdef,
|
|
640
633
|
"validated": validated,
|
|
641
634
|
"on_delete_action": confdeltype if kind == "f" else None,
|
|
635
|
+
"deferrable": condeferrable if kind == "f" else None,
|
|
642
636
|
}
|
|
643
637
|
# Now get indexes. Sort order, opclasses, INCLUDE, and predicates all
|
|
644
638
|
# ride along inside `pg_get_indexdef` and are compared via the
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from enum import Enum
|
|
4
3
|
from types import NoneType
|
|
5
4
|
from typing import TYPE_CHECKING, Any, cast
|
|
6
5
|
|
|
@@ -10,7 +9,6 @@ from plain.postgres.ddl import (
|
|
|
10
9
|
build_include_sql,
|
|
11
10
|
compile_expression_sql,
|
|
12
11
|
compile_index_expressions_sql,
|
|
13
|
-
deferrable_sql,
|
|
14
12
|
)
|
|
15
13
|
from plain.postgres.dialect import quote_name
|
|
16
14
|
from plain.postgres.exceptions import FieldError
|
|
@@ -26,7 +24,7 @@ from plain.postgres.query_utils import Q
|
|
|
26
24
|
if TYPE_CHECKING:
|
|
27
25
|
from plain.postgres.base import Model
|
|
28
26
|
|
|
29
|
-
__all__ = ["BaseConstraint", "CheckConstraint", "
|
|
27
|
+
__all__ = ["BaseConstraint", "CheckConstraint", "UniqueConstraint"]
|
|
30
28
|
|
|
31
29
|
|
|
32
30
|
ViolationError = str | dict[str, Any] | list[Any] | ValidationError
|
|
@@ -190,15 +188,6 @@ class CheckConstraint(BaseConstraint):
|
|
|
190
188
|
return path, args, kwargs
|
|
191
189
|
|
|
192
190
|
|
|
193
|
-
class Deferrable(Enum):
|
|
194
|
-
DEFERRED = "deferred"
|
|
195
|
-
IMMEDIATE = "immediate"
|
|
196
|
-
|
|
197
|
-
# A similar format was proposed for Python 3.10.
|
|
198
|
-
def __repr__(self) -> str:
|
|
199
|
-
return f"{self.__class__.__qualname__}.{self._name_}"
|
|
200
|
-
|
|
201
|
-
|
|
202
191
|
class UniqueConstraint(BaseConstraint):
|
|
203
192
|
expressions: tuple[ReplaceableExpression, ...]
|
|
204
193
|
|
|
@@ -208,7 +197,6 @@ class UniqueConstraint(BaseConstraint):
|
|
|
208
197
|
fields: tuple[str, ...] | list[str] = (),
|
|
209
198
|
name: str | None = None,
|
|
210
199
|
condition: Q | None = None,
|
|
211
|
-
deferrable: Deferrable | None = None,
|
|
212
200
|
include: tuple[str, ...] | list[str] | None = None,
|
|
213
201
|
opclasses: tuple[str, ...] | list[str] = (),
|
|
214
202
|
violation_error: ViolationError | None = None,
|
|
@@ -226,23 +214,11 @@ class UniqueConstraint(BaseConstraint):
|
|
|
226
214
|
)
|
|
227
215
|
if not isinstance(condition, NoneType | Q):
|
|
228
216
|
raise TypeError("UniqueConstraint.condition must be a Q instance.")
|
|
229
|
-
if condition and deferrable:
|
|
230
|
-
raise ValueError("UniqueConstraint with conditions cannot be deferred.")
|
|
231
|
-
if include and deferrable:
|
|
232
|
-
raise ValueError("UniqueConstraint with include fields cannot be deferred.")
|
|
233
|
-
if opclasses and deferrable:
|
|
234
|
-
raise ValueError("UniqueConstraint with opclasses cannot be deferred.")
|
|
235
|
-
if expressions and deferrable:
|
|
236
|
-
raise ValueError("UniqueConstraint with expressions cannot be deferred.")
|
|
237
217
|
if expressions and opclasses:
|
|
238
218
|
raise ValueError(
|
|
239
219
|
"UniqueConstraint.opclasses cannot be used with expressions. "
|
|
240
220
|
"Use a custom OpClass() instead."
|
|
241
221
|
)
|
|
242
|
-
if not isinstance(deferrable, NoneType | Deferrable):
|
|
243
|
-
raise TypeError(
|
|
244
|
-
"UniqueConstraint.deferrable must be a Deferrable instance."
|
|
245
|
-
)
|
|
246
222
|
if not isinstance(include, NoneType | list | tuple):
|
|
247
223
|
raise TypeError("UniqueConstraint.include must be a list or tuple.")
|
|
248
224
|
if not isinstance(opclasses, list | tuple):
|
|
@@ -254,7 +230,6 @@ class UniqueConstraint(BaseConstraint):
|
|
|
254
230
|
)
|
|
255
231
|
self.fields = tuple(fields)
|
|
256
232
|
self.condition = condition
|
|
257
|
-
self.deferrable = deferrable
|
|
258
233
|
self.include = tuple(include) if include else ()
|
|
259
234
|
self.opclasses = opclasses
|
|
260
235
|
self.expressions = tuple(
|
|
@@ -311,7 +286,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
311
286
|
elif condition or self.include or self.opclasses or self.expressions:
|
|
312
287
|
return f"CREATE UNIQUE INDEX {name} ON {table} ({columns_sql}){include_sql}{condition_sql}"
|
|
313
288
|
else:
|
|
314
|
-
return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE ({columns_sql})
|
|
289
|
+
return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE ({columns_sql})"
|
|
315
290
|
|
|
316
291
|
def to_attach_sql(self, model: type[Model]) -> str:
|
|
317
292
|
"""Generate ALTER TABLE ADD CONSTRAINT UNIQUE USING INDEX SQL.
|
|
@@ -321,18 +296,15 @@ class UniqueConstraint(BaseConstraint):
|
|
|
321
296
|
"""
|
|
322
297
|
table = quote_name(model.model_options.db_table)
|
|
323
298
|
name = quote_name(self.name)
|
|
324
|
-
|
|
325
|
-
sql += deferrable_sql(self.deferrable)
|
|
326
|
-
return sql
|
|
299
|
+
return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE USING INDEX {name}"
|
|
327
300
|
|
|
328
301
|
def __repr__(self) -> str:
|
|
329
|
-
return "<{}:{}{}{}{}{}{}{}
|
|
302
|
+
return "<{}:{}{}{}{}{}{}{}>".format(
|
|
330
303
|
self.__class__.__qualname__,
|
|
331
304
|
"" if not self.fields else f" fields={self.fields!r}",
|
|
332
305
|
"" if not self.expressions else f" expressions={self.expressions!r}",
|
|
333
306
|
f" name={self.name!r}",
|
|
334
307
|
"" if self.condition is None else f" condition={self.condition}",
|
|
335
|
-
"" if self.deferrable is None else f" deferrable={self.deferrable!r}",
|
|
336
308
|
"" if not self.include else f" include={self.include!r}",
|
|
337
309
|
"" if not self.opclasses else f" opclasses={self.opclasses!r}",
|
|
338
310
|
(
|
|
@@ -348,7 +320,6 @@ class UniqueConstraint(BaseConstraint):
|
|
|
348
320
|
self.name == other.name
|
|
349
321
|
and self.fields == other.fields
|
|
350
322
|
and self.condition == other.condition
|
|
351
|
-
and self.deferrable == other.deferrable
|
|
352
323
|
and self.include == other.include
|
|
353
324
|
and self.opclasses == other.opclasses
|
|
354
325
|
and self.expressions == other.expressions
|
|
@@ -362,8 +333,6 @@ class UniqueConstraint(BaseConstraint):
|
|
|
362
333
|
kwargs["fields"] = self.fields
|
|
363
334
|
if self.condition:
|
|
364
335
|
kwargs["condition"] = self.condition
|
|
365
|
-
if self.deferrable:
|
|
366
|
-
kwargs["deferrable"] = self.deferrable
|
|
367
336
|
if self.include:
|
|
368
337
|
kwargs["include"] = self.include
|
|
369
338
|
if self.opclasses:
|