plain.postgres 0.111.2__tar.gz → 0.113.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.111.2/plain/postgres/README.md → plain_postgres-0.113.0/PKG-INFO +36 -2
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/CHANGELOG.md +32 -0
- plain_postgres-0.111.2/PKG-INFO → plain_postgres-0.113.0/plain/postgres/README.md +23 -15
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/converge.py +28 -8
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/core.py +23 -9
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/decorators.py +17 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/migrations.py +70 -54
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/schema.py +9 -9
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/sync.py +8 -4
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/__init__.py +6 -6
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/analysis.py +3 -3
- plain_postgres-0.111.2/plain/postgres/convergence/fixes.py → plain_postgres-0.113.0/plain/postgres/convergence/corrections.py +18 -18
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/planning.py +67 -57
- plain_postgres-0.113.0/plain/postgres/databases.py +190 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/default_settings.py +7 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/expressions.py +3 -5
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/base.py +53 -18
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/binary.py +5 -4
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/encrypted.py +56 -104
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/json.py +12 -5
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related.py +8 -30
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_managers.py +8 -4
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_related.py +3 -9
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/forms.py +4 -11
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/lookups.py +2 -2
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/autodetector.py +26 -16
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/query.py +4 -87
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/query_utils.py +6 -50
- plain_postgres-0.113.0/plain/postgres/schema_lock.py +194 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sources.py +2 -2
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/__init__.py +1 -2
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/compiler.py +36 -153
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/datastructures.py +1 -36
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/query.py +26 -284
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/where.py +4 -42
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/database.py +36 -56
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/transaction.py +12 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/types.pyi +36 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/pyproject.toml +1 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/encrypted.py +1 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/nullability.py +1 -1
- plain_postgres-0.113.0/tests/internal/test_apply_replan.py +56 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_autodetector_not_null_errors.py +87 -43
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence.py +112 -70
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_constraints.py +81 -73
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_defaults.py +102 -71
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_fk.py +65 -40
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_indexes.py +95 -64
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_nullability.py +75 -55
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_storage_parameters.py +15 -15
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_timeouts.py +51 -47
- plain_postgres-0.113.0/tests/internal/test_databases_not_on_runtime_path.py +118 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_db_expression_defaults.py +1 -1
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_literal_default_persistence.py +43 -5
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_otel_metrics.py +21 -0
- plain_postgres-0.113.0/tests/internal/test_rollback_exc_attribution.py +85 -0
- plain_postgres-0.113.0/tests/internal/test_schema_lock.py +138 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_schema_normalize_type.py +7 -0
- plain_postgres-0.113.0/tests/public/test_databases.py +182 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_encrypted_fields.py +13 -3
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_m2m.py +37 -0
- plain_postgres-0.113.0/tests/public/test_only_empty_defaults.py +76 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/.gitignore +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/CLAUDE.md +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/LICENSE +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/README.md +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/conftest.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_related.py +0 -0
- {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.postgres
|
|
3
|
+
Version: 0.113.0
|
|
4
|
+
Summary: Model your data and store it in a database.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
+
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
+
Requires-Dist: psycopg>=3.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# plain.postgres
|
|
2
15
|
|
|
3
16
|
**Model your data and store it in a database.**
|
|
@@ -775,6 +788,26 @@ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you
|
|
|
775
788
|
PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
|
|
776
789
|
```
|
|
777
790
|
|
|
791
|
+
### Schema lock
|
|
792
|
+
|
|
793
|
+
Schema-changing commands — `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` — serialize on a single session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. You don't have to do anything to get this.
|
|
794
|
+
|
|
795
|
+
A second process warns and waits, retrying until the holder finishes:
|
|
796
|
+
|
|
797
|
+
```python
|
|
798
|
+
# app/settings.py — defaults shown (waits up to an hour total)
|
|
799
|
+
POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL = 5.0
|
|
800
|
+
POSTGRES_SCHEMA_LOCK_MAX_RETRIES = 720
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
The wait is generous by default because a legitimate holder can be mid index build. If the budget runs out, the command fails with the holder's `pid` so you can see what's blocking. A crashed holder is not a problem — the lock releases automatically when its database session closes.
|
|
804
|
+
|
|
805
|
+
The lock is held on its own connection, separate from the one running DDL, so non-transactional operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) work normally while it's held. Session-level locks don't survive transaction-mode poolers like pgbouncer — if your `POSTGRES_URL` points at one, set [`POSTGRES_MANAGEMENT_URL`](#bypassing-a-connection-pooler-for-management-operations) to a direct connection.
|
|
806
|
+
|
|
807
|
+
The lock connection sits idle while your DDL runs, so it enables TCP keepalives to survive NAT and load-balancer idle timeouts. A server-side `idle_session_timeout` would still kill it (releasing the lock mid-run) — don't set one for the role that runs migrations. `sync` re-verifies the lock between its migrate and converge phases and stops with a clear error if the session died.
|
|
808
|
+
|
|
809
|
+
To see the lock live: `SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objid = 1047265496`.
|
|
810
|
+
|
|
778
811
|
## Fields
|
|
779
812
|
|
|
780
813
|
You can use many field types for different data:
|
|
@@ -908,6 +941,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
|
|
|
908
941
|
|
|
909
942
|
- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
|
|
910
943
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
944
|
+
- **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
|
|
911
945
|
|
|
912
946
|
**Key rotation:**
|
|
913
947
|
|
|
@@ -1459,9 +1493,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
|
|
|
1459
1493
|
|
|
1460
1494
|
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1461
1495
|
|
|
1462
|
-
If the field
|
|
1496
|
+
If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
|
|
1463
1497
|
|
|
1464
|
-
1. Declare a `default=` on the field so the new column has a value for existing rows.
|
|
1498
|
+
1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
|
|
1465
1499
|
2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
|
|
1466
1500
|
|
|
1467
1501
|
#### How do I make an existing column `NOT NULL`?
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.113.0](https://github.com/dropseed/plain/releases/plain-postgres@0.113.0) (2026-08-02)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **`QuerySet.extra()` has been removed**, along with all of its supporting machinery (`extra_select`, `extra_tables`, `extra_order_by`, `ExtraWhere`). Use `annotate()` with `RawSQL` or other expressions instead. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1))
|
|
8
|
+
- **`FilteredRelation` has been removed** — it's no longer importable from `plain.postgres` or usable in `annotate()`. Express conditional joins with `Q` filters or subqueries instead. ([434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
|
|
9
|
+
- **`QuerySet.alias()` and logical XOR have been removed** — `Q(...) ^ Q(...)` and `queryset ^ queryset` are no longer supported. Use `annotate()` in place of `alias()`, and compose AND/OR/NOT in place of XOR. ([c12cf81af4](https://github.com/dropseed/plain/commit/c12cf81af4))
|
|
10
|
+
- **Encrypted and binary fields now accept their empty value as a declared `default=`.** `EncryptedTextField` (now a `TextField` subclass) accepts exactly `default=""` and `BinaryField` accepts exactly `default=b""`, each paired with `required=False` — the empty value is stored as plaintext so it's the one default expressible as a column `DEFAULT`, which is what lets the field be added to a populated table. `default=None` requires `allow_null=True` + `required=False`. The `types.pyi` stub overloads encode the same coupling. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
11
|
+
- **The migration autodetector no longer waves through `required=False` fields with no declared default.** Adding a non-nullable field without a `default=` now refuses at `plain migrations create` time with a field-accurate remedy (previously these could generate an `AddField` that failed at apply time on any populated table). The optional-string idiom is `required=False, default=""`. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
12
|
+
- **`EncryptedJSONField` is now a `JSONField` subclass** and `EncryptedTextField` a `TextField` subclass, instead of parallel `ColumnField` implementations — behavior like max_length validation and JSON encoding now comes from the real base classes. The encrypted lookup surface is still just `exact`/`isnull`, and JSON key transforms are blocked (they'd operate on ciphertext). `EncryptedJSONField` accepts no `default=` at all — even `{}` would need ciphertext; use `allow_null=True`. ([5153ceb106](https://github.com/dropseed/plain/commit/5153ceb106), [2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
13
|
+
- Model forms no longer prefill a `BinaryField`'s `b""` default as a text input's initial value (it rendered as the literal `b''`), and encrypted fields now map to form fields through the regular `TextField`/`JSONField` branches. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
|
|
14
|
+
- M2M prefetches now expose the through-table join column via `annotate(RawSQL(...))` instead of the removed `extra()`, and internal path-info plumbing lost its `filtered_relation` threading. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1), [434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
|
|
15
|
+
|
|
16
|
+
### Upgrade instructions
|
|
17
|
+
|
|
18
|
+
- Replace `QuerySet.extra()` calls: `extra(select=...)` becomes `annotate(name=RawSQL("...", []))`, `extra(where=...)` becomes `filter()` with expressions or `RawSQL`, and `extra(order_by=...)` becomes `order_by()` on an annotation.
|
|
19
|
+
- Replace `FilteredRelation` annotations with filtered subqueries or `Q` conditions, and `QuerySet.alias()` with `annotate()`.
|
|
20
|
+
- Replace `Q(a) ^ Q(b)` / `qs1 ^ qs2` with explicit AND/OR logic: `(Q(a) | Q(b)) & ~(Q(a) & Q(b))`.
|
|
21
|
+
- If `plain migrations create` now refuses a field it previously accepted, follow the error's remedy: declare `required=False, default=""` (or `default=b""` / an appropriate literal) so existing rows get a value, or add it with `allow_null=True` plus a backfill data migration.(https://github.com/dropseed/plain/releases/plain-postgres@0.112.0) (2026-07-21)
|
|
22
|
+
|
|
23
|
+
### What's changed
|
|
24
|
+
|
|
25
|
+
- **Schema-changing commands now serialize on an advisory schema lock.** `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` take a session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. A second process warns and waits, retrying up to an hour by default (`POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL`, `POSTGRES_SCHEMA_LOCK_MAX_RETRIES`), and each command re-checks its plan after acquiring the lock — `migrations apply` skips work another process already did, `converge` refuses to execute corrections the operator never approved, and `drop-unknown-tables` skips tables that became known while waiting. The lock is held on its own connection so `CREATE INDEX CONCURRENTLY` still works, and it releases automatically if the holder crashes. ([f12f5f1bd4](https://github.com/dropseed/plain/commit/f12f5f1bd4))
|
|
26
|
+
- **New `plain.postgres.databases` module** for cluster-level database management — `create_database` (with `TEMPLATE` support), `drop_database`, `database_exists`, `list_databases`, database comments, connection counting/termination, and a `maintenance_cursor` helper. It's a development/test capability (it needs `CREATEDB` and a connection to the `postgres` maintenance database) and is what powers plain-dev's managed development databases; test database creation now uses it too. ([0a747b8933](https://github.com/dropseed/plain/commit/0a747b8933))
|
|
27
|
+
- Fixed a stale `rollback_exc` on reused connections: entering an outermost atomic block now clears it, and a rollback caused by an exception inside a non-savepoint block records that exception — so a broken-transaction error chains from the real cause instead of a stale or absent one. ([350f34b291](https://github.com/dropseed/plain/commit/350f34b291))
|
|
28
|
+
- Convergence terminology changed from "fix" to "correction" in CLI output and internals — `plain postgres converge` now reports "N corrections to apply" and `plain postgres schema` marks drift as `(auto-correct)`. ([08da420a9b](https://github.com/dropseed/plain/commit/08da420a9b))
|
|
29
|
+
- The unconfigured-database error now points at `PLAIN_POSTGRES_URL` (or `POSTGRES_URL` in settings) instead of `DATABASE_URL`-first wording. ([8b7aa38e95](https://github.com/dropseed/plain/commit/8b7aa38e95))
|
|
30
|
+
|
|
31
|
+
### Upgrade instructions
|
|
32
|
+
|
|
33
|
+
- No changes required. If your migration role's Postgres server sets `idle_session_timeout`, note the schema lock holds an idle session for the duration of a schema command — exempt that role, or the lock can be lost mid-run. If `POSTGRES_URL` points at a transaction-mode pooler (pgbouncer), set `POSTGRES_MANAGEMENT_URL` to a direct connection so the session-level lock works.
|
|
34
|
+
|
|
3
35
|
## [0.111.2](https://github.com/dropseed/plain/releases/plain-postgres@0.111.2) (2026-07-15)
|
|
4
36
|
|
|
5
37
|
### What's changed
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: plain.postgres
|
|
3
|
-
Version: 0.111.2
|
|
4
|
-
Summary: Model your data and store it in a database.
|
|
5
|
-
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
-
License-Expression: BSD-3-Clause
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Requires-Python: >=3.13
|
|
9
|
-
Requires-Dist: plain<1.0.0,>=0.134.0
|
|
10
|
-
Requires-Dist: psycopg-pool>=3.2
|
|
11
|
-
Requires-Dist: psycopg>=3.2
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# plain.postgres
|
|
15
2
|
|
|
16
3
|
**Model your data and store it in a database.**
|
|
@@ -788,6 +775,26 @@ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you
|
|
|
788
775
|
PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
|
|
789
776
|
```
|
|
790
777
|
|
|
778
|
+
### Schema lock
|
|
779
|
+
|
|
780
|
+
Schema-changing commands — `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` — serialize on a single session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. You don't have to do anything to get this.
|
|
781
|
+
|
|
782
|
+
A second process warns and waits, retrying until the holder finishes:
|
|
783
|
+
|
|
784
|
+
```python
|
|
785
|
+
# app/settings.py — defaults shown (waits up to an hour total)
|
|
786
|
+
POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL = 5.0
|
|
787
|
+
POSTGRES_SCHEMA_LOCK_MAX_RETRIES = 720
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
The wait is generous by default because a legitimate holder can be mid index build. If the budget runs out, the command fails with the holder's `pid` so you can see what's blocking. A crashed holder is not a problem — the lock releases automatically when its database session closes.
|
|
791
|
+
|
|
792
|
+
The lock is held on its own connection, separate from the one running DDL, so non-transactional operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) work normally while it's held. Session-level locks don't survive transaction-mode poolers like pgbouncer — if your `POSTGRES_URL` points at one, set [`POSTGRES_MANAGEMENT_URL`](#bypassing-a-connection-pooler-for-management-operations) to a direct connection.
|
|
793
|
+
|
|
794
|
+
The lock connection sits idle while your DDL runs, so it enables TCP keepalives to survive NAT and load-balancer idle timeouts. A server-side `idle_session_timeout` would still kill it (releasing the lock mid-run) — don't set one for the role that runs migrations. `sync` re-verifies the lock between its migrate and converge phases and stops with a clear error if the session died.
|
|
795
|
+
|
|
796
|
+
To see the lock live: `SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objid = 1047265496`.
|
|
797
|
+
|
|
791
798
|
## Fields
|
|
792
799
|
|
|
793
800
|
You can use many field types for different data:
|
|
@@ -921,6 +928,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
|
|
|
921
928
|
|
|
922
929
|
- **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
|
|
923
930
|
- **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
|
|
931
|
+
- **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
|
|
924
932
|
|
|
925
933
|
**Key rotation:**
|
|
926
934
|
|
|
@@ -1472,9 +1480,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
|
|
|
1472
1480
|
|
|
1473
1481
|
Add the field to your model class, then run `plain migrations create` to create a migration.
|
|
1474
1482
|
|
|
1475
|
-
If the field
|
|
1483
|
+
If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
|
|
1476
1484
|
|
|
1477
|
-
1. Declare a `default=` on the field so the new column has a value for existing rows.
|
|
1485
|
+
1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
|
|
1478
1486
|
2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
|
|
1479
1487
|
|
|
1480
1488
|
#### How do I make an existing column `NOT NULL`?
|
|
@@ -79,7 +79,7 @@ Run `uv run plain docs postgres` for full patterns with code examples.
|
|
|
79
79
|
- Indexes: `{table}_{column(s)}_idx`
|
|
80
80
|
- Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
|
|
81
81
|
- Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
|
|
82
|
-
- No `allow_null` on string fields — use `default=""`
|
|
82
|
+
- No `allow_null` on string fields — use `default=""`. Optional string fields are `required=False, default=""` — the declared default is what lets the column be added to a populated table (`required=False` alone only affects Python-side validation)
|
|
83
83
|
|
|
84
84
|
Run `uv run plain docs postgres` for full patterns with code examples.
|
|
85
85
|
|
|
@@ -5,7 +5,7 @@ import sys
|
|
|
5
5
|
import click
|
|
6
6
|
|
|
7
7
|
from ..convergence import execute_plan, plan_convergence
|
|
8
|
-
from .decorators import database_management_command
|
|
8
|
+
from .decorators import cli_schema_lock, database_management_command
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@click.command()
|
|
@@ -17,16 +17,16 @@ from .decorators import database_management_command
|
|
|
17
17
|
)
|
|
18
18
|
@database_management_command
|
|
19
19
|
def converge(yes: bool) -> None:
|
|
20
|
-
"""
|
|
20
|
+
"""Correct schema mismatches between models and the database.
|
|
21
21
|
|
|
22
|
-
Detects and
|
|
22
|
+
Detects and corrects:
|
|
23
23
|
- Missing indexes (using CONCURRENTLY)
|
|
24
24
|
- Missing constraints (check, unique)
|
|
25
25
|
- NOT VALID constraints needing validation
|
|
26
26
|
- Undeclared indexes and constraints (dropped automatically)
|
|
27
27
|
|
|
28
|
-
Each
|
|
29
|
-
failures don't block subsequent
|
|
28
|
+
Each correction is applied and committed independently so partial
|
|
29
|
+
failures don't block subsequent corrections.
|
|
30
30
|
"""
|
|
31
31
|
plan = plan_convergence()
|
|
32
32
|
items = plan.executable()
|
|
@@ -34,7 +34,7 @@ def converge(yes: bool) -> None:
|
|
|
34
34
|
|
|
35
35
|
if items:
|
|
36
36
|
click.secho(
|
|
37
|
-
f"{len(items)}
|
|
37
|
+
f"{len(items)} correction{'s' if len(items) != 1 else ''} to apply:\n",
|
|
38
38
|
bold=True,
|
|
39
39
|
)
|
|
40
40
|
for item in items:
|
|
@@ -48,7 +48,27 @@ def converge(yes: bool) -> None:
|
|
|
48
48
|
|
|
49
49
|
click.echo()
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
with cli_schema_lock():
|
|
52
|
+
# Re-plan under the lock — another converge or sync may have
|
|
53
|
+
# already applied (or changed) these corrections while we waited.
|
|
54
|
+
confirmed = {item.describe() for item in items}
|
|
55
|
+
plan = plan_convergence()
|
|
56
|
+
items = plan.executable()
|
|
57
|
+
|
|
58
|
+
new_items = [item for item in items if item.describe() not in confirmed]
|
|
59
|
+
if new_items and not yes:
|
|
60
|
+
# The plan grew while we waited at the prompt or for the
|
|
61
|
+
# lock — don't execute work the operator never approved.
|
|
62
|
+
click.secho(
|
|
63
|
+
"The schema changed while waiting — new corrections not in the confirmed plan:",
|
|
64
|
+
fg="red",
|
|
65
|
+
bold=True,
|
|
66
|
+
)
|
|
67
|
+
for item in new_items:
|
|
68
|
+
click.secho(f" {item.describe()}", fg="red")
|
|
69
|
+
raise click.ClickException("Re-run to review the updated plan.")
|
|
70
|
+
|
|
71
|
+
result = execute_plan(items)
|
|
52
72
|
|
|
53
73
|
for r in result.results:
|
|
54
74
|
if r.ok:
|
|
@@ -73,4 +93,4 @@ def converge(yes: bool) -> None:
|
|
|
73
93
|
if not success:
|
|
74
94
|
sys.exit(1)
|
|
75
95
|
elif not items:
|
|
76
|
-
click.secho("Schema is converged — nothing to
|
|
96
|
+
click.secho("Schema is converged — nothing to correct.", fg="green")
|
|
@@ -16,7 +16,7 @@ from ..database_url import postgres_cli_args, postgres_cli_env
|
|
|
16
16
|
from ..db import get_connection
|
|
17
17
|
from ..dialect import quote_name
|
|
18
18
|
from .converge import converge
|
|
19
|
-
from .decorators import database_management_command
|
|
19
|
+
from .decorators import cli_schema_lock, database_management_command
|
|
20
20
|
from .diagnose import diagnose
|
|
21
21
|
from .schema import schema
|
|
22
22
|
from .sync import sync
|
|
@@ -25,7 +25,7 @@ from .sync import sync
|
|
|
25
25
|
@register_cli("postgres")
|
|
26
26
|
@click.group()
|
|
27
27
|
def cli() -> None:
|
|
28
|
-
"""
|
|
28
|
+
"""Work inside the connected database — schema, health, shell."""
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
cli.add_command(converge)
|
|
@@ -126,13 +126,27 @@ def drop_unknown_tables(yes: bool) -> None:
|
|
|
126
126
|
if not click.confirm(f"Drop {tables_label} (CASCADE)? This cannot be undone."):
|
|
127
127
|
return
|
|
128
128
|
|
|
129
|
-
with
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
with cli_schema_lock():
|
|
130
|
+
# Re-check under the lock — while we sat at the prompt or waited for
|
|
131
|
+
# the lock, another process may have claimed one of these names for a
|
|
132
|
+
# real table (e.g. a migration creating a model table). Only drop
|
|
133
|
+
# what was unknown at confirm time AND is still unknown now.
|
|
134
|
+
still_unknown = set(get_unknown_tables(conn))
|
|
135
|
+
dropped_count = 0
|
|
136
|
+
with conn.cursor() as cursor:
|
|
137
|
+
for table in unknown_tables:
|
|
138
|
+
if table not in still_unknown:
|
|
139
|
+
click.secho(f" Skipping {table} — no longer unknown.", fg="yellow")
|
|
140
|
+
continue
|
|
141
|
+
click.echo(f" Dropping {table}...", nl=False)
|
|
142
|
+
cursor.execute(f"DROP TABLE IF EXISTS {quote_name(table)} CASCADE")
|
|
143
|
+
click.echo(" OK")
|
|
144
|
+
dropped_count += 1
|
|
145
|
+
|
|
146
|
+
click.secho(
|
|
147
|
+
f"✓ Dropped {dropped_count} table{'s' if dropped_count != 1 else ''}.",
|
|
148
|
+
fg="green",
|
|
149
|
+
)
|
|
136
150
|
|
|
137
151
|
|
|
138
152
|
@cli.command()
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
-
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
8
|
+
import click
|
|
9
|
+
|
|
7
10
|
from ..db import use_management_connection
|
|
11
|
+
from ..schema_lock import SchemaLockLost, SchemaLockTimeout, schema_lock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def cli_schema_lock() -> Iterator[Callable[[], None]]:
|
|
16
|
+
"""`schema_lock()` for CLI commands: a timeout or lost lock surfaces as a
|
|
17
|
+
clean one-line error instead of a traceback burying it. Yields the lock's
|
|
18
|
+
verify callable for multi-phase commands to check between phases."""
|
|
19
|
+
try:
|
|
20
|
+
with schema_lock() as verify:
|
|
21
|
+
yield verify
|
|
22
|
+
except (SchemaLockTimeout, SchemaLockLost) as e:
|
|
23
|
+
raise click.ClickException(str(e)) from e
|
|
8
24
|
|
|
9
25
|
|
|
10
26
|
def database_management_command[F: Callable[..., Any]](f: F) -> F:
|
|
@@ -27,7 +27,7 @@ from ..migrations.recorder import MigrationRecorder
|
|
|
27
27
|
from ..migrations.state import ModelState, ProjectState
|
|
28
28
|
from ..migrations.writer import MigrationWriter
|
|
29
29
|
from ..registry import models_registry
|
|
30
|
-
from .decorators import database_management_command
|
|
30
|
+
from .decorators import cli_schema_lock, database_management_command
|
|
31
31
|
|
|
32
32
|
if TYPE_CHECKING:
|
|
33
33
|
from ..connection import DatabaseConnection
|
|
@@ -511,68 +511,84 @@ def apply(
|
|
|
511
511
|
click.secho(package_label, dim=True)
|
|
512
512
|
click.echo() # Add newline after package
|
|
513
513
|
|
|
514
|
-
pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
|
|
515
|
-
|
|
516
514
|
if migration_plan:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if non_atomic_migrations:
|
|
527
|
-
names = ", ".join(
|
|
528
|
-
f"{m.package_label}.{m.name}" for m in non_atomic_migrations[:3]
|
|
529
|
-
)
|
|
530
|
-
if len(non_atomic_migrations) > 3:
|
|
531
|
-
names += f", and {len(non_atomic_migrations) - 3} more"
|
|
532
|
-
raise click.UsageError(
|
|
533
|
-
f"--atomic-batch requested but these migrations have atomic=False: {names}"
|
|
534
|
-
)
|
|
535
|
-
use_atomic_batch = True
|
|
536
|
-
atomic_batch_message = (
|
|
537
|
-
f"Running {len(migration_plan)} migrations in atomic batch"
|
|
538
|
-
)
|
|
539
|
-
elif atomic_batch is False:
|
|
540
|
-
# User explicitly disabled atomic batch
|
|
541
|
-
use_atomic_batch = False
|
|
542
|
-
if len(migration_plan) > 1:
|
|
543
|
-
atomic_batch_message = (
|
|
544
|
-
f"Running {len(migration_plan)} migrations separately"
|
|
515
|
+
with cli_schema_lock():
|
|
516
|
+
# Re-plan under the lock — another process may have applied some
|
|
517
|
+
# or all of these migrations while we waited for it.
|
|
518
|
+
executor = MigrationExecutor(get_connection(), migration_progress_callback)
|
|
519
|
+
migration_plan = executor.migration_plan(targets)
|
|
520
|
+
if not migration_plan:
|
|
521
|
+
if not quiet:
|
|
522
|
+
click.echo(
|
|
523
|
+
"No migrations to apply (another process already applied them)."
|
|
545
524
|
)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
# Determine whether to use atomic batch
|
|
528
|
+
use_atomic_batch = False
|
|
529
|
+
atomic_batch_message = None
|
|
530
|
+
if len(migration_plan) > 1:
|
|
531
|
+
# Check if all migrations support atomic
|
|
532
|
+
non_atomic_migrations = [m for m in migration_plan if not m.atomic]
|
|
533
|
+
|
|
534
|
+
if atomic_batch is True:
|
|
535
|
+
# User explicitly requested atomic batch
|
|
536
|
+
if non_atomic_migrations:
|
|
537
|
+
names = ", ".join(
|
|
538
|
+
f"{m.package_label}.{m.name}"
|
|
539
|
+
for m in non_atomic_migrations[:3]
|
|
540
|
+
)
|
|
541
|
+
if len(non_atomic_migrations) > 3:
|
|
542
|
+
names += f", and {len(non_atomic_migrations) - 3} more"
|
|
543
|
+
raise click.UsageError(
|
|
544
|
+
f"--atomic-batch requested but these migrations have atomic=False: {names}"
|
|
545
|
+
)
|
|
550
546
|
use_atomic_batch = True
|
|
551
547
|
atomic_batch_message = (
|
|
552
548
|
f"Running {len(migration_plan)} migrations in atomic batch"
|
|
553
549
|
)
|
|
554
|
-
|
|
550
|
+
elif atomic_batch is False:
|
|
551
|
+
# User explicitly disabled atomic batch
|
|
555
552
|
use_atomic_batch = False
|
|
556
553
|
if len(migration_plan) > 1:
|
|
557
|
-
atomic_batch_message =
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
554
|
+
atomic_batch_message = (
|
|
555
|
+
f"Running {len(migration_plan)} migrations separately"
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
# Auto-detect (atomic_batch is None)
|
|
559
|
+
# Use atomic batch by default
|
|
560
|
+
if not non_atomic_migrations:
|
|
561
|
+
use_atomic_batch = True
|
|
562
|
+
atomic_batch_message = (
|
|
563
|
+
f"Running {len(migration_plan)} migrations in atomic batch"
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
use_atomic_batch = False
|
|
567
|
+
if len(migration_plan) > 1:
|
|
568
|
+
atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
|
|
569
|
+
|
|
570
|
+
if not quiet:
|
|
571
|
+
click.echo() # Add blank line before applying
|
|
572
|
+
|
|
573
|
+
if not quiet:
|
|
574
|
+
if atomic_batch_message:
|
|
575
|
+
click.secho(
|
|
576
|
+
f"Applying migrations ({atomic_batch_message.lower()}):",
|
|
577
|
+
bold=True,
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
click.secho("Applying migrations:", bold=True)
|
|
561
581
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
state=pre_migrate_state.clone(),
|
|
573
|
-
fake=fake,
|
|
574
|
-
atomic_batch=use_atomic_batch,
|
|
575
|
-
)
|
|
582
|
+
pre_migrate_state = executor._create_project_state(
|
|
583
|
+
with_applied_migrations=True
|
|
584
|
+
)
|
|
585
|
+
post_migrate_state = executor.migrate(
|
|
586
|
+
targets,
|
|
587
|
+
plan=migration_plan,
|
|
588
|
+
state=pre_migrate_state.clone(),
|
|
589
|
+
fake=fake,
|
|
590
|
+
atomic_batch=use_atomic_batch,
|
|
591
|
+
)
|
|
576
592
|
# post_migrate signals have access to all models. Ensure that all models
|
|
577
593
|
# are reloaded in case any are delayed.
|
|
578
594
|
post_migrate_state.clear_delayed_models_cache()
|
|
@@ -6,7 +6,7 @@ import sys
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
8
|
from ..convergence.analysis import ModelAnalysis, analyze_model
|
|
9
|
-
from ..convergence.planning import
|
|
9
|
+
from ..convergence.planning import can_auto_correct
|
|
10
10
|
from ..db import get_connection
|
|
11
11
|
from ..introspection import MANAGED_CONSTRAINT_TYPES, get_unknown_tables
|
|
12
12
|
from ..registry import models_registry
|
|
@@ -21,8 +21,8 @@ def _err(msg: str) -> None:
|
|
|
21
21
|
click.secho(f" ✗ {msg}", fg="red")
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
click.secho(f" ~ {msg} (auto-
|
|
24
|
+
def _correctable(msg: str) -> None:
|
|
25
|
+
click.secho(f" ~ {msg} (auto-correct)", fg="yellow")
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _unmanaged(type_label: str) -> None:
|
|
@@ -57,8 +57,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
|
|
|
57
57
|
|
|
58
58
|
click.echo(f" {col_display:30s} {' '.join(type_parts)}", nl=False)
|
|
59
59
|
|
|
60
|
-
if col.issue and col.drifts and all(
|
|
61
|
-
|
|
60
|
+
if col.issue and col.drifts and all(can_auto_correct(d) for d in col.drifts):
|
|
61
|
+
_correctable(col.issue)
|
|
62
62
|
elif col.issue:
|
|
63
63
|
_err(col.issue)
|
|
64
64
|
else:
|
|
@@ -75,8 +75,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
|
|
|
75
75
|
|
|
76
76
|
if idx.access_method:
|
|
77
77
|
_unmanaged(idx.access_method)
|
|
78
|
-
elif idx.issue and idx.drift and
|
|
79
|
-
|
|
78
|
+
elif idx.issue and idx.drift and can_auto_correct(idx.drift):
|
|
79
|
+
_correctable(idx.issue)
|
|
80
80
|
elif idx.issue:
|
|
81
81
|
_err(idx.issue)
|
|
82
82
|
else:
|
|
@@ -98,8 +98,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
|
|
|
98
98
|
|
|
99
99
|
if con.constraint_type not in MANAGED_CONSTRAINT_TYPES:
|
|
100
100
|
_unmanaged(con.constraint_type.label)
|
|
101
|
-
elif con.issue and con.drift and
|
|
102
|
-
|
|
101
|
+
elif con.issue and con.drift and can_auto_correct(con.drift):
|
|
102
|
+
_correctable(con.issue)
|
|
103
103
|
elif con.issue:
|
|
104
104
|
_err(con.issue)
|
|
105
105
|
else:
|
|
@@ -7,7 +7,8 @@ import click
|
|
|
7
7
|
from plain.runtime import settings
|
|
8
8
|
|
|
9
9
|
from ..convergence import execute_plan, plan_convergence
|
|
10
|
-
from
|
|
10
|
+
from ..db import get_connection
|
|
11
|
+
from .decorators import cli_schema_lock, database_management_command
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@click.command()
|
|
@@ -33,8 +34,12 @@ def sync(check: bool) -> None:
|
|
|
33
34
|
if settings.DEBUG:
|
|
34
35
|
_create_migrations()
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
with cli_schema_lock() as verify_lock:
|
|
38
|
+
_migrate()
|
|
39
|
+
# If the lock session died during migrations, another process may
|
|
40
|
+
# already be converging — stop instead of interleaving with it.
|
|
41
|
+
verify_lock()
|
|
42
|
+
_converge()
|
|
38
43
|
|
|
39
44
|
|
|
40
45
|
def _check() -> None:
|
|
@@ -108,7 +113,6 @@ def _create_migrations() -> None:
|
|
|
108
113
|
|
|
109
114
|
|
|
110
115
|
def _migrate() -> None:
|
|
111
|
-
from ..db import get_connection
|
|
112
116
|
from ..migrations.executor import MigrationExecutor
|
|
113
117
|
|
|
114
118
|
click.secho("Applying migrations...", bold=True)
|
|
@@ -5,8 +5,8 @@ This is not a public import API. End users drive convergence through those CLI
|
|
|
5
5
|
commands, not by importing from here, and it is intentionally absent from the
|
|
6
6
|
top-level ``plain.postgres`` surface. The names re-exported below exist for the
|
|
7
7
|
rest of ``plain.postgres`` (the CLI) to use; the drift types, ``DriftKind``,
|
|
8
|
-
the ``
|
|
9
|
-
live in the ``.analysis`` / ``.
|
|
8
|
+
the ``Correction`` classes, and the ``Status`` records are convergence-internal and
|
|
9
|
+
live in the ``.analysis`` / ``.corrections`` / ``.planning`` submodules.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from .analysis import (
|
|
@@ -17,9 +17,9 @@ from .analysis import (
|
|
|
17
17
|
from .planning import (
|
|
18
18
|
ConvergencePlan,
|
|
19
19
|
ConvergenceResult,
|
|
20
|
-
|
|
20
|
+
CorrectionResult,
|
|
21
21
|
PlanItem,
|
|
22
|
-
|
|
22
|
+
can_auto_correct,
|
|
23
23
|
execute_plan,
|
|
24
24
|
plan_convergence,
|
|
25
25
|
plan_model_convergence,
|
|
@@ -28,12 +28,12 @@ from .planning import (
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
"ConvergencePlan",
|
|
30
30
|
"ConvergenceResult",
|
|
31
|
-
"
|
|
31
|
+
"CorrectionResult",
|
|
32
32
|
"ModelAnalysis",
|
|
33
33
|
"PlanItem",
|
|
34
34
|
"ReadOnlyConnectionError",
|
|
35
35
|
"analyze_model",
|
|
36
|
-
"
|
|
36
|
+
"can_auto_correct",
|
|
37
37
|
"execute_plan",
|
|
38
38
|
"plan_convergence",
|
|
39
39
|
"plan_model_convergence",
|
|
@@ -220,7 +220,7 @@ class ColumnShouldBeNotNullDrift:
|
|
|
220
220
|
|
|
221
221
|
table: str
|
|
222
222
|
column: str
|
|
223
|
-
has_null_rows: bool = False # existing NULL rows block an auto-
|
|
223
|
+
has_null_rows: bool = False # existing NULL rows block an auto-correction
|
|
224
224
|
|
|
225
225
|
def describe(self) -> str:
|
|
226
226
|
if self.has_null_rows:
|
|
@@ -1166,7 +1166,7 @@ def _compare_check_constraints(
|
|
|
1166
1166
|
)
|
|
1167
1167
|
|
|
1168
1168
|
# Build set of framework-owned temp NOT NULL check names so leftover
|
|
1169
|
-
# artifacts from a partially-completed
|
|
1169
|
+
# artifacts from a partially-completed SetNotNullCorrection are silently
|
|
1170
1170
|
# ignored rather than surfaced as undeclared user constraints.
|
|
1171
1171
|
internal_checks = {
|
|
1172
1172
|
generate_notnull_check_name(table, f.column)
|
|
@@ -1326,7 +1326,7 @@ def _compare_foreign_keys(
|
|
|
1326
1326
|
def generate_notnull_check_name(table: str, column: str) -> str:
|
|
1327
1327
|
"""Generate a hashed name for the temporary NOT NULL check constraint.
|
|
1328
1328
|
|
|
1329
|
-
Used by
|
|
1329
|
+
Used by SetNotNullCorrection for the CHECK NOT VALID → VALIDATE → SET NOT NULL
|
|
1330
1330
|
pattern, and by analysis to recognize (and ignore) leftover temp checks.
|
|
1331
1331
|
"""
|
|
1332
1332
|
from ..utils import generate_identifier_name
|