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.
Files changed (224) hide show
  1. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/PKG-INFO +1 -1
  2. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/CHANGELOG.md +12 -0
  3. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/query.py +48 -26
  4. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/pyproject.toml +1 -1
  5. plain_postgres-0.111.0/tests/conftest.py +56 -0
  6. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_constraint_violation_error.py +3 -11
  7. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_constraints.py +7 -8
  8. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_deferred_loading.py +15 -25
  9. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_delete_behaviors.py +4 -12
  10. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_queryset_repr.py +6 -18
  11. plain_postgres-0.111.0/tests/public/test_queryset_slicing.py +144 -0
  12. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_related.py +30 -37
  13. plain_postgres-0.110.2/tests/conftest.py +0 -26
  14. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/.gitignore +0 -0
  15. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/CLAUDE.md +0 -0
  16. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/LICENSE +0 -0
  17. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/README.md +0 -0
  18. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/README.md +0 -0
  19. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/__init__.py +0 -0
  20. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/adapters.py +0 -0
  21. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  22. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  23. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/aggregates.py +0 -0
  24. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/base.py +0 -0
  25. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/__init__.py +0 -0
  26. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/converge.py +0 -0
  27. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/core.py +0 -0
  28. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/decorators.py +0 -0
  29. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/diagnose.py +0 -0
  30. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/migrations.py +0 -0
  31. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/schema.py +0 -0
  32. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/cli/sync.py +0 -0
  33. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/config.py +0 -0
  34. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/connection.py +0 -0
  35. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/constants.py +0 -0
  36. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/constraints.py +0 -0
  37. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/__init__.py +0 -0
  38. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/analysis.py +0 -0
  39. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/fixes.py +0 -0
  40. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/convergence/planning.py +0 -0
  41. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/database_url.py +0 -0
  42. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/db.py +0 -0
  43. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/ddl.py +0 -0
  44. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/default_settings.py +0 -0
  45. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/deletion.py +0 -0
  46. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/dialect.py +0 -0
  47. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/entrypoints.py +0 -0
  48. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/enums.py +0 -0
  49. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/exceptions.py +0 -0
  50. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/expressions.py +0 -0
  51. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/__init__.py +0 -0
  52. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/base.py +0 -0
  53. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/binary.py +0 -0
  54. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/boolean.py +0 -0
  55. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/duration.py +0 -0
  56. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/encrypted.py +0 -0
  57. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/json.py +0 -0
  58. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/mixins.py +0 -0
  59. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/network.py +0 -0
  60. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/numeric.py +0 -0
  61. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/primary_key.py +0 -0
  62. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related.py +0 -0
  63. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_descriptors.py +0 -0
  64. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_lookups.py +0 -0
  65. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/related_managers.py +0 -0
  66. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  67. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/reverse_related.py +0 -0
  68. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/temporal.py +0 -0
  69. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/text.py +0 -0
  70. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/timezones.py +0 -0
  71. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/fields/uuid.py +0 -0
  72. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/forms.py +0 -0
  73. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/__init__.py +0 -0
  74. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/comparison.py +0 -0
  75. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/datetime.py +0 -0
  76. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/math.py +0 -0
  77. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/mixins.py +0 -0
  78. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/random.py +0 -0
  79. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/text.py +0 -0
  80. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/uuid.py +0 -0
  81. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/functions/window.py +0 -0
  82. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/indexes.py +0 -0
  83. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/__init__.py +0 -0
  84. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/__init__.py +0 -0
  85. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  86. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  87. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  88. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/context.py +0 -0
  89. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/helpers.py +0 -0
  90. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/ownership.py +0 -0
  91. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/runner.py +0 -0
  92. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/health/types.py +0 -0
  93. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/introspection/schema.py +0 -0
  94. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/lookups.py +0 -0
  95. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/meta.py +0 -0
  96. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/middleware.py +0 -0
  97. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/__init__.py +0 -0
  98. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/autodetector.py +0 -0
  99. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/exceptions.py +0 -0
  100. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/executor.py +0 -0
  101. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/graph.py +0 -0
  102. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/loader.py +0 -0
  103. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/migration.py +0 -0
  104. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  105. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/base.py +0 -0
  106. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/fields.py +0 -0
  107. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/models.py +0 -0
  108. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/operations/special.py +0 -0
  109. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/optimizer.py +0 -0
  110. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/questioner.py +0 -0
  111. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/recorder.py +0 -0
  112. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/serializer.py +0 -0
  113. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/state.py +0 -0
  114. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/utils.py +0 -0
  115. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/migrations/writer.py +0 -0
  116. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/options.py +0 -0
  117. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/otel.py +0 -0
  118. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/__init__.py +0 -0
  119. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/database.py +0 -0
  120. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/indexes.py +0 -0
  121. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/preflight/models.py +0 -0
  122. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/query_utils.py +0 -0
  123. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/registry.py +0 -0
  124. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/schema.py +0 -0
  125. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sources.py +0 -0
  126. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/__init__.py +0 -0
  127. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/compiler.py +0 -0
  128. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/constants.py +0 -0
  129. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/datastructures.py +0 -0
  130. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/query.py +0 -0
  131. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/sql/where.py +0 -0
  132. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/__init__.py +0 -0
  133. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/database.py +0 -0
  134. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/test/pytest.py +0 -0
  135. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/transaction.py +0 -0
  136. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/types.py +0 -0
  137. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/types.pyi +0 -0
  138. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/plain/postgres/utils.py +0 -0
  139. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/forms.py +0 -0
  140. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  141. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  142. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  143. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  144. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  145. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  146. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  147. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  148. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  149. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  150. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  151. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  152. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  153. {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
  154. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  155. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  156. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  157. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  158. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/migrations/__init__.py +0 -0
  159. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/__init__.py +0 -0
  160. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/constraints.py +0 -0
  161. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/defaults.py +0 -0
  162. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/delete.py +0 -0
  163. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/encrypted.py +0 -0
  164. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/forms.py +0 -0
  165. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/indexes.py +0 -0
  166. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/iteration.py +0 -0
  167. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/mixins.py +0 -0
  168. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/nullability.py +0 -0
  169. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/querysets.py +0 -0
  170. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/relationships.py +0 -0
  171. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/storage_parameters.py +0 -0
  172. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/trees.py +0 -0
  173. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/models/unregistered.py +0 -0
  174. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/urls.py +0 -0
  175. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/examples/views.py +0 -0
  176. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/settings.py +0 -0
  177. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/app/urls.py +0 -0
  178. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/conftest_convergence.py +0 -0
  179. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  180. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_autodetector_type_change.py +0 -0
  181. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_isolation.py +0 -0
  182. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_lifecycle.py +0 -0
  183. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_pool.py +0 -0
  184. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_connection_self_heal.py +0 -0
  185. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence.py +0 -0
  186. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_defaults.py +0 -0
  187. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_fk.py +0 -0
  188. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_indexes.py +0 -0
  189. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_nullability.py +0 -0
  190. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  191. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_convergence_timeouts.py +0 -0
  192. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_db_expression_defaults.py +0 -0
  193. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_diagnose.py +0 -0
  194. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_executor_connection_hook.py +0 -0
  195. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_fk_characterization.py +0 -0
  196. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_health.py +0 -0
  197. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_introspection.py +0 -0
  198. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_literal_default_persistence.py +0 -0
  199. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_management_connection.py +0 -0
  200. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_migration_executor.py +0 -0
  201. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_no_callable_defaults.py +0 -0
  202. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_otel_metrics.py +0 -0
  203. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  204. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  205. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  206. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_schema_normalize_type.py +0 -0
  207. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/internal/test_schema_timeouts.py +0 -0
  208. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_create_update.py +0 -0
  209. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_database_url.py +0 -0
  210. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_encrypted_fields.py +0 -0
  211. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_exceptions.py +0 -0
  212. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_field_defaults.py +0 -0
  213. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_functions_uuid.py +0 -0
  214. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_integrity_error_mapping.py +0 -0
  215. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_iterator.py +0 -0
  216. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_m2m.py +0 -0
  217. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_manager_assignment.py +0 -0
  218. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_manual_pk.py +0 -0
  219. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_mixins.py +0 -0
  220. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_modelform_roundtrip.py +0 -0
  221. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_random_string_field.py +0 -0
  222. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_raw_query.py +0 -0
  223. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_read_only_transactions.py +0 -0
  224. {plain_postgres-0.110.2 → plain_postgres-0.111.0}/tests/public/test_related_instance_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.110.2
3
+ Version: 0.111.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -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] | list[T]: ...
426
+ def __getitem__(self, k: slice) -> QuerySet[T]: ...
427
427
 
