plain.postgres 0.99.1__tar.gz → 0.101.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.99.1/plain/postgres/README.md → plain_postgres-0.101.0/PKG-INFO +47 -5
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/CHANGELOG.md +27 -0
- plain_postgres-0.99.1/PKG-INFO → plain_postgres-0.101.0/plain/postgres/README.md +33 -19
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +4 -4
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/base.py +2 -8
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/constraints.py +55 -80
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/convergence/fixes.py +16 -6
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/pyproject.toml +1 -1
- plain_postgres-0.101.0/tests/test_constraint_violation_error.py +216 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_constraints.py +6 -19
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_timeouts.py +5 -2
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_otel_metrics.py +57 -57
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/.gitignore +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/CLAUDE.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/LICENSE +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/README.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/conftest.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_connection_pool.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_diagnose.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_field_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_functions_uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_health.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_m2m.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_management_connection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_random_string_field.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_raw_query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.101.0}/tests/test_schema_timeouts.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.101.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.134.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Requires-Dist: sqlparse>=0.3.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
1
15
|
# plain.postgres
|
|
2
16
|
|
|
3
17
|
**Model your data and store it in a database.**
|
|
@@ -1003,7 +1017,7 @@ author.books.query.published()
|
|
|
1003
1017
|
|
|
1004
1018
|
### Validation
|
|
1005
1019
|
|
|
1006
|
-
|
|
1020
|
+
`save()` runs `full_clean()` by default — field validators, model `clean()`, and any constraints with a `validate()` method are all checked, raising `ValidationError` on violation. Pass `clean_and_validate=False` to skip it (e.g. for trusted bulk loads).
|
|
1007
1021
|
|
|
1008
1022
|
```python
|
|
1009
1023
|
@postgres.register_model
|
|
@@ -1020,10 +1034,6 @@ class User(postgres.Model):
|
|
|
1020
1034
|
def clean(self):
|
|
1021
1035
|
if self.age < 18:
|
|
1022
1036
|
raise ValidationError("User must be 18 or older")
|
|
1023
|
-
|
|
1024
|
-
def save(self, *args, **kwargs):
|
|
1025
|
-
self.full_clean() # Runs validation
|
|
1026
|
-
super().save(*args, **kwargs)
|
|
1027
1037
|
```
|
|
1028
1038
|
|
|
1029
1039
|
Field-level validation happens automatically based on field types and constraints.
|
|
@@ -1050,6 +1060,38 @@ class User(postgres.Model):
|
|
|
1050
1060
|
)
|
|
1051
1061
|
```
|
|
1052
1062
|
|
|
1063
|
+
Constraints are also checked during `full_clean()` (which `save()` runs by default — see [Validation](#validation)). Pass `violation_error` to customize the resulting `ValidationError`. It accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, or a fully-formed `ValidationError`:
|
|
1064
|
+
|
|
1065
|
+
```python
|
|
1066
|
+
# Simple message — lands on NON_FIELD_ERRORS
|
|
1067
|
+
postgres.CheckConstraint(
|
|
1068
|
+
check=postgres.Q(age__gte=0),
|
|
1069
|
+
name="age_positive",
|
|
1070
|
+
violation_error="Age must be zero or greater.",
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Dict form — routes to a specific field
|
|
1074
|
+
postgres.CheckConstraint(
|
|
1075
|
+
check=postgres.Q(age__gte=0),
|
|
1076
|
+
name="age_positive",
|
|
1077
|
+
violation_error={"age": "Age must be zero or greater."},
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
# Full ValidationError — for code, params, multiple fields
|
|
1081
|
+
postgres.CheckConstraint(
|
|
1082
|
+
check=postgres.Q(age__gte=0),
|
|
1083
|
+
name="age_positive",
|
|
1084
|
+
violation_error=ValidationError(
|
|
1085
|
+
{"age": "Age must be zero or greater."},
|
|
1086
|
+
code="age_negative",
|
|
1087
|
+
),
|
|
1088
|
+
)
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
A `code` becomes `ValidationError.code` — useful for test assertions, error tracking buckets, and code that branches on specific error types without string-matching.
|
|
1092
|
+
|
|
1093
|
+
`UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
|
|
1094
|
+
|
|
1053
1095
|
### Schema design
|
|
1054
1096
|
|
|
1055
1097
|
#### Index fields used in filters and ordering
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.101.0](https://github.com/dropseed/plain/releases/plain-postgres@0.101.0) (2026-04-30)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Validate CHECK constraints in the same converge run that adds them.** `AddConstraintFix` now runs `ALTER TABLE ... ADD CONSTRAINT ... NOT VALID` followed by `ALTER TABLE ... VALIDATE CONSTRAINT` in a single `apply()`. The add is catalog-only (brief lock) and validate uses `SHARE UPDATE EXCLUSIVE` (doesn't block writes), so there's no benefit to deferring validation to a later run. Existing rows are checked before convergence reports success — previously, a CHECK constraint could be added in `NOT VALID` state and the validation step was its own follow-up fix. ([dc7eb8d3c2b7](https://github.com/dropseed/plain/commit/dc7eb8d3c2b7))
|
|
8
|
+
- `plain-postgres` rule references updated for the simpler `plain docs` CLI (no more `--section`). ([e03c3bd8b6d3](https://github.com/dropseed/plain/commit/e03c3bd8b6d3))
|
|
9
|
+
|
|
10
|
+
### Upgrade instructions
|
|
11
|
+
|
|
12
|
+
- No changes required. The next `plain postgres sync` (or scheduled converge run) on a database with pending CHECK constraints will now both add and validate them in one step instead of two.
|
|
13
|
+
|
|
14
|
+
## [0.100.0](https://github.com/dropseed/plain/releases/plain-postgres@0.100.0) (2026-04-28)
|
|
15
|
+
|
|
16
|
+
### What's changed
|
|
17
|
+
|
|
18
|
+
- **Replaced the `violation_error_message` / `violation_error_code` triad on `CheckConstraint` and `UniqueConstraint` with a single `violation_error` kwarg.** The new kwarg accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, a list, or a fully-formed `ValidationError` — so message text, error code, and field routing all live on one object. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
|
|
19
|
+
- **Single-field `UniqueConstraint` now auto-routes flat errors to its field.** A `violation_error="That email is taken."` on `UniqueConstraint(fields=["email"])` lands on the `email` form field instead of `NON_FIELD_ERRORS`. A caller-built `ValidationError({"other_field": ...})` is preserved as-is. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
|
|
20
|
+
- **Dropped the hardcoded `code == "unique"` routing in `validate_constraints()`.** Routing is now uniform across constraint types: dict-form errors land on fields, flat errors go to `NON_FIELD_ERRORS`. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
|
|
21
|
+
- **Removed the `%(name)s` interpolation magic** on `BaseConstraint.default_violation_error_message`. The default message still includes the constraint name; users wanting runtime interpolation can pass `ValidationError(..., params={"name": ...})`. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
|
|
22
|
+
- Documented that `save()` runs `full_clean()` by default (`clean_and_validate=True`); fixed the README's Validation example which previously implied users had to override `save()` to call `full_clean()` manually. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
|
|
23
|
+
|
|
24
|
+
### Upgrade instructions
|
|
25
|
+
|
|
26
|
+
- Replace `violation_error_message="..."` and `violation_error_code="..."` on `CheckConstraint` / `UniqueConstraint` with a single `violation_error=ValidationError("...", code="...")` (or a string if you only need the message).
|
|
27
|
+
- If you relied on the implicit single-field-unique routing for a constraint with a custom `violation_error_code`, no change needed — single-field `UniqueConstraint` still auto-routes by default.
|
|
28
|
+
- If you used `%(name)s` in `violation_error_message`, switch to `ValidationError("...", params={"name": "your_constraint_name"})` or hardcode the name.
|
|
29
|
+
|
|
3
30
|
## [0.99.1](https://github.com/dropseed/plain/releases/plain-postgres@0.99.1) (2026-04-26)
|
|
4
31
|
|
|
5
32
|
### What's changed
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.99.1
|
|
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.134.0
|
|
10
|
-
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
-
Requires-Dist: psycopg>=3.2
|
|
12
|
-
Requires-Dist: sqlparse>=0.3.1
|
|
13
|
-
Description-Content-Type: text/markdown
|
|
14
|
-
|
|
15
1
|
# plain.postgres
|
|
16
2
|
|
|
17
3
|
**Model your data and store it in a database.**
|
|
@@ -1017,7 +1003,7 @@ author.books.query.published()
|
|
|
1017
1003
|
|
|
1018
1004
|
### Validation
|
|
1019
1005
|
|
|
1020
|
-
|
|
1006
|
+
`save()` runs `full_clean()` by default — field validators, model `clean()`, and any constraints with a `validate()` method are all checked, raising `ValidationError` on violation. Pass `clean_and_validate=False` to skip it (e.g. for trusted bulk loads).
|
|
1021
1007
|
|
|
1022
1008
|
```python
|
|
1023
1009
|
@postgres.register_model
|
|
@@ -1034,10 +1020,6 @@ class User(postgres.Model):
|
|
|
1034
1020
|
def clean(self):
|
|
1035
1021
|
if self.age < 18:
|
|
1036
1022
|
raise ValidationError("User must be 18 or older")
|
|
1037
|
-
|
|
1038
|
-
def save(self, *args, **kwargs):
|
|
1039
|
-
self.full_clean() # Runs validation
|
|
1040
|
-
super().save(*args, **kwargs)
|
|
1041
1023
|
```
|
|
1042
1024
|
|
|
1043
1025
|
Field-level validation happens automatically based on field types and constraints.
|
|
@@ -1064,6 +1046,38 @@ class User(postgres.Model):
|
|
|
1064
1046
|
)
|
|
1065
1047
|
```
|
|
1066
1048
|
|
|
1049
|
+
Constraints are also checked during `full_clean()` (which `save()` runs by default — see [Validation](#validation)). Pass `violation_error` to customize the resulting `ValidationError`. It accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, or a fully-formed `ValidationError`:
|
|
1050
|
+
|
|
1051
|
+
```python
|
|
1052
|
+
# Simple message — lands on NON_FIELD_ERRORS
|
|
1053
|
+
postgres.CheckConstraint(
|
|
1054
|
+
check=postgres.Q(age__gte=0),
|
|
1055
|
+
name="age_positive",
|
|
1056
|
+
violation_error="Age must be zero or greater.",
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
# Dict form — routes to a specific field
|
|
1060
|
+
postgres.CheckConstraint(
|
|
1061
|
+
check=postgres.Q(age__gte=0),
|
|
1062
|
+
name="age_positive",
|
|
1063
|
+
violation_error={"age": "Age must be zero or greater."},
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
# Full ValidationError — for code, params, multiple fields
|
|
1067
|
+
postgres.CheckConstraint(
|
|
1068
|
+
check=postgres.Q(age__gte=0),
|
|
1069
|
+
name="age_positive",
|
|
1070
|
+
violation_error=ValidationError(
|
|
1071
|
+
{"age": "Age must be zero or greater."},
|
|
1072
|
+
code="age_negative",
|
|
1073
|
+
),
|
|
1074
|
+
)
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
A `code` becomes `ValidationError.code` — useful for test assertions, error tracking buckets, and code that branches on specific error types without string-matching.
|
|
1078
|
+
|
|
1079
|
+
`UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
|
|
1080
|
+
|
|
1067
1081
|
### Schema design
|
|
1068
1082
|
|
|
1069
1083
|
#### Index fields used in filters and ordering
|
|
@@ -42,7 +42,7 @@ This means: when you add an `Index` or `UniqueConstraint` to a model, no migrati
|
|
|
42
42
|
|
|
43
43
|
For custom data migrations, use `uv run plain migrations create --empty --name <name>` to scaffold the file.
|
|
44
44
|
|
|
45
|
-
Run `uv run plain docs postgres
|
|
45
|
+
Run `uv run plain docs postgres` for full workflow details.
|
|
46
46
|
|
|
47
47
|
## Querying
|
|
48
48
|
|
|
@@ -57,7 +57,7 @@ Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`)
|
|
|
57
57
|
- Wrap multi-step writes in `transaction.atomic()`
|
|
58
58
|
- Always paginate list queries — unbounded querysets get slower as data grows
|
|
59
59
|
|
|
60
|
-
Run `uv run plain docs postgres
|
|
60
|
+
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
61
61
|
|
|
62
62
|
## Schema Design
|
|
63
63
|
|
|
@@ -68,13 +68,13 @@ Run `uv run plain docs postgres --section querying` for full patterns with code
|
|
|
68
68
|
- Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
|
|
69
69
|
- No `allow_null` on string fields — use `default=""`
|
|
70
70
|
|
|
71
|
-
Run `uv run plain docs postgres
|
|
71
|
+
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
72
72
|
|
|
73
73
|
## Database Doctor
|
|
74
74
|
|
|
75
75
|
Use the `/plain-postgres-doctor` skill to check overall database health — migration sync, schema correctness, and operational health.
|
|
76
76
|
|
|
77
|
-
Run `uv run plain docs postgres
|
|
77
|
+
Run `uv run plain docs postgres` for check details, thresholds, and production usage.
|
|
78
78
|
|
|
79
79
|
## Differences from Django
|
|
80
80
|
|
|
@@ -812,19 +812,13 @@ class Model(metaclass=ModelBase):
|
|
|
812
812
|
def validate_constraints(self, exclude: set[str] | None = None) -> None:
|
|
813
813
|
constraints = self.get_constraints()
|
|
814
814
|
|
|
815
|
-
errors = {}
|
|
815
|
+
errors: dict[str, list[ValidationError]] = {}
|
|
816
816
|
for model_class, model_constraints in constraints:
|
|
817
817
|
for constraint in model_constraints:
|
|
818
818
|
try:
|
|
819
819
|
constraint.validate(model_class, self, exclude=exclude)
|
|
820
820
|
except ValidationError as e:
|
|
821
|
-
|
|
822
|
-
getattr(e, "code", None) == "unique"
|
|
823
|
-
and len(constraint.fields) == 1
|
|
824
|
-
):
|
|
825
|
-
errors.setdefault(constraint.fields[0], []).append(e)
|
|
826
|
-
else:
|
|
827
|
-
errors = e.update_error_dict(errors)
|
|
821
|
+
errors = e.update_error_dict(errors)
|
|
828
822
|
if errors:
|
|
829
823
|
raise ValidationError(errors)
|
|
830
824
|
|
|
@@ -28,25 +28,20 @@ if TYPE_CHECKING:
|
|
|
28
28
|
__all__ = ["BaseConstraint", "CheckConstraint", "Deferrable", "UniqueConstraint"]
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
ViolationError = str | dict[str, Any] | list[Any] | ValidationError
|
|
32
|
+
|
|
33
|
+
|
|
31
34
|
class BaseConstraint:
|
|
32
|
-
|
|
33
|
-
violation_error_code: str | None = None
|
|
34
|
-
violation_error_message: str | None = None
|
|
35
|
+
violation_error: ViolationError | None = None
|
|
35
36
|
|
|
36
37
|
def __init__(
|
|
37
38
|
self,
|
|
38
39
|
*,
|
|
39
40
|
name: str,
|
|
40
|
-
|
|
41
|
-
violation_error_message: str | None = None,
|
|
41
|
+
violation_error: ViolationError | None = None,
|
|
42
42
|
) -> None:
|
|
43
43
|
self.name = name
|
|
44
|
-
|
|
45
|
-
self.violation_error_code = violation_error_code
|
|
46
|
-
if violation_error_message is not None:
|
|
47
|
-
self.violation_error_message = violation_error_message
|
|
48
|
-
else:
|
|
49
|
-
self.violation_error_message = self.default_violation_error_message
|
|
44
|
+
self.violation_error = violation_error
|
|
50
45
|
|
|
51
46
|
@property
|
|
52
47
|
def contains_expressions(self) -> bool:
|
|
@@ -64,21 +59,19 @@ class BaseConstraint:
|
|
|
64
59
|
"subclasses of BaseConstraint must provide a validate() method"
|
|
65
60
|
)
|
|
66
61
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
def _build_violation_error(self) -> ValidationError:
|
|
63
|
+
if self.violation_error is None:
|
|
64
|
+
return ValidationError(f'Constraint "{self.name}" is violated.')
|
|
65
|
+
if isinstance(self.violation_error, ValidationError):
|
|
66
|
+
return self.violation_error
|
|
67
|
+
return ValidationError(self.violation_error)
|
|
70
68
|
|
|
71
69
|
def deconstruct(self) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
|
|
72
70
|
path = f"{self.__class__.__module__}.{self.__class__.__name__}"
|
|
73
71
|
path = path.replace("plain.postgres.constraints", "plain.postgres")
|
|
74
72
|
kwargs: dict[str, Any] = {"name": self.name}
|
|
75
|
-
if
|
|
76
|
-
self.
|
|
77
|
-
and self.violation_error_message != self.default_violation_error_message
|
|
78
|
-
):
|
|
79
|
-
kwargs["violation_error_message"] = self.violation_error_message
|
|
80
|
-
if self.violation_error_code is not None:
|
|
81
|
-
kwargs["violation_error_code"] = self.violation_error_code
|
|
73
|
+
if self.violation_error is not None:
|
|
74
|
+
kwargs["violation_error"] = self.violation_error
|
|
82
75
|
return (path, (), kwargs)
|
|
83
76
|
|
|
84
77
|
def clone(self) -> BaseConstraint:
|
|
@@ -92,19 +85,14 @@ class CheckConstraint(BaseConstraint):
|
|
|
92
85
|
*,
|
|
93
86
|
check: Q,
|
|
94
87
|
name: str,
|
|
95
|
-
|
|
96
|
-
violation_error_message: str | None = None,
|
|
88
|
+
violation_error: ViolationError | None = None,
|
|
97
89
|
) -> None:
|
|
98
90
|
self.check = check
|
|
99
91
|
if not getattr(check, "conditional", False):
|
|
100
92
|
raise TypeError(
|
|
101
93
|
"CheckConstraint.check must be a Q instance or boolean expression."
|
|
102
94
|
)
|
|
103
|
-
super().__init__(
|
|
104
|
-
name=name,
|
|
105
|
-
violation_error_code=violation_error_code,
|
|
106
|
-
violation_error_message=violation_error_message,
|
|
107
|
-
)
|
|
95
|
+
super().__init__(name=name, violation_error=violation_error)
|
|
108
96
|
|
|
109
97
|
def to_sql(self, model: type[Model], *, not_valid: bool = False) -> str:
|
|
110
98
|
"""Generate ALTER TABLE ADD CONSTRAINT CHECK SQL as a plain string."""
|
|
@@ -122,27 +110,19 @@ class CheckConstraint(BaseConstraint):
|
|
|
122
110
|
against = instance._get_field_value_map(meta=model._model_meta, exclude=exclude)
|
|
123
111
|
try:
|
|
124
112
|
if not Q(self.check).check(against):
|
|
125
|
-
raise
|
|
126
|
-
self.get_violation_error_message(), code=self.violation_error_code
|
|
127
|
-
)
|
|
113
|
+
raise self._build_violation_error()
|
|
128
114
|
except FieldError:
|
|
129
115
|
pass
|
|
130
116
|
|
|
131
117
|
def __repr__(self) -> str:
|
|
132
|
-
return "<{}: check={} name={}{}
|
|
118
|
+
return "<{}: check={} name={}{}>".format(
|
|
133
119
|
self.__class__.__qualname__,
|
|
134
120
|
self.check,
|
|
135
121
|
repr(self.name),
|
|
136
122
|
(
|
|
137
123
|
""
|
|
138
|
-
if self.
|
|
139
|
-
else f"
|
|
140
|
-
),
|
|
141
|
-
(
|
|
142
|
-
""
|
|
143
|
-
if self.violation_error_message is None
|
|
144
|
-
or self.violation_error_message == self.default_violation_error_message
|
|
145
|
-
else f" violation_error_message={self.violation_error_message!r}"
|
|
124
|
+
if self.violation_error is None
|
|
125
|
+
else f" violation_error={self.violation_error!r}"
|
|
146
126
|
),
|
|
147
127
|
)
|
|
148
128
|
|
|
@@ -151,8 +131,7 @@ class CheckConstraint(BaseConstraint):
|
|
|
151
131
|
return (
|
|
152
132
|
self.name == other.name
|
|
153
133
|
and self.check == other.check
|
|
154
|
-
and self.
|
|
155
|
-
and self.violation_error_message == other.violation_error_message
|
|
134
|
+
and self.violation_error == other.violation_error
|
|
156
135
|
)
|
|
157
136
|
return super().__eq__(other)
|
|
158
137
|
|
|
@@ -183,8 +162,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
183
162
|
deferrable: Deferrable | None = None,
|
|
184
163
|
include: tuple[str, ...] | list[str] | None = None,
|
|
185
164
|
opclasses: tuple[str, ...] | list[str] = (),
|
|
186
|
-
|
|
187
|
-
violation_error_message: str | None = None,
|
|
165
|
+
violation_error: ViolationError | None = None,
|
|
188
166
|
) -> None:
|
|
189
167
|
if not name:
|
|
190
168
|
raise ValueError("A unique constraint must be named.")
|
|
@@ -234,11 +212,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
234
212
|
F(expression) if isinstance(expression, str) else expression
|
|
235
213
|
for expression in expressions
|
|
236
214
|
)
|
|
237
|
-
super().__init__(
|
|
238
|
-
name=name,
|
|
239
|
-
violation_error_code=violation_error_code,
|
|
240
|
-
violation_error_message=violation_error_message,
|
|
241
|
-
)
|
|
215
|
+
super().__init__(name=name, violation_error=violation_error)
|
|
242
216
|
|
|
243
217
|
@property
|
|
244
218
|
def contains_expressions(self) -> bool:
|
|
@@ -299,7 +273,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
299
273
|
return sql
|
|
300
274
|
|
|
301
275
|
def __repr__(self) -> str:
|
|
302
|
-
return "<{}:{}{}{}{}{}{}{}{}
|
|
276
|
+
return "<{}:{}{}{}{}{}{}{}{}>".format(
|
|
303
277
|
self.__class__.__qualname__,
|
|
304
278
|
"" if not self.fields else f" fields={repr(self.fields)}",
|
|
305
279
|
"" if not self.expressions else f" expressions={repr(self.expressions)}",
|
|
@@ -310,14 +284,8 @@ class UniqueConstraint(BaseConstraint):
|
|
|
310
284
|
"" if not self.opclasses else f" opclasses={repr(self.opclasses)}",
|
|
311
285
|
(
|
|
312
286
|
""
|
|
313
|
-
if self.
|
|
314
|
-
else f"
|
|
315
|
-
),
|
|
316
|
-
(
|
|
317
|
-
""
|
|
318
|
-
if self.violation_error_message is None
|
|
319
|
-
or self.violation_error_message == self.default_violation_error_message
|
|
320
|
-
else f" violation_error_message={self.violation_error_message!r}"
|
|
287
|
+
if self.violation_error is None
|
|
288
|
+
else f" violation_error={self.violation_error!r}"
|
|
321
289
|
),
|
|
322
290
|
)
|
|
323
291
|
|
|
@@ -331,8 +299,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
331
299
|
and self.include == other.include
|
|
332
300
|
and self.opclasses == other.opclasses
|
|
333
301
|
and self.expressions == other.expressions
|
|
334
|
-
and self.
|
|
335
|
-
and self.violation_error_message == other.violation_error_message
|
|
302
|
+
and self.violation_error == other.violation_error
|
|
336
303
|
)
|
|
337
304
|
return super().__eq__(other)
|
|
338
305
|
|
|
@@ -395,22 +362,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
395
362
|
queryset = queryset.exclude(id=model_class_id)
|
|
396
363
|
if not self.condition:
|
|
397
364
|
if queryset.exists():
|
|
398
|
-
|
|
399
|
-
raise ValidationError(
|
|
400
|
-
self.get_violation_error_message(),
|
|
401
|
-
code=self.violation_error_code,
|
|
402
|
-
)
|
|
403
|
-
# When fields are defined, use the unique_error_message() for
|
|
404
|
-
# backward compatibility.
|
|
405
|
-
for constraint_model, constraints in instance.get_constraints():
|
|
406
|
-
for constraint in constraints:
|
|
407
|
-
if constraint is self:
|
|
408
|
-
raise ValidationError(
|
|
409
|
-
instance.unique_error_message(
|
|
410
|
-
constraint_model,
|
|
411
|
-
self.fields,
|
|
412
|
-
),
|
|
413
|
-
)
|
|
365
|
+
raise self._build_unique_violation(instance, model)
|
|
414
366
|
else:
|
|
415
367
|
against = instance._get_field_value_map(
|
|
416
368
|
meta=model._model_meta, exclude=exclude
|
|
@@ -419,9 +371,32 @@ class UniqueConstraint(BaseConstraint):
|
|
|
419
371
|
if (self.condition & Exists(queryset.filter(self.condition))).check(
|
|
420
372
|
against
|
|
421
373
|
):
|
|
422
|
-
raise
|
|
423
|
-
self.get_violation_error_message(),
|
|
424
|
-
code=self.violation_error_code,
|
|
425
|
-
)
|
|
374
|
+
raise self._build_unique_violation(instance, model)
|
|
426
375
|
except FieldError:
|
|
427
376
|
pass
|
|
377
|
+
|
|
378
|
+
def _build_unique_violation(
|
|
379
|
+
self, instance: Model, model: type[Model]
|
|
380
|
+
) -> ValidationError:
|
|
381
|
+
"""Build the ValidationError for a unique violation.
|
|
382
|
+
|
|
383
|
+
Single-field unique constraints route the error to that field via the
|
|
384
|
+
dict form so it surfaces under the field rather than NON_FIELD_ERRORS.
|
|
385
|
+
"""
|
|
386
|
+
single_field = self.fields[0] if len(self.fields) == 1 else None
|
|
387
|
+
|
|
388
|
+
if self.violation_error is not None:
|
|
389
|
+
err = self._build_violation_error()
|
|
390
|
+
# Only auto-route flat errors. A ValidationError that already has
|
|
391
|
+
# an error_dict (from dict-form input or a caller-built instance)
|
|
392
|
+
# already declares its own field routing — don't override it.
|
|
393
|
+
if single_field and not hasattr(err, "error_dict"):
|
|
394
|
+
return ValidationError({single_field: [err]})
|
|
395
|
+
return err
|
|
396
|
+
|
|
397
|
+
if self.fields:
|
|
398
|
+
err = instance.unique_error_message(model, self.fields)
|
|
399
|
+
if single_field:
|
|
400
|
+
return ValidationError({single_field: [err]})
|
|
401
|
+
return err
|
|
402
|
+
return ValidationError(f'Constraint "{self.name}" is violated.')
|
|
@@ -196,7 +196,10 @@ class CreateIndexFix(Fix):
|
|
|
196
196
|
class AddConstraintFix(Fix):
|
|
197
197
|
"""Add a missing constraint.
|
|
198
198
|
|
|
199
|
-
Check constraints use NOT VALID
|
|
199
|
+
Check constraints use ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT
|
|
200
|
+
in a single apply() — the add is catalog-only (brief lock) and the
|
|
201
|
+
validate uses SHARE UPDATE EXCLUSIVE which doesn't block writes, so
|
|
202
|
+
there's no benefit to deferring validation to a later run.
|
|
200
203
|
Unique constraints use CREATE UNIQUE INDEX CONCURRENTLY + USING INDEX
|
|
201
204
|
to avoid blocking writes.
|
|
202
205
|
"""
|
|
@@ -208,8 +211,6 @@ class AddConstraintFix(Fix):
|
|
|
208
211
|
model: type[Model]
|
|
209
212
|
|
|
210
213
|
def describe(self) -> str:
|
|
211
|
-
if isinstance(self.constraint, CheckConstraint):
|
|
212
|
-
return f"{self.table}: add constraint {self.constraint.name} (NOT VALID)"
|
|
213
214
|
return f"{self.table}: add constraint {self.constraint.name}"
|
|
214
215
|
|
|
215
216
|
def apply(self) -> str:
|
|
@@ -244,9 +245,18 @@ class AddConstraintFix(Fix):
|
|
|
244
245
|
|
|
245
246
|
def _apply_other(self) -> str:
|
|
246
247
|
if isinstance(self.constraint, CheckConstraint):
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
add_sql = self.constraint.to_sql(self.model, not_valid=True)
|
|
249
|
+
_execute_and_commit(add_sql)
|
|
250
|
+
|
|
251
|
+
validate_sql = (
|
|
252
|
+
f"ALTER TABLE {quote_name(self.table)}"
|
|
253
|
+
f" VALIDATE CONSTRAINT {quote_name(self.constraint.name)}"
|
|
254
|
+
)
|
|
255
|
+
_execute_and_commit(validate_sql, blocking=False)
|
|
256
|
+
|
|
257
|
+
return f"{add_sql}; {validate_sql}"
|
|
258
|
+
|
|
259
|
+
sql = self.constraint.to_sql(self.model)
|
|
250
260
|
_execute_and_commit(sql)
|
|
251
261
|
return sql
|
|
252
262
|
|