plain.postgres 0.99.0__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.0/plain/postgres/README.md → plain_postgres-0.100.0/PKG-INFO +47 -5
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/CHANGELOG.md +26 -0
- plain_postgres-0.99.0/PKG-INFO → plain_postgres-0.100.0/plain/postgres/README.md +33 -19
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/base.py +2 -8
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/constraints.py +55 -80
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_structural.py +32 -17
- {plain_postgres-0.99.0 → 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.0 → plain_postgres-0.100.0}/tests/test_diagnose.py +168 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_otel_metrics.py +57 -57
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/.gitignore +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/CLAUDE.md +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/LICENSE +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/README.md +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/preflight.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.99.0 → 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.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/conftest.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_connection_pool.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_constraints.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_defaults.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_fk.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_indexes.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_nullability.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_field_defaults.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_functions_uuid.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_health.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_introspection.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_m2m.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_management_connection.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_migration_executor.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_mixins.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_random_string_field.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_raw_query.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_related.py +0 -0
- {plain_postgres-0.99.0 → plain_postgres-0.100.0}/tests/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.99.0 → 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,31 @@
|
|
|
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
|
+
|
|
19
|
+
## [0.99.1](https://github.com/dropseed/plain/releases/plain-postgres@0.99.1) (2026-04-26)
|
|
20
|
+
|
|
21
|
+
### What's changed
|
|
22
|
+
|
|
23
|
+
- **Duplicate-index check now catches expression-prefix duplicates.** Previously the check excluded any index containing expressions (it compared raw `indkey`/`indclass` arrays), so a redundant `(LOWER(email))` alongside `(LOWER(email), team_id)` was missed. The query now compares per-column `pg_get_indexdef(indexrelid, k, false)` text — canonical output that includes column name/expression, opclass, collation, and sort order — and checks `pg_am.amname` separately so a hash and btree on the same column don't false-match. ([4bd8a713649f](https://github.com/dropseed/plain/commit/4bd8a713649f))
|
|
24
|
+
|
|
25
|
+
### Upgrade instructions
|
|
26
|
+
|
|
27
|
+
- No changes required.
|
|
28
|
+
|
|
3
29
|
## [0.99.0](https://github.com/dropseed/plain/releases/plain-postgres@0.99.0) (2026-04-23)
|
|
4
30
|
|
|
5
31
|
### What's changed
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.99.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
|
-
|
|
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.')
|
|
@@ -66,50 +66,65 @@ def check_invalid_indexes(
|
|
|
66
66
|
def check_duplicate_indexes(
|
|
67
67
|
cursor: Any, table_owners: dict[str, TableOwner]
|
|
68
68
|
) -> CheckResult:
|
|
69
|
-
"""Indexes where one is a column-prefix of another on the same table.
|
|
69
|
+
"""Indexes where one is a column-prefix of another on the same table.
|
|
70
|
+
|
|
71
|
+
Each index column's canonical definition comes from
|
|
72
|
+
``pg_get_indexdef(indexrelid, k, false)`` — that text includes the
|
|
73
|
+
column name or expression, plus any non-default operator class,
|
|
74
|
+
collation, or sort order. Comparing per-column definitions means we
|
|
75
|
+
catch expression duplicates (e.g. two ``LOWER(email)`` columns) and
|
|
76
|
+
won't false-positive across different opclasses or collations.
|
|
77
|
+
Access method (``pg_am.amname``) is checked separately because the
|
|
78
|
+
per-column text doesn't include it — a hash and btree on the same
|
|
79
|
+
column have identical column text but support different operators.
|
|
80
|
+
"""
|
|
70
81
|
cursor.execute("""
|
|
71
82
|
SELECT
|
|
72
83
|
ct.relname AS table_name,
|
|
73
84
|
ci.relname AS index_name,
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
am.amname AS access_method,
|
|
86
|
+
array(
|
|
87
|
+
SELECT pg_get_indexdef(i.indexrelid, k, false)
|
|
88
|
+
FROM generate_series(1, i.indnatts) AS k
|
|
89
|
+
) AS column_defs,
|
|
76
90
|
i.indisunique,
|
|
77
|
-
pg_size_pretty(pg_relation_size(ci.oid)) AS index_size
|
|
78
|
-
pg_relation_size(ci.oid) AS index_size_bytes
|
|
91
|
+
pg_size_pretty(pg_relation_size(ci.oid)) AS index_size
|
|
79
92
|
FROM pg_catalog.pg_index i
|
|
80
93
|
JOIN pg_catalog.pg_class ci ON ci.oid = i.indexrelid
|
|
81
94
|
JOIN pg_catalog.pg_class ct ON ct.oid = i.indrelid
|
|
95
|
+
JOIN pg_catalog.pg_am am ON am.oid = ci.relam
|
|
82
96
|
JOIN pg_catalog.pg_namespace n ON n.oid = ct.relnamespace
|
|
83
97
|
WHERE n.nspname = 'public'
|
|
84
98
|
AND i.indisvalid
|
|
85
|
-
AND i.indexprs IS NULL
|
|
86
99
|
AND i.indpred IS NULL
|
|
87
100
|
ORDER BY ct.relname, ci.relname
|
|
88
101
|
""")
|
|
89
102
|
rows = cursor.fetchall()
|
|
90
103
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
for table_name, index_name, cols, opclasses, is_unique, size, size_bytes in rows:
|
|
104
|
+
by_table: dict[str, list[tuple[str, str, list[str], bool, str]]] = {}
|
|
105
|
+
for table_name, index_name, am_name, defs, is_unique, size in rows:
|
|
94
106
|
by_table.setdefault(table_name, []).append(
|
|
95
|
-
(index_name,
|
|
107
|
+
(index_name, am_name, defs, is_unique, size)
|
|
96
108
|
)
|
|
97
109
|
|
|
98
110
|
items: list[CheckItem] = []
|
|
99
|
-
flagged: set[str] = set()
|
|
111
|
+
flagged: set[str] = set()
|
|
100
112
|
for table_name, indexes in by_table.items():
|
|
101
113
|
for i, idx_a in enumerate(indexes):
|
|
102
114
|
for idx_b in indexes[i + 1 :]:
|
|
103
115
|
# Check both directions: is either a prefix of the other?
|
|
104
116
|
for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
|
|
105
|
-
name_s,
|
|
106
|
-
name_l,
|
|
117
|
+
name_s, am_s, defs_s, unique_s, size_s = shorter
|
|
118
|
+
name_l, am_l, defs_l, _, _ = longer
|
|
119
|
+
# Different access methods serve different operators
|
|
120
|
+
# (e.g. hash supports `=` only, btree supports ordering),
|
|
121
|
+
# and unique indexes serve a constraint purpose.
|
|
107
122
|
if (
|
|
108
123
|
name_s not in flagged
|
|
109
|
-
and
|
|
110
|
-
and
|
|
111
|
-
and
|
|
112
|
-
and
|
|
124
|
+
and am_s == am_l
|
|
125
|
+
and not unique_s
|
|
126
|
+
and len(defs_s) < len(defs_l)
|
|
127
|
+
and defs_l[: len(defs_s)] == defs_s
|
|
113
128
|
):
|
|
114
129
|
source, package, model_class, model_file = _table_info(
|
|
115
130
|
table_name, table_owners
|