428
- def __getitem__(self, k: int | slice) -> T | QuerySet[T] | list[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
- start = int(k.start)
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
- return list(qs)[:: k.step] if k.step else qs
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
- # This is important for prefetch_related() to work correctly.
1140
- obj._result_cache = self._result_cache
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.110.2"
3
+ version = "0.111.0"
4
4
  description = "Model your data and store it in a database."
5
5
  authors = [{ name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev" }]
6
6
  readme = "README.md"
@@ -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)
@@ -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
- conn = get_connection()
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
- query_count = len(conn.queries_log)
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(
@@ -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(self, db):
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 conn.cursor() as cursor:
1406
- analyze_model(conn, cursor, ConstraintExample)
1407
- sqls = [q["sql"] for q in conn.queries_log]
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
- _, queries = _count_queries(lambda: widget.name)
50
- assert queries == 0
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
- _, first = _count_queries(lambda: widget.name)
68
- assert first == 1
69
- _, second = _count_queries(lambda: widget.size)
70
- assert second == 0
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
- _, queries = _count_queries(lambda: widget_tag.widget)
88
- assert queries == 1
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() # ty: ignore[unresolved-attribute]
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
- conn = get_connection()
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
- query_count = len(conn.queries_log)
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
- queries = _capture_queries(lambda: repr(qs))
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
- queries = _capture_queries(lambda: repr(qs))
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"