plain.postgres 0.108.0__tar.gz → 0.109.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.108.0 → plain_postgres-0.109.0}/PKG-INFO +2 -2
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/CHANGELOG.md +27 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/README.md +1 -1
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/connection.py +14 -2
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/analysis.py +163 -86
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/__init__.py +1 -3
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related.py +5 -98
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_descriptors.py +5 -23
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/reverse_related.py +1 -32
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/otel.py +30 -19
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/query.py +0 -17
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/types.pyi +0 -9
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/pyproject.toml +1 -1
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -18
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/delete.py +0 -11
- plain_postgres-0.109.0/tests/conftest.py +26 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_pool.py +0 -14
- plain_postgres-0.109.0/tests/internal/test_connection_self_heal.py +131 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_constraints.py +112 -47
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_fk.py +1 -15
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_indexes.py +20 -20
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_literal_default_persistence.py +5 -5
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_otel_metrics.py +29 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_delete_behaviors.py +0 -24
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_related.py +0 -27
- plain_postgres-0.108.0/tests/conftest.py +0 -11
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/.gitignore +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/CLAUDE.md +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/LICENSE +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/README.md +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_read_only_transactions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.109.0
|
|
4
4
|
Summary: Model your data and store it in a database.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -957,7 +957,7 @@ book.author.name # one query — loads the rest of the row
|
|
|
957
957
|
|
|
958
958
|
The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
|
|
959
959
|
|
|
960
|
-
The partial-instance shortcut
|
|
960
|
+
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
961
961
|
|
|
962
962
|
### Reverse relationships
|
|
963
963
|
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.109.0](https://github.com/dropseed/plain/releases/plain-postgres@0.109.0) (2026-06-17)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **The `db_constraint` argument is removed from `ForeignKeyField` — every foreign key is now backed by a database `FOREIGN KEY` constraint, always.** Because the database guarantees the referenced row exists, accessing a foreign key always builds a partial related instance from just the stored id (no query); the old "query on access when unconstrained" path is gone. The `fields.foreign_key_unconstrained_requires_no_action` preflight check is also removed. ([9ec559db38](https://github.com/dropseed/plain/commit/9ec559db38))
|
|
8
|
+
- **`limit_choices_to` is removed from `ForeignKeyField` and `ManyToManyField`.** The supporting machinery goes with it: `RelatedField.get_choices()` / `get_limit_choices_to()`, `ForeignObjectRel.get_choices()`, and `QuerySet.complex_filter()` are removed, and `BLANK_CHOICE_DASH` is no longer exported from `plain.postgres.fields`. To restrict the rows offered for a relation, filter the queryset explicitly in your form or view. ([68b6ced4f0](https://github.com/dropseed/plain/commit/68b6ced4f0))
|
|
9
|
+
- **The redundant foreign-key existence pre-check is removed.** `ForeignKeyField.validate()` no longer runs a `SELECT ... EXISTS` query to confirm the target row exists — one fewer query per FK validation. The database `FOREIGN KEY` constraint already enforces this, rejecting an invalid reference at write time (as a `psycopg.IntegrityError`). ([68b6ced4f0](https://github.com/dropseed/plain/commit/68b6ced4f0))
|
|
10
|
+
- Internal: convergence (schema-drift) analysis now reuses a single session-private temp "probe" table per model across all of that model's round-trip comparisons, instead of creating and dropping one per index/constraint/default comparison — fewer DDL statements during schema checks. The internal `canon*` helpers were renamed to `probe`/`normalize`. ([67167a11ee](https://github.com/dropseed/plain/commit/67167a11ee), [06650bea63](https://github.com/dropseed/plain/commit/06650bea63))
|
|
11
|
+
|
|
12
|
+
### Upgrade instructions
|
|
13
|
+
|
|
14
|
+
- Remove any `db_constraint=False` arguments from `ForeignKeyField`. Foreign keys are always DB-constrained now; an unconstrained FK (e.g. for soft/cross-system references) is no longer supported.
|
|
15
|
+
- Remove `limit_choices_to=` from `ForeignKeyField` / `ManyToManyField` definitions. To constrain the rows offered for a relation, filter the related queryset explicitly in your form or view.
|
|
16
|
+
- Replace any `QuerySet.complex_filter(...)` calls with `.filter(...)` — pass a dict of lookups or a `Q` object directly.
|
|
17
|
+
- Most projects use none of these and need no changes. The `/plain-upgrade` skill handles the rewrites.
|
|
18
|
+
|
|
19
|
+
## [0.108.1](https://github.com/dropseed/plain/releases/plain-postgres@0.108.1) (2026-06-09)
|
|
20
|
+
|
|
21
|
+
### What's changed
|
|
22
|
+
|
|
23
|
+
- `ensure_connection()` now detects a pooled connection the server has closed while it was held (Postgres restart, failover, idle timeout) and discards it before establishing a fresh one — logging a "Discarding dead database connection" warning — instead of repeatedly failing on the dead connection. Inside an atomic block the dead connection is intentionally left in place so the transaction's normal error handling runs, rather than silently continuing the rest of the block outside its transaction. ([9e82cb4454](https://github.com/dropseed/plain/commit/9e82cb4454))
|
|
24
|
+
- Query span instrumentation no longer pays for expensive attributes when the span is sampled out: the per-query Python stack walk (for `code.*` attributes) and the `DEBUG`-mode stringification of query parameters now only happen when the span is actually recording. The cheap connection attributes are still passed at span creation so attribute-aware samplers can see them in `should_sample()`. ([af736043b0](https://github.com/dropseed/plain/commit/af736043b0))
|
|
25
|
+
|
|
26
|
+
### Upgrade instructions
|
|
27
|
+
|
|
28
|
+
- No changes required.
|
|
29
|
+
|
|
3
30
|
## [0.108.0](https://github.com/dropseed/plain/releases/plain-postgres@0.108.0) (2026-06-09)
|
|
4
31
|
|
|
5
32
|
### What's changed
|
|
@@ -944,7 +944,7 @@ book.author.name # one query — loads the rest of the row
|
|
|
944
944
|
|
|
945
945
|
The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
|
|
946
946
|
|
|
947
|
-
The partial-instance shortcut
|
|
947
|
+
The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
|
|
948
948
|
|
|
949
949
|
### Reverse relationships
|
|
950
950
|
|
|
@@ -170,7 +170,19 @@ class DatabaseConnection:
|
|
|
170
170
|
self.set_autocommit(True)
|
|
171
171
|
|
|
172
172
|
def ensure_connection(self) -> None:
|
|
173
|
-
"""Guarantee that a connection to the database is established."""
|
|
173
|
+
"""Guarantee that a live connection to the database is established."""
|
|
174
|
+
if (
|
|
175
|
+
self.connection is not None
|
|
176
|
+
and self.connection.closed
|
|
177
|
+
and not self.in_atomic_block
|
|
178
|
+
):
|
|
179
|
+
# The server closed this connection while we held it (restart,
|
|
180
|
+
# failover, idle timeout) — psycopg marks it closed once an
|
|
181
|
+
# operation fails on it. Mid-atomic, swapping it would silently
|
|
182
|
+
# run the rest of the block outside its transaction — leave the
|
|
183
|
+
# dead connection for Atomic.__exit__'s error recovery instead.
|
|
184
|
+
logger.warning("Discarding dead database connection")
|
|
185
|
+
self.close()
|
|
174
186
|
if self.connection is None:
|
|
175
187
|
self.connect()
|
|
176
188
|
|
|
@@ -634,7 +646,7 @@ class DatabaseConnection:
|
|
|
634
646
|
}
|
|
635
647
|
# Now get indexes. Sort order, opclasses, INCLUDE, and predicates all
|
|
636
648
|
# ride along inside `pg_get_indexdef` and are compared via the
|
|
637
|
-
#
|
|
649
|
+
# normalized-tail round-trip in convergence — no need to introspect
|
|
638
650
|
# them here as separate columns.
|
|
639
651
|
cursor.execute(
|
|
640
652
|
"""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextvars
|
|
3
4
|
import json
|
|
4
5
|
import re
|
|
5
6
|
from collections.abc import Iterator
|
|
@@ -370,14 +371,15 @@ def analyze_model(
|
|
|
370
371
|
table_issues=["table missing from database"],
|
|
371
372
|
)
|
|
372
373
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
374
|
+
with _probe_session(cursor, model):
|
|
375
|
+
return ModelAnalysis(
|
|
376
|
+
label=model.model_options.label,
|
|
377
|
+
table=table_name,
|
|
378
|
+
columns=_compare_columns(model, db, table_name, cursor),
|
|
379
|
+
indexes=_compare_indexes(cursor, model, db, table_name),
|
|
380
|
+
constraints=_compare_constraints(cursor, model, db, table_name),
|
|
381
|
+
storage_parameter_drifts=_compare_storage_parameters(model, db, table_name),
|
|
382
|
+
)
|
|
381
383
|
|
|
382
384
|
|
|
383
385
|
def _compare_storage_parameters(
|
|
@@ -538,7 +540,7 @@ def _extract_jsonb_literal(sql: str) -> str | None:
|
|
|
538
540
|
return m.group(1).replace("''", "'")
|
|
539
541
|
|
|
540
542
|
|
|
541
|
-
def
|
|
543
|
+
def _normalize_default_expr(
|
|
542
544
|
cursor: CursorWrapper, model: type[Model], column: str, default_sql: str
|
|
543
545
|
) -> str:
|
|
544
546
|
"""Round-trip a column DEFAULT through Postgres so both sides of the
|
|
@@ -550,9 +552,9 @@ def _canonicalize_default_expr(
|
|
|
550
552
|
comparison then sees inequality and reports drift.
|
|
551
553
|
"""
|
|
552
554
|
try:
|
|
553
|
-
with
|
|
555
|
+
with _probe_table(cursor, model):
|
|
554
556
|
cursor.execute(
|
|
555
|
-
f"ALTER TABLE {
|
|
557
|
+
f"ALTER TABLE {_PROBE_TABLE} ALTER COLUMN {quote_name(column)} "
|
|
556
558
|
f"SET DEFAULT {default_sql}"
|
|
557
559
|
)
|
|
558
560
|
cursor.execute(
|
|
@@ -561,11 +563,11 @@ def _canonicalize_default_expr(
|
|
|
561
563
|
"WHERE a.attrelid = (SELECT oid FROM pg_class WHERE relname = %s "
|
|
562
564
|
"AND relnamespace = pg_my_temp_schema()) "
|
|
563
565
|
"AND a.attname = %s",
|
|
564
|
-
[
|
|
566
|
+
[_PROBE_TABLE, column],
|
|
565
567
|
)
|
|
566
568
|
row = cursor.fetchone()
|
|
567
569
|
return row[0] if row else ""
|
|
568
|
-
except
|
|
570
|
+
except _PROBE_FALLBACK_ERRORS:
|
|
569
571
|
return ""
|
|
570
572
|
|
|
571
573
|
|
|
@@ -589,14 +591,14 @@ def _compare_column_default(
|
|
|
589
591
|
model_default_sql=expected_sql,
|
|
590
592
|
)
|
|
591
593
|
|
|
592
|
-
|
|
594
|
+
normalized_expected = _normalize_default_expr(
|
|
593
595
|
cursor, field.model, field.column, expected_sql
|
|
594
596
|
)
|
|
595
|
-
if
|
|
597
|
+
if normalized_expected == actual.default_sql:
|
|
596
598
|
return None
|
|
597
|
-
# Semantic compare for jsonb — PG
|
|
599
|
+
# Semantic compare for jsonb — PG normalizes object keys, which
|
|
598
600
|
# won't match Python's dict-insertion order even after a round-trip.
|
|
599
|
-
m_json = _extract_jsonb_literal(
|
|
601
|
+
m_json = _extract_jsonb_literal(normalized_expected)
|
|
600
602
|
d_json = _extract_jsonb_literal(actual.default_sql)
|
|
601
603
|
if m_json is not None and d_json is not None:
|
|
602
604
|
try:
|
|
@@ -681,7 +683,7 @@ def _compare_indexes(
|
|
|
681
683
|
|
|
682
684
|
# Check if definition matches
|
|
683
685
|
if db_idx.definition:
|
|
684
|
-
issue =
|
|
686
|
+
issue = _compare_normalized_index(
|
|
685
687
|
cursor=cursor,
|
|
686
688
|
model=model,
|
|
687
689
|
expressions=index.expressions,
|
|
@@ -725,9 +727,9 @@ def _compare_indexes(
|
|
|
725
727
|
if non_unique_indexes[n].access_method in MANAGED_INDEX_ACCESS_METHODS
|
|
726
728
|
]
|
|
727
729
|
|
|
728
|
-
# Detect renames by
|
|
730
|
+
# Detect renames by normalized (round-tripped) index body. Build the cheap
|
|
729
731
|
# already-introspected side first so we can skip the per-missing
|
|
730
|
-
#
|
|
732
|
+
# normalization loop on the steady-state path.
|
|
731
733
|
renamed_missing: set[str] = set()
|
|
732
734
|
renamed_extra: set[str] = set()
|
|
733
735
|
|
|
@@ -740,7 +742,7 @@ def _compare_indexes(
|
|
|
740
742
|
missing_by_def: dict[str, list[Index]] = {}
|
|
741
743
|
if extra_by_def:
|
|
742
744
|
for index in missing:
|
|
743
|
-
expected_tail =
|
|
745
|
+
expected_tail = _normalize_index_def(
|
|
744
746
|
cursor,
|
|
745
747
|
model,
|
|
746
748
|
expressions=index.expressions,
|
|
@@ -750,7 +752,7 @@ def _compare_indexes(
|
|
|
750
752
|
include=index.include,
|
|
751
753
|
)
|
|
752
754
|
if not expected_tail:
|
|
753
|
-
#
|
|
755
|
+
# Normalization failed; bucketing under "" would conflate
|
|
754
756
|
# multiple sentinel-failing indexes and disable rename
|
|
755
757
|
# detection for the rest.
|
|
756
758
|
continue
|
|
@@ -908,7 +910,7 @@ def _compare_unique_constraints(
|
|
|
908
910
|
if expected_def:
|
|
909
911
|
issue = f"definition differs: DB has {actual_def!r}, model expects {expected_def!r}"
|
|
910
912
|
else:
|
|
911
|
-
# Round-trip
|
|
913
|
+
# Round-trip normalization couldn't complete; normalized
|
|
912
914
|
# model text is unavailable for the diagnostic.
|
|
913
915
|
issue = f"definition differs: DB has {actual_def!r}"
|
|
914
916
|
drift = ConstraintDrift(
|
|
@@ -1013,7 +1015,7 @@ def _compare_check_constraints(
|
|
|
1013
1015
|
if expected_def:
|
|
1014
1016
|
issue = f"definition differs: DB has {actual_def!r}, model expects {expected_def!r}"
|
|
1015
1017
|
else:
|
|
1016
|
-
# Round-trip
|
|
1018
|
+
# Round-trip normalization couldn't complete; normalized
|
|
1017
1019
|
# model text is unavailable for the diagnostic.
|
|
1018
1020
|
issue = f"definition differs: DB has {actual_def!r}"
|
|
1019
1021
|
drift = ConstraintDrift(
|
|
@@ -1099,7 +1101,7 @@ def _compare_foreign_keys(
|
|
|
1099
1101
|
# Value: (field_name, constraint_name, expected_on_delete_clause, expected_confdeltype)
|
|
1100
1102
|
expected_fks: dict[tuple[str, str, str], tuple[str, str, str, str]] = {}
|
|
1101
1103
|
for f in model._model_meta.local_fields:
|
|
1102
|
-
if isinstance(f, ForeignKeyField)
|
|
1104
|
+
if isinstance(f, ForeignKeyField):
|
|
1103
1105
|
assert f.name is not None
|
|
1104
1106
|
to_table = f.target_field.model.model_options.db_table
|
|
1105
1107
|
to_column = f.target_field.column
|
|
@@ -1251,7 +1253,7 @@ def _detect_unique_renames(
|
|
|
1251
1253
|
"""Match missing and extra unique constraints by structure.
|
|
1252
1254
|
|
|
1253
1255
|
Constraint-backed (not index_only): matched by resolved column tuple.
|
|
1254
|
-
Index-only (condition/expression/opclass): matched by
|
|
1256
|
+
Index-only (condition/expression/opclass): matched by normalized
|
|
1255
1257
|
(round-tripped) index definition, which captures the full semantics
|
|
1256
1258
|
including WHERE clauses, opclasses, and expressions.
|
|
1257
1259
|
"""
|
|
@@ -1290,7 +1292,7 @@ def _detect_unique_renames(
|
|
|
1290
1292
|
old_def = actual_dict[old_name].definition
|
|
1291
1293
|
if not old_def:
|
|
1292
1294
|
continue
|
|
1293
|
-
expected_tail =
|
|
1295
|
+
expected_tail = _normalize_index_def(
|
|
1294
1296
|
cursor,
|
|
1295
1297
|
model,
|
|
1296
1298
|
expressions=constraint.expressions,
|
|
@@ -1322,7 +1324,7 @@ def _detect_unique_renames(
|
|
|
1322
1324
|
renamed_missing.add(constraint.name)
|
|
1323
1325
|
renamed_extra.add(old_name)
|
|
1324
1326
|
|
|
1325
|
-
# Phase 2: Expression-based — match by
|
|
1327
|
+
# Phase 2: Expression-based — match by normalized (round-tripped) index
|
|
1326
1328
|
# body. Build the cheap already-introspected extras side first so we can
|
|
1327
1329
|
# skip the per-missing round-trip on the steady-state path.
|
|
1328
1330
|
extra_by_def: dict[str, list[str]] = {}
|
|
@@ -1340,7 +1342,7 @@ def _detect_unique_renames(
|
|
|
1340
1342
|
continue
|
|
1341
1343
|
# constraint.fields is empty here (filtered above), so this is
|
|
1342
1344
|
# the expression-based path — fields_orders is unused.
|
|
1343
|
-
expected_tail =
|
|
1345
|
+
expected_tail = _normalize_index_def(
|
|
1344
1346
|
cursor,
|
|
1345
1347
|
model,
|
|
1346
1348
|
expressions=constraint.expressions,
|
|
@@ -1389,12 +1391,12 @@ def _detect_check_renames(
|
|
|
1389
1391
|
model: type[Model],
|
|
1390
1392
|
table: str,
|
|
1391
1393
|
) -> tuple[list[ConstraintStatus], set[str], set[str]]:
|
|
1392
|
-
"""Match missing and extra check constraints by
|
|
1394
|
+
"""Match missing and extra check constraints by normalized definition."""
|
|
1393
1395
|
statuses: list[ConstraintStatus] = []
|
|
1394
1396
|
renamed_missing: set[str] = set()
|
|
1395
1397
|
renamed_extra: set[str] = set()
|
|
1396
1398
|
|
|
1397
|
-
# Skip the round-trip
|
|
1399
|
+
# Skip the round-trip normalization loop if there are no extras to
|
|
1398
1400
|
# potentially match — the steady-state path with no drift.
|
|
1399
1401
|
extra_by_def: dict[str, list[str]] = {}
|
|
1400
1402
|
for name in extra_names:
|
|
@@ -1406,7 +1408,7 @@ def _detect_check_renames(
|
|
|
1406
1408
|
for constraint in missing:
|
|
1407
1409
|
expected_def = _get_expected_check_definition(cursor, model, constraint)
|
|
1408
1410
|
if not expected_def:
|
|
1409
|
-
#
|
|
1411
|
+
# Normalization failed; bucketing under "" would conflate
|
|
1410
1412
|
# multiple sentinel-failing constraints and disable rename
|
|
1411
1413
|
# detection for the rest.
|
|
1412
1414
|
continue
|
|
@@ -1454,7 +1456,7 @@ def _compare_index_only_unique(
|
|
|
1454
1456
|
if not actual_def:
|
|
1455
1457
|
return None, None
|
|
1456
1458
|
|
|
1457
|
-
issue =
|
|
1459
|
+
issue = _compare_normalized_index(
|
|
1458
1460
|
cursor=cursor,
|
|
1459
1461
|
model=model,
|
|
1460
1462
|
expressions=constraint.expressions,
|
|
@@ -1474,7 +1476,7 @@ def _compare_index_only_unique(
|
|
|
1474
1476
|
return None, None
|
|
1475
1477
|
|
|
1476
1478
|
|
|
1477
|
-
def
|
|
1479
|
+
def _compare_normalized_index(
|
|
1478
1480
|
*,
|
|
1479
1481
|
cursor: CursorWrapper,
|
|
1480
1482
|
model: type[Model],
|
|
@@ -1489,11 +1491,11 @@ def _compare_canonical_index(
|
|
|
1489
1491
|
"""Compare a model index/constraint against pg_get_indexdef text.
|
|
1490
1492
|
|
|
1491
1493
|
Round-trips the model side through Postgres so both sides come from
|
|
1492
|
-
pg_get_indexdef, then string-compares the
|
|
1494
|
+
pg_get_indexdef, then string-compares the normalized `USING ...` bodies.
|
|
1493
1495
|
|
|
1494
1496
|
Returns an issue string if definitions differ, None if they match.
|
|
1495
1497
|
"""
|
|
1496
|
-
expected_tail =
|
|
1498
|
+
expected_tail = _normalize_index_def(
|
|
1497
1499
|
cursor,
|
|
1498
1500
|
model,
|
|
1499
1501
|
expressions=expressions,
|
|
@@ -1505,9 +1507,9 @@ def _compare_canonical_index(
|
|
|
1505
1507
|
)
|
|
1506
1508
|
actual_tail = _index_def_tail(actual_def)
|
|
1507
1509
|
if not expected_tail:
|
|
1508
|
-
# Round-trip
|
|
1510
|
+
# Round-trip normalization couldn't complete (model SQL
|
|
1509
1511
|
# incompatible with live shape). Report drift with the actual text;
|
|
1510
|
-
# the
|
|
1512
|
+
# the normalized model text is unavailable for the diagnostic.
|
|
1511
1513
|
return f"definition differs: DB has {actual_tail!r}"
|
|
1512
1514
|
|
|
1513
1515
|
if actual_tail != expected_tail:
|
|
@@ -1518,11 +1520,11 @@ def _compare_canonical_index(
|
|
|
1518
1520
|
return None
|
|
1519
1521
|
|
|
1520
1522
|
|
|
1521
|
-
# Round-trip
|
|
1523
|
+
# Round-trip normalization: feed model-side SQL to Postgres on a
|
|
1522
1524
|
# session-private temp table, read back via pg_get_*.
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1525
|
+
_PROBE_TABLE = "_plain_convergence_probe"
|
|
1526
|
+
_PROBE_CONSTRAINT = "_c"
|
|
1527
|
+
_PROBE_INDEX = "_probe_ix"
|
|
1526
1528
|
|
|
1527
1529
|
# Errors raised by Postgres when the model SQL is incompatible with the live
|
|
1528
1530
|
# table shape (unmigrated column types, references to dropped columns, etc.).
|
|
@@ -1534,7 +1536,7 @@ _CANON_INDEX = "_canon_ix"
|
|
|
1534
1536
|
# privilege failures (InsufficientPrivilege, also a ProgrammingError) and
|
|
1535
1537
|
# plain-side syntax bugs must propagate so users get a clear diagnostic
|
|
1536
1538
|
# instead of silent drift noise.
|
|
1537
|
-
|
|
1539
|
+
_PROBE_FALLBACK_ERRORS: tuple[type[Exception], ...] = (
|
|
1538
1540
|
psycopg.errors.UndefinedColumn,
|
|
1539
1541
|
psycopg.errors.UndefinedFunction,
|
|
1540
1542
|
psycopg.errors.UndefinedObject,
|
|
@@ -1553,45 +1555,120 @@ _CANON_FALLBACK_ERRORS: tuple[type[Exception], ...] = (
|
|
|
1553
1555
|
class ReadOnlyConnectionError(RuntimeError):
|
|
1554
1556
|
"""Raised when convergence analysis runs on a read-only connection.
|
|
1555
1557
|
|
|
1556
|
-
Analysis
|
|
1558
|
+
Analysis normalizes the model side of each comparison by round-tripping
|
|
1557
1559
|
SQL through a session-private temp table. That requires DDL, which is
|
|
1558
1560
|
rejected on standby connections and inside `read_only()` blocks.
|
|
1559
1561
|
"""
|
|
1560
1562
|
|
|
1561
1563
|
|
|
1564
|
+
_READ_ONLY_MESSAGE = (
|
|
1565
|
+
"Convergence analysis requires write access — it normalizes model SQL by "
|
|
1566
|
+
"creating a session-private temp table. The current connection rejected "
|
|
1567
|
+
"DDL (read-only transaction or standby). Run analysis against a "
|
|
1568
|
+
"primary/writable connection."
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
@dataclass
|
|
1573
|
+
class _ProbeSession:
|
|
1574
|
+
"""Reuse scope for one model's probe table (see `_probe_session`)."""
|
|
1575
|
+
|
|
1576
|
+
model: type[Model]
|
|
1577
|
+
created: bool = False
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
# Set while `analyze_model` runs so the per-comparison probes share one temp
|
|
1581
|
+
# table instead of creating and dropping one each. A single connection is
|
|
1582
|
+
# single-threaded, but a ContextVar keeps the scope clean and never leaks the
|
|
1583
|
+
# session if analysis raises.
|
|
1584
|
+
_active_probe_session: contextvars.ContextVar[_ProbeSession | None] = (
|
|
1585
|
+
contextvars.ContextVar("active_probe_session", default=None)
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
|
|
1562
1589
|
@contextmanager
|
|
1563
|
-
def
|
|
1564
|
-
"""
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1590
|
+
def _probe_session(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
|
|
1591
|
+
"""Reuse one probe table across every round-trip in a model's analysis.
|
|
1592
|
+
|
|
1593
|
+
The temp table is created lazily by the first probe inside this scope and
|
|
1594
|
+
dropped once on exit, so a model with no expression/constraint round-trips
|
|
1595
|
+
creates nothing while a model with many shares a single table instead of
|
|
1596
|
+
churning one per comparison. The exit DROP runs only on the success path —
|
|
1597
|
+
on an error it's skipped to avoid dropping against an aborted connection, so
|
|
1598
|
+
the table can briefly outlive the analysis (autocommit commits the CREATE);
|
|
1599
|
+
the next analysis recreates it cleanly (see `_create_probe_table`).
|
|
1600
|
+
"""
|
|
1601
|
+
session = _ProbeSession(model=model)
|
|
1602
|
+
token = _active_probe_session.set(session)
|
|
1603
|
+
try:
|
|
1604
|
+
yield
|
|
1605
|
+
finally:
|
|
1606
|
+
_active_probe_session.reset(token)
|
|
1607
|
+
if session.created:
|
|
1608
|
+
cursor.execute(f"DROP TABLE pg_temp.{_PROBE_TABLE}")
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _create_probe_table(cursor: CursorWrapper, model: type[Model]) -> None:
|
|
1612
|
+
"""(Re)create the session-private probe table mirroring the model's real table.
|
|
1613
|
+
|
|
1614
|
+
Drops any stale table of the same name first so a leak can't wedge analysis:
|
|
1615
|
+
the shipped CLI runs convergence in autocommit, so the CREATE commits before
|
|
1616
|
+
later probes run, and a non-fallback error escaping `analyze_model` would
|
|
1617
|
+
otherwise leave the table on a pooled connection to collide with the next
|
|
1618
|
+
run's CREATE. The
|
|
1619
|
+
DROP is schema-qualified to `pg_temp` so a real table sharing the name (in
|
|
1620
|
+
the user's own schema) can't be hit by mistake. Raises ReadOnlyConnectionError
|
|
1621
|
+
when the connection rejects the DDL.
|
|
1578
1622
|
"""
|
|
1579
1623
|
table = quote_name(model.model_options.db_table)
|
|
1580
1624
|
try:
|
|
1581
1625
|
with cursor.connection.transaction():
|
|
1582
|
-
cursor.execute(f"
|
|
1583
|
-
|
|
1584
|
-
cursor.execute(f"DROP TABLE pg_temp.{_CANON_TABLE}")
|
|
1626
|
+
cursor.execute(f"DROP TABLE IF EXISTS pg_temp.{_PROBE_TABLE}")
|
|
1627
|
+
cursor.execute(f"CREATE TEMP TABLE {_PROBE_TABLE} (LIKE {table})")
|
|
1585
1628
|
except psycopg.errors.ReadOnlySqlTransaction as exc:
|
|
1586
|
-
raise ReadOnlyConnectionError(
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1629
|
+
raise ReadOnlyConnectionError(_READ_ONLY_MESSAGE) from exc
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
@contextmanager
|
|
1633
|
+
def _probe_table(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
|
|
1634
|
+
"""Provide an empty temp table mirroring the model's real table for one
|
|
1635
|
+
round-trip, isolating the probe's DDL so it can't leak into the analyze
|
|
1636
|
+
transaction.
|
|
1637
|
+
|
|
1638
|
+
Inside an active `_probe_session` for the same model, the table is created
|
|
1639
|
+
once and reused: each probe runs in a SAVEPOINT that is always rolled back,
|
|
1640
|
+
undoing the probe's ADD/ALTER while leaving the shared table in place.
|
|
1641
|
+
Outside a session, the table is created and dropped for this one probe.
|
|
1642
|
+
|
|
1643
|
+
`cursor.connection.transaction()` issues a SAVEPOINT when nested (or BEGIN in
|
|
1644
|
+
autocommit), so model SQL incompatible with the live column shape rolls back
|
|
1645
|
+
to this scope instead of poisoning the surrounding transaction; helpers catch
|
|
1646
|
+
the psycopg error and fall back to a sentinel.
|
|
1647
|
+
"""
|
|
1648
|
+
session = _active_probe_session.get()
|
|
1649
|
+
if session is not None and session.model is model:
|
|
1650
|
+
# Reuse the session's shared table; `_probe_session` owns its lifetime.
|
|
1651
|
+
if not session.created:
|
|
1652
|
+
_create_probe_table(cursor, model)
|
|
1653
|
+
session.created = True
|
|
1654
|
+
drop_on_exit = False
|
|
1655
|
+
else:
|
|
1656
|
+
# No session: this probe owns the table for its lifetime.
|
|
1657
|
+
_create_probe_table(cursor, model)
|
|
1658
|
+
drop_on_exit = True
|
|
1659
|
+
|
|
1660
|
+
try:
|
|
1661
|
+
with cursor.connection.transaction() as savepoint:
|
|
1662
|
+
yield
|
|
1663
|
+
# Probe read its definition; undo the ADD/ALTER but keep the table.
|
|
1664
|
+
# psycopg rolls the SAVEPOINT back without surfacing an error.
|
|
1665
|
+
raise psycopg.Rollback(savepoint)
|
|
1666
|
+
finally:
|
|
1667
|
+
if drop_on_exit:
|
|
1668
|
+
cursor.execute(f"DROP TABLE pg_temp.{_PROBE_TABLE}")
|
|
1592
1669
|
|
|
1593
1670
|
|
|
1594
|
-
def
|
|
1671
|
+
def _normalize_constraint_def(
|
|
1595
1672
|
cursor: CursorWrapper, model: type[Model], constraint_clause: str
|
|
1596
1673
|
) -> str:
|
|
1597
1674
|
"""Round-trip a constraint clause through Postgres so both sides of the
|
|
@@ -1601,28 +1678,28 @@ def _canonicalize_constraint_def(
|
|
|
1601
1678
|
|
|
1602
1679
|
Returns "" if the model SQL is incompatible with the live table shape
|
|
1603
1680
|
(e.g. unmigrated column-type drift). Drift still gets reported via the
|
|
1604
|
-
inequality with the actual live definition; only the
|
|
1681
|
+
inequality with the actual live definition; only the normalized model
|
|
1605
1682
|
text is omitted from the diagnostic.
|
|
1606
1683
|
"""
|
|
1607
1684
|
try:
|
|
1608
|
-
with
|
|
1685
|
+
with _probe_table(cursor, model):
|
|
1609
1686
|
# Add as validated: the temp table is empty so the implicit scan is
|
|
1610
1687
|
# instant. NOT VALID would leave a trailing " NOT VALID" suffix in
|
|
1611
1688
|
# pg_get_constraintdef that the live constraint won't have.
|
|
1612
1689
|
cursor.execute(
|
|
1613
|
-
f"ALTER TABLE {
|
|
1614
|
-
f"ADD CONSTRAINT {
|
|
1690
|
+
f"ALTER TABLE {_PROBE_TABLE} "
|
|
1691
|
+
f"ADD CONSTRAINT {_PROBE_CONSTRAINT} {constraint_clause}"
|
|
1615
1692
|
)
|
|
1616
1693
|
cursor.execute(
|
|
1617
1694
|
"SELECT pg_get_constraintdef(c.oid) FROM pg_constraint c "
|
|
1618
1695
|
"WHERE c.conname = %s "
|
|
1619
1696
|
"AND c.conrelid = (SELECT oid FROM pg_class WHERE relname = %s "
|
|
1620
1697
|
"AND relnamespace = pg_my_temp_schema())",
|
|
1621
|
-
[
|
|
1698
|
+
[_PROBE_CONSTRAINT, _PROBE_TABLE],
|
|
1622
1699
|
)
|
|
1623
1700
|
row = cursor.fetchone()
|
|
1624
1701
|
return row[0] if row else ""
|
|
1625
|
-
except
|
|
1702
|
+
except _PROBE_FALLBACK_ERRORS:
|
|
1626
1703
|
return ""
|
|
1627
1704
|
|
|
1628
1705
|
|
|
@@ -1630,10 +1707,10 @@ def _get_expected_check_definition(
|
|
|
1630
1707
|
cursor: CursorWrapper, model: type[Model], constraint: CheckConstraint
|
|
1631
1708
|
) -> str:
|
|
1632
1709
|
check_sql = compile_expression_sql(model, constraint.check)
|
|
1633
|
-
return
|
|
1710
|
+
return _normalize_constraint_def(cursor, model, f"CHECK ({check_sql})")
|
|
1634
1711
|
|
|
1635
1712
|
|
|
1636
|
-
def
|
|
1713
|
+
def _normalize_index_def(
|
|
1637
1714
|
cursor: CursorWrapper,
|
|
1638
1715
|
model: type[Model],
|
|
1639
1716
|
*,
|
|
@@ -1644,7 +1721,7 @@ def _canonicalize_index_def(
|
|
|
1644
1721
|
include: tuple[str, ...] | None = None,
|
|
1645
1722
|
unique: bool = False,
|
|
1646
1723
|
) -> str:
|
|
1647
|
-
"""Round-trip an index through Postgres and return its
|
|
1724
|
+
"""Round-trip an index through Postgres and return its normalized body.
|
|
1648
1725
|
|
|
1649
1726
|
Returns the `USING ... [INCLUDE (...)] [WHERE (...)]` tail of pg_get_indexdef,
|
|
1650
1727
|
safe to compare directly against `_index_def_tail(actual_def)` from the
|
|
@@ -1653,7 +1730,7 @@ def _canonicalize_index_def(
|
|
|
1653
1730
|
|
|
1654
1731
|
Returns "" if the model SQL is incompatible with the live table shape;
|
|
1655
1732
|
comparison sites then see inequality and report drift without the
|
|
1656
|
-
|
|
1733
|
+
normalized model text.
|
|
1657
1734
|
"""
|
|
1658
1735
|
if expressions:
|
|
1659
1736
|
columns_sql = compile_index_expressions_sql(model, expressions)
|
|
@@ -1676,25 +1753,25 @@ def _canonicalize_index_def(
|
|
|
1676
1753
|
create_kw = "CREATE UNIQUE INDEX" if unique else "CREATE INDEX"
|
|
1677
1754
|
|
|
1678
1755
|
try:
|
|
1679
|
-
with
|
|
1756
|
+
with _probe_table(cursor, model):
|
|
1680
1757
|
cursor.execute(
|
|
1681
|
-
f"{create_kw} {
|
|
1758
|
+
f"{create_kw} {_PROBE_INDEX} ON {_PROBE_TABLE} "
|
|
1682
1759
|
f"({columns_sql}){include_sql}{where_sql}"
|
|
1683
1760
|
)
|
|
1684
1761
|
cursor.execute(
|
|
1685
1762
|
"SELECT pg_get_indexdef(c.oid) FROM pg_class c "
|
|
1686
1763
|
"WHERE c.relname = %s AND c.relnamespace = pg_my_temp_schema()",
|
|
1687
|
-
[
|
|
1764
|
+
[_PROBE_INDEX],
|
|
1688
1765
|
)
|
|
1689
1766
|
row = cursor.fetchone()
|
|
1690
1767
|
return _index_def_tail(row[0]) if row else ""
|
|
1691
|
-
except
|
|
1768
|
+
except _PROBE_FALLBACK_ERRORS:
|
|
1692
1769
|
return ""
|
|
1693
1770
|
|
|
1694
1771
|
|
|
1695
1772
|
def _index_def_tail(definition: str) -> str:
|
|
1696
1773
|
"""Strip the `CREATE [UNIQUE] INDEX <name> ON <schema>.<table>` prefix
|
|
1697
|
-
from a pg_get_indexdef output, leaving the
|
|
1774
|
+
from a pg_get_indexdef output, leaving the normalized body that's safe to
|
|
1698
1775
|
compare across different index names/tables."""
|
|
1699
1776
|
using_pos = definition.find("USING ")
|
|
1700
1777
|
return definition[using_pos:] if using_pos >= 0 else definition
|
|
@@ -1703,7 +1780,7 @@ def _index_def_tail(definition: str) -> str:
|
|
|
1703
1780
|
def _get_expected_unique_definition(
|
|
1704
1781
|
cursor: CursorWrapper, model: type[Model], constraint: UniqueConstraint
|
|
1705
1782
|
) -> str:
|
|
1706
|
-
"""
|
|
1783
|
+
"""Normalized UNIQUE definition for the model's constraint, as Postgres
|
|
1707
1784
|
prints it.
|
|
1708
1785
|
|
|
1709
1786
|
PostgreSQL only stores field-based unique constraints (with optional
|
|
@@ -1718,4 +1795,4 @@ def _get_expected_unique_definition(
|
|
|
1718
1795
|
include_sql = build_include_sql(model, constraint.include)
|
|
1719
1796
|
defer_sql = deferrable_sql(constraint.deferrable)
|
|
1720
1797
|
clause = f"UNIQUE ({columns_sql}){include_sql}{defer_sql}"
|
|
1721
|
-
return
|
|
1798
|
+
return _normalize_constraint_def(cursor, model, clause)
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from .base import DATABASE_DEFAULT as DATABASE_DEFAULT
|
|
3
4
|
from .base import (
|
|
4
|
-
BLANK_CHOICE_DASH,
|
|
5
5
|
NOT_PROVIDED,
|
|
6
6
|
Empty,
|
|
7
7
|
Field,
|
|
8
8
|
)
|
|
9
|
-
from .base import DATABASE_DEFAULT as DATABASE_DEFAULT
|
|
10
9
|
from .base import ChoicesField as ChoicesField
|
|
11
10
|
from .base import DatabaseDefault as DatabaseDefault
|
|
12
11
|
from .binary import BinaryField
|
|
@@ -26,7 +25,6 @@ from .text import EmailField, RandomStringField, TextField, URLField
|
|
|
26
25
|
from .uuid import UUIDField
|
|
27
26
|
|
|
28
27
|
__all__ = [
|
|
29
|
-
"BLANK_CHOICE_DASH",
|
|
30
28
|
"PrimaryKeyField",
|
|
31
29
|
"BigIntegerField",
|
|
32
30
|
"BinaryField",
|