plain.postgres 0.103.1__tar.gz → 0.103.3__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.103.1 → plain_postgres-0.103.3}/PKG-INFO +1 -1
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/CHANGELOG.md +23 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/constraints.py +4 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/related.py +1 -1
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/indexes.py +4 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/checks_structural.py +11 -1
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/preflight.py +57 -4
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/query.py +7 -1
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/pyproject.toml +1 -1
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_diagnose.py +28 -0
- plain_postgres-0.103.3/tests/internal/test_preflight_fk_coverage.py +157 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_m2m.py +29 -0
- plain_postgres-0.103.3/tests/public/test_queryset_repr.py +60 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/.gitignore +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/CLAUDE.md +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/LICENSE +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/README.md +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/README.md +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/base.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/config.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/db.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/options.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/types.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/settings.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/app/urls.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/conftest.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.103.1 → plain_postgres-0.103.3}/tests/public/test_related.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.103.3](https://github.com/dropseed/plain/releases/plain-postgres@0.103.3) (2026-05-08)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **`QuerySet.__repr__` no longer issues SQL for unevaluated querysets.** Error reporters (Sentry, pdb, exception templates) call `repr()` on stack-frame locals to build error events, and a surprise `SELECT … LIMIT 21` inside an exception path is a known footgun — especially on production where the underlying query may itself be the cause of the failure. Unevaluated querysets now render as `<QuerySet [unevaluated]>`; once the result cache is populated, `repr()` formats from the cache as before (still truncating past 20 rows). ([d8b7c4ec30](https://github.com/dropseed/plain/commit/d8b7c4ec30))
|
|
8
|
+
- **`ManyToManyField.value_from_object` no longer calls `.all()` on a manager.** The form-roundtrip path went through `getattr(obj, attname).all()`, which on the M2M manager dispatched to its descriptor and could trigger an unintended fetch/SQL path. Switched to the manager's `.query` queryset, which is the documented entry point. ([83da86b19b](https://github.com/dropseed/plain/commit/83da86b19b))
|
|
9
|
+
|
|
10
|
+
### Upgrade instructions
|
|
11
|
+
|
|
12
|
+
- No changes required. Note that interactive shell users who relied on `repr()` triggering evaluation (e.g., typing `qs` at the prompt to print rows) will now see `<QuerySet [unevaluated]>` — call `list(qs)` or slice it to materialize.
|
|
13
|
+
|
|
14
|
+
## [0.103.2](https://github.com/dropseed/plain/releases/plain-postgres@0.103.2) (2026-05-06)
|
|
15
|
+
|
|
16
|
+
### What's changed
|
|
17
|
+
|
|
18
|
+
- **`postgres.missing_fk_indexes` preflight now recognizes bare-column leading expressions.** A `UniqueConstraint(F("team"), Lower("email"))` declared via the `expressions=` API previously slipped past the model-level check — the preflight only inspected `fields=`, so it warned about a missing FK index even though the underlying btree's leading column was the real `team` attribute and the live diagnose check correctly recognized coverage. The preflight now extracts the leading column from `F(...)` and `OrderBy(F(...))` expressions to match Postgres' actual coverage semantics. ([ae3880098f](https://github.com/dropseed/plain/commit/ae3880098f))
|
|
19
|
+
- **Both FK-coverage checks now skip partial indexes.** A partial index like `Index(fields=["team"], condition=Q(deleted_at__isnull=True))` only satisfies queries whose predicate implies the partial-index `WHERE`, so an unfiltered FK lookup or cascade delete still sequential-scans. The preflight (`_fk_covered_field_names`) and the live diagnose check (`check_missing_fk_indexes`) both used to silently treat partial indexes as covering — now they don't, so the warning fires on the real coverage gap. The narrow `WHERE fk IS NOT NULL` case is conservatively also treated as not covering; users wanting guaranteed FK coverage should add a regular non-partial `Index(fields=[...])`. ([b1d13a6b42](https://github.com/dropseed/plain/commit/b1d13a6b42))
|
|
20
|
+
- New `is_partial` property on `Index` and `UniqueConstraint` returning `condition is not None`, for callers that need the distinction. ([b1d13a6b42](https://github.com/dropseed/plain/commit/b1d13a6b42))
|
|
21
|
+
|
|
22
|
+
### Upgrade instructions
|
|
23
|
+
|
|
24
|
+
- No changes required. After upgrading, `postgres.missing_fk_indexes` may surface previously-undetected FK coverage gaps (where the only matching index was partial) and may suppress previously-warning false positives (where the only matching index used `expressions=`). Add a non-partial `Index(fields=["fk"])` to silence the warning in the partial-index case.
|
|
25
|
+
|
|
3
26
|
## [0.103.1](https://github.com/dropseed/plain/releases/plain-postgres@0.103.1) (2026-05-06)
|
|
4
27
|
|
|
5
28
|
### What's changed
|
|
@@ -250,6 +250,10 @@ class UniqueConstraint(BaseConstraint):
|
|
|
250
250
|
def contains_expressions(self) -> bool:
|
|
251
251
|
return bool(self.expressions)
|
|
252
252
|
|
|
253
|
+
@property
|
|
254
|
+
def is_partial(self) -> bool:
|
|
255
|
+
return self.condition is not None
|
|
256
|
+
|
|
253
257
|
@property
|
|
254
258
|
def index_only(self) -> bool:
|
|
255
259
|
"""Whether PostgreSQL can only store this as a unique index, not a constraint.
|
|
@@ -1174,7 +1174,7 @@ class ManyToManyField(RelatedField):
|
|
|
1174
1174
|
pass
|
|
1175
1175
|
|
|
1176
1176
|
def value_from_object(self, obj: Model) -> list[Any]:
|
|
1177
|
-
return [] if obj.id is None else list(getattr(obj, self.attname).
|
|
1177
|
+
return [] if obj.id is None else list(getattr(obj, self.attname).query)
|
|
1178
1178
|
|
|
1179
1179
|
def save_form_data(self, instance: Model, data: Any) -> None:
|
|
1180
1180
|
getattr(instance, self.attname).set(data)
|
|
@@ -83,6 +83,10 @@ class Index:
|
|
|
83
83
|
def contains_expressions(self) -> bool:
|
|
84
84
|
return bool(self.expressions)
|
|
85
85
|
|
|
86
|
+
@property
|
|
87
|
+
def is_partial(self) -> bool:
|
|
88
|
+
return self.condition is not None
|
|
89
|
+
|
|
86
90
|
def to_sql(self, model: type[Model]) -> str:
|
|
87
91
|
"""Generate CREATE INDEX CONCURRENTLY SQL as a plain string."""
|
|
88
92
|
table = model.model_options.db_table
|
|
@@ -187,7 +187,16 @@ def check_duplicate_indexes(
|
|
|
187
187
|
def check_missing_fk_indexes(
|
|
188
188
|
cursor: Any, table_owners: dict[str, TableOwner]
|
|
189
189
|
) -> CheckResult:
|
|
190
|
-
"""Foreign key columns without a leading index — JOINs on these do sequential scans.
|
|
190
|
+
"""Foreign key columns without a leading index — JOINs on these do sequential scans.
|
|
191
|
+
|
|
192
|
+
Partial indexes (``WHERE`` clause set on ``pg_index.indpred``) don't
|
|
193
|
+
count: Postgres only uses them for queries that imply the partial
|
|
194
|
+
predicate, so FK lookups and cascade deletes outside that predicate
|
|
195
|
+
still sequential-scan. The narrow ``WHERE fk IS NOT NULL`` case —
|
|
196
|
+
which Postgres can match to ``WHERE fk = ?`` — is conservatively
|
|
197
|
+
treated as not covering; users wanting guaranteed FK coverage should
|
|
198
|
+
add a regular non-partial index. Match the preflight's coverage rule.
|
|
199
|
+
"""
|
|
191
200
|
cursor.execute("""
|
|
192
201
|
SELECT
|
|
193
202
|
ct.relname AS table_name,
|
|
@@ -207,6 +216,7 @@ def check_missing_fk_indexes(
|
|
|
207
216
|
FROM pg_catalog.pg_index i
|
|
208
217
|
WHERE i.indrelid = c.conrelid
|
|
209
218
|
AND i.indkey[0] = c.conkey[1]
|
|
219
|
+
AND i.indpred IS NULL
|
|
210
220
|
)
|
|
211
221
|
ORDER BY ct.relname, a.attname
|
|
212
222
|
""")
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from plain.packages import packages_registry
|
|
9
9
|
from plain.postgres.constraints import UniqueConstraint
|
|
10
10
|
from plain.postgres.db import get_connection
|
|
11
|
+
from plain.postgres.expressions import F, OrderBy
|
|
11
12
|
from plain.postgres.fields.related import ForeignKeyField
|
|
12
13
|
from plain.postgres.registry import ModelsRegistry, models_registry
|
|
13
14
|
from plain.preflight import PreflightCheck, PreflightResult, register_check
|
|
@@ -41,6 +42,61 @@ def _collect_model_indexes(model: Any) -> list[tuple[str, list[str], bool]]:
|
|
|
41
42
|
return all_indexes
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def _bare_column_name(expr: Any) -> str | None:
|
|
46
|
+
"""Return the column name if `expr` resolves to a bare column, else `None`.
|
|
47
|
+
|
|
48
|
+
Postgres can range-scan the leading column of an index for `WHERE col = ?`
|
|
49
|
+
only when that column is a real attribute, not an expression — so a
|
|
50
|
+
compound leading expression like `Lower("email")` returns `None` here.
|
|
51
|
+
Sort direction (`F("col").desc()` / `OrderBy(F)`) doesn't affect equality
|
|
52
|
+
lookups, so we unwrap one layer of `OrderBy` around a bare `F`.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(expr, OrderBy):
|
|
55
|
+
expr = expr.expression
|
|
56
|
+
if isinstance(expr, F):
|
|
57
|
+
return expr.name
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fk_covered_field_names(model: Any) -> set[str]:
|
|
62
|
+
"""Field names that appear as the leading column of a non-partial index
|
|
63
|
+
or unique constraint — covering arbitrary FK lookups via the index's
|
|
64
|
+
leading column.
|
|
65
|
+
|
|
66
|
+
Partial indexes/constraints (declared with ``condition=Q(...)``) are
|
|
67
|
+
excluded: Postgres can only use them for queries whose predicate
|
|
68
|
+
implies the partial-index predicate, so an FK lookup or cascade
|
|
69
|
+
delete that doesn't filter by that condition still does a sequential
|
|
70
|
+
scan. (The narrow ``WHERE fk IS NOT NULL`` case — which Postgres can
|
|
71
|
+
match to ``WHERE fk = ?`` — is conservatively treated as not
|
|
72
|
+
covering; users wanting guaranteed FK coverage should add a regular
|
|
73
|
+
non-partial ``Index(fields=[...])``.) Includes expression-based
|
|
74
|
+
indexes/constraints whose leading expression is a bare
|
|
75
|
+
``F(field_name)``.
|
|
76
|
+
"""
|
|
77
|
+
covered: set[str] = set()
|
|
78
|
+
|
|
79
|
+
def _record_leading(
|
|
80
|
+
fields: tuple[str, ...] | list[str], expressions: tuple
|
|
81
|
+
) -> None:
|
|
82
|
+
if fields:
|
|
83
|
+
covered.add(fields[0].lstrip("-"))
|
|
84
|
+
elif expressions:
|
|
85
|
+
name = _bare_column_name(expressions[0])
|
|
86
|
+
if name is not None:
|
|
87
|
+
covered.add(name)
|
|
88
|
+
|
|
89
|
+
for index in model.model_options.indexes:
|
|
90
|
+
if not index.is_partial:
|
|
91
|
+
_record_leading(index.fields, index.expressions)
|
|
92
|
+
|
|
93
|
+
for constraint in model.model_options.constraints:
|
|
94
|
+
if isinstance(constraint, UniqueConstraint) and not constraint.is_partial:
|
|
95
|
+
_record_leading(constraint.fields, constraint.expressions)
|
|
96
|
+
|
|
97
|
+
return covered
|
|
98
|
+
|
|
99
|
+
|
|
44
100
|
@register_check("postgres.all_models")
|
|
45
101
|
class CheckAllModels(PreflightCheck):
|
|
46
102
|
"""Validates all model definitions for common issues."""
|
|
@@ -386,10 +442,7 @@ class CheckMissingFKIndexes(PreflightCheck):
|
|
|
386
442
|
results = []
|
|
387
443
|
|
|
388
444
|
for model in _get_app_models():
|
|
389
|
-
|
|
390
|
-
covered_fields = {
|
|
391
|
-
fields[0] for _, fields, _ in _collect_model_indexes(model)
|
|
392
|
-
}
|
|
445
|
+
covered_fields = _fk_covered_field_names(model)
|
|
393
446
|
|
|
394
447
|
for field in model._model_meta.local_fields:
|
|
395
448
|
if (
|
|
@@ -380,7 +380,13 @@ class QuerySet[T: "Model"]:
|
|
|
380
380
|
self.__dict__.update(state)
|
|
381
381
|
|
|
382
382
|
def __repr__(self) -> str:
|
|
383
|
-
|
|
383
|
+
# Don't run SQL from __repr__ — error reporters (Sentry, pdb,
|
|
384
|
+
# exception templates) call repr() on stack-frame locals to
|
|
385
|
+
# build error events. If the queryset hasn't been evaluated, a
|
|
386
|
+
# surprise SELECT inside an exception path is a known footgun.
|
|
387
|
+
if self._result_cache is None:
|
|
388
|
+
return f"<{self.__class__.__name__} [unevaluated]>"
|
|
389
|
+
data: list[Any] = list(self._result_cache[: REPR_OUTPUT_SIZE + 1])
|
|
384
390
|
if len(data) > REPR_OUTPUT_SIZE:
|
|
385
391
|
data[-1] = "...(remaining elements truncated)..."
|
|
386
392
|
return f"<{self.__class__.__name__} {data!r}>"
|
|
@@ -404,6 +404,34 @@ class TestStructuralScenarios:
|
|
|
404
404
|
flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child2"]
|
|
405
405
|
assert flagged == [], f"indexed FK should not be flagged; got {flagged}"
|
|
406
406
|
|
|
407
|
+
def test_missing_fk_index_detected_when_only_partial_index_covers(self) -> None:
|
|
408
|
+
"""A partial index on the FK column doesn't cover arbitrary FK
|
|
409
|
+
lookups — Postgres can only use it for queries whose predicate
|
|
410
|
+
implies the partial-index `WHERE`. The check must still flag the
|
|
411
|
+
FK as missing index coverage."""
|
|
412
|
+
_execute('CREATE TABLE "_diag_fk_parent3" ("id" serial PRIMARY KEY)')
|
|
413
|
+
_execute(
|
|
414
|
+
'CREATE TABLE "_diag_fk_child3" ('
|
|
415
|
+
'"id" serial PRIMARY KEY, '
|
|
416
|
+
'"parent_id" int REFERENCES "_diag_fk_parent3"("id"), '
|
|
417
|
+
'"deleted_at" timestamptz)'
|
|
418
|
+
)
|
|
419
|
+
_execute(
|
|
420
|
+
'CREATE INDEX "_diag_fk_child3_partial_parent_idx" '
|
|
421
|
+
'ON "_diag_fk_child3" ("parent_id") '
|
|
422
|
+
"WHERE deleted_at IS NULL"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
conn = get_connection()
|
|
426
|
+
with conn.cursor() as cursor:
|
|
427
|
+
result = check_missing_fk_indexes(cursor, {})
|
|
428
|
+
|
|
429
|
+
flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child3"]
|
|
430
|
+
assert len(flagged) == 1, (
|
|
431
|
+
f"partial index must not satisfy FK coverage; got {flagged}"
|
|
432
|
+
)
|
|
433
|
+
assert flagged[0]["name"] == "_diag_fk_child3.parent_id"
|
|
434
|
+
|
|
407
435
|
def test_sequence_exhaustion_critical_above_90pct(self) -> None:
|
|
408
436
|
_execute('CREATE TABLE "_diag_seq" ("id" serial PRIMARY KEY, "n" int)')
|
|
409
437
|
# int4 sequence max is 2^31-1 = 2147483647; push past 90% to trip critical.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Unit tests for `_fk_covered_field_names` — the helper that powers
|
|
2
|
+
`postgres.missing_fk_indexes`'s preflight check.
|
|
3
|
+
|
|
4
|
+
The check fires on FK fields whose name doesn't appear here. The helper
|
|
5
|
+
must recognize bare `F("col")` leading expressions so a constraint like
|
|
6
|
+
`UniqueConstraint(F("team"), Lower("email"))` counts as covering the
|
|
7
|
+
`team` FK — Postgres' underlying btree's leading column is the real
|
|
8
|
+
`team` attribute, not an expression.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from types import SimpleNamespace
|
|
14
|
+
|
|
15
|
+
from plain.postgres import Q
|
|
16
|
+
from plain.postgres.constraints import UniqueConstraint
|
|
17
|
+
from plain.postgres.expressions import F
|
|
18
|
+
from plain.postgres.functions import Lower
|
|
19
|
+
from plain.postgres.indexes import Index
|
|
20
|
+
from plain.postgres.preflight import _fk_covered_field_names
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _model(*, indexes=(), constraints=()) -> SimpleNamespace:
|
|
24
|
+
"""Minimal model_options stand-in for the helper."""
|
|
25
|
+
return SimpleNamespace(
|
|
26
|
+
model_options=SimpleNamespace(
|
|
27
|
+
indexes=list(indexes), constraints=list(constraints)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_covers_index_with_fields():
|
|
33
|
+
model = _model(indexes=[Index(name="t_team_idx", fields=["team"])])
|
|
34
|
+
assert _fk_covered_field_names(model) == {"team"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_covers_unique_constraint_with_fields():
|
|
38
|
+
model = _model(
|
|
39
|
+
constraints=[
|
|
40
|
+
UniqueConstraint(fields=["team", "account"], name="t_team_acct_uniq")
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
assert _fk_covered_field_names(model) == {"team"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_covers_unique_constraint_with_bare_f_leading_expression():
|
|
47
|
+
"""A unique constraint declared via `expressions=` whose leading
|
|
48
|
+
expression is a bare `F("col")` covers the FK — the underlying btree's
|
|
49
|
+
leading attribute is still the real column."""
|
|
50
|
+
model = _model(
|
|
51
|
+
constraints=[
|
|
52
|
+
UniqueConstraint(
|
|
53
|
+
F("team"),
|
|
54
|
+
Lower("email"),
|
|
55
|
+
name="t_team_email_uniq",
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
assert "team" in _fk_covered_field_names(model)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_covers_unique_constraint_with_ordered_bare_f_leading_expression():
|
|
63
|
+
"""`F("team").desc()` produces `OrderBy(F("team"))`. Postgres still emits
|
|
64
|
+
`team_id DESC` as a real leading column attribute, so equality FK
|
|
65
|
+
lookups are covered (sort direction doesn't matter for `WHERE = ?`)."""
|
|
66
|
+
model = _model(
|
|
67
|
+
constraints=[
|
|
68
|
+
UniqueConstraint(
|
|
69
|
+
F("team").desc(),
|
|
70
|
+
Lower("email"),
|
|
71
|
+
name="t_team_desc_email_uniq",
|
|
72
|
+
)
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
assert "team" in _fk_covered_field_names(model)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_does_not_cover_when_leading_expression_is_compound():
|
|
79
|
+
"""`(LOWER(email), team)` cannot satisfy `WHERE team = ?` from the
|
|
80
|
+
leading column — the leading "column" is an expression, so Postgres
|
|
81
|
+
can't range-scan it for a value lookup on team."""
|
|
82
|
+
model = _model(
|
|
83
|
+
constraints=[
|
|
84
|
+
UniqueConstraint(
|
|
85
|
+
Lower("email"),
|
|
86
|
+
F("team"),
|
|
87
|
+
name="t_lower_email_team_uniq",
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
assert "team" not in _fk_covered_field_names(model)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_strips_descending_prefix_from_field_name():
|
|
95
|
+
"""`fields=["-created_at"]` (descending) still has `created_at` as the
|
|
96
|
+
underlying column. The leading-column extraction must strip the prefix."""
|
|
97
|
+
model = _model(indexes=[Index(name="t_created_idx", fields=["-created_at"])])
|
|
98
|
+
assert _fk_covered_field_names(model) == {"created_at"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_unions_indexes_and_constraints():
|
|
102
|
+
model = _model(
|
|
103
|
+
indexes=[Index(name="t_a_idx", fields=["a"])],
|
|
104
|
+
constraints=[
|
|
105
|
+
UniqueConstraint(fields=["b"], name="t_b_uniq"),
|
|
106
|
+
UniqueConstraint(F("c"), Lower("d"), name="t_c_d_uniq"),
|
|
107
|
+
],
|
|
108
|
+
)
|
|
109
|
+
assert _fk_covered_field_names(model) == {"a", "b", "c"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_partial_index_does_not_cover():
|
|
113
|
+
"""`Index(fields=["team"], condition=Q(...))` only satisfies queries
|
|
114
|
+
whose predicate implies the partial-index `WHERE`. An unfiltered FK
|
|
115
|
+
lookup or cascade delete still sequential-scans, so the partial index
|
|
116
|
+
must not count as covering."""
|
|
117
|
+
model = _model(
|
|
118
|
+
indexes=[
|
|
119
|
+
Index(
|
|
120
|
+
name="t_team_active_idx",
|
|
121
|
+
fields=["team"],
|
|
122
|
+
condition=Q(deleted_at__isnull=True),
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
assert "team" not in _fk_covered_field_names(model)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_partial_unique_constraint_does_not_cover():
|
|
130
|
+
"""Same logic as the partial index — soft-delete-style partial unique
|
|
131
|
+
constraints don't guarantee FK lookup coverage."""
|
|
132
|
+
model = _model(
|
|
133
|
+
constraints=[
|
|
134
|
+
UniqueConstraint(
|
|
135
|
+
fields=["team"],
|
|
136
|
+
name="t_team_active_uniq",
|
|
137
|
+
condition=Q(deleted_at__isnull=True),
|
|
138
|
+
)
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
assert "team" not in _fk_covered_field_names(model)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_full_index_still_covers_when_partial_sibling_exists():
|
|
145
|
+
"""A non-partial index on the same column wins — partial-ness is per
|
|
146
|
+
declaration, not per column."""
|
|
147
|
+
model = _model(
|
|
148
|
+
indexes=[
|
|
149
|
+
Index(
|
|
150
|
+
name="t_team_active_idx",
|
|
151
|
+
fields=["team"],
|
|
152
|
+
condition=Q(deleted_at__isnull=True),
|
|
153
|
+
),
|
|
154
|
+
Index(name="t_team_idx", fields=["team"]),
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
assert "team" in _fk_covered_field_names(model)
|
|
@@ -5,10 +5,13 @@ exercise ManyToManyField accessors, the through model, and the Widget-specific
|
|
|
5
5
|
unique constraint that produces a realistic ValidationError on duplicate create.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
8
10
|
import pytest
|
|
9
11
|
from app.examples.models.relationships import Tag, Widget, WidgetTag
|
|
10
12
|
|
|
11
13
|
from plain.exceptions import ValidationError
|
|
14
|
+
from plain.postgres.fields.related import ManyToManyField
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def test_create_unique_constraint(db):
|
|
@@ -94,6 +97,32 @@ def test_many_to_many_clear(db):
|
|
|
94
97
|
assert widget.tags.query.count() == 0
|
|
95
98
|
|
|
96
99
|
|
|
100
|
+
def test_value_from_object_returns_related_objects(db):
|
|
101
|
+
"""ManyToManyField.value_from_object must return the currently-related
|
|
102
|
+
objects. ModelForm's `model_to_dict` calls this when given an instance
|
|
103
|
+
so the form can populate `initial` for the M2M field — a regression
|
|
104
|
+
here breaks UpdateView for any model with an M2M.
|
|
105
|
+
"""
|
|
106
|
+
widget = Widget.query.create(name="Subaru", size="Outback")
|
|
107
|
+
gps = Tag.query.create(name="GPS")
|
|
108
|
+
sunroof = Tag.query.create(name="Sunroof")
|
|
109
|
+
widget.tags.add(gps, sunroof)
|
|
110
|
+
|
|
111
|
+
field = cast(ManyToManyField, Widget._model_meta.get_forward_field("tags"))
|
|
112
|
+
result = field.value_from_object(widget)
|
|
113
|
+
|
|
114
|
+
assert {t.name for t in result} == {"GPS", "Sunroof"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_value_from_object_unsaved_instance_returns_empty(db):
|
|
118
|
+
"""An unsaved instance has no related rows; value_from_object should
|
|
119
|
+
return an empty list rather than crash.
|
|
120
|
+
"""
|
|
121
|
+
widget = Widget(name="Mazda", size="3")
|
|
122
|
+
field = cast(ManyToManyField, Widget._model_meta.get_forward_field("tags"))
|
|
123
|
+
assert list(field.value_from_object(widget)) == []
|
|
124
|
+
|
|
125
|
+
|
|
97
126
|
def test_many_to_many_through_model(db):
|
|
98
127
|
"""Test accessing the through model directly."""
|
|
99
128
|
widget = Widget.query.create(name="Ford", size="Mustang")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from app.examples.models.iteration import IterationExample
|
|
4
|
+
|
|
5
|
+
from plain.postgres.db import get_connection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _capture_queries(callable_):
|
|
9
|
+
conn = get_connection()
|
|
10
|
+
prev_force = conn.force_debug_cursor
|
|
11
|
+
conn.force_debug_cursor = True
|
|
12
|
+
conn.queries_log.clear()
|
|
13
|
+
try:
|
|
14
|
+
callable_()
|
|
15
|
+
return list(conn.queries_log)
|
|
16
|
+
finally:
|
|
17
|
+
conn.force_debug_cursor = prev_force
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_repr_does_not_execute_sql_when_unevaluated(db):
|
|
21
|
+
"""repr() of an unevaluated queryset must not issue a SQL query.
|
|
22
|
+
|
|
23
|
+
Error reporters (Sentry, pdb, exception templates) call repr() on
|
|
24
|
+
stack-frame locals; a surprise SELECT inside an exception path can
|
|
25
|
+
overload the database.
|
|
26
|
+
"""
|
|
27
|
+
qs = IterationExample.query.all()
|
|
28
|
+
|
|
29
|
+
queries = _capture_queries(lambda: repr(qs))
|
|
30
|
+
|
|
31
|
+
assert queries == []
|
|
32
|
+
assert repr(qs) == "<QuerySet [unevaluated]>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_repr_uses_cache_when_evaluated(db):
|
|
36
|
+
"""Once a queryset is evaluated, repr() reflects its rows without re-querying."""
|
|
37
|
+
IterationExample.query.create(name="alpha", tag="a")
|
|
38
|
+
IterationExample.query.create(name="beta", tag="b")
|
|
39
|
+
|
|
40
|
+
qs = IterationExample.query.all()
|
|
41
|
+
list(qs) # force evaluation, populates _result_cache
|
|
42
|
+
|
|
43
|
+
queries = _capture_queries(lambda: repr(qs))
|
|
44
|
+
|
|
45
|
+
assert queries == []
|
|
46
|
+
rendered = repr(qs)
|
|
47
|
+
assert "[unevaluated]" not in rendered
|
|
48
|
+
assert rendered.count("IterationExample") == 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_repr_truncates_large_evaluated_querysets(db):
|
|
52
|
+
"""The truncation marker still appears past REPR_OUTPUT_SIZE."""
|
|
53
|
+
for i in range(25):
|
|
54
|
+
IterationExample.query.create(name=f"name{i:02d}", tag="t")
|
|
55
|
+
|
|
56
|
+
qs = IterationExample.query.all()
|
|
57
|
+
list(qs)
|
|
58
|
+
|
|
59
|
+
rendered = repr(qs)
|
|
60
|
+
assert "remaining elements truncated" in rendered
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/related_descriptors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{plain_postgres-0.103.1 → plain_postgres-0.103.3}/plain/postgres/fields/reverse_descriptors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|