plain.postgres 0.110.1__tar.gz → 0.111.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.110.1 → plain_postgres-0.111.0}/PKG-INFO +1 -1
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/CHANGELOG.md +22 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -10
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/enums.py +1 -1
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/expressions.py +13 -9
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/query.py +48 -26
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/compiler.py +1 -1
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/test/pytest.py +3 -3
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/pyproject.toml +1 -1
- plain_postgres-0.111.0/tests/conftest.py +56 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_constraint_violation_error.py +3 -11
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_constraints.py +7 -8
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_deferred_loading.py +15 -25
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_delete_behaviors.py +4 -12
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_exceptions.py +2 -2
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_queryset_repr.py +6 -18
- plain_postgres-0.111.0/tests/public/test_queryset_slicing.py +144 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_related.py +31 -38
- plain_postgres-0.110.1/tests/conftest.py +0 -26
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/.gitignore +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/CLAUDE.md +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/LICENSE +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/README.md +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/README.md +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.111.0](https://github.com/dropseed/plain/releases/plain-postgres@0.111.0) (2026-06-30)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Slicing a `QuerySet` now always returns a `QuerySet`, even when its results are already cached — previously, slicing an already-evaluated queryset returned a plain `list`. The returned queryset carries the sliced cache, so iterating it won't re-query, but re-chaining it (adding a `.filter()`, ordering, etc.) drops the cache and applies the slice as SQL `LIMIT`/`OFFSET` so the rows stay correct. ([d60e07d6ad](https://github.com/dropseed/plain/commit/d60e07d6ad))
|
|
8
|
+
- Step slicing (e.g. `qs[::2]`) now raises `ValueError` instead of silently evaluating the queryset and returning a stepped `list`. Negative indexing and negative slice bounds continue to raise, as before. `RawQuerySet` is unaffected — it is always fully materialized, so its slicing keeps plain `list` semantics, where step and negative slicing still work. ([d60e07d6ad](https://github.com/dropseed/plain/commit/d60e07d6ad), [a873a521d4](https://github.com/dropseed/plain/commit/a873a521d4))
|
|
9
|
+
|
|
10
|
+
### Upgrade instructions
|
|
11
|
+
|
|
12
|
+
- If you relied on `queryset[a:b]` returning a `list` — for example checking `isinstance(result, list)`, calling list methods like `.append()` on it, or re-slicing it with a step — wrap it in `list(queryset[a:b])` to materialize it explicitly.
|
|
13
|
+
- Replace any step slicing of a queryset (`queryset[::2]`) with a `list(...)` conversion first, since it now raises `ValueError`.
|
|
14
|
+
|
|
15
|
+
## [0.110.2](https://github.com/dropseed/plain/releases/plain-postgres@0.110.2) (2026-06-26)
|
|
16
|
+
|
|
17
|
+
### What's changed
|
|
18
|
+
|
|
19
|
+
- Internal type-annotation cleanups (`Func` expression attributes, enum choices) and a dev-dependency bump (pytest 9.1.1) to clear a security advisory. No public API changes. ([288bbf0bb1](https://github.com/dropseed/plain/commit/288bbf0bb1))
|
|
20
|
+
|
|
21
|
+
### Upgrade instructions
|
|
22
|
+
|
|
23
|
+
- No changes required.
|
|
24
|
+
|
|
3
25
|
## [0.110.1](https://github.com/dropseed/plain/releases/plain-postgres@0.110.1) (2026-06-22)
|
|
4
26
|
|
|
5
27
|
### What's changed
|
|
@@ -39,16 +39,7 @@ Do NOT import field classes directly from `plain.postgres` or `plain.postgres.fi
|
|
|
39
39
|
|
|
40
40
|
## Schema Changes
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
In your plan, present:
|
|
45
|
-
|
|
46
|
-
- Proposed schema as a table (model, field, type, constraints)
|
|
47
|
-
- Relationship cardinality (1:1, 1:N, M:N)
|
|
48
|
-
- Key decisions: nullable vs default, indexing, cascade behavior
|
|
49
|
-
- Whether the data could live on an existing model instead of a new one
|
|
50
|
-
|
|
51
|
-
Get approval before writing any model code or generating migrations.
|
|
42
|
+
Migrations are annoying to revise after the fact; convergence is cheap. So before making a batch of migration-generating changes — new models, new columns, or column-type changes — think the design through first. Nullability, defaults, indexes, constraints, `on_delete`, and `choices` aren't migrations; they're convergence (edit the model and re-sync), so they stay cheap to revise. For a large set of migration-dependent changes, surfacing the design first (plan mode fits) is worth it.
|
|
52
43
|
|
|
53
44
|
## Migrations vs Convergence
|
|
54
45
|
|
|
@@ -58,7 +58,7 @@ class ChoicesMeta(enum.EnumMeta):
|
|
|
58
58
|
@property
|
|
59
59
|
def choices(cls) -> list[tuple[Any, str]]:
|
|
60
60
|
empty = [(None, cls.__empty__)] if hasattr(cls, "__empty__") else []
|
|
61
|
-
return empty + [(member.value, member.label) for member in cls] # ty: ignore[unresolved-attribute
|
|
61
|
+
return empty + [(member.value, member.label) for member in cls] # ty: ignore[unresolved-attribute]
|
|
62
62
|
|
|
63
63
|
@property
|
|
64
64
|
def labels(cls) -> list[str]:
|
|
@@ -8,7 +8,7 @@ from collections import defaultdict
|
|
|
8
8
|
from decimal import Decimal
|
|
9
9
|
from functools import cached_property
|
|
10
10
|
from types import NoneType
|
|
11
|
-
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Protocol, Self, cast, runtime_checkable
|
|
12
12
|
from uuid import UUID
|
|
13
13
|
|
|
14
14
|
import psycopg
|
|
@@ -911,10 +911,10 @@ class OuterRef(F):
|
|
|
911
911
|
class Func(Expression):
|
|
912
912
|
"""An SQL function call."""
|
|
913
913
|
|
|
914
|
-
function = None
|
|
915
|
-
template = "%(function)s(%(expressions)s)"
|
|
916
|
-
arg_joiner = ", "
|
|
917
|
-
arity = None # The number of arguments the function accepts.
|
|
914
|
+
function: str | None = None
|
|
915
|
+
template: str = "%(function)s(%(expressions)s)"
|
|
916
|
+
arg_joiner: str = ", "
|
|
917
|
+
arity: int | None = None # The number of arguments the function accepts.
|
|
918
918
|
|
|
919
919
|
def __init__(
|
|
920
920
|
self, *expressions: Any, output_field: Field | None = None, **extra: Any
|
|
@@ -1001,10 +1001,14 @@ class Func(Expression):
|
|
|
1001
1001
|
data["function"] = function
|
|
1002
1002
|
else:
|
|
1003
1003
|
data.setdefault("function", self.function)
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1004
|
+
# `data` is typed dict[str, Any], so the override values come back as
|
|
1005
|
+
# Any; they are always strings, so cast to keep the `or` fallback typed.
|
|
1006
|
+
resolved_template = template or cast(str, data.get("template", self.template))
|
|
1007
|
+
resolved_joiner = arg_joiner or cast(
|
|
1008
|
+
str, data.get("arg_joiner", self.arg_joiner)
|
|
1009
|
+
)
|
|
1010
|
+
data["expressions"] = data["field"] = resolved_joiner.join(sql_parts)
|
|
1011
|
+
return resolved_template % data, params
|
|
1008
1012
|
|
|
1009
1013
|
def copy(self) -> Self:
|
|
1010
1014
|
clone = super().copy()
|
|
@@ -423,38 +423,47 @@ class QuerySet[T: "Model"]:
|
|
|
423
423
|
def __getitem__(self, k: int) -> T: ...
|
|
424
424
|
|
|
425
425
|
@overload
|
|
426
|
-
def __getitem__(self, k: slice) -> QuerySet[T]
|
|
426
|
+
def __getitem__(self, k: slice) -> QuerySet[T]: ...
|
|
427
427
|
|
|
428
|
-
def __getitem__(self, k: int | slice) -> T | QuerySet[T]
|
|
429
|
-
"""Retrieve an item or slice from the set of results.
|
|
428
|
+
def __getitem__(self, k: int | slice) -> T | QuerySet[T]:
|
|
429
|
+
"""Retrieve an item or slice from the set of results.
|
|
430
|
+
|
|
431
|
+
Slicing always returns a QuerySet, even when the results are
|
|
432
|
+
already cached. The returned QuerySet behaves exactly like one
|
|
433
|
+
sliced before evaluation — same allowed operations — except it
|
|
434
|
+
carries the sliced cache so iterating it won't re-query. The slice
|
|
435
|
+
is also applied as SQL limits, so any operation that re-chains the
|
|
436
|
+
QuerySet drops the cache and re-queries the correct rows.
|
|
437
|
+
|
|
438
|
+
Negative indexing and step slicing both raise.
|
|
439
|
+
"""
|
|
430
440
|
if not isinstance(k, int | slice):
|
|
431
441
|
raise TypeError(
|
|
432
442
|
f"QuerySet indices must be integers or slices, not {type(k).__name__}."
|
|
433
443
|
)
|
|
434
|
-
if (isinstance(k, int) and k < 0) or (
|
|
435
|
-
isinstance(k, slice)
|
|
436
|
-
and (
|
|
437
|
-
(k.start is not None and k.start < 0)
|
|
438
|
-
or (k.stop is not None and k.stop < 0)
|
|
439
|
-
)
|
|
440
|
-
):
|
|
441
|
-
raise ValueError("Negative indexing is not supported.")
|
|
442
|
-
|
|
443
|
-
if self._result_cache is not None:
|
|
444
|
-
return self._result_cache[k]
|
|
445
444
|
|
|
446
445
|
if isinstance(k, slice):
|
|
446
|
+
if (k.start is not None and k.start < 0) or (
|
|
447
|
+
k.stop is not None and k.stop < 0
|
|
448
|
+
):
|
|
449
|
+
raise ValueError("Negative indexing is not supported.")
|
|
450
|
+
if k.step is not None:
|
|
451
|
+
raise ValueError("Step slicing is not supported.")
|
|
447
452
|
qs = self._chain()
|
|
448
|
-
if k.start is not None
|
|
449
|
-
|
|
450
|
-
else:
|
|
451
|
-
start = None
|
|
452
|
-
if k.stop is not None:
|
|
453
|
-
stop = int(k.stop)
|
|
454
|
-
else:
|
|
455
|
-
stop = None
|
|
453
|
+
start = int(k.start) if k.start is not None else None
|
|
454
|
+
stop = int(k.stop) if k.stop is not None else None
|
|
456
455
|
qs.sql_query.set_limits(start, stop)
|
|
457
|
-
|
|
456
|
+
if self._result_cache is not None:
|
|
457
|
+
# Carry the sliced cache so the new QuerySet won't re-query on
|
|
458
|
+
# iteration. The SQL limits above keep it correct if it's later
|
|
459
|
+
# re-chained, which drops the cache.
|
|
460
|
+
self._attach_result_cache(qs, self._result_cache[k])
|
|
461
|
+
return qs
|
|
462
|
+
|
|
463
|
+
if k < 0:
|
|
464
|
+
raise ValueError("Negative indexing is not supported.")
|
|
465
|
+
if self._result_cache is not None:
|
|
466
|
+
return self._result_cache[k]
|
|
458
467
|
|
|
459
468
|
qs = self._chain()
|
|
460
469
|
qs.sql_query.set_limits(k, k + 1)
|
|
@@ -1136,9 +1145,8 @@ class QuerySet[T: "Model"]:
|
|
|
1136
1145
|
"""
|
|
1137
1146
|
obj = self._chain()
|
|
1138
1147
|
# Preserve cache since all() doesn't modify the query.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
obj._prefetch_done = self._prefetch_done
|
|
1148
|
+
if self._result_cache is not None:
|
|
1149
|
+
self._attach_result_cache(obj, self._result_cache)
|
|
1142
1150
|
return obj
|
|
1143
1151
|
|
|
1144
1152
|
def filter(self, *args: Any, **kwargs: Any) -> Self:
|
|
@@ -1511,6 +1519,17 @@ class QuerySet[T: "Model"]:
|
|
|
1511
1519
|
c._fields = self._fields
|
|
1512
1520
|
return c
|
|
1513
1521
|
|
|
1522
|
+
def _attach_result_cache(self, obj: Self, cache: list[T]) -> None:
|
|
1523
|
+
"""Carry a result cache onto a chained QuerySet.
|
|
1524
|
+
|
|
1525
|
+
Whenever a cache is moved onto a new QuerySet, the prefetch state
|
|
1526
|
+
must ride along with it — otherwise prefetch_related() would re-run
|
|
1527
|
+
(or be skipped). Keep both writes together here so callers can't
|
|
1528
|
+
forget the pairing.
|
|
1529
|
+
"""
|
|
1530
|
+
obj._result_cache = cache
|
|
1531
|
+
obj._prefetch_done = self._prefetch_done
|
|
1532
|
+
|
|
1514
1533
|
def _fetch_all(self) -> None:
|
|
1515
1534
|
if self._result_cache is None:
|
|
1516
1535
|
self._result_cache = list(self._iterable_class(self))
|
|
@@ -1691,6 +1710,9 @@ class RawQuerySet:
|
|
|
1691
1710
|
return f"<{self.__class__.__name__}: {self.sql_query}>"
|
|
1692
1711
|
|
|
1693
1712
|
def __getitem__(self, k: int | slice) -> Model | list[Model]:
|
|
1713
|
+
# Unlike QuerySet, a RawQuerySet is always fully materialized — there's
|
|
1714
|
+
# no lazy query to push a slice into — so indexing keeps plain list
|
|
1715
|
+
# semantics: a slice returns a list (and step/negative slicing work).
|
|
1694
1716
|
return list(self)[k]
|
|
1695
1717
|
|
|
1696
1718
|
@cached_property
|
|
@@ -344,7 +344,7 @@ class SQLCompiler:
|
|
|
344
344
|
if self.query.select_related:
|
|
345
345
|
related_klass_infos = self.get_related_selections(select, select_mask)
|
|
346
346
|
if klass_info is not None:
|
|
347
|
-
klass_info["related_klass_infos"] = related_klass_infos
|
|
347
|
+
klass_info["related_klass_infos"] = related_klass_infos
|
|
348
348
|
|
|
349
349
|
ret = []
|
|
350
350
|
col_idx = 1
|
|
@@ -24,7 +24,7 @@ def _db_disabled() -> Generator[None]:
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
def cursor_disabled(self: Any) -> None:
|
|
27
|
-
pytest.fail("Database access not allowed without the `db` fixture")
|
|
27
|
+
pytest.fail("Database access not allowed without the `db` fixture")
|
|
28
28
|
|
|
29
29
|
# Save original cursor method and replace with disabled version
|
|
30
30
|
setattr(DatabaseConnection, "_enabled_cursor", DatabaseConnection.cursor)
|
|
@@ -63,7 +63,7 @@ def setup_db(request: Any) -> Generator[None]:
|
|
|
63
63
|
@pytest.fixture
|
|
64
64
|
def db(setup_db: Any, request: Any) -> Generator[None]:
|
|
65
65
|
if "isolated_db" in request.fixturenames:
|
|
66
|
-
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
|
|
66
|
+
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
|
|
67
67
|
|
|
68
68
|
# Set .cursor() back to the original implementation to unblock it
|
|
69
69
|
DatabaseConnection.cursor = getattr(DatabaseConnection, "_enabled_cursor")
|
|
@@ -101,7 +101,7 @@ def isolated_db(request: Any) -> Generator[None]:
|
|
|
101
101
|
test database.
|
|
102
102
|
"""
|
|
103
103
|
if "db" in request.fixturenames:
|
|
104
|
-
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
|
|
104
|
+
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
|
|
105
105
|
# Set .cursor() back to the original implementation to unblock it
|
|
106
106
|
DatabaseConnection.cursor = getattr(DatabaseConnection, "_enabled_cursor")
|
|
107
107
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from plain.postgres.connection import DatabaseConnection
|
|
8
|
+
from plain.postgres.db import _db_conn, get_connection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def capture_queries():
|
|
13
|
+
"""Return a context manager that records the SQL run within its block.
|
|
14
|
+
|
|
15
|
+
with capture_queries() as queries:
|
|
16
|
+
list(qs)
|
|
17
|
+
assert len(queries) == 1
|
|
18
|
+
|
|
19
|
+
``queries`` is populated when the block exits with the executed query
|
|
20
|
+
dicts (each has a ``"sql"`` key), so inspect it after the ``with``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def _capture():
|
|
25
|
+
conn = get_connection()
|
|
26
|
+
previous = conn.force_debug_cursor
|
|
27
|
+
conn.force_debug_cursor = True
|
|
28
|
+
conn.queries_log.clear()
|
|
29
|
+
captured: list[dict] = []
|
|
30
|
+
try:
|
|
31
|
+
yield captured
|
|
32
|
+
finally:
|
|
33
|
+
captured.extend(conn.queries_log)
|
|
34
|
+
conn.force_debug_cursor = previous
|
|
35
|
+
|
|
36
|
+
return _capture
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def _unblock_cursor() -> None:
|
|
41
|
+
"""Restore the real cursor method (blocked by the autouse _db_disabled fixture)."""
|
|
42
|
+
DatabaseConnection.cursor = getattr(DatabaseConnection, "_enabled_cursor")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def _clean_connection():
|
|
47
|
+
"""Ensure the ContextVar starts empty and clean up any connection afterward."""
|
|
48
|
+
token = _db_conn.set(None)
|
|
49
|
+
yield
|
|
50
|
+
conn = _db_conn.get()
|
|
51
|
+
if conn is not None:
|
|
52
|
+
try:
|
|
53
|
+
conn.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
_db_conn.reset(token)
|
{plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_constraint_violation_error.py
RENAMED
|
@@ -12,7 +12,6 @@ from app.examples.models.constraints import ConstraintExample
|
|
|
12
12
|
from plain.exceptions import NON_FIELD_ERRORS, ValidationError
|
|
13
13
|
from plain.postgres import CheckConstraint, Q, UniqueConstraint
|
|
14
14
|
from plain.postgres.constraints import BaseConstraint
|
|
15
|
-
from plain.postgres.db import get_connection
|
|
16
15
|
from plain.postgres.expressions import F
|
|
17
16
|
from plain.postgres.forms import ModelForm
|
|
18
17
|
from plain.test import RequestFactory
|
|
@@ -172,20 +171,13 @@ def test_save_runs_full_clean_by_default(
|
|
|
172
171
|
assert ConstraintExample.query.filter(name="bad").count() == 0
|
|
173
172
|
|
|
174
173
|
|
|
175
|
-
def test_save_skips_constraint_pre_check_select(db: None) -> None:
|
|
174
|
+
def test_save_skips_constraint_pre_check_select(db: None, capture_queries) -> None:
|
|
176
175
|
"""A default save() issues only the INSERT — the per-unique-constraint
|
|
177
176
|
pre-check SELECT is gone. The database enforces the constraint and
|
|
178
177
|
save_base maps any violation."""
|
|
179
|
-
|
|
180
|
-
previous = conn.force_debug_cursor
|
|
181
|
-
conn.force_debug_cursor = True
|
|
182
|
-
conn.queries_log.clear()
|
|
183
|
-
try:
|
|
178
|
+
with capture_queries() as queries:
|
|
184
179
|
ConstraintExample(name="solo", description="row").create()
|
|
185
|
-
|
|
186
|
-
finally:
|
|
187
|
-
conn.force_debug_cursor = previous
|
|
188
|
-
assert query_count == 1, [q["sql"] for q in conn.queries_log]
|
|
180
|
+
assert len(queries) == 1, [q["sql"] for q in queries]
|
|
189
181
|
|
|
190
182
|
|
|
191
183
|
def test_save_clean_and_validate_false_skips_validation(
|
{plain_postgres-0.110.1 → plain_postgres-0.111.0}/tests/internal/test_convergence_constraints.py
RENAMED
|
@@ -1379,7 +1379,9 @@ class TestProbeTableReuse:
|
|
|
1379
1379
|
"""analyze_model creates the probe temp table once per model and reuses it
|
|
1380
1380
|
for every round-trip, instead of churning one CREATE/DROP per comparison."""
|
|
1381
1381
|
|
|
1382
|
-
def test_analyze_model_shares_one_temp_table_across_round_trips(
|
|
1382
|
+
def test_analyze_model_shares_one_temp_table_across_round_trips(
|
|
1383
|
+
self, db, capture_queries
|
|
1384
|
+
):
|
|
1383
1385
|
from plain.postgres.convergence.analysis import _PROBE_TABLE
|
|
1384
1386
|
|
|
1385
1387
|
# ConstraintExample already has a converged UNIQUE constraint (one
|
|
@@ -1398,15 +1400,12 @@ class TestProbeTableReuse:
|
|
|
1398
1400
|
)
|
|
1399
1401
|
|
|
1400
1402
|
conn = get_connection()
|
|
1401
|
-
previous = conn.force_debug_cursor
|
|
1402
|
-
conn.force_debug_cursor = True
|
|
1403
|
-
conn.queries_log.clear()
|
|
1404
1403
|
try:
|
|
1405
|
-
with
|
|
1406
|
-
|
|
1407
|
-
|
|
1404
|
+
with capture_queries() as queries:
|
|
1405
|
+
with conn.cursor() as cursor:
|
|
1406
|
+
analyze_model(conn, cursor, ConstraintExample)
|
|
1407
|
+
sqls = [q["sql"] for q in queries]
|
|
1408
1408
|
finally:
|
|
1409
|
-
conn.force_debug_cursor = previous
|
|
1410
1409
|
ConstraintExample.model_options.constraints = original
|
|
1411
1410
|
execute(
|
|
1412
1411
|
'ALTER TABLE "examples_constraintexample" DROP CONSTRAINT '
|
|
@@ -9,20 +9,6 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from app.examples.models.relationships import Tag, Widget, WidgetTag
|
|
11
11
|
|
|
12
|
-
from plain.postgres.db import get_connection
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _count_queries(fn):
|
|
16
|
-
conn = get_connection()
|
|
17
|
-
previous = conn.force_debug_cursor
|
|
18
|
-
conn.force_debug_cursor = True
|
|
19
|
-
conn.queries_log.clear()
|
|
20
|
-
try:
|
|
21
|
-
result = fn()
|
|
22
|
-
return result, len(conn.queries_log)
|
|
23
|
-
finally:
|
|
24
|
-
conn.force_debug_cursor = previous
|
|
25
|
-
|
|
26
12
|
|
|
27
13
|
def test_only_defers_unlisted_fields(db):
|
|
28
14
|
Widget.query.create(name="W", size="L")
|
|
@@ -43,11 +29,12 @@ def test_deferred_field_loads_correct_value(db):
|
|
|
43
29
|
assert widget.size == "L"
|
|
44
30
|
|
|
45
31
|
|
|
46
|
-
def test_listed_field_is_available_without_a_query(db):
|
|
32
|
+
def test_listed_field_is_available_without_a_query(db, capture_queries):
|
|
47
33
|
Widget.query.create(name="W", size="L")
|
|
48
34
|
widget = Widget.query.only("id", "name").get()
|
|
49
|
-
|
|
50
|
-
|
|
35
|
+
with capture_queries() as queries:
|
|
36
|
+
_ = widget.name
|
|
37
|
+
assert len(queries) == 0
|
|
51
38
|
|
|
52
39
|
|
|
53
40
|
def test_refresh_from_db_reloads_values(db):
|
|
@@ -57,20 +44,22 @@ def test_refresh_from_db_reloads_values(db):
|
|
|
57
44
|
assert widget.name == "changed"
|
|
58
45
|
|
|
59
46
|
|
|
60
|
-
def test_deferred_field_access_query_count(db):
|
|
47
|
+
def test_deferred_field_access_query_count(db, capture_queries):
|
|
61
48
|
# WAS: each deferred field loaded in its own query (two fields = two
|
|
62
49
|
# queries). NOW: the first deferred access hydrates every still-missing
|
|
63
50
|
# field, so the second field needs no further query.
|
|
64
51
|
Widget.query.create(name="W", size="L")
|
|
65
52
|
widget = Widget.query.only("id").get()
|
|
66
53
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
54
|
+
with capture_queries() as first:
|
|
55
|
+
_ = widget.name
|
|
56
|
+
assert len(first) == 1
|
|
57
|
+
with capture_queries() as second:
|
|
58
|
+
_ = widget.size
|
|
59
|
+
assert len(second) == 0
|
|
71
60
|
|
|
72
61
|
|
|
73
|
-
def test_deferred_fk_column_loads_only_the_fk(db):
|
|
62
|
+
def test_deferred_fk_column_loads_only_the_fk(db, capture_queries):
|
|
74
63
|
# The foreign key descriptor is the partial-related-instance fast path:
|
|
75
64
|
# hydrating other deferred columns just to materialize a related-object
|
|
76
65
|
# handle would defeat the optimization. So accessing a deferred FK loads
|
|
@@ -84,8 +73,9 @@ def test_deferred_fk_column_loads_only_the_fk(db):
|
|
|
84
73
|
assert widget_tag.get_deferred_fields() == {"widget", "tag"}
|
|
85
74
|
|
|
86
75
|
# Accessing the deferred FK loads exactly one column (the FK column).
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
with capture_queries() as queries:
|
|
77
|
+
_ = widget_tag.widget
|
|
78
|
+
assert len(queries) == 1
|
|
89
79
|
# The other deferred FK column is still deferred -- not hydrated as a
|
|
90
80
|
# side effect of the first FK access.
|
|
91
81
|
assert "tag" in widget_tag.get_deferred_fields()
|
|
@@ -376,7 +376,7 @@ def test_delete_rejects_sliced_queryset(db):
|
|
|
376
376
|
_create_parents()
|
|
377
377
|
qs = DeleteParent.query.all()[:1]
|
|
378
378
|
with pytest.raises(TypeError):
|
|
379
|
-
qs.delete()
|
|
379
|
+
qs.delete()
|
|
380
380
|
|
|
381
381
|
|
|
382
382
|
def test_delete_rejects_distinct_queryset(db):
|
|
@@ -485,25 +485,17 @@ def test_delete_already_deleted_instance_raises(db):
|
|
|
485
485
|
# ===========================================================================
|
|
486
486
|
|
|
487
487
|
|
|
488
|
-
def test_cascade_delete_issues_one_query(db):
|
|
488
|
+
def test_cascade_delete_issues_one_query(db, capture_queries):
|
|
489
489
|
"""Single-level CASCADE fires exactly one DELETE — Postgres handles the
|
|
490
490
|
cascade internally. This is the headline win of the DB-level rewrite."""
|
|
491
|
-
from plain.postgres.db import get_connection
|
|
492
|
-
|
|
493
491
|
_create_parents()
|
|
494
492
|
parent = DeleteParent.query.get(name="parent")
|
|
495
493
|
for _ in range(5):
|
|
496
494
|
ChildCascade.query.create(parent=parent)
|
|
497
495
|
|
|
498
|
-
|
|
499
|
-
prev_force = conn.force_debug_cursor
|
|
500
|
-
conn.force_debug_cursor = True
|
|
501
|
-
conn.queries_log.clear()
|
|
502
|
-
try:
|
|
496
|
+
with capture_queries() as queries:
|
|
503
497
|
parent.delete()
|
|
504
|
-
|
|
505
|
-
finally:
|
|
506
|
-
conn.force_debug_cursor = prev_force
|
|
498
|
+
query_count = len(queries)
|
|
507
499
|
|
|
508
500
|
assert query_count == 1, (
|
|
509
501
|
f"Expected one DELETE; got {query_count} queries — Collector may have "
|
|
@@ -62,11 +62,11 @@ def test_base_exceptions_from_plain_exceptions():
|
|
|
62
62
|
except ObjectDoesNotExist:
|
|
63
63
|
pass # Should work due to inheritance
|
|
64
64
|
except Exception:
|
|
65
|
-
pytest.fail("Should have caught with base ObjectDoesNotExist")
|
|
65
|
+
pytest.fail("Should have caught with base ObjectDoesNotExist")
|
|
66
66
|
|
|
67
67
|
try:
|
|
68
68
|
raise IterationExample.MultipleObjectsReturned("model-specific exception")
|
|
69
69
|
except MultipleObjectsReturned:
|
|
70
70
|
pass # Should work due to inheritance
|
|
71
71
|
except Exception:
|
|
72
|
-
pytest.fail("Should have caught with base MultipleObjectsReturned")
|
|
72
|
+
pytest.fail("Should have caught with base MultipleObjectsReturned")
|
|
@@ -2,22 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from app.examples.models.iteration import IterationExample
|
|
4
4
|
|
|
5
|
-
from plain.postgres.db import get_connection
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
def _capture_queries(callable_):
|
|
9
|
-
conn = get_connection()
|
|
10
|
-
prev_force = conn.force_debug_cursor
|
|
11
|
-
conn.force_debug_cursor = True
|
|
12
|
-
conn.queries_log.clear()
|
|
13
|
-
try:
|
|
14
|
-
callable_()
|
|
15
|
-
return list(conn.queries_log)
|
|
16
|
-
finally:
|
|
17
|
-
conn.force_debug_cursor = prev_force
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_repr_does_not_execute_sql_when_unevaluated(db):
|
|
6
|
+
def test_repr_does_not_execute_sql_when_unevaluated(db, capture_queries):
|
|
21
7
|
"""repr() of an unevaluated queryset must not issue a SQL query.
|
|
22
8
|
|
|
23
9
|
Error reporters (Sentry, pdb, exception templates) call repr() on
|
|
@@ -26,13 +12,14 @@ def test_repr_does_not_execute_sql_when_unevaluated(db):
|
|
|
26
12
|
"""
|
|
27
13
|
qs = IterationExample.query.all()
|
|
28
14
|
|
|
29
|
-
|
|
15
|
+
with capture_queries() as queries:
|
|
16
|
+
repr(qs)
|
|
30
17
|
|
|
31
18
|
assert queries == []
|
|
32
19
|
assert repr(qs) == "<QuerySet [unevaluated]>"
|
|
33
20
|
|
|
34
21
|
|
|
35
|
-
def test_repr_uses_cache_when_evaluated(db):
|
|
22
|
+
def test_repr_uses_cache_when_evaluated(db, capture_queries):
|
|
36
23
|
"""Once a queryset is evaluated, repr() reflects its rows without re-querying."""
|
|
37
24
|
IterationExample.query.create(name="alpha", tag="a")
|
|
38
25
|
IterationExample.query.create(name="beta", tag="b")
|
|
@@ -40,7 +27,8 @@ def test_repr_uses_cache_when_evaluated(db):
|
|
|
40
27
|
qs = IterationExample.query.all()
|
|
41
28
|
list(qs) # force evaluation, populates _result_cache
|
|
42
29
|
|
|
43
|
-
|
|
30
|
+
with capture_queries() as queries:
|
|
31
|
+
repr(qs)
|
|
44
32
|
|
|
45
33
|
assert queries == []
|
|
46
34
|
rendered = repr(qs)
|