plain.postgres 0.113.2__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.113.2/plain/postgres/README.md → plain_postgres-0.115.0/PKG-INFO +75 -24
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/CHANGELOG.md +45 -1
- plain_postgres-0.113.2/PKG-INFO → plain_postgres-0.115.0/plain/postgres/README.md +62 -37
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/__init__.py +22 -41
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +5 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/base.py +19 -21
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/converge.py +2 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/core.py +4 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/migrations.py +2 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/sync.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/connection.py +12 -22
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/constraints.py +13 -44
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/convergence/analysis.py +146 -98
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/convergence/corrections.py +65 -26
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/convergence/planning.py +8 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/databases.py +3 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/ddl.py +0 -17
- plain_postgres-0.115.0/plain/postgres/deletion.py +32 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/dialect.py +3 -7
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/exceptions.py +0 -12
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/expressions.py +23 -32
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/base.py +8 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/binary.py +1 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/duration.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/encrypted.py +3 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/json.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/network.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/primary_key.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/related.py +42 -8
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/related_descriptors.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_descriptors.py +5 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/temporal.py +5 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/text.py +4 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/timezones.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/forms.py +13 -18
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/__init__.py +63 -70
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/datetime.py +5 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/math.py +1 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/mixins.py +2 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/text.py +11 -8
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/indexes.py +9 -9
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/context.py +1 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/runner.py +24 -14
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/schema.py +2 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/lookups.py +7 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/meta.py +29 -11
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/__init__.py +9 -14
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/autodetector.py +66 -71
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/exceptions.py +0 -12
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/executor.py +1 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/graph.py +3 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/loader.py +4 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/migration.py +13 -13
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/base.py +3 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/fields.py +2 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/models.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/special.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/questioner.py +1 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/serializer.py +8 -8
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/state.py +10 -12
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/utils.py +10 -11
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/writer.py +5 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/otel.py +2 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/preflight/database.py +1 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/query.py +17 -21
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/query_utils.py +7 -10
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/registry.py +14 -14
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/schema.py +8 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/schema_lock.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sources.py +1 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/__init__.py +11 -12
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/compiler.py +10 -12
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/query.py +13 -13
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/test/database.py +1 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/test/pytest.py +0 -13
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/transaction.py +2 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/utils.py +9 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/pyproject.toml +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0001_initial.py +5 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0002_test_field_removed.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +5 -19
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +6 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0006_secretstore.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +6 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0010_hideableitem.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0011_defaultsexample.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0012_iterationexample.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +6 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +6 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0016_formsexample.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0017_random_string_token.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/0018_storageparametersexample.py +5 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/constraints.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/defaults.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/delete.py +4 -10
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/encrypted.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/forms.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/indexes.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/iteration.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/mixins.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/nullability.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/querysets.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/relationships.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/storage_parameters.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/trees.py +2 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/urls.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/urls.py +2 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/conftest.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_autodetector_not_null_errors.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_autodetector_type_change.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_connection_isolation.py +0 -3
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_connection_lifecycle.py +3 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_connection_pool.py +1 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_connection_self_heal.py +7 -10
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_constraint_violation_error.py +4 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_constraints.py +20 -81
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_defaults.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_fk.py +223 -72
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_indexes.py +7 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_nullability.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_storage_parameters.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_convergence_timeouts.py +1 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_db_expression_defaults.py +12 -6
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_diagnose.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_executor_connection_hook.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_fk_characterization.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_health.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_literal_default_persistence.py +1 -5
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_management_connection.py +2 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_migration_executor.py +34 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_no_callable_defaults.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_otel_metrics.py +3 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_rollback_exc_attribution.py +6 -10
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_schema_lock.py +15 -14
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_schema_normalize_type.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_schema_timeouts.py +4 -13
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_create_update.py +9 -10
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_databases.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_delete_behaviors.py +25 -71
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_encrypted_fields.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_exceptions.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_field_defaults.py +8 -7
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_integrity_error_mapping.py +33 -9
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_m2m.py +2 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_manager_assignment.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_modelform_roundtrip.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_only_empty_defaults.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_order_by_expressions.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_queryset_slicing.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_random_string_field.py +0 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_read_only_transactions.py +20 -20
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_related.py +1 -2
- plain_postgres-0.113.2/plain/postgres/deletion.py +0 -48
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/.gitignore +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/CLAUDE.md +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/LICENSE +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/README.md +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/__init__.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/numeric.py +1 -1
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/__init__.py +4 -4
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/__init__.py +7 -7
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/migrations/recorder.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/types.py +2 -2
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.113.2 → plain_postgres-0.115.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.113.2 → 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.**
|
|
@@ -166,9 +179,8 @@ user = User.query.get(email="test@example.com")
|
|
|
166
179
|
|
|
167
180
|
# Complex queries with Q objects
|
|
168
181
|
from plain.postgres import Q
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)
|
|
182
|
+
|
|
183
|
+
users = User.query.filter(Q(is_admin=True) | Q(email__endswith="@example.com"))
|
|
172
184
|
|
|
173
185
|
# Ordering
|
|
174
186
|
users = User.query.order_by("-created_at")
|
|
@@ -187,6 +199,7 @@ You can customize [`QuerySet`](./query.py#QuerySet) classes to provide specializ
|
|
|
187
199
|
from typing import Self
|
|
188
200
|
from plain.postgres import types
|
|
189
201
|
|
|
202
|
+
|
|
190
203
|
class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
191
204
|
def published_only(self) -> Self:
|
|
192
205
|
return self.filter(status="published")
|
|
@@ -194,6 +207,7 @@ class PublishedQuerySet(postgres.QuerySet["Article"]):
|
|
|
194
207
|
def draft_only(self) -> Self:
|
|
195
208
|
return self.filter(status="draft")
|
|
196
209
|
|
|
210
|
+
|
|
197
211
|
@postgres.register_model
|
|
198
212
|
class Article(postgres.Model):
|
|
199
213
|
title: str = types.TextField(max_length=200)
|
|
@@ -201,6 +215,7 @@ class Article(postgres.Model):
|
|
|
201
215
|
|
|
202
216
|
query = PublishedQuerySet()
|
|
203
217
|
|
|
218
|
+
|
|
204
219
|
# Usage - all methods available on Article.query
|
|
205
220
|
all_articles = Article.query.all()
|
|
206
221
|
published_articles = Article.query.published_only()
|
|
@@ -226,6 +241,7 @@ from __future__ import annotations
|
|
|
226
241
|
from plain import postgres
|
|
227
242
|
from plain.postgres import types
|
|
228
243
|
|
|
244
|
+
|
|
229
245
|
@postgres.register_model
|
|
230
246
|
class User(postgres.Model):
|
|
231
247
|
email: str = types.EmailField()
|
|
@@ -243,11 +259,14 @@ For complex queries that can't be expressed with the ORM, you can use raw SQL.
|
|
|
243
259
|
Use `Model.query.raw()` to execute raw SQL and get model instances back:
|
|
244
260
|
|
|
245
261
|
```python
|
|
246
|
-
users = User.query.raw(
|
|
262
|
+
users = User.query.raw(
|
|
263
|
+
"""
|
|
247
264
|
SELECT * FROM users
|
|
248
265
|
WHERE created_at > %s
|
|
249
266
|
ORDER BY created_at DESC
|
|
250
|
-
""",
|
|
267
|
+
""",
|
|
268
|
+
[some_date],
|
|
269
|
+
)
|
|
251
270
|
|
|
252
271
|
for user in users:
|
|
253
272
|
print(user.email) # Full model instance with all fields
|
|
@@ -320,6 +339,7 @@ for category in Category.query.all():
|
|
|
320
339
|
|
|
321
340
|
# Good — single query with annotation
|
|
322
341
|
from plain.postgres.aggregates import Count
|
|
342
|
+
|
|
323
343
|
for category in Category.query.annotate(num_products=Count("products")).all():
|
|
324
344
|
print(category.num_products)
|
|
325
345
|
```
|
|
@@ -333,6 +353,7 @@ Templates should only render data, never trigger queries. Prepare everything in
|
|
|
333
353
|
def get_template_context(self):
|
|
334
354
|
return {"posts": Post.query.all()} # related lookups happen in template
|
|
335
355
|
|
|
356
|
+
|
|
336
357
|
# Good — eagerly load everything
|
|
337
358
|
def get_template_context(self):
|
|
338
359
|
return {"posts": Post.query.select_related("author").prefetch_related("tags").all()}
|
|
@@ -356,10 +377,12 @@ emails = list(User.query.values_list("email", flat=True))
|
|
|
356
377
|
|
|
357
378
|
```python
|
|
358
379
|
# Bad
|
|
359
|
-
if User.query.filter(is_active=True).count() > 0:
|
|
380
|
+
if User.query.filter(is_active=True).count() > 0:
|
|
381
|
+
...
|
|
360
382
|
|
|
361
383
|
# Good
|
|
362
|
-
if User.query.filter(is_active=True).exists():
|
|
384
|
+
if User.query.filter(is_active=True).exists():
|
|
385
|
+
...
|
|
363
386
|
```
|
|
364
387
|
|
|
365
388
|
#### Use `.count()` instead of `len(queryset)`
|
|
@@ -466,8 +489,8 @@ Run a block of code in a read-only transaction using `read_only()`. Any write (I
|
|
|
466
489
|
from plain.postgres.db import read_only
|
|
467
490
|
|
|
468
491
|
with read_only():
|
|
469
|
-
users = User.query.all()
|
|
470
|
-
User.query.create(name="x")
|
|
492
|
+
users = User.query.all() # reads work
|
|
493
|
+
User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
|
|
471
494
|
```
|
|
472
495
|
|
|
473
496
|
`read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
|
|
@@ -480,10 +503,10 @@ Because the whole block is one transaction, catching a database error inside `re
|
|
|
480
503
|
with read_only():
|
|
481
504
|
try:
|
|
482
505
|
with atomic():
|
|
483
|
-
User.query.create(name="x")
|
|
506
|
+
User.query.create(name="x") # raises, savepoint rolls back
|
|
484
507
|
except psycopg.errors.ReadOnlySqlTransaction:
|
|
485
508
|
pass
|
|
486
|
-
User.query.count()
|
|
509
|
+
User.query.count() # still works — outer txn is healthy
|
|
487
510
|
```
|
|
488
511
|
|
|
489
512
|
## Schema management
|
|
@@ -705,7 +728,9 @@ class User(postgres.Model):
|
|
|
705
728
|
],
|
|
706
729
|
constraints=[
|
|
707
730
|
postgres.UniqueConstraint(fields=["email"], name="users_email_uniq"),
|
|
708
|
-
postgres.CheckConstraint(
|
|
731
|
+
postgres.CheckConstraint(
|
|
732
|
+
check=postgres.Q(age__gte=0), name="users_age_positive"
|
|
733
|
+
),
|
|
709
734
|
],
|
|
710
735
|
)
|
|
711
736
|
```
|
|
@@ -806,6 +831,7 @@ from datetime import datetime
|
|
|
806
831
|
from plain import postgres
|
|
807
832
|
from plain.postgres import types
|
|
808
833
|
|
|
834
|
+
|
|
809
835
|
class Product(postgres.Model):
|
|
810
836
|
# Text fields
|
|
811
837
|
name: str = types.TextField(max_length=200)
|
|
@@ -910,6 +936,7 @@ This is **not** for passwords or tokens you issue — those should be hashed (on
|
|
|
910
936
|
from plain import postgres
|
|
911
937
|
from plain.postgres import types
|
|
912
938
|
|
|
939
|
+
|
|
913
940
|
@postgres.register_model
|
|
914
941
|
class Integration(postgres.Model):
|
|
915
942
|
name: str = types.TextField(max_length=100)
|
|
@@ -946,6 +973,7 @@ Use [`ForeignKeyField`](./fields/related.py#ForeignKeyField) for many-to-one and
|
|
|
946
973
|
from plain import postgres
|
|
947
974
|
from plain.postgres import types
|
|
948
975
|
|
|
976
|
+
|
|
949
977
|
@postgres.register_model
|
|
950
978
|
class Book(postgres.Model):
|
|
951
979
|
title: str = types.TextField(max_length=200)
|
|
@@ -959,15 +987,23 @@ Accessing a foreign key gives you the related object without a query — only it
|
|
|
959
987
|
|
|
960
988
|
```python
|
|
961
989
|
book = Book.query.get(id=1)
|
|
962
|
-
book.author
|
|
963
|
-
book.author.id
|
|
964
|
-
book.author.name
|
|
990
|
+
book.author # no query — a partial Author instance
|
|
991
|
+
book.author.id # no query — the foreign key value
|
|
992
|
+
book.author.name # one query — loads the rest of the row
|
|
965
993
|
```
|
|
966
994
|
|
|
967
995
|
The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
|
|
968
996
|
|
|
969
997
|
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
970
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
|
+
|
|
971
1007
|
### Reverse relationships
|
|
972
1008
|
|
|
973
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):
|
|
@@ -976,17 +1012,20 @@ When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates
|
|
|
976
1012
|
from plain import postgres
|
|
977
1013
|
from plain.postgres import types
|
|
978
1014
|
|
|
1015
|
+
|
|
979
1016
|
@postgres.register_model
|
|
980
1017
|
class Author(postgres.Model):
|
|
981
1018
|
name: str = types.TextField(max_length=200)
|
|
982
1019
|
# Explicit reverse accessor for all books by this author
|
|
983
1020
|
books = types.ReverseForeignKey(to="Book", field="author")
|
|
984
1021
|
|
|
1022
|
+
|
|
985
1023
|
@postgres.register_model
|
|
986
1024
|
class Book(postgres.Model):
|
|
987
1025
|
title: str = types.TextField(max_length=200)
|
|
988
1026
|
author: Author = types.ForeignKeyField(Author, on_delete=postgres.CASCADE)
|
|
989
1027
|
|
|
1028
|
+
|
|
990
1029
|
# Usage
|
|
991
1030
|
author = Author.query.get(name="Jane Doe")
|
|
992
1031
|
for book in author.books.all():
|
|
@@ -1005,11 +1044,13 @@ class Feature(postgres.Model):
|
|
|
1005
1044
|
# Explicit reverse accessor for all cars with this feature
|
|
1006
1045
|
cars = types.ReverseManyToMany(to="Car", field="features")
|
|
1007
1046
|
|
|
1047
|
+
|
|
1008
1048
|
@postgres.register_model
|
|
1009
1049
|
class Car(postgres.Model):
|
|
1010
1050
|
model: str = types.TextField(max_length=100)
|
|
1011
1051
|
features = types.ManyToManyField(Feature)
|
|
1012
1052
|
|
|
1053
|
+
|
|
1013
1054
|
# Usage
|
|
1014
1055
|
feature = Feature.query.get(name="Sunroof")
|
|
1015
1056
|
for car in feature.cars.all():
|
|
@@ -1029,10 +1070,14 @@ To get type checking for custom QuerySet methods on reverse relations, specify t
|
|
|
1029
1070
|
|
|
1030
1071
|
```python
|
|
1031
1072
|
# Basic usage
|
|
1032
|
-
books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(
|
|
1073
|
+
books: types.ReverseForeignKey[Book] = types.ReverseForeignKey(
|
|
1074
|
+
to="Book", field="author"
|
|
1075
|
+
)
|
|
1033
1076
|
|
|
1034
1077
|
# With custom QuerySet for proper method recognition
|
|
1035
|
-
books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(
|
|
1078
|
+
books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(
|
|
1079
|
+
to="Book", field="author"
|
|
1080
|
+
)
|
|
1036
1081
|
|
|
1037
1082
|
# Now type checkers recognize custom methods like .published()
|
|
1038
1083
|
author.books.query.published()
|
|
@@ -1063,7 +1108,7 @@ class User(postgres.Model):
|
|
|
1063
1108
|
|
|
1064
1109
|
Field-level validation happens automatically based on field types and constraints.
|
|
1065
1110
|
|
|
1066
|
-
**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`.)
|
|
1067
1112
|
|
|
1068
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:
|
|
1069
1114
|
|
|
@@ -1077,7 +1122,7 @@ except ValidationError:
|
|
|
1077
1122
|
|
|
1078
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.
|
|
1079
1124
|
|
|
1080
|
-
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:
|
|
1081
1126
|
|
|
1082
1127
|
```python
|
|
1083
1128
|
try:
|
|
@@ -1088,8 +1133,6 @@ except (psycopg.IntegrityError, ValidationError):
|
|
|
1088
1133
|
|
|
1089
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.
|
|
1090
1135
|
|
|
1091
|
-
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.
|
|
1092
|
-
|
|
1093
1136
|
### Indexes and constraints
|
|
1094
1137
|
|
|
1095
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`.
|
|
@@ -1176,6 +1219,7 @@ class Order(postgres.Model):
|
|
|
1176
1219
|
status: str = types.TextField(max_length=20)
|
|
1177
1220
|
created_at: datetime = types.DateTimeField()
|
|
1178
1221
|
|
|
1222
|
+
|
|
1179
1223
|
# Good — indexed for common queries
|
|
1180
1224
|
class Order(postgres.Model):
|
|
1181
1225
|
status: str = types.TextField(max_length=20)
|
|
@@ -1197,6 +1241,7 @@ def create(self):
|
|
|
1197
1241
|
raise ValueError("duplicate")
|
|
1198
1242
|
return super().create()
|
|
1199
1243
|
|
|
1244
|
+
|
|
1200
1245
|
# Good — database-enforced
|
|
1201
1246
|
model_options = postgres.Options(
|
|
1202
1247
|
constraints=[postgres.UniqueConstraint(fields=["email"])],
|
|
@@ -1209,7 +1254,9 @@ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional
|
|
|
1209
1254
|
|
|
1210
1255
|
```python
|
|
1211
1256
|
# Bad — blindly using CASCADE everywhere
|
|
1212
|
-
company: Company = types.ForeignKeyField(
|
|
1257
|
+
company: Company = types.ForeignKeyField(
|
|
1258
|
+
"Company", on_delete=postgres.CASCADE
|
|
1259
|
+
) # deleting company deletes invoices!
|
|
1213
1260
|
|
|
1214
1261
|
# Good — block the delete while invoices reference the company
|
|
1215
1262
|
company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
|
|
@@ -1235,10 +1282,12 @@ Models integrate with [plain.forms](../../../plain-forms/plain/forms/README.md):
|
|
|
1235
1282
|
from plain import forms
|
|
1236
1283
|
from .models import User
|
|
1237
1284
|
|
|
1285
|
+
|
|
1238
1286
|
class UserForm(forms.ModelForm):
|
|
1239
1287
|
class Meta:
|
|
1240
1288
|
model = User
|
|
1241
|
-
fields =
|
|
1289
|
+
fields = ("email", "is_admin")
|
|
1290
|
+
|
|
1242
1291
|
|
|
1243
1292
|
# Usage
|
|
1244
1293
|
form = UserForm(request=request)
|
|
@@ -1499,7 +1548,9 @@ Use `UniqueConstraint` in your model's `model_options`:
|
|
|
1499
1548
|
```python
|
|
1500
1549
|
model_options = postgres.Options(
|
|
1501
1550
|
constraints=[
|
|
1502
|
-
postgres.UniqueConstraint(
|
|
1551
|
+
postgres.UniqueConstraint(
|
|
1552
|
+
fields=["email", "organization"], name="unique_email_per_org"
|
|
1553
|
+
),
|
|
1503
1554
|
],
|
|
1504
1555
|
)
|
|
1505
1556
|
```
|
|
@@ -1,5 +1,45 @@
|
|
|
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
|
+
|
|
26
|
+
## [0.114.0](https://github.com/dropseed/plain/releases/plain-postgres@0.114.0) (2026-08-12)
|
|
27
|
+
|
|
28
|
+
### What's changed
|
|
29
|
+
|
|
30
|
+
- Wrong-type arguments now raise `TypeError` instead of `ValueError` across the schema and query APIs: `UniqueConstraint`/`Index` options (`condition`, `deferrable`, `include`, `opclasses`, `fields`), `Extract`/`Trunc` field types, non-bool `isnull` lookups, `RunPython` without a callable, serializer registration, assigning a wrong-model instance (or bool) to a foreign key, and reverse descriptors on non-FK/M2M fields ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
31
|
+
- `Migration.operations`/`dependencies`/`replaces` are typed as `Sequence`, and `plain migrations create` now writes them as tuples in generated files. Existing list-based migration files keep working ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
32
|
+
- Migration filename timestamps use the app's `TIME_ZONE` (via `timezone.localtime()`) instead of the machine's local clock, so a team generates consistently named migrations ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
33
|
+
- Model field `default_validators` is now a tuple and `empty_values` a tuple; `ignored_tables` on the connection is a tuple ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
34
|
+
- `ModelsRegistry.get_models()` caching was reworked to a per-instance memo (still cleared by `clear_cache()`), replacing a `functools.cache` keyed on the registry instance ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
35
|
+
- `on_commit()` handler errors are logged with `logger.exception()` — same message and traceback, standard idiom ([f52e18f532](https://github.com/dropseed/plain/commit/f52e18f532))
|
|
36
|
+
|
|
37
|
+
### Upgrade instructions
|
|
38
|
+
|
|
39
|
+
- If you catch `ValueError` around any of the argument-validation cases above, catch `TypeError` instead
|
|
40
|
+
- Newly generated migrations use tuples for `dependencies`/`operations`; existing migration files don't need to change
|
|
41
|
+
- Requires `plain>=0.157.0`
|
|
42
|
+
|
|
3
43
|
## [0.113.2](https://github.com/dropseed/plain/releases/plain-postgres@0.113.2) (2026-08-10)
|
|
4
44
|
|
|
5
45
|
### What's changed
|
|
@@ -408,7 +448,11 @@
|
|
|
408
448
|
|
|
409
449
|
```python
|
|
410
450
|
# Before
|
|
411
|
-
from plain.postgres.connections import
|
|
451
|
+
from plain.postgres.connections import (
|
|
452
|
+
get_connection,
|
|
453
|
+
read_only,
|
|
454
|
+
use_management_connection,
|
|
455
|
+
)
|
|
412
456
|
|
|
413
457
|
# After
|
|
414
458
|
from plain.postgres.db import get_connection, read_only, use_management_connection
|