plain.postgres 0.110.2__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.2 → plain_postgres-0.111.0}/PKG-INFO +1 -1
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/CHANGELOG.md +12 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/query.py +48 -26
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/pyproject.toml +1 -1
- plain_postgres-0.111.0/tests/conftest.py +56 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_constraint_violation_error.py +3 -11
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_constraints.py +7 -8
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_deferred_loading.py +15 -25
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_delete_behaviors.py +4 -12
- {plain_postgres-0.110.2 → 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.2 → plain_postgres-0.111.0}/tests/public/test_related.py +30 -37
- plain_postgres-0.110.2/tests/conftest.py +0 -26
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/.gitignore +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/CLAUDE.md +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/LICENSE +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/README.md +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/README.md +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/adapters.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/converge.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/core.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/decorators.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/diagnose.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/schema.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/sync.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/analysis.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/fixes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/planning.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/ddl.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/base.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/binary.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/boolean.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/duration.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/network.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/numeric.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/primary_key.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/temporal.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/text.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/uuid.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/random.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/uuid.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/context.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/helpers.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/ownership.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/runner.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/types.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/schema.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/middleware.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/database.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/indexes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/models.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sources.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/database.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/forms.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
- {plain_postgres-0.110.2 → 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.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/__init__.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/constraints.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/defaults.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/delete.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/encrypted.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/forms.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/indexes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/iteration.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/mixins.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/nullability.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/querysets.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/relationships.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/storage_parameters.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/trees.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/unregistered.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/urls.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/views.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/conftest_convergence.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_autodetector_type_change.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_isolation.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_pool.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_self_heal.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_defaults.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_fk.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_indexes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_nullability.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_timeouts.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_db_expression_defaults.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_diagnose.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_executor_connection_hook.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_fk_characterization.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_health.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_introspection.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_literal_default_persistence.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_management_connection.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_migration_executor.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_no_callable_defaults.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_otel_metrics.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_schema_normalize_type.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_schema_timeouts.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_create_update.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_database_url.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_encrypted_fields.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_exceptions.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_field_defaults.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_functions_uuid.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_integrity_error_mapping.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_iterator.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_m2m.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_manager_assignment.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_manual_pk.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_mixins.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_modelform_roundtrip.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_random_string_field.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_raw_query.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_read_only_transactions.py +0 -0
- {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_related_instance_filter.py +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## [0.110.2](https://github.com/dropseed/plain/releases/plain-postgres@0.110.2) (2026-06-26)
|
|
4
16
|
|
|
5
17
|
### What's changed
|
|
@@ -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
|
|
@@ -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.2 → 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.2 → 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 "
|
|
@@ -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)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from app.examples.models.iteration import IterationExample
|
|
5
|
+
|
|
6
|
+
from plain.postgres import F, QuerySet
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def rows(db):
|
|
11
|
+
for i in range(25):
|
|
12
|
+
IterationExample.query.create(name=f"name{i:02d}", tag="t")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_slice_of_unevaluated_queryset_returns_queryset(rows):
|
|
16
|
+
sliced = IterationExample.query.order_by("name")[:5]
|
|
17
|
+
|
|
18
|
+
assert isinstance(sliced, QuerySet)
|
|
19
|
+
assert [r.name for r in sliced] == [f"name{i:02d}" for i in range(5)]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_slice_of_evaluated_queryset_returns_queryset(rows):
|
|
23
|
+
"""Slicing a cached queryset returns a QuerySet, not a list.
|
|
24
|
+
|
|
25
|
+
Previously an evaluated queryset sliced to a plain list, so the return
|
|
26
|
+
type depended on whether the queryset had been iterated first.
|
|
27
|
+
"""
|
|
28
|
+
qs = IterationExample.query.order_by("name")
|
|
29
|
+
list(qs) # evaluate -> populates _result_cache
|
|
30
|
+
|
|
31
|
+
sliced = qs[:5]
|
|
32
|
+
|
|
33
|
+
assert isinstance(sliced, QuerySet)
|
|
34
|
+
assert [r.name for r in sliced] == [f"name{i:02d}" for i in range(5)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_slice_of_evaluated_queryset_does_not_requery(rows, capture_queries):
|
|
38
|
+
"""Iterating a cached slice reuses the cache instead of hitting the DB."""
|
|
39
|
+
qs = IterationExample.query.order_by("name")
|
|
40
|
+
list(qs) # evaluate
|
|
41
|
+
|
|
42
|
+
with capture_queries() as queries:
|
|
43
|
+
list(qs[:5])
|
|
44
|
+
|
|
45
|
+
assert queries == []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_cached_slice_matches_sql_slice(rows):
|
|
49
|
+
"""A cached slice yields the same rows as slicing via SQL limits."""
|
|
50
|
+
sql_slice = [r.name for r in IterationExample.query.order_by("name")[3:9]]
|
|
51
|
+
|
|
52
|
+
evaluated = IterationExample.query.order_by("name")
|
|
53
|
+
list(evaluated) # evaluate
|
|
54
|
+
cache_slice = [r.name for r in evaluated[3:9]]
|
|
55
|
+
|
|
56
|
+
assert cache_slice == sql_slice == [f"name{i:02d}" for i in range(3, 9)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_reslicing_cached_slice_composes(rows):
|
|
60
|
+
"""Re-slicing a cached slice composes offsets correctly."""
|
|
61
|
+
qs = IterationExample.query.order_by("name")
|
|
62
|
+
list(qs)
|
|
63
|
+
|
|
64
|
+
inner = qs[5:15][2:4]
|
|
65
|
+
|
|
66
|
+
assert isinstance(inner, QuerySet)
|
|
67
|
+
assert [r.name for r in inner] == [f"name{i:02d}" for i in range(7, 9)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_filtering_a_cached_slice_raises_like_any_slice(rows):
|
|
71
|
+
"""A cached slice obeys the same "no filtering after slicing" rule."""
|
|
72
|
+
qs = IterationExample.query.order_by("name")
|
|
73
|
+
list(qs)
|
|
74
|
+
sliced = qs[:5]
|
|
75
|
+
|
|
76
|
+
with pytest.raises(TypeError, match="once a slice has been taken"):
|
|
77
|
+
sliced.filter(tag="t")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_step_slicing_raises_when_unevaluated(rows):
|
|
81
|
+
with pytest.raises(ValueError, match="Step slicing is not supported"):
|
|
82
|
+
IterationExample.query.all()[::2]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_step_slicing_raises_when_evaluated(rows):
|
|
86
|
+
qs = IterationExample.query.all()
|
|
87
|
+
list(qs)
|
|
88
|
+
|
|
89
|
+
with pytest.raises(ValueError, match="Step slicing is not supported"):
|
|
90
|
+
qs[::2]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_negative_slicing_raises(rows):
|
|
94
|
+
with pytest.raises(ValueError, match="Negative indexing is not supported"):
|
|
95
|
+
IterationExample.query.all()[-1:]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_slice_then_annotate_works_regardless_of_evaluation(rows):
|
|
99
|
+
"""A function slicing then annotating behaves the same either way.
|
|
100
|
+
|
|
101
|
+
This is the motivating case: previously, passing an already-evaluated
|
|
102
|
+
queryset made `qs[:n]` a list and the `.annotate()` raised
|
|
103
|
+
AttributeError. Now it works whether or not the caller evaluated first.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def slice_then_annotate(qs):
|
|
107
|
+
return qs[:5].annotate(name2=F("name"))
|
|
108
|
+
|
|
109
|
+
unevaluated = IterationExample.query.order_by("name")
|
|
110
|
+
from_unevaluated = [r.name2 for r in slice_then_annotate(unevaluated)]
|
|
111
|
+
|
|
112
|
+
evaluated = IterationExample.query.order_by("name")
|
|
113
|
+
list(evaluated) # evaluate first
|
|
114
|
+
from_evaluated = [r.name2 for r in slice_then_annotate(evaluated)]
|
|
115
|
+
|
|
116
|
+
assert from_unevaluated == from_evaluated == [f"name{i:02d}" for i in range(5)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_values_list_cached_slice_reuses_cache(rows, capture_queries):
|
|
120
|
+
"""A cached values_list() slice stays a QuerySet and reuses the cache.
|
|
121
|
+
|
|
122
|
+
values()/values_list() use a different iterable class than the default
|
|
123
|
+
model iterable, so exercise that the cache-carry path works for it too.
|
|
124
|
+
"""
|
|
125
|
+
qs = IterationExample.query.order_by("name").values_list("name", flat=True)
|
|
126
|
+
list(qs) # evaluate -> cache holds the values
|
|
127
|
+
|
|
128
|
+
sliced = qs[:3]
|
|
129
|
+
with capture_queries() as queries:
|
|
130
|
+
list(sliced)
|
|
131
|
+
|
|
132
|
+
assert isinstance(sliced, QuerySet)
|
|
133
|
+
assert queries == []
|
|
134
|
+
assert list(sliced) == [f"name{i:02d}" for i in range(3)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_integer_index_on_cached_queryset_returns_item(rows):
|
|
138
|
+
qs = IterationExample.query.order_by("name")
|
|
139
|
+
list(qs)
|
|
140
|
+
|
|
141
|
+
item = qs[0]
|
|
142
|
+
|
|
143
|
+
assert isinstance(item, IterationExample)
|
|
144
|
+
assert item.name == "name00"
|