plain.postgres 0.103.0__tar.gz → 0.103.2__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.0 → plain_postgres-0.103.2}/PKG-INFO +1 -1
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/CHANGELOG.md +22 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/constraints.py +4 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/indexes.py +4 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_structural.py +61 -31
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/preflight.py +106 -19
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/pyproject.toml +1 -1
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_diagnose.py +97 -0
- plain_postgres-0.103.2/tests/internal/test_preflight_fk_coverage.py +157 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/.gitignore +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/CLAUDE.md +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/LICENSE +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/README.md +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/README.md +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/base.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/config.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/db.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/options.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/query.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/types.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/settings.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/urls.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/conftest.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_related.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.103.2](https://github.com/dropseed/plain/releases/plain-postgres@0.103.2) (2026-05-06)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **`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))
|
|
8
|
+
- **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))
|
|
9
|
+
- 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))
|
|
10
|
+
|
|
11
|
+
### Upgrade instructions
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
## [0.103.1](https://github.com/dropseed/plain/releases/plain-postgres@0.103.1) (2026-05-06)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- **`postgres.duplicate_indexes` now flags exact-column duplicates**, not just prefix-redundancy. Previously the check required the redundant index to be strictly shorter than the index covering it, so an `Index(fields=["x"])` declared next to a same-column `UniqueConstraint(fields=["x"])` slipped past — even though the unique-backed btree already covers the same lookups and enforces uniqueness. The check (and the matching preflight) now flag the non-unique side of a same-column pair. Two non-unique indexes on identical columns flag the alphabetically later name (deterministic). ([253513b9](https://github.com/dropseed/plain/commit/253513b9))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- No changes required. After upgrading, `plain postgres diagnose` and `plain preflight` may surface previously-undetected duplicate indexes — drop the redundant `Index(...)` declaration on the model and run `plain postgres sync`.
|
|
24
|
+
|
|
3
25
|
## [0.103.0](https://github.com/dropseed/plain/releases/plain-postgres@0.103.0) (2026-05-06)
|
|
4
26
|
|
|
5
27
|
### 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.
|
|
@@ -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
|
|
@@ -66,7 +66,17 @@ def check_invalid_indexes(
|
|
|
66
66
|
def check_duplicate_indexes(
|
|
67
67
|
cursor: Any, table_owners: dict[str, TableOwner]
|
|
68
68
|
) -> CheckResult:
|
|
69
|
-
"""Indexes
|
|
69
|
+
"""Indexes redundant with another on the same table.
|
|
70
|
+
|
|
71
|
+
Two flavors are flagged:
|
|
72
|
+
|
|
73
|
+
- **Prefix duplicate** — a non-unique index whose columns are a strict
|
|
74
|
+
prefix of another index's. The longer index covers the same queries.
|
|
75
|
+
- **Exact duplicate** — same columns and access method as another index.
|
|
76
|
+
If the pair contains a unique-backed btree, the non-unique one is the
|
|
77
|
+
redundant one (the unique already covers the queries and enforces
|
|
78
|
+
uniqueness). If both are non-unique, the alphabetically later name
|
|
79
|
+
gets flagged (deterministic).
|
|
70
80
|
|
|
71
81
|
Each index column's canonical definition comes from
|
|
72
82
|
``pg_get_indexdef(indexrelid, k, false)`` — that text includes the
|
|
@@ -112,46 +122,56 @@ def check_duplicate_indexes(
|
|
|
112
122
|
for table_name, indexes in by_table.items():
|
|
113
123
|
for i, idx_a in enumerate(indexes):
|
|
114
124
|
for idx_b in indexes[i + 1 :]:
|
|
115
|
-
#
|
|
125
|
+
# Try both orderings — prefix-redundancy is asymmetric, and
|
|
126
|
+
# exact-duplicate flagging picks one side deterministically.
|
|
116
127
|
for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
|
|
117
128
|
name_s, am_s, defs_s, unique_s, size_s = shorter
|
|
118
|
-
name_l, am_l, defs_l,
|
|
129
|
+
name_l, am_l, defs_l, unique_l, _ = longer
|
|
119
130
|
# Different access methods serve different operators
|
|
120
|
-
# (e.g. hash supports `=` only, btree supports ordering)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
# (e.g. hash supports `=` only, btree supports ordering).
|
|
132
|
+
if name_s in flagged or am_s != am_l:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
is_prefix_dup = (
|
|
136
|
+
not unique_s
|
|
126
137
|
and len(defs_s) < len(defs_l)
|
|
127
138
|
and defs_l[: len(defs_s)] == defs_s
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
)
|
|
140
|
+
is_exact_dup = (
|
|
141
|
+
defs_s == defs_l
|
|
142
|
+
and not unique_s
|
|
143
|
+
and (unique_l or name_s > name_l)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not (is_prefix_dup or is_exact_dup):
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
source, package, model_class, model_file = _table_info(
|
|
150
|
+
table_name, table_owners
|
|
151
|
+
)
|
|
152
|
+
app_suggestion = f'Remove "{name_s}" from model indexes/constraints, then run plain postgres sync'
|
|
133
153
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
items.append(
|
|
155
|
+
CheckItem(
|
|
156
|
+
table=table_name,
|
|
157
|
+
name=name_s,
|
|
158
|
+
detail=f"{size_s}, redundant with {name_l}",
|
|
159
|
+
source=source,
|
|
160
|
+
package=package,
|
|
161
|
+
model_class=model_class,
|
|
162
|
+
model_file=model_file,
|
|
163
|
+
suggestion=_index_suggestion(
|
|
139
164
|
source=source,
|
|
140
165
|
package=package,
|
|
141
166
|
model_class=model_class,
|
|
142
167
|
model_file=model_file,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
model_file=model_file,
|
|
148
|
-
app_suggestion=app_suggestion,
|
|
149
|
-
unmanaged_suggestion=f'DROP INDEX CONCURRENTLY "{name_s}";',
|
|
150
|
-
),
|
|
151
|
-
caveats=[],
|
|
152
|
-
)
|
|
168
|
+
app_suggestion=app_suggestion,
|
|
169
|
+
unmanaged_suggestion=f'DROP INDEX CONCURRENTLY "{name_s}";',
|
|
170
|
+
),
|
|
171
|
+
caveats=[],
|
|
153
172
|
)
|
|
154
|
-
|
|
173
|
+
)
|
|
174
|
+
flagged.add(name_s)
|
|
155
175
|
|
|
156
176
|
return CheckResult(
|
|
157
177
|
name="duplicate_indexes",
|
|
@@ -167,7 +187,16 @@ def check_duplicate_indexes(
|
|
|
167
187
|
def check_missing_fk_indexes(
|
|
168
188
|
cursor: Any, table_owners: dict[str, TableOwner]
|
|
169
189
|
) -> CheckResult:
|
|
170
|
-
"""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
|
+
"""
|
|
171
200
|
cursor.execute("""
|
|
172
201
|
SELECT
|
|
173
202
|
ct.relname AS table_name,
|
|
@@ -187,6 +216,7 @@ def check_missing_fk_indexes(
|
|
|
187
216
|
FROM pg_catalog.pg_index i
|
|
188
217
|
WHERE i.indrelid = c.conrelid
|
|
189
218
|
AND i.indkey[0] = c.conkey[1]
|
|
219
|
+
AND i.indpred IS NULL
|
|
190
220
|
)
|
|
191
221
|
ORDER BY ct.relname, a.attname
|
|
192
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 (
|
|
@@ -412,7 +465,12 @@ class CheckMissingFKIndexes(PreflightCheck):
|
|
|
412
465
|
|
|
413
466
|
@register_check("postgres.duplicate_indexes")
|
|
414
467
|
class CheckDuplicateIndexes(PreflightCheck):
|
|
415
|
-
"""Warns about indexes
|
|
468
|
+
"""Warns about indexes redundant with other indexes or constraints.
|
|
469
|
+
|
|
470
|
+
Catches both prefix-redundancy (a 1-column index shadowed by a wider
|
|
471
|
+
composite) and exact-column duplicates (an `Index(fields=["x"])` that
|
|
472
|
+
duplicates a same-column `UniqueConstraint`).
|
|
473
|
+
"""
|
|
416
474
|
|
|
417
475
|
def run(self) -> list[PreflightResult]:
|
|
418
476
|
results = []
|
|
@@ -425,23 +483,52 @@ class CheckDuplicateIndexes(PreflightCheck):
|
|
|
425
483
|
for idx_b in all_indexes[i + 1 :]:
|
|
426
484
|
for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
|
|
427
485
|
s_name, s_fields, s_unique = shorter
|
|
428
|
-
l_name, l_fields,
|
|
429
|
-
|
|
430
|
-
|
|
486
|
+
l_name, l_fields, l_unique = longer
|
|
487
|
+
|
|
488
|
+
if s_name in flagged:
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
is_prefix_dup = (
|
|
492
|
+
not s_unique
|
|
431
493
|
and len(s_fields) < len(l_fields)
|
|
432
494
|
and l_fields[: len(s_fields)] == s_fields
|
|
495
|
+
)
|
|
496
|
+
is_exact_dup = (
|
|
497
|
+
s_fields == l_fields
|
|
433
498
|
and not s_unique
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
)
|
|
499
|
+
and (l_unique or s_name > l_name)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if not (is_prefix_dup or is_exact_dup):
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
if is_prefix_dup:
|
|
506
|
+
fix = (
|
|
507
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
508
|
+
f"is redundant with '{l_name}' on [{', '.join(l_fields)}]. "
|
|
509
|
+
f"The longer index covers the same queries."
|
|
444
510
|
)
|
|
445
|
-
|
|
511
|
+
elif l_unique:
|
|
512
|
+
fix = (
|
|
513
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
514
|
+
f"is redundant with '{l_name}' on the same columns. "
|
|
515
|
+
f"The unique-backed index already covers these queries."
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
fix = (
|
|
519
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
520
|
+
f"is an exact duplicate of '{l_name}'. "
|
|
521
|
+
f"Drop one of them."
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
results.append(
|
|
525
|
+
PreflightResult(
|
|
526
|
+
fix=fix,
|
|
527
|
+
obj=model.model_options.label,
|
|
528
|
+
id="postgres.duplicate_index",
|
|
529
|
+
warning=True,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
flagged.add(s_name)
|
|
446
533
|
|
|
447
534
|
return results
|
|
@@ -266,6 +266,75 @@ class TestStructuralScenarios:
|
|
|
266
266
|
f"expected no duplicates flagged across access methods, got {flagged}"
|
|
267
267
|
)
|
|
268
268
|
|
|
269
|
+
def test_duplicate_indexes_detected_when_non_unique_shadows_unique(self) -> None:
|
|
270
|
+
"""A non-unique `Index(fields=["x"])` declared alongside a same-column
|
|
271
|
+
`UniqueConstraint(fields=["x"])` is pure overhead — the unique-backed
|
|
272
|
+
btree already covers the same queries. Flag the non-unique side."""
|
|
273
|
+
_execute(
|
|
274
|
+
'CREATE TABLE "_diag_dup_exact" ('
|
|
275
|
+
'"id" serial PRIMARY KEY, "uuid" uuid NOT NULL)'
|
|
276
|
+
)
|
|
277
|
+
_execute(
|
|
278
|
+
'CREATE UNIQUE INDEX "_diag_dup_exact_unique_uuid" '
|
|
279
|
+
'ON "_diag_dup_exact" ("uuid")'
|
|
280
|
+
)
|
|
281
|
+
_execute(
|
|
282
|
+
'CREATE INDEX "_diag_dup_exact_uuid_idx" ON "_diag_dup_exact" ("uuid")'
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
conn = get_connection()
|
|
286
|
+
with conn.cursor() as cursor:
|
|
287
|
+
result = check_duplicate_indexes(cursor, {})
|
|
288
|
+
|
|
289
|
+
flagged = [i for i in result["items"] if i["table"] == "_diag_dup_exact"]
|
|
290
|
+
assert len(flagged) == 1, (
|
|
291
|
+
f"expected one duplicate on _diag_dup_exact, got {flagged}"
|
|
292
|
+
)
|
|
293
|
+
assert flagged[0]["name"] == "_diag_dup_exact_uuid_idx"
|
|
294
|
+
assert "_diag_dup_exact_unique_uuid" in flagged[0]["detail"]
|
|
295
|
+
|
|
296
|
+
def test_duplicate_indexes_detected_when_two_non_unique_match_exactly(
|
|
297
|
+
self,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Two non-unique indexes on identical columns — flag the alphabetically
|
|
300
|
+
later name (deterministic) so we don't double-report or oscillate."""
|
|
301
|
+
_execute('CREATE TABLE "_diag_dup_pair" ("id" serial PRIMARY KEY, "x" int)')
|
|
302
|
+
_execute('CREATE INDEX "_diag_dup_pair_a_idx" ON "_diag_dup_pair" ("x")')
|
|
303
|
+
_execute('CREATE INDEX "_diag_dup_pair_z_idx" ON "_diag_dup_pair" ("x")')
|
|
304
|
+
|
|
305
|
+
conn = get_connection()
|
|
306
|
+
with conn.cursor() as cursor:
|
|
307
|
+
result = check_duplicate_indexes(cursor, {})
|
|
308
|
+
|
|
309
|
+
flagged = [i for i in result["items"] if i["table"] == "_diag_dup_pair"]
|
|
310
|
+
assert len(flagged) == 1, (
|
|
311
|
+
f"expected one duplicate on _diag_dup_pair, got {flagged}"
|
|
312
|
+
)
|
|
313
|
+
assert flagged[0]["name"] == "_diag_dup_pair_z_idx"
|
|
314
|
+
|
|
315
|
+
def test_duplicate_indexes_not_flagged_for_two_unique_same_columns(self) -> None:
|
|
316
|
+
"""Two unique indexes on identical columns is a Postgres-level redundancy
|
|
317
|
+
but neither is "the" overhead — both enforce uniqueness. Don't flag."""
|
|
318
|
+
_execute(
|
|
319
|
+
'CREATE TABLE "_diag_dup_two_uniq" ('
|
|
320
|
+
'"id" serial PRIMARY KEY, "x" int NOT NULL)'
|
|
321
|
+
)
|
|
322
|
+
_execute(
|
|
323
|
+
'CREATE UNIQUE INDEX "_diag_dup_two_uniq_a" ON "_diag_dup_two_uniq" ("x")'
|
|
324
|
+
)
|
|
325
|
+
_execute(
|
|
326
|
+
'CREATE UNIQUE INDEX "_diag_dup_two_uniq_b" ON "_diag_dup_two_uniq" ("x")'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
conn = get_connection()
|
|
330
|
+
with conn.cursor() as cursor:
|
|
331
|
+
result = check_duplicate_indexes(cursor, {})
|
|
332
|
+
|
|
333
|
+
flagged = [i for i in result["items"] if i["table"] == "_diag_dup_two_uniq"]
|
|
334
|
+
assert flagged == [], (
|
|
335
|
+
f"expected no duplicates flagged for two unique same-column indexes, got {flagged}"
|
|
336
|
+
)
|
|
337
|
+
|
|
269
338
|
def test_duplicate_indexes_not_flagged_when_longer_starts_with_expression(
|
|
270
339
|
self,
|
|
271
340
|
) -> None:
|
|
@@ -335,6 +404,34 @@ class TestStructuralScenarios:
|
|
|
335
404
|
flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child2"]
|
|
336
405
|
assert flagged == [], f"indexed FK should not be flagged; got {flagged}"
|
|
337
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
|
+
|
|
338
435
|
def test_sequence_exhaustion_critical_above_90pct(self) -> None:
|
|
339
436
|
_execute('CREATE TABLE "_diag_seq" ("id" serial PRIMARY KEY, "n" int)')
|
|
340
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)
|
|
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
|