plain.postgres 0.107.0__tar.gz → 0.108.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/PKG-INFO +1 -1
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/CHANGELOG.md +22 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/constraints.py +1 -1
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_structural.py +10 -5
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/meta.py +3 -2
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/executor.py +1 -1
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/state.py +4 -2
- plain_postgres-0.108.0/plain/postgres/preflight/__init__.py +2 -0
- plain_postgres-0.108.0/plain/postgres/preflight/database.py +158 -0
- plain_postgres-0.108.0/plain/postgres/preflight/indexes.py +250 -0
- plain_postgres-0.108.0/plain/postgres/preflight/models.py +198 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/query.py +1 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/compiler.py +2 -2
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/query.py +3 -1
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/pyproject.toml +1 -1
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_diagnose.py +29 -4
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_preflight_duplicate_indexes.py +1 -1
- plain_postgres-0.108.0/tests/internal/test_preflight_fk_composite_hint.py +85 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_preflight_fk_coverage.py +73 -1
- plain_postgres-0.107.0/plain/postgres/preflight.py +0 -541
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/.gitignore +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/CLAUDE.md +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/LICENSE +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/README.md +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/README.md +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/conftest.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.107.0 → plain_postgres-0.108.0}/tests/public/test_related.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.108.0](https://github.com/dropseed/plain/releases/plain-postgres@0.108.0) (2026-06-09)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- A partial index of exactly `WHERE <fk> IS NOT NULL` on the FK column now counts as FK index coverage — every FK lookup and referencing-side sweep is a `WHERE fk = ?`, which implies the predicate, so Postgres can always use it. Both the `postgres.missing_fk_indexes` preflight check (an `Index`/`UniqueConstraint` with `condition=Q(fk__isnull=False)`) and the doctor's missing-FK check (matching the live index predicate) accept it; any other partial predicate still doesn't count. ([4836069767](https://github.com/dropseed/plain/commit/4836069767))
|
|
8
|
+
- The missing-FK-index warning now points out when an existing composite index or constraint already contains the FK at a non-leading position, suggesting a reorder to lead with the FK instead of adding a new index (safe when every query using the composite filters all of its columns with equality). ([084b6abba5](https://github.com/dropseed/plain/commit/084b6abba5))
|
|
9
|
+
- Internal: the preflight checks moved from a single `preflight.py` into a `preflight/` package (`models.py`, `database.py`, `indexes.py`). Check names and behavior are unchanged. ([edc792d358](https://github.com/dropseed/plain/commit/edc792d358))
|
|
10
|
+
|
|
11
|
+
### Upgrade instructions
|
|
12
|
+
|
|
13
|
+
- No changes required.
|
|
14
|
+
|
|
15
|
+
## [0.107.1](https://github.com/dropseed/plain/releases/plain-postgres@0.107.1) (2026-06-08)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- Internal: typing-only cleanup for the `ty` 0.0.45 upgrade. `Query.order_by` is now annotated `tuple[Any, ...]` instead of relying on empty-tuple inference, a few field-name dict keys are narrowed via `assert` or the known-`str` loop variable, and `ty: ignore` comments on the intentionally heterogeneous `get_fields()` / `klass_info` structures were adjusted (net removal of several ignores). No runtime behavior changes. ([5c8015795d](https://github.com/dropseed/plain/commit/5c8015795d), [95f54e880d](https://github.com/dropseed/plain/commit/95f54e880d))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- No changes required.
|
|
24
|
+
|
|
3
25
|
## [0.107.0](https://github.com/dropseed/plain/releases/plain-postgres@0.107.0) (2026-06-07)
|
|
4
26
|
|
|
5
27
|
### What's changed
|
|
@@ -385,7 +385,7 @@ class UniqueConstraint(BaseConstraint):
|
|
|
385
385
|
# A composite constraint containing NULL value cannot cause
|
|
386
386
|
# a violation since NULL != NULL in SQL.
|
|
387
387
|
return
|
|
388
|
-
lookup_kwargs[
|
|
388
|
+
lookup_kwargs[field_name] = lookup_value
|
|
389
389
|
queryset = queryset.filter(**lookup_kwargs)
|
|
390
390
|
else:
|
|
391
391
|
# Ignore constraints with excluded fields.
|
|
@@ -192,10 +192,11 @@ def check_missing_fk_indexes(
|
|
|
192
192
|
Partial indexes (``WHERE`` clause set on ``pg_index.indpred``) don't
|
|
193
193
|
count: Postgres only uses them for queries that imply the partial
|
|
194
194
|
predicate, so FK lookups and cascade deletes outside that predicate
|
|
195
|
-
still sequential-scan. The
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
still sequential-scan. The one exception is a predicate of exactly
|
|
196
|
+
``<fk> IS NOT NULL`` on the FK column itself — every FK lookup and
|
|
197
|
+
referencing-side sweep is a ``WHERE fk = ?``, which implies
|
|
198
|
+
``fk IS NOT NULL``, so Postgres can always use that partial. Match
|
|
199
|
+
the preflight's coverage rule.
|
|
199
200
|
"""
|
|
200
201
|
cursor.execute("""
|
|
201
202
|
SELECT
|
|
@@ -216,7 +217,11 @@ def check_missing_fk_indexes(
|
|
|
216
217
|
FROM pg_catalog.pg_index i
|
|
217
218
|
WHERE i.indrelid = c.conrelid
|
|
218
219
|
AND i.indkey[0] = c.conkey[1]
|
|
219
|
-
AND
|
|
220
|
+
AND (
|
|
221
|
+
i.indpred IS NULL
|
|
222
|
+
OR pg_get_expr(i.indpred, i.indrelid) =
|
|
223
|
+
format('(%s IS NOT NULL)', quote_ident(a.attname))
|
|
224
|
+
)
|
|
220
225
|
)
|
|
221
226
|
ORDER BY ct.relname, a.attname
|
|
222
227
|
""")
|
|
@@ -511,8 +511,9 @@ class Meta:
|
|
|
511
511
|
fields.append(field.remote_field)
|
|
512
512
|
|
|
513
513
|
if forward:
|
|
514
|
-
|
|
515
|
-
fields += self.
|
|
514
|
+
# get_fields() intentionally returns a heterogeneous list of field types.
|
|
515
|
+
fields += self.local_fields # ty: ignore[unsupported-operator]
|
|
516
|
+
fields += self.local_many_to_many # ty: ignore[unsupported-operator]
|
|
516
517
|
|
|
517
518
|
# In order to avoid list manipulation. Always
|
|
518
519
|
# return a shallow copy of the results
|
|
@@ -47,7 +47,7 @@ class MigrationExecutor:
|
|
|
47
47
|
if migration not in applied:
|
|
48
48
|
plan.append(self.loader.graph.nodes[migration])
|
|
49
49
|
applied[migration] = self.loader.graph.nodes[migration]
|
|
50
|
-
return plan
|
|
50
|
+
return plan # ty: ignore[invalid-return-type] (graph.nodes may hold dummy None, never reached here)
|
|
51
51
|
|
|
52
52
|
def _create_project_state(
|
|
53
53
|
self, with_applied_migrations: bool = False
|
|
@@ -550,7 +550,7 @@ class StateModelsRegistry(ModelsRegistry):
|
|
|
550
550
|
self.ready = True
|
|
551
551
|
|
|
552
552
|
# There shouldn't be any operations pending at this point.
|
|
553
|
-
from plain.postgres.preflight import _check_lazy_references
|
|
553
|
+
from plain.postgres.preflight.models import _check_lazy_references
|
|
554
554
|
|
|
555
555
|
if errors := _check_lazy_references(self, packages_registry):
|
|
556
556
|
raise ValueError("\n".join(error.fix for error in errors))
|
|
@@ -679,6 +679,7 @@ class ModelState:
|
|
|
679
679
|
if getattr(field, "remote_field", None) and exclude_rels:
|
|
680
680
|
continue
|
|
681
681
|
name = field.name
|
|
682
|
+
assert name is not None
|
|
682
683
|
try:
|
|
683
684
|
fields.append((name, field.clone()))
|
|
684
685
|
except TypeError as e:
|
|
@@ -688,6 +689,7 @@ class ModelState:
|
|
|
688
689
|
if not exclude_rels:
|
|
689
690
|
for field in model._model_meta.local_many_to_many:
|
|
690
691
|
name = field.name
|
|
692
|
+
assert name is not None
|
|
691
693
|
try:
|
|
692
694
|
fields.append((name, field.clone()))
|
|
693
695
|
except TypeError as e:
|
|
@@ -699,7 +701,7 @@ class ModelState:
|
|
|
699
701
|
bases = []
|
|
700
702
|
for base in model.__bases__:
|
|
701
703
|
bases.append(base)
|
|
702
|
-
return bases
|
|
704
|
+
return bases # ty: ignore[invalid-return-type] (__bases__ widens to list[type])
|
|
703
705
|
|
|
704
706
|
# We can't rely on __mro__ directly because we only want to flatten
|
|
705
707
|
# abstract models and not the whole tree. However by recursing on
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Preflight checks on the database connection and migration state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from plain.postgres.db import get_connection
|
|
6
|
+
from plain.preflight import PreflightCheck, PreflightResult, register_check
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@register_check("postgres.middleware_installed")
|
|
11
|
+
class CheckMiddlewareInstalled(PreflightCheck):
|
|
12
|
+
"""Errors if `DatabaseConnectionMiddleware` isn't in `MIDDLEWARE`.
|
|
13
|
+
|
|
14
|
+
Without it, pooled connections are only released by GC at the end of
|
|
15
|
+
each request — relying on refcount timing under load is a recipe for
|
|
16
|
+
pool exhaustion under cyclic refs or delayed finalization.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
REQUIRED = "plain.postgres.DatabaseConnectionMiddleware"
|
|
20
|
+
|
|
21
|
+
def run(self) -> list[PreflightResult]:
|
|
22
|
+
if self.REQUIRED in settings.MIDDLEWARE:
|
|
23
|
+
return []
|
|
24
|
+
return [
|
|
25
|
+
PreflightResult(
|
|
26
|
+
fix=(
|
|
27
|
+
f"Add '{self.REQUIRED}' to MIDDLEWARE so pooled "
|
|
28
|
+
"database connections are returned at the end of each "
|
|
29
|
+
"request. Place it first so its after_response runs "
|
|
30
|
+
"after any middleware that queries the database."
|
|
31
|
+
),
|
|
32
|
+
id="postgres.middleware_not_installed",
|
|
33
|
+
)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@register_check("postgres.postgres_version")
|
|
38
|
+
class CheckPostgresVersion(PreflightCheck):
|
|
39
|
+
"""Checks that the PostgreSQL server meets the minimum version requirement."""
|
|
40
|
+
|
|
41
|
+
MINIMUM_VERSION = 16
|
|
42
|
+
|
|
43
|
+
def run(self) -> list[PreflightResult]:
|
|
44
|
+
conn = get_connection()
|
|
45
|
+
conn.ensure_connection()
|
|
46
|
+
assert conn.connection is not None
|
|
47
|
+
major, minor = divmod(conn.connection.info.server_version, 10000)
|
|
48
|
+
if major < self.MINIMUM_VERSION:
|
|
49
|
+
return [
|
|
50
|
+
PreflightResult(
|
|
51
|
+
fix=f"PostgreSQL {self.MINIMUM_VERSION} or later is required (found {major}.{minor}).",
|
|
52
|
+
id="postgres.postgres_version_too_old",
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@register_check("postgres.database_tables")
|
|
59
|
+
class CheckDatabaseTables(PreflightCheck):
|
|
60
|
+
"""Checks for unknown tables in the database when plain.postgres is available."""
|
|
61
|
+
|
|
62
|
+
def run(self) -> list[PreflightResult]:
|
|
63
|
+
from plain.postgres.introspection import get_unknown_tables
|
|
64
|
+
|
|
65
|
+
unknown_tables = get_unknown_tables()
|
|
66
|
+
|
|
67
|
+
if not unknown_tables:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
table_names = ", ".join(unknown_tables)
|
|
71
|
+
return [
|
|
72
|
+
PreflightResult(
|
|
73
|
+
fix=f"Unknown tables in default database: {table_names}. "
|
|
74
|
+
"Tables may be from packages/models that have been uninstalled. "
|
|
75
|
+
"Make sure you have a backup, then run `plain postgres drop-unknown-tables` to remove them.",
|
|
76
|
+
id="postgres.unknown_database_tables",
|
|
77
|
+
warning=True,
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@register_check("postgres.prunable_migrations")
|
|
83
|
+
class CheckPrunableMigrations(PreflightCheck):
|
|
84
|
+
"""Warns about stale migration records in the database."""
|
|
85
|
+
|
|
86
|
+
def run(self) -> list[PreflightResult]:
|
|
87
|
+
# Import here to avoid circular import issues
|
|
88
|
+
from plain.postgres.migrations.loader import MigrationLoader
|
|
89
|
+
from plain.postgres.migrations.recorder import MigrationRecorder
|
|
90
|
+
|
|
91
|
+
errors = []
|
|
92
|
+
|
|
93
|
+
# Load migrations from disk and database
|
|
94
|
+
conn = get_connection()
|
|
95
|
+
loader = MigrationLoader(conn, ignore_no_migrations=True)
|
|
96
|
+
recorder = MigrationRecorder(conn)
|
|
97
|
+
recorded_migrations = recorder.applied_migrations()
|
|
98
|
+
|
|
99
|
+
# disk_migrations should not be None after MigrationLoader initialization,
|
|
100
|
+
# but check to satisfy type checker
|
|
101
|
+
if loader.disk_migrations is None:
|
|
102
|
+
return errors
|
|
103
|
+
|
|
104
|
+
# Find all prunable migrations (recorded but not on disk)
|
|
105
|
+
all_prunable = [
|
|
106
|
+
migration
|
|
107
|
+
for migration in recorded_migrations
|
|
108
|
+
if migration not in loader.disk_migrations
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
if not all_prunable:
|
|
112
|
+
return errors
|
|
113
|
+
|
|
114
|
+
# Separate into existing packages vs orphaned packages
|
|
115
|
+
existing_packages = set(loader.migrated_packages)
|
|
116
|
+
prunable_existing: list[tuple[str, str]] = []
|
|
117
|
+
prunable_orphaned: list[tuple[str, str]] = []
|
|
118
|
+
|
|
119
|
+
for migration in all_prunable:
|
|
120
|
+
package, name = migration
|
|
121
|
+
if package in existing_packages:
|
|
122
|
+
prunable_existing.append(migration)
|
|
123
|
+
else:
|
|
124
|
+
prunable_orphaned.append(migration)
|
|
125
|
+
|
|
126
|
+
# Build the warning message
|
|
127
|
+
total_count = len(all_prunable)
|
|
128
|
+
message_parts = [
|
|
129
|
+
f"Found {total_count} stale migration record{'s' if total_count != 1 else ''} in the database."
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
if prunable_existing:
|
|
133
|
+
existing_list = ", ".join(
|
|
134
|
+
f"{pkg}.{name}" for pkg, name in prunable_existing[:3]
|
|
135
|
+
)
|
|
136
|
+
if len(prunable_existing) > 3:
|
|
137
|
+
existing_list += f" (and {len(prunable_existing) - 3} more)"
|
|
138
|
+
message_parts.append(f"From existing packages: {existing_list}.")
|
|
139
|
+
|
|
140
|
+
if prunable_orphaned:
|
|
141
|
+
orphaned_list = ", ".join(
|
|
142
|
+
f"{pkg}.{name}" for pkg, name in prunable_orphaned[:3]
|
|
143
|
+
)
|
|
144
|
+
if len(prunable_orphaned) > 3:
|
|
145
|
+
orphaned_list += f" (and {len(prunable_orphaned) - 3} more)"
|
|
146
|
+
message_parts.append(f"From removed packages: {orphaned_list}.")
|
|
147
|
+
|
|
148
|
+
message_parts.append("Run 'plain migrations prune' to review and remove them.")
|
|
149
|
+
|
|
150
|
+
errors.append(
|
|
151
|
+
PreflightResult(
|
|
152
|
+
fix=" ".join(message_parts),
|
|
153
|
+
id="postgres.prunable_migrations",
|
|
154
|
+
warning=True,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return errors
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Preflight checks for index coverage on app models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from plain.packages import packages_registry
|
|
8
|
+
from plain.postgres.constraints import UniqueConstraint
|
|
9
|
+
from plain.postgres.expressions import F, OrderBy
|
|
10
|
+
from plain.postgres.fields.related import ForeignKeyField
|
|
11
|
+
from plain.postgres.query_utils import Q
|
|
12
|
+
from plain.postgres.registry import models_registry
|
|
13
|
+
from plain.preflight import PreflightCheck, PreflightResult, register_check
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_app_models() -> list[Any]:
|
|
17
|
+
"""Return models from the user's app packages only (not framework/third-party)."""
|
|
18
|
+
app_models = []
|
|
19
|
+
for package_config in packages_registry.get_package_configs():
|
|
20
|
+
if package_config.name.startswith("app."):
|
|
21
|
+
app_models.extend(
|
|
22
|
+
models_registry.get_models(package_label=package_config.package_label)
|
|
23
|
+
)
|
|
24
|
+
return app_models
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _collect_model_indexes(model: Any) -> list[tuple[str, list[str], bool]]:
|
|
28
|
+
"""Collect (name, fields, is_unique) for non-partial indexes/constraints.
|
|
29
|
+
|
|
30
|
+
Partials are skipped: they only apply to rows matching their predicate,
|
|
31
|
+
so they're never interchangeable with a full index for duplicate
|
|
32
|
+
detection or reorder suggestions.
|
|
33
|
+
"""
|
|
34
|
+
all_indexes: list[tuple[str, list[str], bool]] = []
|
|
35
|
+
|
|
36
|
+
for index in model.model_options.indexes:
|
|
37
|
+
if index.fields and not index.is_partial:
|
|
38
|
+
fields = [f.lstrip("-") for f in index.fields]
|
|
39
|
+
all_indexes.append((index.name, fields, False))
|
|
40
|
+
|
|
41
|
+
for constraint in model.model_options.constraints:
|
|
42
|
+
if (
|
|
43
|
+
isinstance(constraint, UniqueConstraint)
|
|
44
|
+
and constraint.fields
|
|
45
|
+
and not constraint.is_partial
|
|
46
|
+
):
|
|
47
|
+
all_indexes.append((constraint.name, list(constraint.fields), True))
|
|
48
|
+
|
|
49
|
+
return all_indexes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _bare_column_name(expr: Any) -> str | None:
|
|
53
|
+
"""Return the column name if `expr` resolves to a bare column, else `None`.
|
|
54
|
+
|
|
55
|
+
Postgres can range-scan the leading column of an index for `WHERE col = ?`
|
|
56
|
+
only when that column is a real attribute, not an expression — so a
|
|
57
|
+
compound leading expression like `Lower("email")` returns `None` here.
|
|
58
|
+
Sort direction (`F("col").desc()` / `OrderBy(F)`) doesn't affect equality
|
|
59
|
+
lookups, so we unwrap one layer of `OrderBy` around a bare `F`.
|
|
60
|
+
"""
|
|
61
|
+
if isinstance(expr, OrderBy):
|
|
62
|
+
expr = expr.expression
|
|
63
|
+
if isinstance(expr, F):
|
|
64
|
+
return expr.name
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _leading_field_name(
|
|
69
|
+
fields: tuple[str, ...] | list[str], expressions: tuple
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""The leading column's field name, or `None` if it's an expression."""
|
|
72
|
+
if fields:
|
|
73
|
+
return fields[0].lstrip("-")
|
|
74
|
+
if expressions:
|
|
75
|
+
return _bare_column_name(expressions[0])
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _condition_is_not_null_on(condition: Q, field_name: str) -> bool:
|
|
80
|
+
"""True if `condition` is exactly ``Q(<field_name>__isnull=False)``."""
|
|
81
|
+
return (
|
|
82
|
+
not condition.negated
|
|
83
|
+
and len(condition.children) == 1
|
|
84
|
+
and condition.children[0] == (f"{field_name}__isnull", False)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fk_covered_field_names(model: Any) -> set[str]:
|
|
89
|
+
"""Field names that appear as the leading column of an index or unique
|
|
90
|
+
constraint — covering arbitrary FK lookups via the index's leading
|
|
91
|
+
column. Includes expression-based indexes/constraints whose leading
|
|
92
|
+
expression is a bare ``F(field_name)``.
|
|
93
|
+
|
|
94
|
+
Partial indexes/constraints (declared with ``condition=Q(...)``) are
|
|
95
|
+
excluded: Postgres can only use them for queries whose predicate
|
|
96
|
+
implies the partial-index predicate, so an FK lookup or cascade
|
|
97
|
+
delete that doesn't filter by that condition still does a sequential
|
|
98
|
+
scan. The one exception is a predicate of exactly
|
|
99
|
+
``Q(<fk>__isnull=False)`` on the leading FK itself — every FK lookup
|
|
100
|
+
and referencing-side sweep is a ``WHERE fk = ?``, which implies
|
|
101
|
+
``fk IS NOT NULL``, so Postgres can always use that partial. Match
|
|
102
|
+
the doctor's coverage rule in
|
|
103
|
+
``introspection/health/checks_structural.py``.
|
|
104
|
+
"""
|
|
105
|
+
covered: set[str] = set()
|
|
106
|
+
|
|
107
|
+
def _record(index_or_constraint: Any) -> None:
|
|
108
|
+
leading = _leading_field_name(
|
|
109
|
+
index_or_constraint.fields, index_or_constraint.expressions
|
|
110
|
+
)
|
|
111
|
+
if leading is None:
|
|
112
|
+
return
|
|
113
|
+
if not index_or_constraint.is_partial or _condition_is_not_null_on(
|
|
114
|
+
index_or_constraint.condition, leading
|
|
115
|
+
):
|
|
116
|
+
covered.add(leading)
|
|
117
|
+
|
|
118
|
+
for index in model.model_options.indexes:
|
|
119
|
+
_record(index)
|
|
120
|
+
|
|
121
|
+
for constraint in model.model_options.constraints:
|
|
122
|
+
if isinstance(constraint, UniqueConstraint):
|
|
123
|
+
_record(constraint)
|
|
124
|
+
|
|
125
|
+
return covered
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _composite_containing(model: Any, field_name: str) -> tuple[str, list[str]] | None:
|
|
129
|
+
"""First non-partial index/constraint with `field_name` at a non-leading position.
|
|
130
|
+
|
|
131
|
+
A non-leading column doesn't cover the FK, but reordering the composite to
|
|
132
|
+
lead with it often can — worth suggesting in the fix message.
|
|
133
|
+
"""
|
|
134
|
+
for name, fields, _is_unique in _collect_model_indexes(model):
|
|
135
|
+
if field_name in fields[1:]:
|
|
136
|
+
return name, fields
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@register_check("postgres.missing_fk_indexes")
|
|
141
|
+
class CheckMissingFKIndexes(PreflightCheck):
|
|
142
|
+
"""Warns about foreign key fields without index coverage."""
|
|
143
|
+
|
|
144
|
+
def run(self) -> list[PreflightResult]:
|
|
145
|
+
results = []
|
|
146
|
+
|
|
147
|
+
for model in _get_app_models():
|
|
148
|
+
covered_fields = _fk_covered_field_names(model)
|
|
149
|
+
|
|
150
|
+
for field in model._model_meta.local_fields:
|
|
151
|
+
if (
|
|
152
|
+
isinstance(field, ForeignKeyField)
|
|
153
|
+
and not field.primary_key
|
|
154
|
+
and field.name not in covered_fields
|
|
155
|
+
):
|
|
156
|
+
fix = (
|
|
157
|
+
f"Foreign key '{field.name}' has no index coverage. "
|
|
158
|
+
f"Add an Index on [\"{field.name}\"] or a constraint with '{field.name}' as the first field."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if composite := _composite_containing(model, field.name):
|
|
162
|
+
composite_name, composite_fields = composite
|
|
163
|
+
fix += (
|
|
164
|
+
f" Alternatively, '{composite_name}' on [{', '.join(composite_fields)}] "
|
|
165
|
+
f"already includes '{field.name}' — reordering it to put '{field.name}' first "
|
|
166
|
+
f"covers this FK without a new index (safe when every query using it "
|
|
167
|
+
f"filters all of its columns with equality)."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
results.append(
|
|
171
|
+
PreflightResult(
|
|
172
|
+
fix=fix,
|
|
173
|
+
obj=f"{model.model_options.label}.{field.name}",
|
|
174
|
+
id="postgres.missing_fk_index",
|
|
175
|
+
warning=True,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@register_check("postgres.duplicate_indexes")
|
|
183
|
+
class CheckDuplicateIndexes(PreflightCheck):
|
|
184
|
+
"""Warns about indexes redundant with other indexes or constraints.
|
|
185
|
+
|
|
186
|
+
Catches both prefix-redundancy (a 1-column index shadowed by a wider
|
|
187
|
+
composite) and exact-column duplicates (an `Index(fields=["x"])` that
|
|
188
|
+
duplicates a same-column `UniqueConstraint`).
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def run(self) -> list[PreflightResult]:
|
|
192
|
+
results = []
|
|
193
|
+
|
|
194
|
+
for model in _get_app_models():
|
|
195
|
+
all_indexes = _collect_model_indexes(model)
|
|
196
|
+
|
|
197
|
+
flagged: set[str] = set()
|
|
198
|
+
for i, idx_a in enumerate(all_indexes):
|
|
199
|
+
for idx_b in all_indexes[i + 1 :]:
|
|
200
|
+
for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
|
|
201
|
+
s_name, s_fields, s_unique = shorter
|
|
202
|
+
l_name, l_fields, l_unique = longer
|
|
203
|
+
|
|
204
|
+
if s_name in flagged:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
is_prefix_dup = (
|
|
208
|
+
not s_unique
|
|
209
|
+
and len(s_fields) < len(l_fields)
|
|
210
|
+
and l_fields[: len(s_fields)] == s_fields
|
|
211
|
+
)
|
|
212
|
+
is_exact_dup = (
|
|
213
|
+
s_fields == l_fields
|
|
214
|
+
and not s_unique
|
|
215
|
+
and (l_unique or s_name > l_name)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not (is_prefix_dup or is_exact_dup):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if is_prefix_dup:
|
|
222
|
+
fix = (
|
|
223
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
224
|
+
f"is redundant with '{l_name}' on [{', '.join(l_fields)}]. "
|
|
225
|
+
f"The longer index covers the same queries."
|
|
226
|
+
)
|
|
227
|
+
elif l_unique:
|
|
228
|
+
fix = (
|
|
229
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
230
|
+
f"is redundant with '{l_name}' on the same columns. "
|
|
231
|
+
f"The unique-backed index already covers these queries."
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
fix = (
|
|
235
|
+
f"Index '{s_name}' on [{', '.join(s_fields)}] "
|
|
236
|
+
f"is an exact duplicate of '{l_name}'. "
|
|
237
|
+
f"Drop one of them."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
results.append(
|
|
241
|
+
PreflightResult(
|
|
242
|
+
fix=fix,
|
|
243
|
+
obj=model.model_options.label,
|
|
244
|
+
id="postgres.duplicate_index",
|
|
245
|
+
warning=True,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
flagged.add(s_name)
|
|
249
|
+
|
|
250
|
+
return results
|