plain.postgres 0.116.0__tar.gz → 0.118.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.116.0 → plain_postgres-0.118.0}/PKG-INFO +62 -37
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/CHANGELOG.md +53 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/README.md +61 -36
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/base.py +17 -13
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/decorators.py +9 -2
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/migrations.py +243 -249
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/sync.py +28 -3
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/expressions.py +14 -2
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/base.py +0 -2
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/related.py +48 -45
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/reverse_related.py +34 -11
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/meta.py +6 -7
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/autodetector.py +196 -124
- plain_postgres-0.118.0/plain/postgres/migrations/baselines.py +124 -0
- plain_postgres-0.118.0/plain/postgres/migrations/exceptions.py +118 -0
- plain_postgres-0.118.0/plain/postgres/migrations/executor.py +261 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/graph.py +8 -86
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/loader.py +132 -83
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/migration.py +12 -6
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/operations/base.py +6 -6
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/operations/special.py +8 -4
- plain_postgres-0.118.0/plain/postgres/migrations/reset.py +319 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/state.py +22 -18
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/utils.py +7 -2
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/writer.py +26 -29
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/options.py +11 -5
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/preflight/database.py +36 -55
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/preflight/models.py +1 -1
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/query.py +8 -13
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/compiler.py +5 -7
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/pyproject.toml +1 -1
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0002_test_field_removed.py +2 -1
- plain_postgres-0.118.0/tests/internal/conftest.py +55 -0
- plain_postgres-0.118.0/tests/internal/test_baselines.py +749 -0
- plain_postgres-0.118.0/tests/internal/test_migrations_reset.py +472 -0
- plain_postgres-0.118.0/tests/internal/test_replaces_removed.py +98 -0
- plain_postgres-0.118.0/tests/internal/test_shipped_baselines.py +51 -0
- plain_postgres-0.118.0/tests/internal/test_unresolved_relation_refs.py +144 -0
- plain_postgres-0.118.0/tests/internal/test_writer_operation_options.py +28 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_databases.py +8 -2
- plain_postgres-0.118.0/tests/public/test_queryset_ordered.py +14 -0
- plain_postgres-0.116.0/plain/postgres/migrations/exceptions.py +0 -51
- plain_postgres-0.116.0/plain/postgres/migrations/executor.py +0 -183
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/.gitignore +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/CLAUDE.md +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/LICENSE +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/README.md +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/convergence/corrections.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/databases.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/schema_lock.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/conftest.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_apply_replan.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_constraint_violation_error.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_constraints.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_schema_lock.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_deferred_loading.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_delete_behaviors.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_only_empty_defaults.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_order_by_expressions.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_queryset_repr.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_queryset_slicing.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_related.py +0 -0
- {plain_postgres-0.116.0 → plain_postgres-0.118.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.118.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
|
|
@@ -144,7 +144,7 @@ PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
|
144
144
|
|
|
145
145
|
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
146
146
|
|
|
147
|
-
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune
|
|
147
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`
|
|
148
148
|
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
149
149
|
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
150
150
|
|
|
@@ -603,15 +603,16 @@ Key flags:
|
|
|
603
603
|
|
|
604
604
|
Shared commands (apply equally to structural and data migrations):
|
|
605
605
|
|
|
606
|
-
| Command
|
|
607
|
-
|
|
|
608
|
-
| `plain migrations apply`
|
|
609
|
-
| `plain migrations apply --plan`
|
|
610
|
-
| `plain migrations apply --check`
|
|
611
|
-
| `plain migrations apply --fake`
|
|
612
|
-
| `plain migrations list`
|
|
613
|
-
| `plain migrations
|
|
614
|
-
| `plain migrations prune
|
|
606
|
+
| Command | Purpose |
|
|
607
|
+
| ---------------------------------- | ----------------------------------------------------------------- |
|
|
608
|
+
| `plain migrations apply` | Apply pending migrations |
|
|
609
|
+
| `plain migrations apply --plan` | Preview what would run |
|
|
610
|
+
| `plain migrations apply --check` | Exit non-zero if unapplied migrations exist (for CI) |
|
|
611
|
+
| `plain migrations apply --fake` | Mark as applied without running SQL |
|
|
612
|
+
| `plain migrations list` | View migration status by package |
|
|
613
|
+
| `plain migrations prune` | Remove orphan migration records |
|
|
614
|
+
| `plain migrations prune <package>` | Remove every record for one package (lets its baseline run again) |
|
|
615
|
+
| `plain migrations reset <package>` | Replace the package's history with one baseline |
|
|
615
616
|
|
|
616
617
|
#### Development workflow
|
|
617
618
|
|
|
@@ -626,43 +627,65 @@ Use this when migrations exist only in your local dev environment and haven't be
|
|
|
626
627
|
3. `plain migrations create` — creates a single fresh migration with all the changes
|
|
627
628
|
4. `plain migrations apply --fake` — marks the new migration as applied (the schema is already correct from the old migrations)
|
|
628
629
|
|
|
629
|
-
|
|
630
|
+
Migrations that are committed but not yet deployed anywhere can be consolidated the same way if every developer resets their database; production then applies the consolidated migration normally. Once a migration has reached any deployed environment, use a full reset (below).
|
|
630
631
|
|
|
631
|
-
|
|
632
|
+
#### Baselines
|
|
632
633
|
|
|
633
|
-
|
|
634
|
+
A package whose migration history has been reset ships one **baseline** migration in place of the deleted files. It is an ordinary migration with three extra attributes:
|
|
634
635
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
636
|
+
```python
|
|
637
|
+
class Migration(migrations.Migration):
|
|
638
|
+
supersedes = "0008_add_widgets" # the sentinel: the last deleted migration; its record proves a database is caught up
|
|
639
|
+
retired = (
|
|
640
|
+
"0001_initial",
|
|
641
|
+
...,
|
|
642
|
+
"0008_add_widgets",
|
|
643
|
+
) # every deleted name, so dependencies on them still resolve
|
|
644
|
+
shipped_in = "0.61" # the release that shipped the reset; empty until it has
|
|
645
|
+
dependencies = (("users", "0001_initial"),)
|
|
646
|
+
operations = (migrations.CreateModel(...), ...)
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
What `plain postgres sync` (or `plain migrations apply`) does with it depends only on what the database has recorded:
|
|
650
|
+
|
|
651
|
+
| Database has | Result |
|
|
652
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
653
|
+
| No records for the package, no tables (fresh, or newly installed) | Runs the baseline like any migration |
|
|
654
|
+
| No records for the package, but its tables exist | Refuses: record it with `apply <pkg> <baseline> --fake` if the schema is current, else drop the tables |
|
|
655
|
+
| The baseline recorded | Nothing |
|
|
656
|
+
| The `supersedes` migration recorded | Records the baseline without running it - one row, once |
|
|
657
|
+
| Records, but not the `supersedes` migration | Refuses: upgrade through the last release that still has that migration |
|
|
658
|
+
| Records, but none of the package's tables | Refuses: `plain migrations prune <pkg>` drops the records; then it runs |
|
|
659
|
+
|
|
660
|
+
`plain migrations apply --plan --check` and `plain postgres sync --check` report a baseline waiting to be recorded as a pending change, and `plain preflight` reports a refusal as an error. Nothing ever deletes records on its own; `prune` stays explicit. A database where the baseline _ran_ (rather than was recorded) cannot roll back to code from before the reset.
|
|
642
661
|
|
|
643
662
|
#### Resetting migrations
|
|
644
663
|
|
|
645
|
-
|
|
664
|
+
Once **every environment** has applied a package's migrations, its history can be collapsed into one baseline:
|
|
646
665
|
|
|
647
|
-
|
|
666
|
+
```bash
|
|
667
|
+
plain migrations reset <package>
|
|
668
|
+
```
|
|
648
669
|
|
|
649
|
-
|
|
650
|
-
- The first migration is named `0001_initial` (the default). If it has a different name, this workflow won't work cleanly.
|
|
670
|
+
The command writes `NNNN_baseline.py` past the current leaf (the package's newest migration) — the package's schema as `CreateModel`s, `supersedes` set to the leaf, `retired` set to every deleted name — and deletes the old files. Commit the new file and the deletions together. Existing databases adopt it with one record on their next `plain postgres sync`; the retired records stay as the rollback path; a fresh database runs it. Other packages' migrations that depended on a deleted name resolve to the baseline; nothing there needs rewriting. Do not add a `prune` step. Before it touches anything it checks, in this order:
|
|
651
671
|
|
|
652
|
-
|
|
672
|
+
- The models agree with the history (`plain migrations create` would write nothing). A change the history doesn't hold would be folded into the baseline, and databases that adopt it would never run it.
|
|
673
|
+
- The package ends in a single leaf, and no earlier baseline is waiting unreleased (see second resets below).
|
|
674
|
+
- The deleted history holds nothing a fresh database would miss. `RunPython`, `RunSQL`, any custom operation, and anything on the database side of `SeparateDatabaseAndState` are listed and refused until each carries `skip_on_reset=True` — "a fresh database can do without this." If it can't (a seed, an extension), move the effect somewhere a fresh database does get it, then mark it.
|
|
675
|
+
- The migrations directory is committed — tracked, unmodified, inside a git repository (so an installed package in site-packages cannot be reset; the check applies to `--dry-run` too). The leaf becomes the baseline's sentinel, so it cannot be something you created a minute ago. No check can prove every environment applied it — the command prints that obligation, and a database that hasn't is refused until it does. If anything goes wrong the output has already printed the one line that puts it back: `git checkout -- <migrations dir> && rm <the new baseline>`.
|
|
676
|
+
- The result loads: dependencies on retired names resolve, the graph has no cycle, the baseline reproduces the models exactly.
|
|
677
|
+
- Nothing the baseline needs is defined inside a migration file. A custom field class or callable written in the history disappears with it; move it into the app first.
|
|
653
678
|
|
|
654
|
-
|
|
655
|
-
2. Delete every file in the package's `migrations/` directory except `__init__.py`.
|
|
656
|
-
3. Run `plain migrations create` to generate a fresh `0001_initial`.
|
|
657
|
-
4. Run `plain migrations prune --yes` to remove stale DB records. The existing `0001_initial` record matches the new file, so the database is immediately up to date.
|
|
658
|
-
5. Verify with `plain postgres schema` (zero issues means the reset is clean) and `plain migrations create --check` (no pending changes).
|
|
659
|
-
6. Commit and deploy. On every other environment, run `plain migrations prune --yes`. No actual SQL runs — it only cleans up migration history records. If `migrations prune` is already in your deploy steps, no changes are needed.
|
|
679
|
+
Options:
|
|
660
680
|
|
|
661
|
-
|
|
681
|
+
- `--shipped-in <version>` — the release this reset ships in, named in the refusal a database that missed the leaf gets, and required before the package can be reset again. Plain's own packages leave it empty and fill it in at release time (a repository test enforces it, and the package's `plain.postgres` minimum is raised to the first release that understands baselines); an app should pass the version or deploy this ships in.
|
|
682
|
+
- `--dry-run` — print the baseline and the deletion list, write nothing.
|
|
662
683
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
684
|
+
**Dependencies.** The baseline depends on each other package its models reference, at the earliest migration of that package where the referenced models exist — and on nothing else. A package that another package pinned early _and_ whose models now point back at that package cannot get a single root: the graph would be a cycle, and the command refuses with it. That is a limit of migration graphs, not of the command; nothing to do with a second package fixes it.
|
|
685
|
+
|
|
686
|
+
**Support boundaries.** Adoption checks that the sentinel is recorded and the tables exist, not columns; editing history behind a released leaf is outside what any check can catch. A data migration in _another_ package that read this package's historical state (a field the baseline no longer has) keeps loading but can fail on a fresh database; the command cannot see it.
|
|
687
|
+
|
|
688
|
+
**Second reset.** Run it again later and the previous baseline joins `retired`. It refuses while the previous baseline's `shipped_in` is empty: nothing shipped it yet, so superseding its name would strand every database still at the original sentinel — once that baseline has shipped everywhere, set its `shipped_in` to the version that shipped it and reset again; otherwise restore the history and reset once.
|
|
666
689
|
|
|
667
690
|
### Data migrations
|
|
668
691
|
|
|
@@ -685,7 +708,9 @@ def forwards(models, schema_editor):
|
|
|
685
708
|
|
|
686
709
|
For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
|
|
687
710
|
|
|
688
|
-
|
|
711
|
+
When the package's history is later collapsed (see [Resetting migrations](#resetting-migrations)), `plain migrations reset` refuses while any `RunPython`/`RunSQL` is unmarked. Pass `skip_on_reset=True` once a fresh database can do without its effect; if it can't, move the effect into a seed first.
|
|
712
|
+
|
|
713
|
+
See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `prune`).
|
|
689
714
|
|
|
690
715
|
#### Cascading deletes inside data migrations
|
|
691
716
|
|
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.118.0](https://github.com/dropseed/plain/releases/plain-postgres@0.118.0) (2026-09-18)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Added baseline migrations. A package whose migration history has been collapsed ships one baseline in place of the deleted files: an ordinary migration with `supersedes` (the last deleted migration, whose record proves a database is caught up), `retired` (every deleted name, so other packages' dependencies on them still resolve), and `shipped_in` (the release that shipped the reset). `plain postgres sync` and `plain migrations apply` decide what to do from the database's records alone — a fresh database runs the baseline, a database that recorded the superseded migration adopts it with a single record write and no DDL, and a database from before the reset, or one whose tables are missing or unrecorded, is refused with a one-line remedy. Nothing deletes records on its own ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
8
|
+
- Added `plain migrations reset <package>`, which writes the baseline (the package's current schema as `CreateModel`s, named past the current leaf) and deletes the old migration files. Before touching anything it checks that the models agree with the history, that the package has a single leaf, that the deleted history holds nothing a fresh database would miss, that the migrations directory is committed and unmodified in git, and that the result loads. `--dry-run` prints the baseline and the deletion list; `--shipped-in <version>` records the release the reset ships in, which is named in the refusal a stale database gets and is required before the package can be reset a second time ([7c6045b1b9](https://github.com/dropseed/plain/commit/7c6045b1b9), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
9
|
+
- Removed `Migration.replaces` and the `plain migrations squash` command. A migration file that still declares `replaces` now fails to load with a `BadMigrationError` that lists the steps to convert it back to ordinary records ([e154abc239](https://github.com/dropseed/plain/commit/e154abc239))
|
|
10
|
+
- Renamed `Operation.elidable` to `skip_on_reset`; `RunPython` and `RunSQL` take it as `skip_on_reset=True`, and it is serialized into migration files when set. It means "a fresh database can do without this operation's effect" — `plain migrations reset` refuses while any `RunPython`, `RunSQL`, custom operation, or database-side `SeparateDatabaseAndState` in the deleted history is unmarked. The optimizer no longer elides operations across it ([7c6045b1b9](https://github.com/dropseed/plain/commit/7c6045b1b9))
|
|
11
|
+
- `plain migrations prune` takes an optional `<package>` argument that removes every record for that package, which is how you let its baseline run again once the tables are gone. Without an argument it removes orphan records — recorded migrations with no file on disk that no baseline retired — and keeps retired records as the rollback path across a reset. "Stale" became "orphan" in its output ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae))
|
|
12
|
+
- `plain migrations list` annotates a baseline waiting to be recorded with `(baseline: will be recorded, not run)` and prints the planner's refusal for a package as a red `!` line. The `[-]` "partially recorded" marker for squashed migrations is gone with `replaces` ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae))
|
|
13
|
+
- `plain migrations apply --plan` and `plain postgres sync` report baselines being recorded separately from migrations being run ("Applying 2 migration(s), recording 1 baseline(s)"). `plain migrations apply <package> <migration> --fake` skips the consistent-history check — faking a named migration is how that history gets repaired — and can record a baseline by hand on a database whose tables already exist ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
14
|
+
- Preflight: `postgres.prunable_migrations` now reports orphan records as a warning without telling you to prune them (they are what rollback needs, and a branch switch or a reset leaves them behind on purpose), and the new `postgres.migration_history` check reports as an error any database the planner would refuse, so a deploy finds out before the release phase ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae))
|
|
15
|
+
- Added `MigrationsError` as the base class for `AmbiguityError`, `BadMigrationError`, `CircularDependencyError`, `InconsistentMigrationHistory`, `NodeNotFoundError`, and the new `MigrationHistoryError` family (`ResetBoundaryError`, `StaleMigrationRecordsError`, `UnrecordedTablesError`), so a caller that only reports can catch one thing. A dependency on an unknown or unmigrated package raises `BadMigrationError` instead of `ValueError`. Database management commands print a `MigrationHistoryError` as one clean line instead of a traceback ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
16
|
+
- `MigrationExecutor` gained `split_plan()`, which returns a `PendingMigrations(run=, record=)` for a plan, a `record_only` property, and a `repair_baseline` argument on `migration_plan()`; `record_migration()` and `check_replacements()` were removed and a migration's record is now written inside the schema editor's transaction. `MigrationLoader` lost the `replace_migrations` argument and `replacements` attribute and gained `baselines`, `baseline_status`, `retired_to_baseline`, `resolved_dependencies()`, `orphan_records()`, and `with_migrations()`. `MigrationGraph.remove_replaced_nodes()` and `remove_replacement_node()` are gone ([e154abc239](https://github.com/dropseed/plain/commit/e154abc239), [b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
17
|
+
- The migration writer serializes an operation from what its `deconstruct()` returns — positional arguments as given, then every keyword argument in order — instead of inspecting the operation's `__init__` signature. A custom operation whose `deconstruct()` returns a keyword its `__init__` doesn't name positionally is now written faithfully ([4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
18
|
+
- `MigrationAutodetector.arrange_for_graph()` and `deep_deconstruct()` are now module-level functions in `plain.postgres.migrations.autodetector` (`arrange_for_graph` takes `questioner=` as a keyword), alongside the new `detect_model_changes()`, `describe_changes()`, and `package_creation_operations()`. `parse_number()` no longer special-cases `_squashed_` names ([7c6045b1b9](https://github.com/dropseed/plain/commit/7c6045b1b9), [4eb466fa46](https://github.com/dropseed/plain/commit/4eb466fa46))
|
|
19
|
+
- Added `plain.postgres.options.default_db_table()` and `CONVERGENCE_OPTIONS` ([b89b33acae](https://github.com/dropseed/plain/commit/b89b33acae))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- If any migration file declares `replaces`, the loader refuses to start and prints the conversion steps: delete the `replaces = ...` line, delete any replaced migration still on disk and repoint dependencies that named it to the replacing migration, run `plain migrations apply <package> <migration> --fake` on every database that had applied all of the replaced migrations, then `plain migrations prune` on each. Do this on staging and production before their next deploy.
|
|
24
|
+
- Replace `elidable=True` with `skip_on_reset=True` on `RunPython` and `RunSQL`, and rename the `elidable` class attribute to `skip_on_reset` on custom operations.
|
|
25
|
+
- Replace `plain migrations squash` in any docs or scripts with `plain migrations reset <package>`. Read the "Resetting migrations" section of the postgres docs first — it deletes files, and every environment must have applied the package's current leaf before the reset ships.
|
|
26
|
+
- Deploy steps that ran `plain migrations prune` after a reset can drop it: records a baseline retired stay on purpose, and `prune` now leaves them alone.
|
|
27
|
+
- If you subclassed or called migration internals: `MigrationLoader(replace_migrations=...)` and `loader.replacements` no longer exist, `MigrationExecutor.record_migration()` and `check_replacements()` are gone, and `MigrationAutodetector.arrange_for_graph()` / `.deep_deconstruct()` are now module-level imports from `plain.postgres.migrations.autodetector`.
|
|
28
|
+
- If you caught `ValueError` from the loader for a dependency on an unknown package, catch `BadMigrationError` (or `MigrationsError`).
|
|
29
|
+
|
|
30
|
+
## [0.117.0](https://github.com/dropseed/plain/releases/plain-postgres@0.117.0) (2026-09-04)
|
|
31
|
+
|
|
32
|
+
### What's changed
|
|
33
|
+
|
|
34
|
+
- Fixed `QuerySet.ordered`, which raised `AttributeError: 'Meta' object has no attribute 'ordering'` for any queryset without an explicit `order_by()`. It was reading the model's default ordering off `_model_meta` instead of `model_options`, so the property only ever worked on the one branch that never reached the lookup. It now correctly reports `True` when the model declares `Meta.ordering` and `False` when it doesn't ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
|
|
35
|
+
- Unresolved relation references are now separate attributes from resolved model classes. `rel.model` and `ManyToManyRel.through` used to hold whatever you passed to the field — a `"package.Model"` string early on, the class after `lazy_related_operation` overwrote it — so every reader had to guess which it had, and the guessing was done inconsistently (`isinstance(..., str)` in some places, `isinstance(..., ModelBase)` in others, `hasattr(..., "_model_meta")` in others still). The raw reference now lives on `rel.model_ref` and `rel.through_ref`, and `rel.model` / `rel.through` are read-only properties that return the resolved class or raise `TypeError` if it isn't resolved yet. The `TypeError` is deliberate: an `AttributeError` would be swallowed by `hasattr`/`getattr` and silently read as "no model" ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
36
|
+
- `ForeignKeyField.target_field` dropped its own unresolved-reference `TypeError` — the `rel.model` property raises it now, with a message naming the field ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
37
|
+
- `ManyToManyField._check_through_fields` reports an unresolved target by name instead of crashing on `related_model.model_options.object_name`, so a `through_fields` mistake on a not-yet-resolved relation produces the intended preflight message ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
38
|
+
- `ModelState` now raises `TypeError` (was `ValueError`) when a field's `to` or `through` refers to a model class instead of a string reference, and detects it by checking the reference directly rather than probing for `_model_meta` ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
39
|
+
- Removed `Field.concrete`. It was set to `self.column is not None` right after `column` was assigned the field's name, so it was `True` on every forward field and the checks that consulted it were dead. `bulk_create()` and `bulk_update()` now say what they actually reject — "bulk_update() cannot be used with many-to-many fields." rather than "can only be used with concrete fields." — and `Model.save(update_fields=...)` drops "non-concrete fields" from its error message. `ForeignObjectRel.concrete` and the unreachable virtual-field skip in `Model.__init__` are gone too ([af56244677](https://github.com/dropseed/plain/commit/af56244677))
|
|
40
|
+
- `OrderBy.asc()` and `.desc()` return the `OrderBy` instead of `None`, matching every other expression's `asc`/`desc` and making `expr.desc().asc()` and inline use work. They still take no options — passing `nulls_first` or `nulls_last` raises `TypeError` pointing you at the attributes on the `OrderBy` itself, rather than being silently ignored ([f00e707ed0](https://github.com/dropseed/plain/commit/f00e707ed0))
|
|
41
|
+
- Preflight expression checks no longer crash when a lookup path continues past a non-relation field — for example `Meta.ordering = ("name__nope",)` on a text field. The traversal now raises `FieldDoesNotExist` for the trailing part, which preflight already reports, instead of an `AttributeError` on `None` ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
|
|
42
|
+
- `MigrationGraph.make_state()` raises `NodeNotFoundError` when the plan hits a placeholder node, instead of an `AttributeError` on `None` ([d9406dfc6f](https://github.com/dropseed/plain/commit/d9406dfc6f))
|
|
43
|
+
- The migration autodetector and `ProjectState` narrow on `RelatedField` / `ManyToManyField` / `ManyToManyRel` instead of `hasattr(field, "remote_field")` and `getattr(remote_field, "through", None)`. Rename handling only rewrites a reference when the old field is the same kind of relation, so a field swapped between a plain relation and an M2M during a rename no longer copies a mismatched reference across ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
44
|
+
- `ProjectState._get_related_models` reads a forward relation's target from `remote_field.model_ref` rather than `f.model` — which returned the model the field is _declared on_, not the one it points at ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
45
|
+
- `Meta._expire_cache` propagation to a related model no longer swallows `AttributeError` to handle the unresolved-string case; it checks `model_ref` up front, so a genuine `AttributeError` inside `_expire_cache` surfaces ([a3962f7255](https://github.com/dropseed/plain/commit/a3962f7255))
|
|
46
|
+
|
|
47
|
+
### Upgrade instructions
|
|
48
|
+
|
|
49
|
+
- If you read `rel.model` or `rel.through` (via `field.remote_field`) in code that runs before models are fully loaded — preflight checks, `contribute_to_class`, custom fields, migration operations — switch to `rel.model_ref` / `rel.through_ref` and check `isinstance(ref, str)`. After model loading completes, `rel.model` and `rel.through` behave as before.
|
|
50
|
+
- If you _assigned_ to `rel.model` or `rel.through`, assign to `rel.model_ref` / `rel.through_ref` instead — the plain names are now read-only properties.
|
|
51
|
+
- If you referenced `Field.concrete` or `ForeignObjectRel.concrete`, remove the check. `concrete` was `True` for every forward field and `False` only on reverse relations, which `get_fields()` doesn't return unless you pass `include_reverse=True`.
|
|
52
|
+
- If you asserted on the exact text of the `bulk_create()`, `bulk_update()`, or `save(update_fields=...)` errors, update the expected strings.
|
|
53
|
+
- If you caught `ValueError` from `ModelState` for a model-class `to`/`through` reference, catch `TypeError`.
|
|
54
|
+
- `OrderBy.asc()`/`.desc()` returning `self` is additive — existing calls that discarded the return value are unaffected.
|
|
55
|
+
|
|
3
56
|
## [0.116.0](https://github.com/dropseed/plain/releases/plain-postgres@0.116.0) (2026-09-04)
|
|
4
57
|
|
|
5
58
|
### What's changed
|
|
@@ -131,7 +131,7 @@ PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
|
|
|
131
131
|
|
|
132
132
|
When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
|
|
133
133
|
|
|
134
|
-
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune
|
|
134
|
+
- `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`
|
|
135
135
|
- `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
|
|
136
136
|
- `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
|
|
137
137
|
|
|
@@ -590,15 +590,16 @@ Key flags:
|
|
|
590
590
|
|
|
591
591
|
Shared commands (apply equally to structural and data migrations):
|
|
592
592
|
|
|
593
|
-
| Command
|
|
594
|
-
|
|
|
595
|
-
| `plain migrations apply`
|
|
596
|
-
| `plain migrations apply --plan`
|
|
597
|
-
| `plain migrations apply --check`
|
|
598
|
-
| `plain migrations apply --fake`
|
|
599
|
-
| `plain migrations list`
|
|
600
|
-
| `plain migrations
|
|
601
|
-
| `plain migrations prune
|
|
593
|
+
| Command | Purpose |
|
|
594
|
+
| ---------------------------------- | ----------------------------------------------------------------- |
|
|
595
|
+
| `plain migrations apply` | Apply pending migrations |
|
|
596
|
+
| `plain migrations apply --plan` | Preview what would run |
|
|
597
|
+
| `plain migrations apply --check` | Exit non-zero if unapplied migrations exist (for CI) |
|
|
598
|
+
| `plain migrations apply --fake` | Mark as applied without running SQL |
|
|
599
|
+
| `plain migrations list` | View migration status by package |
|
|
600
|
+
| `plain migrations prune` | Remove orphan migration records |
|
|
601
|
+
| `plain migrations prune <package>` | Remove every record for one package (lets its baseline run again) |
|
|
602
|
+
| `plain migrations reset <package>` | Replace the package's history with one baseline |
|
|
602
603
|
|
|
603
604
|
#### Development workflow
|
|
604
605
|
|
|
@@ -613,43 +614,65 @@ Use this when migrations exist only in your local dev environment and haven't be
|
|
|
613
614
|
3. `plain migrations create` — creates a single fresh migration with all the changes
|
|
614
615
|
4. `plain migrations apply --fake` — marks the new migration as applied (the schema is already correct from the old migrations)
|
|
615
616
|
|
|
616
|
-
|
|
617
|
+
Migrations that are committed but not yet deployed anywhere can be consolidated the same way if every developer resets their database; production then applies the consolidated migration normally. Once a migration has reached any deployed environment, use a full reset (below).
|
|
617
618
|
|
|
618
|
-
|
|
619
|
+
#### Baselines
|
|
619
620
|
|
|
620
|
-
|
|
621
|
+
A package whose migration history has been reset ships one **baseline** migration in place of the deleted files. It is an ordinary migration with three extra attributes:
|
|
621
622
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
623
|
+
```python
|
|
624
|
+
class Migration(migrations.Migration):
|
|
625
|
+
supersedes = "0008_add_widgets" # the sentinel: the last deleted migration; its record proves a database is caught up
|
|
626
|
+
retired = (
|
|
627
|
+
"0001_initial",
|
|
628
|
+
...,
|
|
629
|
+
"0008_add_widgets",
|
|
630
|
+
) # every deleted name, so dependencies on them still resolve
|
|
631
|
+
shipped_in = "0.61" # the release that shipped the reset; empty until it has
|
|
632
|
+
dependencies = (("users", "0001_initial"),)
|
|
633
|
+
operations = (migrations.CreateModel(...), ...)
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
What `plain postgres sync` (or `plain migrations apply`) does with it depends only on what the database has recorded:
|
|
637
|
+
|
|
638
|
+
| Database has | Result |
|
|
639
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
640
|
+
| No records for the package, no tables (fresh, or newly installed) | Runs the baseline like any migration |
|
|
641
|
+
| No records for the package, but its tables exist | Refuses: record it with `apply <pkg> <baseline> --fake` if the schema is current, else drop the tables |
|
|
642
|
+
| The baseline recorded | Nothing |
|
|
643
|
+
| The `supersedes` migration recorded | Records the baseline without running it - one row, once |
|
|
644
|
+
| Records, but not the `supersedes` migration | Refuses: upgrade through the last release that still has that migration |
|
|
645
|
+
| Records, but none of the package's tables | Refuses: `plain migrations prune <pkg>` drops the records; then it runs |
|
|
646
|
+
|
|
647
|
+
`plain migrations apply --plan --check` and `plain postgres sync --check` report a baseline waiting to be recorded as a pending change, and `plain preflight` reports a refusal as an error. Nothing ever deletes records on its own; `prune` stays explicit. A database where the baseline _ran_ (rather than was recorded) cannot roll back to code from before the reset.
|
|
629
648
|
|
|
630
649
|
#### Resetting migrations
|
|
631
650
|
|
|
632
|
-
|
|
651
|
+
Once **every environment** has applied a package's migrations, its history can be collapsed into one baseline:
|
|
633
652
|
|
|
634
|
-
|
|
653
|
+
```bash
|
|
654
|
+
plain migrations reset <package>
|
|
655
|
+
```
|
|
635
656
|
|
|
636
|
-
|
|
637
|
-
- The first migration is named `0001_initial` (the default). If it has a different name, this workflow won't work cleanly.
|
|
657
|
+
The command writes `NNNN_baseline.py` past the current leaf (the package's newest migration) — the package's schema as `CreateModel`s, `supersedes` set to the leaf, `retired` set to every deleted name — and deletes the old files. Commit the new file and the deletions together. Existing databases adopt it with one record on their next `plain postgres sync`; the retired records stay as the rollback path; a fresh database runs it. Other packages' migrations that depended on a deleted name resolve to the baseline; nothing there needs rewriting. Do not add a `prune` step. Before it touches anything it checks, in this order:
|
|
638
658
|
|
|
639
|
-
|
|
659
|
+
- The models agree with the history (`plain migrations create` would write nothing). A change the history doesn't hold would be folded into the baseline, and databases that adopt it would never run it.
|
|
660
|
+
- The package ends in a single leaf, and no earlier baseline is waiting unreleased (see second resets below).
|
|
661
|
+
- The deleted history holds nothing a fresh database would miss. `RunPython`, `RunSQL`, any custom operation, and anything on the database side of `SeparateDatabaseAndState` are listed and refused until each carries `skip_on_reset=True` — "a fresh database can do without this." If it can't (a seed, an extension), move the effect somewhere a fresh database does get it, then mark it.
|
|
662
|
+
- The migrations directory is committed — tracked, unmodified, inside a git repository (so an installed package in site-packages cannot be reset; the check applies to `--dry-run` too). The leaf becomes the baseline's sentinel, so it cannot be something you created a minute ago. No check can prove every environment applied it — the command prints that obligation, and a database that hasn't is refused until it does. If anything goes wrong the output has already printed the one line that puts it back: `git checkout -- <migrations dir> && rm <the new baseline>`.
|
|
663
|
+
- The result loads: dependencies on retired names resolve, the graph has no cycle, the baseline reproduces the models exactly.
|
|
664
|
+
- Nothing the baseline needs is defined inside a migration file. A custom field class or callable written in the history disappears with it; move it into the app first.
|
|
640
665
|
|
|
641
|
-
|
|
642
|
-
2. Delete every file in the package's `migrations/` directory except `__init__.py`.
|
|
643
|
-
3. Run `plain migrations create` to generate a fresh `0001_initial`.
|
|
644
|
-
4. Run `plain migrations prune --yes` to remove stale DB records. The existing `0001_initial` record matches the new file, so the database is immediately up to date.
|
|
645
|
-
5. Verify with `plain postgres schema` (zero issues means the reset is clean) and `plain migrations create --check` (no pending changes).
|
|
646
|
-
6. Commit and deploy. On every other environment, run `plain migrations prune --yes`. No actual SQL runs — it only cleans up migration history records. If `migrations prune` is already in your deploy steps, no changes are needed.
|
|
666
|
+
Options:
|
|
647
667
|
|
|
648
|
-
|
|
668
|
+
- `--shipped-in <version>` — the release this reset ships in, named in the refusal a database that missed the leaf gets, and required before the package can be reset again. Plain's own packages leave it empty and fill it in at release time (a repository test enforces it, and the package's `plain.postgres` minimum is raised to the first release that understands baselines); an app should pass the version or deploy this ships in.
|
|
669
|
+
- `--dry-run` — print the baseline and the deletion list, write nothing.
|
|
649
670
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
671
|
+
**Dependencies.** The baseline depends on each other package its models reference, at the earliest migration of that package where the referenced models exist — and on nothing else. A package that another package pinned early _and_ whose models now point back at that package cannot get a single root: the graph would be a cycle, and the command refuses with it. That is a limit of migration graphs, not of the command; nothing to do with a second package fixes it.
|
|
672
|
+
|
|
673
|
+
**Support boundaries.** Adoption checks that the sentinel is recorded and the tables exist, not columns; editing history behind a released leaf is outside what any check can catch. A data migration in _another_ package that read this package's historical state (a field the baseline no longer has) keeps loading but can fail on a fresh database; the command cannot see it.
|
|
674
|
+
|
|
675
|
+
**Second reset.** Run it again later and the previous baseline joins `retired`. It refuses while the previous baseline's `shipped_in` is empty: nothing shipped it yet, so superseding its name would strand every database still at the original sentinel — once that baseline has shipped everywhere, set its `shipped_in` to the version that shipped it and reset again; otherwise restore the history and reset once.
|
|
653
676
|
|
|
654
677
|
### Data migrations
|
|
655
678
|
|
|
@@ -672,7 +695,9 @@ def forwards(models, schema_editor):
|
|
|
672
695
|
|
|
673
696
|
For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
|
|
674
697
|
|
|
675
|
-
|
|
698
|
+
When the package's history is later collapsed (see [Resetting migrations](#resetting-migrations)), `plain migrations reset` refuses while any `RunPython`/`RunSQL` is unmarked. Pass `skip_on_reset=True` once a fresh database can do without its effect; if it can't, move the effect into a seed first.
|
|
699
|
+
|
|
700
|
+
See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `prune`).
|
|
676
701
|
|
|
677
702
|
#### Cascading deletes inside data migrations
|
|
678
703
|
|
|
@@ -99,3 +99,4 @@ Run `uv run plain docs postgres` for check details, thresholds, and production u
|
|
|
99
99
|
- Use `model_options = postgres.Options(...)` not `class Meta`
|
|
100
100
|
- Never format raw SQL strings — always use parameterized queries
|
|
101
101
|
- Migrations are forward-only — no reverse migrations. `RunPython` takes a single callable (no `reverse_code` or `noop`). The callable signature is `fn(models, schema_editor)`, not `fn(apps, schema_editor)`
|
|
102
|
+
- No `squashmigrations`; collapse a package's history with `plain migrations reset <package>`. `RunPython`/`RunSQL` take `skip_on_reset=True` (Django's `elidable`)
|
|
@@ -123,9 +123,6 @@ class Model(metaclass=ModelBase):
|
|
|
123
123
|
from plain.postgres.fields.related import RelatedField
|
|
124
124
|
|
|
125
125
|
is_related_object = False
|
|
126
|
-
# Virtual field
|
|
127
|
-
if field.name not in kwargs and field.column is None:
|
|
128
|
-
continue
|
|
129
126
|
if isinstance(field, RelatedField) and isinstance(
|
|
130
127
|
field.remote_field, ForeignObjectRel
|
|
131
128
|
):
|
|
@@ -395,8 +392,8 @@ class Model(metaclass=ModelBase):
|
|
|
395
392
|
non_model_fields = fields.difference(field_names)
|
|
396
393
|
if non_model_fields:
|
|
397
394
|
raise ValueError(
|
|
398
|
-
"The following fields do not exist in this model
|
|
399
|
-
"fields
|
|
395
|
+
"The following fields do not exist in this model or are m2m "
|
|
396
|
+
"fields: "
|
|
400
397
|
f"{', '.join(sorted(non_model_fields))}"
|
|
401
398
|
)
|
|
402
399
|
deferred_in_update = fields & deferred_fields
|
|
@@ -755,11 +752,14 @@ class Model(metaclass=ModelBase):
|
|
|
755
752
|
|
|
756
753
|
fields = cls._model_meta.many_to_many
|
|
757
754
|
|
|
758
|
-
# Skip when the target model wasn't found
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
755
|
+
# Skip when the target or relationship model wasn't found; the field's
|
|
756
|
+
# own preflight reports those.
|
|
757
|
+
fields = (
|
|
758
|
+
f
|
|
759
|
+
for f in fields
|
|
760
|
+
if not isinstance(f.remote_field.model_ref, str)
|
|
761
|
+
and not isinstance(f.remote_field.through_ref, str)
|
|
762
|
+
)
|
|
763
763
|
|
|
764
764
|
for f in fields:
|
|
765
765
|
signature = (
|
|
@@ -1033,7 +1033,11 @@ class Model(metaclass=ModelBase):
|
|
|
1033
1033
|
fld = None
|
|
1034
1034
|
for part in field.split(LOOKUP_SEP):
|
|
1035
1035
|
try:
|
|
1036
|
-
|
|
1036
|
+
if _cls is None:
|
|
1037
|
+
# The previous part was not a relation, so there is
|
|
1038
|
+
# no model left to look the next part up on.
|
|
1039
|
+
raise FieldDoesNotExist(part)
|
|
1040
|
+
fld = _cls._model_meta.get_field(part)
|
|
1037
1041
|
if isinstance(fld, RelatedField):
|
|
1038
1042
|
_cls = fld.path_infos[-1].to_meta.model
|
|
1039
1043
|
else:
|
|
@@ -1111,11 +1115,11 @@ class Model(metaclass=ModelBase):
|
|
|
1111
1115
|
|
|
1112
1116
|
for f in cls._model_meta.many_to_many:
|
|
1113
1117
|
# Skip nonexistent models.
|
|
1114
|
-
if isinstance(f.remote_field.
|
|
1118
|
+
if isinstance(f.remote_field.through_ref, str):
|
|
1115
1119
|
continue
|
|
1116
1120
|
|
|
1117
1121
|
# Check if column name for the M2M field is too long for the database.
|
|
1118
|
-
for m2m in f.remote_field.
|
|
1122
|
+
for m2m in f.remote_field.through_ref._model_meta.fields:
|
|
1119
1123
|
rel_name = m2m.column
|
|
1120
1124
|
if rel_name is not None and len(rel_name) > allowed_len:
|
|
1121
1125
|
errors.append(
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
import click
|
|
9
9
|
|
|
10
10
|
from ..db import use_management_connection
|
|
11
|
+
from ..migrations.exceptions import MigrationHistoryError
|
|
11
12
|
from ..schema_lock import SchemaLockLost, SchemaLockTimeout, schema_lock
|
|
12
13
|
|
|
13
14
|
|
|
@@ -30,11 +31,17 @@ def database_management_command[F: Callable[..., Any]](f: F) -> F:
|
|
|
30
31
|
database management operations. Inside the command, `get_connection()`
|
|
31
32
|
returns a connection opened against `POSTGRES_MANAGEMENT_URL` (falling
|
|
32
33
|
back to `POSTGRES_URL` when unset).
|
|
34
|
+
|
|
35
|
+
A planner refusal (`MigrationHistoryError`) surfaces as one clean line
|
|
36
|
+
instead of a traceback, the same way `cli_schema_lock` treats lock errors.
|
|
33
37
|
"""
|
|
34
38
|
|
|
35
39
|
@functools.wraps(f)
|
|
36
40
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
try:
|
|
42
|
+
with use_management_connection():
|
|
43
|
+
return f(*args, **kwargs)
|
|
44
|
+
except MigrationHistoryError as e:
|
|
45
|
+
raise click.ClickException(str(e)) from e
|
|
39
46
|
|
|
40
47
|
return wrapper # ty: ignore[invalid-return-type]
|