plain.postgres 0.99.1__tar.gz → 0.100.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.100.0/PKG-INFO +47 -5
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/CHANGELOG.md +16 -0
- plain_postgres-0.99.1/PKG-INFO → plain_postgres-0.100.0/plain/postgres/README.md +33 -19
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/base.py +2 -8
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/constraints.py +55 -80
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/pyproject.toml +1 -1
- plain_postgres-0.100.0/tests/test_constraint_violation_error.py +216 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_otel_metrics.py +57 -57
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/.gitignore +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/CLAUDE.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/LICENSE +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/README.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.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.100.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/conftest.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_pool.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_diagnose.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_field_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_functions_uuid.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_health.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_m2m.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_management_connection.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_mixins.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_random_string_field.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_raw_query.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_related.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_schema_timeouts.py +0 -0
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.100.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,21 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.100.0](https://github.com/dropseed/plain/releases/plain-postgres@0.100.0) (2026-04-28)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **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))
|
|
8
|
+
- **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))
|
|
9
|
+
- **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))
|
|
10
|
+
- **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))
|
|
11
|
+
- 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))
|
|
12
|
+
|
|
13
|
+
### Upgrade instructions
|
|
14
|
+
|
|
15
|
+
- 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).
|
|
16
|
+
- 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.
|
|
17
|
+
- If you used `%(name)s` in `violation_error_message`, switch to `ValidationError("...", params={"name": "your_constraint_name"})` or hardcode the name.
|
|
18
|
+
|
|
3
19
|
## [0.99.1](https://github.com/dropseed/plain/releases/plain-postgres@0.99.1) (2026-04-26)
|
|
4
20
|
|
|
5
21
|
### 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
|
|
@@ -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.')
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Tests for the `violation_error` kwarg on CheckConstraint and
|
|
2
|
+
UniqueConstraint, plus the full_clean() / save() integration that surfaces
|
|
3
|
+
constraint errors."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from app.examples.models.constraints import ConstraintExample
|
|
9
|
+
|
|
10
|
+
from plain.exceptions import ValidationError
|
|
11
|
+
from plain.postgres import CheckConstraint, Q, UniqueConstraint
|
|
12
|
+
from plain.postgres.forms import ModelForm
|
|
13
|
+
from plain.test import RequestFactory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_constraint() -> CheckConstraint:
|
|
17
|
+
return CheckConstraint(
|
|
18
|
+
check=Q(name__startswith="ok-"),
|
|
19
|
+
name="constraint_must_start_ok",
|
|
20
|
+
violation_error=ValidationError(
|
|
21
|
+
'Name must start with "ok-".', code="bad_prefix"
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_check_constraint(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
27
|
+
monkeypatch.setattr(
|
|
28
|
+
ConstraintExample.model_options,
|
|
29
|
+
"constraints",
|
|
30
|
+
(*ConstraintExample.model_options.constraints, _check_constraint()),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_validate_uses_violation_error(db: None) -> None:
|
|
35
|
+
constraint = _check_constraint()
|
|
36
|
+
instance = ConstraintExample(name="bad", description="d")
|
|
37
|
+
|
|
38
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
39
|
+
constraint.validate(ConstraintExample, instance)
|
|
40
|
+
|
|
41
|
+
err = exc_info.value.error_list[0]
|
|
42
|
+
assert err.message == 'Name must start with "ok-".'
|
|
43
|
+
assert err.code == "bad_prefix"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_validate_string_violation_error(db: None) -> None:
|
|
47
|
+
constraint = CheckConstraint(
|
|
48
|
+
check=Q(name__startswith="ok-"),
|
|
49
|
+
name="c",
|
|
50
|
+
violation_error="bad name",
|
|
51
|
+
)
|
|
52
|
+
instance = ConstraintExample(name="bad", description="d")
|
|
53
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
54
|
+
constraint.validate(ConstraintExample, instance)
|
|
55
|
+
assert exc_info.value.messages == ["bad name"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_validate_default_violation_error(db: None) -> None:
|
|
59
|
+
constraint = CheckConstraint(check=Q(name__startswith="ok-"), name="my_constraint")
|
|
60
|
+
instance = ConstraintExample(name="bad", description="d")
|
|
61
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
62
|
+
constraint.validate(ConstraintExample, instance)
|
|
63
|
+
assert exc_info.value.messages == ['Constraint "my_constraint" is violated.']
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_validate_passes_when_check_satisfied(db: None) -> None:
|
|
67
|
+
constraint = _check_constraint()
|
|
68
|
+
instance = ConstraintExample(name="ok-fine", description="d")
|
|
69
|
+
constraint.validate(ConstraintExample, instance)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_full_clean_runs_constraint_validation(
|
|
73
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
74
|
+
) -> None:
|
|
75
|
+
_add_check_constraint(monkeypatch)
|
|
76
|
+
instance = ConstraintExample(name="bad", description="d")
|
|
77
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
78
|
+
instance.full_clean()
|
|
79
|
+
assert any("Name must start" in m for m in exc_info.value.messages)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_save_runs_full_clean_by_default(
|
|
83
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
84
|
+
) -> None:
|
|
85
|
+
_add_check_constraint(monkeypatch)
|
|
86
|
+
with pytest.raises(ValidationError):
|
|
87
|
+
ConstraintExample(name="bad", description="d").save()
|
|
88
|
+
assert ConstraintExample.query.filter(name="bad").count() == 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_save_clean_and_validate_false_skips_validation(
|
|
92
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
93
|
+
) -> None:
|
|
94
|
+
_add_check_constraint(monkeypatch)
|
|
95
|
+
ConstraintExample(name="bad", description="d").save(clean_and_validate=False)
|
|
96
|
+
assert ConstraintExample.query.filter(name="bad").count() == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_check_constraint_dict_violation_error_routes_to_field(
|
|
100
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Dict-form violation_error attaches the error to the named field."""
|
|
103
|
+
constraint = CheckConstraint(
|
|
104
|
+
check=Q(name__startswith="ok-"),
|
|
105
|
+
name="must_start_ok",
|
|
106
|
+
violation_error={"name": 'Name must start with "ok-".'},
|
|
107
|
+
)
|
|
108
|
+
monkeypatch.setattr(
|
|
109
|
+
ConstraintExample.model_options,
|
|
110
|
+
"constraints",
|
|
111
|
+
(*ConstraintExample.model_options.constraints, constraint),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
class Form(ModelForm):
|
|
115
|
+
class Meta:
|
|
116
|
+
model = ConstraintExample
|
|
117
|
+
fields = ["name", "description"]
|
|
118
|
+
|
|
119
|
+
rf = RequestFactory()
|
|
120
|
+
form = Form(request=rf.post("/x/", data={"name": "bad", "description": "d"}))
|
|
121
|
+
assert not form.is_valid()
|
|
122
|
+
assert form.errors.get("name"), form.errors
|
|
123
|
+
assert "name" in form.errors
|
|
124
|
+
assert "__all__" not in form.errors
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_check_constraint_string_violation_error_lands_on_non_field_errors(
|
|
128
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
129
|
+
) -> None:
|
|
130
|
+
"""A bare string violation_error on CheckConstraint goes to NON_FIELD_ERRORS
|
|
131
|
+
because the constraint can't infer which field to attach to from a Q
|
|
132
|
+
expression."""
|
|
133
|
+
constraint = CheckConstraint(
|
|
134
|
+
check=Q(name__startswith="ok-"),
|
|
135
|
+
name="must_start_ok",
|
|
136
|
+
violation_error='Name must start with "ok-".',
|
|
137
|
+
)
|
|
138
|
+
monkeypatch.setattr(
|
|
139
|
+
ConstraintExample.model_options,
|
|
140
|
+
"constraints",
|
|
141
|
+
(*ConstraintExample.model_options.constraints, constraint),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
class Form(ModelForm):
|
|
145
|
+
class Meta:
|
|
146
|
+
model = ConstraintExample
|
|
147
|
+
fields = ["name", "description"]
|
|
148
|
+
|
|
149
|
+
rf = RequestFactory()
|
|
150
|
+
form = Form(request=rf.post("/x/", data={"name": "bad", "description": "d"}))
|
|
151
|
+
assert not form.is_valid()
|
|
152
|
+
assert "name" not in form.errors
|
|
153
|
+
assert "__all__" in form.errors
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_unique_constraint_explicit_validation_error_dict_preserved(
|
|
157
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
158
|
+
) -> None:
|
|
159
|
+
"""A caller-built ValidationError with an error_dict declares its own
|
|
160
|
+
field routing — single-field auto-routing must not flatten it back under
|
|
161
|
+
the constrained field."""
|
|
162
|
+
constraint = UniqueConstraint(
|
|
163
|
+
fields=["name"],
|
|
164
|
+
name="unique_name_explicit",
|
|
165
|
+
violation_error=ValidationError(
|
|
166
|
+
{"description": "Pick a different name to free this description."}
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
monkeypatch.setattr(
|
|
170
|
+
ConstraintExample.model_options,
|
|
171
|
+
"constraints",
|
|
172
|
+
(*ConstraintExample.model_options.constraints, constraint),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
ConstraintExample(name="dup", description="d1").save(clean_and_validate=False)
|
|
176
|
+
instance = ConstraintExample(name="dup", description="d2")
|
|
177
|
+
|
|
178
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
179
|
+
instance.full_clean()
|
|
180
|
+
|
|
181
|
+
err = exc_info.value
|
|
182
|
+
assert hasattr(err, "error_dict")
|
|
183
|
+
assert "description" in err.error_dict
|
|
184
|
+
assert "name" not in err.error_dict
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_unique_constraint_single_field_string_routes_to_field(
|
|
188
|
+
db: None, monkeypatch: pytest.MonkeyPatch
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Single-field UniqueConstraint auto-routes a string violation_error to
|
|
191
|
+
that field (no special routing in validate_constraints — the dict-form is
|
|
192
|
+
built inside validate())."""
|
|
193
|
+
constraint = UniqueConstraint(
|
|
194
|
+
fields=["name"],
|
|
195
|
+
name="unique_name_only",
|
|
196
|
+
violation_error="That name is taken.",
|
|
197
|
+
)
|
|
198
|
+
monkeypatch.setattr(
|
|
199
|
+
ConstraintExample.model_options,
|
|
200
|
+
"constraints",
|
|
201
|
+
(*ConstraintExample.model_options.constraints, constraint),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
ConstraintExample(name="dup", description="d1").save(clean_and_validate=False)
|
|
205
|
+
|
|
206
|
+
class Form(ModelForm):
|
|
207
|
+
class Meta:
|
|
208
|
+
model = ConstraintExample
|
|
209
|
+
fields = ["name", "description"]
|
|
210
|
+
|
|
211
|
+
rf = RequestFactory()
|
|
212
|
+
form = Form(request=rf.post("/x/", data={"name": "dup", "description": "d2"}))
|
|
213
|
+
assert not form.is_valid()
|
|
214
|
+
assert any("That name is taken." in m for m in form.errors.get("name", [])), (
|
|
215
|
+
form.errors
|
|
216
|
+
)
|