django-data-shape 0.17.0__tar.gz → 0.18.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 (175) hide show
  1. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/CHANGELOG.md +61 -1
  2. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/PKG-INFO +1 -1
  3. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/build.py +41 -16
  4. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/projection.py +62 -0
  5. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/scaled_world.py +40 -10
  6. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/utils.py +25 -0
  7. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/version.py +1 -1
  8. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/projections.md +49 -0
  9. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/pyproject.toml +1 -1
  10. django_data_shape-0.18.0/tests/test_projection_ceiling.py +149 -0
  11. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_scaled_world.py +9 -3
  12. django_data_shape-0.18.0/tests/test_scaled_world_restores_sequences.py +66 -0
  13. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/CODEOWNERS +0 -0
  14. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/SECURITY.md +0 -0
  15. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/dependabot.yml +0 -0
  16. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/workflows/release.yml +0 -0
  17. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/workflows/tests.yml +0 -0
  18. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.github/workflows/upstream-drift.yml +0 -0
  19. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.gitignore +0 -0
  20. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/.pre-commit-config.yaml +0 -0
  21. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/CLAUDE.md +0 -0
  22. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/LICENSE +0 -0
  23. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/Makefile +0 -0
  24. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/README.md +0 -0
  25. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/__init__.py +0 -0
  26. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/apply_statistics_targets.py +0 -0
  27. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/build_result.py +0 -0
  28. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/canonical.py +0 -0
  29. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/check_constraints.py +0 -0
  30. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/check_invariants.py +0 -0
  31. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/children_per_parent.py +0 -0
  32. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/clone_database.py +0 -0
  33. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivation_queried_database.py +0 -0
  34. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/__init__.py +0 -0
  35. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/after.py +0 -0
  36. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/aligned.py +0 -0
  37. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/copied.py +0 -0
  38. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/derivation.py +0 -0
  39. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/derived.py +0 -0
  40. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/given.py +0 -0
  41. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/offset.py +0 -0
  42. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/per_parent.py +0 -0
  43. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/product.py +0 -0
  44. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/derivations/scope.py +0 -0
  45. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/__init__.py +0 -0
  46. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/ascending.py +0 -0
  47. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/bounded.py +0 -0
  48. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/categorical.py +0 -0
  49. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/constant.py +0 -0
  50. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/distinct.py +0 -0
  51. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/distribution.py +0 -0
  52. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/sequential.py +0 -0
  53. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/skew.py +0 -0
  54. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/uniform.py +0 -0
  55. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/distributions/zipf.py +0 -0
  56. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/drop_database.py +0 -0
  57. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fan_out.py +0 -0
  58. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fan_out_plan.py +0 -0
  59. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fan_out_sizes.py +0 -0
  60. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fixtures/__init__.py +0 -0
  61. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  62. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  63. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  64. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/generate_rows.py +0 -0
  65. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/infer_key_strategy.py +0 -0
  66. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/invalid_shape.py +0 -0
  67. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/invariant.py +0 -0
  68. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/invariant_violated.py +0 -0
  69. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/__init__.py +0 -0
  70. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/disjoint.py +0 -0
  71. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/key_function.py +0 -0
  72. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/key_strategy.py +0 -0
  73. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/md5_keys.py +0 -0
  74. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/sequential_keys.py +0 -0
  75. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/sql_keys.py +0 -0
  76. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/keys/uuid_keys.py +0 -0
  77. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/order_derivations.py +0 -0
  78. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/order_tables.py +0 -0
  79. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/paired.py +0 -0
  80. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/paired_plan.py +0 -0
  81. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/py.typed +0 -0
  82. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/refuse_queries.py +0 -0
  83. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/require_clone_strategy.py +0 -0
  84. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/require_postgres.py +0 -0
  85. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/resolve_fan_out.py +0 -0
  86. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/resolve_paired.py +0 -0
  87. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/scale_protocol.py +0 -0
  88. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/scaled_shape.py +0 -0
  89. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/shape.py +0 -0
  90. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/shape_digest.py +0 -0
  91. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/shape_from_factory.py +0 -0
  92. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/shape_not_empty.py +0 -0
  93. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/table.py +0 -0
  94. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/table_result.py +0 -0
  95. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/template_database.py +0 -0
  96. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/unhashable_shape.py +0 -0
  97. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/unsupported_backend.py +0 -0
  98. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/django_data_shape/world_changed.py +0 -0
  99. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/derivations.md +0 -0
  100. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/index.md +0 -0
  101. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/invariants.md +0 -0
  102. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/keys.md +0 -0
  103. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/pytest.md +0 -0
  104. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/reference.md +0 -0
  105. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/relations.md +0 -0
  106. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/docs/statistics.md +0 -0
  107. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/mkdocs.yml +0 -0
  108. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/scripts/check-changelog +0 -0
  109. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/scripts/release-publish.sh +0 -0
  110. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/__init__.py +0 -0
  111. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/conftest.py +0 -0
  112. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/conftest_settings.py +0 -0
  113. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/__init__.py +0 -0
  114. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_after.py +0 -0
  115. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_after_units.py +0 -0
  116. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_aligned.py +0 -0
  117. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
  118. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_derived.py +0 -0
  119. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_given.py +0 -0
  120. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/derivations/test_per_parent.py +0 -0
  121. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/__init__.py +0 -0
  122. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/test_constant.py +0 -0
  123. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/test_sequential.py +0 -0
  124. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/test_skew.py +0 -0
  125. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/test_uniform.py +0 -0
  126. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/distributions/test_zipf.py +0 -0
  127. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/keys/__init__.py +0 -0
  128. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/keys/test_key_function.py +0 -0
  129. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/keys/test_md5_keys.py +0 -0
  130. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/keys/test_sequential_keys.py +0 -0
  131. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/keys/test_uuid_keys.py +0 -0
  132. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/scale_protocol_consumers.py +0 -0
  133. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/scale_protocol_impostors.py +0 -0
  134. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_apply_statistics_targets.py +0 -0
  135. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build.py +0 -0
  136. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_derivations.py +0 -0
  137. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_graph.py +0 -0
  138. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_invariants.py +0 -0
  139. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_keys.py +0 -0
  140. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_m2m.py +0 -0
  141. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_portable.py +0 -0
  142. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_build_projection.py +0 -0
  143. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_check_constraints.py +0 -0
  144. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_children_per_parent.py +0 -0
  145. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_documentation.py +0 -0
  146. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_fan_out.py +0 -0
  147. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_fan_out_sizes.py +0 -0
  148. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_fixtures.py +0 -0
  149. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_generate_rows.py +0 -0
  150. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_infer_key_strategy.py +0 -0
  151. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_invariant.py +0 -0
  152. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_order_derivations.py +0 -0
  153. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_order_tables.py +0 -0
  154. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_paired.py +0 -0
  155. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_paired_plan.py +0 -0
  156. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_projection.py +0 -0
  157. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  158. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_refuse_queries.py +0 -0
  159. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_require_clone_strategy.py +0 -0
  160. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_require_postgres.py +0 -0
  161. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_resolve_fan_out.py +0 -0
  162. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_resolve_paired.py +0 -0
  163. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_scale_protocol.py +0 -0
  164. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_scaled_shape.py +0 -0
  165. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_scaled_world_over_a_session_world.py +0 -0
  166. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_shape.py +0 -0
  167. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_shape_digest.py +0 -0
  168. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_shape_from_factory.py +0 -0
  169. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_table.py +0 -0
  170. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_template_database.py +0 -0
  171. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_utils.py +0 -0
  172. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/test_version.py +0 -0
  173. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/testapp/__init__.py +0 -0
  174. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/tests/testapp/models.py +0 -0
  175. {django_data_shape-0.17.0 → django_data_shape-0.18.0}/uv.lock +0 -0
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] — 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **`Projection(..., max_rows=N)`: a declared ceiling, checked before the insert.**
15
+
16
+ A projection is the one declaration with no `rows=`, deliberately -- its
17
+ cardinality comes from the join, which is what reproduces a correlation a
18
+ `FanOut` on the child would destroy. The consequence is that the largest table
19
+ in a database can be the one nobody declared a size for, and it grows as a
20
+ **product**: when both sides of the join fan out over the same parents, the
21
+ busy parents multiply, so raising either declared count by four grows the
22
+ result by sixteen. A consumer measured 2,413,223 rows against a declaration
23
+ whose largest number was 300,000.
24
+
25
+ The count is taken first and compared, so a declaration that has run away
26
+ costs a scan of the join rather than the time to write every row of it. It is
27
+ exact rather than estimated: the derived form counts the same join the insert
28
+ selects from, and a `sql=` projection is counted by wrapping the caller's own
29
+ select, because this package cannot know what that statement is one row per.
30
+
31
+ The refusal names the number it would have written and the tables the join is
32
+ over, because the surprise is never the ceiling -- a reader told only that a
33
+ limit was exceeded still has to work out which of the two counts moved.
34
+
35
+ **A declaration that does not ask is not charged for the answer**: with no
36
+ ceiling, no count is taken. There is no default ceiling and there will not be
37
+ one -- how many rows is too many is a judgement about size, which this package
38
+ does not make on a caller's behalf anywhere else either.
39
+
40
+
41
+ ## [0.17.1] — 2026-09-05
42
+
43
+ ### Fixed
44
+
45
+ - **A scaled world left the identity sequence pointing at rows that came back**,
46
+ a regression introduced by 0.17.0 and found by the consumer that prompted it.
47
+
48
+ 0.17.0 made `scaled_world` empty the tables its shape declares, so a session
49
+ world could sit under one, and the rows come back because the emptying happens
50
+ inside the transaction it rolls back. The **sequence** does not: `setval` is
51
+ not transactional, so the counter kept whatever the scaled build moved it to.
52
+ A scaled world is usually *smaller* than the session world it was built over,
53
+ which left the counter below the ids that had just returned.
54
+
55
+ The symptom was an `IntegrityError` on a primary key, in a later test, for a
56
+ row the failing test never wrote.
57
+
58
+ The sequences are now recomputed once the transaction has ended -- from
59
+ `max(pk)` of whatever actually survived, rather than from a number captured on
60
+ the way in, so it is correct in both directions and correct too when the
61
+ caller's block raised partway through the build.
62
+
63
+ The Postgres statement-count constant moved from 17 to 19: one reset per
64
+ declared table, a property of the declaration rather than the factor, so what
65
+ those tests pin is unchanged. The portable constant did not move, because
66
+ Django emits no sequence reset for a SQLite table without `AUTOINCREMENT`.
67
+
10
68
  ## [0.17.0] — 2026-09-05
11
69
 
12
70
  ### Added
@@ -1115,7 +1173,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1115
1173
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1116
1174
  first.
1117
1175
 
1118
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.17.0...HEAD
1176
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.18.0...HEAD
1177
+ [0.18.0]: https://github.com/Artui/django-data-shape/compare/v0.17.1...v0.18.0
1178
+ [0.17.1]: https://github.com/Artui/django-data-shape/compare/v0.17.0...v0.17.1
1119
1179
  [0.17.0]: https://github.com/Artui/django-data-shape/compare/v0.16.0...v0.17.0
1120
1180
  [0.16.0]: https://github.com/Artui/django-data-shape/compare/v0.15.0...v0.16.0
1121
1181
  [0.15.0]: https://github.com/Artui/django-data-shape/compare/v0.14.0...v0.15.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.17.0
3
+ Version: 0.18.0
4
4
  Summary: A realistically shaped test database from Django models: declare cardinality, skew and fan-out, load by COPY, and make the query planner believe it.
5
5
  Project-URL: Homepage, https://github.com/Artui/django-data-shape
6
6
  Project-URL: Repository, https://github.com/Artui/django-data-shape
@@ -8,7 +8,6 @@ from functools import partial
8
8
  from itertools import islice
9
9
  from typing import Any, cast
10
10
 
11
- from django.core.management.color import no_style
12
11
  from django.db import DEFAULT_DB_ALIAS, connections, transaction
13
12
  from django.db.models import Model
14
13
 
@@ -32,6 +31,7 @@ from django_data_shape.shape import Shape
32
31
  from django_data_shape.shape_not_empty import ShapeNotEmpty
33
32
  from django_data_shape.table import Table
34
33
  from django_data_shape.table_result import TableResult
34
+ from django_data_shape.utils import reset_sequence
35
35
 
36
36
  # Big enough that the per-statement overhead disappears and small enough that
37
37
  # the peak list is a rounding error next to the rows themselves.
@@ -120,7 +120,7 @@ def build(
120
120
  else:
121
121
  plans, pairings = _resolve(connection, table, shape.seed)
122
122
  loaded = _load(connection, table, shape.seed, plans, pairings)
123
- _reset_sequence(connection, table.model)
123
+ reset_sequence(connection, table.model)
124
124
  _analyze(connection, table.db_table)
125
125
  results.append(TableResult(table=table.db_table, rows=loaded))
126
126
  # The second of the three nets, and the only one that covers a rule the
@@ -133,6 +133,44 @@ def build(
133
133
  return BuildResult(tables=tuple(results))
134
134
 
135
135
 
136
+ def _require_within_ceiling(connection: Any, projection: Projection) -> None:
137
+ """Refuse a projection that has outgrown its declared ceiling, before it runs.
138
+
139
+ Before rather than after, which is the whole value: the count costs a
140
+ scan of the join and the insert costs writing every row of it, so a
141
+ declaration that has run away is stopped for the price of the cheaper half.
142
+ A ceiling that is not declared costs nothing at all -- no count is taken.
143
+
144
+ The message names the number it would have written and the tables the join
145
+ is over, because the surprise here is never the ceiling. A projection's size
146
+ is a *product*: when both sides fan out over the same parents, the busy
147
+ parents multiply, and raising either declared count by four grows the result
148
+ by sixteen. A reader who is told only that a limit was exceeded has to go
149
+ and work out which of the two counts moved.
150
+ """
151
+ ceiling = projection.max_rows
152
+ if ceiling is None:
153
+ return
154
+ statement, params = projection.count_statement(connection)
155
+ with connection.cursor() as cursor:
156
+ cursor.execute(statement, params)
157
+ row = cursor.fetchone()
158
+ would_insert = int(row[0])
159
+ if would_insert <= ceiling:
160
+ return
161
+ over = ", ".join(model.__name__ for model in projection.reads)
162
+ raise InvalidShape(
163
+ f"The projection into {projection.db_table} would insert {would_insert:,} rows, "
164
+ f"which is over its declared max_rows={ceiling:,}. Nothing was written. "
165
+ f"A projection has no row count of its own: its size is one row per pair along "
166
+ f"the join it copies, over {over or 'the tables its statement selects from'}. "
167
+ "That size is a product, so when both sides of the join fan out over the same "
168
+ "parents the busy parents multiply and raising either declared count by four "
169
+ "grows this by sixteen. Either the ceiling is too low, or one of those counts "
170
+ "moved further than it looked."
171
+ )
172
+
173
+
136
174
  def _project(connection: Any, projection: Projection, seed: int) -> int:
137
175
  """Fill a table from tables already built, with one ``INSERT ... SELECT``.
138
176
 
@@ -148,6 +186,7 @@ def _project(connection: Any, projection: Projection, seed: int) -> int:
148
186
  one of the few that cannot happen at declaration time, because what it
149
187
  depends on lives in the other tables rather than in the declaration.
150
188
  """
189
+ _require_within_ceiling(connection, projection)
151
190
  statement, params = projection.statement(connection, seed)
152
191
  with connection.cursor() as cursor:
153
192
  cursor.execute(statement, params)
@@ -380,20 +419,6 @@ def _insert(
380
419
  return loaded
381
420
 
382
421
 
383
- def _reset_sequence(connection: Any, model: type[Model]) -> None:
384
- """Move the identity sequence past the keys this package just assigned.
385
-
386
- Skipping this is the first bug the design invites: rows exist at ids 1..N
387
- while the sequence still starts at 1, so the very first ``objects.create()``
388
- inside a test raises ``IntegrityError`` on a primary key that is already
389
- taken. Django's own backend operation is used rather than a hand-written
390
- ``setval`` because it already knows how the column's sequence is named.
391
- """
392
- with connection.cursor() as cursor:
393
- for statement in connection.ops.sequence_reset_sql(no_style(), [model]):
394
- cursor.execute(statement)
395
-
396
-
397
422
  def _analyze(connection: Any, db_table: str) -> None:
398
423
  """Populate the statistics the planner reads.
399
424
 
@@ -177,6 +177,7 @@ class Projection:
177
177
  reads: Sequence[type[Model]] = (),
178
178
  keys: KeyStrategy | None = None,
179
179
  statistics: Mapping[str, int] | None = None,
180
+ max_rows: int | None = None,
180
181
  ) -> None:
181
182
  self._model = model
182
183
  self._per = per
@@ -186,6 +187,16 @@ class Projection:
186
187
  self._params = tuple(params)
187
188
  self._reads: tuple[type[Model], ...] = tuple(reads)
188
189
  self._statistics = dict(statistics or {})
190
+ # A bool is an int as far as isinstance is concerned, and a ceiling of
191
+ # one row is never what ``max_rows=True`` meant. Refused here rather
192
+ # than compared later, where it would silently be a very low ceiling.
193
+ if max_rows is not None and (isinstance(max_rows, bool) or max_rows < 1):
194
+ raise InvalidShape(
195
+ f"{model.__name__} declares max_rows={max_rows!r}. A ceiling is a whole "
196
+ "number of rows and at least one, because a projection that inserts "
197
+ "nothing is already refused."
198
+ )
199
+ self._max_rows = max_rows
189
200
  self._copied: tuple[tuple[str, str, str], ...] = ()
190
201
  self._literals: tuple[tuple[str, object], ...] = ()
191
202
  self._columns: tuple[str, ...] = ()
@@ -246,6 +257,31 @@ class Projection:
246
257
  return self._reads
247
258
  return (cast("type[Model]", self._per), cast("type[Model]", self._copying))
248
259
 
260
+ @property
261
+ def max_rows(self) -> int | None:
262
+ """The largest number of rows this declaration is willing to insert.
263
+
264
+ ``None``, and no count is taken -- a declaration that does not ask is
265
+ not charged for the answer.
266
+
267
+ It exists because a projection is the one declaration with no ``rows=``,
268
+ and that is deliberate: its cardinality comes from the join, which is
269
+ what reproduces a correlation a ``FanOut`` on the child would destroy.
270
+ The consequence is that the largest table in a database can be the one
271
+ nobody declared a size for. **Its size is a product**, so when both
272
+ sides of the join fan out over the same parents the busy parents
273
+ multiply: raise either declared count by four and the projection grows
274
+ by sixteen. A consumer measured 2,413,223 rows against a declaration
275
+ whose largest number was 300,000.
276
+
277
+ There is no default ceiling and there will not be one. How many rows is
278
+ too many is a judgement about size, which this package does not make on
279
+ a caller's behalf anywhere else either -- see the statistics target,
280
+ declared for the same reason. What it can do is act on the caller's own
281
+ number, before the expensive statement rather than after it.
282
+ """
283
+ return self._max_rows
284
+
249
285
  def statement(self, connection: Any, seed: int) -> tuple[str, tuple[object, ...]]:
250
286
  """The ``INSERT ... SELECT`` this declaration means, and its parameters.
251
287
 
@@ -266,6 +302,32 @@ class Projection:
266
302
  defaults = tuple(value for _column, value in self._literals)
267
303
  return f"{into} {self._select(quote, seed)}", defaults
268
304
 
305
+ def count_statement(self, connection: Any) -> tuple[str, tuple[object, ...]]:
306
+ """How many rows the insert would write, without writing them.
307
+
308
+ Exact rather than estimated: it counts the same join the insert selects
309
+ from, and the select is one row per joined pair, so the two agree by
310
+ construction. The derived form counts the join directly rather than
311
+ wrapping the select -- the window function and the ordering decide what
312
+ the rows *are* and cost real time, while the question here is only how
313
+ many. A ``sql=`` projection is wrapped instead, because this package
314
+ cannot know what the caller's statement is one row per.
315
+ """
316
+ quote = connection.ops.quote_name
317
+ if self._sql is not None:
318
+ return f"SELECT count(*) FROM ({self._sql}) AS {quote('counted')}", self._params
319
+ per = cast("type[Model]", self._per)
320
+ source = cast("type[Model]", self._copying)
321
+ per_alias, source_alias = quote(_PER), quote(_SOURCE)
322
+ per_link, source_link = self._join
323
+ return (
324
+ f"SELECT count(*) "
325
+ f"FROM {quote(per._meta.db_table)} AS {per_alias} "
326
+ f"INNER JOIN {quote(source._meta.db_table)} AS {source_alias} "
327
+ f"ON {source_alias}.{quote(source_link)} = {per_alias}.{quote(per_link)}",
328
+ (),
329
+ )
330
+
269
331
  def _select(self, quote: Any, seed: int) -> str:
270
332
  """The derived select: the key, the copied columns, then the join."""
271
333
  per = cast("type[Model]", self._per)
@@ -11,6 +11,7 @@ from django_data_shape.build import build
11
11
  from django_data_shape.keys.disjoint import Disjoint
12
12
  from django_data_shape.scaled_shape import scaled_shape
13
13
  from django_data_shape.shape import Shape
14
+ from django_data_shape.utils import reset_sequence
14
15
 
15
16
 
16
17
  @contextmanager
@@ -83,16 +84,32 @@ def scaled_world(shape: Shape, factor: int, *, using: str = DEFAULT_DB_ALIAS) ->
83
84
  after a world is torn down gets a larger id than it otherwise would.
84
85
  """
85
86
  scaled = scaled_shape(shape, factor)
86
- with transaction.atomic(using=using):
87
- _empty_declared_tables(scaled, using)
88
- result = build(scaled, using=using, require_statistics=False)
89
- yield result.rows
90
- # Rolling back on the way out rather than raising and swallowing an
91
- # exception to get there: set_rollback is the supported way to leave an
92
- # atomic block without keeping it. An exception from the caller's block
93
- # never reaches this line and does not need to -- atomic rolls back for
94
- # exactly that case already.
95
- transaction.set_rollback(True, using=using)
87
+ try:
88
+ with transaction.atomic(using=using):
89
+ _empty_declared_tables(scaled, using)
90
+ result = build(scaled, using=using, require_statistics=False)
91
+ yield result.rows
92
+ # Rolling back on the way out rather than raising and swallowing
93
+ # an exception to get there: set_rollback is the supported way to
94
+ # leave an atomic block without keeping it. An exception from the
95
+ # caller's block never reaches this line and does not need to --
96
+ # atomic rolls back for exactly that case already.
97
+ transaction.set_rollback(True, using=using)
98
+ finally:
99
+ # After the rollback, never inside it. The rows are back and the
100
+ # sequences are not: ``setval`` is not transactional, so the counter
101
+ # still holds whatever the scaled build moved it to -- and a scaled
102
+ # world is usually *smaller* than a session world built over, which
103
+ # leaves it pointing at ids that have just come back. The next
104
+ # ``objects.create()`` then collides with a row the failing test never
105
+ # wrote, in a later test, which is about as far from the cause as a
106
+ # symptom gets.
107
+ #
108
+ # Recomputed from the rows rather than restored from a number captured
109
+ # on the way in, because the reset reads ``max(pk)`` of whatever is
110
+ # actually there: it is then correct in both directions, and correct
111
+ # too when the caller's block raised partway through the build.
112
+ _reset_declared_sequences(scaled, using)
96
113
 
97
114
 
98
115
  def _empty_declared_tables(shape: Shape, using: str) -> None:
@@ -144,3 +161,16 @@ def _empty_declared_tables(shape: Shape, using: str) -> None:
144
161
  # does not matter.
145
162
  for name in reversed(tables):
146
163
  cursor.execute(f"DELETE FROM {connection.ops.quote_name(name)}")
164
+
165
+
166
+ def _reset_declared_sequences(shape: Shape, using: str) -> None:
167
+ """Point every declared table's sequence at the rows it now holds.
168
+
169
+ A :class:`~django_data_shape.projection.Projection` is included: its rows
170
+ come from a statement rather than from generated keys, but it still has a
171
+ key column with a sequence behind it, and the rows it just lost were as real
172
+ as any other's.
173
+ """
174
+ connection = connections[using]
175
+ for table in shape.tables:
176
+ reset_sequence(connection, table.model)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  from typing import Any
7
7
 
8
+ from django.core.management.color import no_style
8
9
  from django.db.models import (
9
10
  DateField,
10
11
  DateTimeField,
@@ -232,3 +233,27 @@ def offsettable_kind(field: Field[Any, Any]) -> str | None:
232
233
  if isinstance(field, (IntegerField, FloatField, DecimalField)):
233
234
  return "number"
234
235
  return None
236
+
237
+
238
+ def reset_sequence(connection: Any, model: type[Model]) -> None:
239
+ """Point the identity sequence at the rows the table currently holds.
240
+
241
+ Skipping it after a load is the first bug the design invites: rows exist at
242
+ ids 1..N while the sequence still starts at 1, so the very first
243
+ ``objects.create()`` inside a test raises ``IntegrityError`` on a primary key
244
+ that is already taken. Django's own backend operation is used rather than a
245
+ hand-written ``setval`` because it already knows how the column's sequence
246
+ is named.
247
+
248
+ **It is computed from the rows, not from a remembered number**, which is
249
+ what makes it correct to run again after a rollback as well as after a load.
250
+ ``setval`` is not transactional, so a scaled world built over a session
251
+ world leaves the counter at the scaled world's size while the session
252
+ world's larger rows come back underneath it -- and the next ``create()``
253
+ then collides with a row the failing test never wrote. Re-running this once
254
+ the transaction has ended reads whatever survived and agrees with it, in
255
+ either direction and with no state carried across the block.
256
+ """
257
+ with connection.cursor() as cursor:
258
+ for statement in connection.ops.sequence_reset_sql(no_style(), [model]):
259
+ cursor.execute(statement)
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.17.0"
5
+ __version__: str = "0.18.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -73,6 +73,55 @@ print(sessions.rows)
73
73
  It also means a projection needs no scale factor of its own. `scaled_shape`
74
74
  passes it through untouched, and it grows because the tables it reads did.
75
75
 
76
+ ### It grows as a product, which is the part that surprises
77
+
78
+ A projection is one row per *pair* along the join, so its size is the inner
79
+ product of the two sides. When both of them fan out over the same parents, the
80
+ busy parents multiply:
81
+
82
+ ```python
83
+ Table(Venue, rows=40, name=Constant("alhambra"))
84
+ Table(VenueSeat, rows=24_000, venue=FanOut(Zipf(1.1)), number=Sequential(1, 1))
85
+ Table(Show, rows=4_000, venue=FanOut(Zipf(1.15)), title=Constant("an evening with"))
86
+ Projection(ShowSeat, per=Show, copying=VenueSeat)
87
+ ```
88
+
89
+ ```
90
+ boxoffice_order 300,000 <- the largest number in the declaration
91
+ boxoffice_showseat 2,413,223 <- eight times bigger, declared nowhere
92
+ ```
93
+
94
+ Raise either declared count by four and the projection grows by **sixteen**.
95
+ That is the model working as intended -- it is what reproduces a correlation a
96
+ `FanOut` on the child would destroy -- but it does mean the largest table in a
97
+ database can be the one nobody wrote a number for.
98
+
99
+ ### `max_rows` bounds it, before the insert rather than after
100
+
101
+ ```python
102
+ Projection(ShowSeat, per=Show, copying=VenueSeat, max_rows=3_000_000)
103
+ ```
104
+
105
+ The count is taken first and compared, so a declaration that has run away costs
106
+ a scan of the join rather than the time to write every row of it. The refusal
107
+ names the number it would have written and the tables the join is over, because
108
+ the surprise is never the ceiling:
109
+
110
+ ```
111
+ The projection into boxoffice_showseat would insert 12,058,114 rows, which is
112
+ over its declared max_rows=3,000,000. Nothing was written. A projection has no
113
+ row count of its own: its size is one row per pair along the join it copies,
114
+ over Show, VenueSeat. That size is a product, so when both sides of the join fan
115
+ out over the same parents the busy parents multiply and raising either declared
116
+ count by four grows this by sixteen. Either the ceiling is too low, or one of
117
+ those counts moved further than it looked.
118
+ ```
119
+
120
+ A declaration that does not set one is not charged for the answer -- no count is
121
+ taken. **There is no default ceiling and there will not be one**: how many rows
122
+ is too many is a judgement about size, which this package does not make on your
123
+ behalf anywhere else either. What it can do is act on your number, early.
124
+
76
125
  ## What gets derived
77
126
 
78
127
  **The join.** `per` and `copying` are joined through a model they both reach in
@@ -170,7 +170,7 @@ skip-magic-trailing-comma = false
170
170
  root = ["./django_data_shape"]
171
171
 
172
172
  [tool.bumpversion]
173
- current_version = "0.17.0"
173
+ current_version = "0.18.0"
174
174
  allow_dirty = false
175
175
  commit = false
176
176
  tag = false
@@ -0,0 +1,149 @@
1
+ """A projection's size is a product, and `max_rows` is how a declaration bounds it.
2
+
3
+ `Projection` has no `rows=` on purpose: its cardinality comes from the join,
4
+ which is what reproduces a correlation a `FanOut` on the child would destroy.
5
+ The consequence is that the largest table in a database can be the one nobody
6
+ declared a size for -- a consumer measured 2,413,223 rows against a declaration
7
+ whose largest number was 300,000, because both sides of the join fan out over
8
+ the same parents and the busy parents multiply.
9
+
10
+ `max_rows` is checked *before* the insert, so a declaration that has run away
11
+ costs a count rather than the ten minutes of writing the rows.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from django_data_shape import (
19
+ Constant,
20
+ FanOut,
21
+ InvalidShape,
22
+ Projection,
23
+ Sequential,
24
+ Shape,
25
+ Skew,
26
+ Table,
27
+ Uniform,
28
+ Zipf,
29
+ build,
30
+ )
31
+ from tests.testapp.models import Event, EventSession, Template, TemplateSession
32
+
33
+ pytestmark = pytest.mark.django_db
34
+
35
+
36
+ def _shape(*, max_rows: int | None) -> Shape:
37
+ return Shape(
38
+ Projection(EventSession, per=Event, copying=TemplateSession, max_rows=max_rows),
39
+ Table(Template, rows=4, name=Constant("t")),
40
+ Table(
41
+ TemplateSession,
42
+ rows=40,
43
+ template=FanOut(Zipf(1.1)),
44
+ title=Skew({"morning": 0.5, "evening": 0.5}),
45
+ minutes=Sequential(15, 1),
46
+ ),
47
+ Table(Event, rows=20, template=FanOut(Uniform(1, 4)), name=Constant("e")),
48
+ seed=3,
49
+ )
50
+
51
+
52
+ def test_a_projection_under_its_ceiling_builds() -> None:
53
+ result = build(_shape(max_rows=100_000), require_statistics=False)
54
+
55
+ projected = next(table for table in result.tables if table.table == "testapp_eventsession")
56
+ assert projected.rows > 0
57
+
58
+
59
+ def test_a_projection_over_its_ceiling_is_refused_before_it_inserts() -> None:
60
+ with pytest.raises(InvalidShape) as excinfo:
61
+ build(_shape(max_rows=3), require_statistics=False)
62
+
63
+ message = str(excinfo.value)
64
+ assert "testapp_eventsession" in message
65
+ assert "max_rows=3" in message
66
+ # The count it would have written, so the reader can decide whether the
67
+ # ceiling is wrong or the declaration is.
68
+ assert "would insert" in message
69
+
70
+ # Nothing was written: the refusal is the point, and a half-filled table
71
+ # would be worse than the runaway it prevents.
72
+ assert EventSession.objects.count() == 0
73
+
74
+
75
+ def test_the_refusal_names_what_drives_the_size() -> None:
76
+ """The number surprises because it is a product, so the message says so."""
77
+ with pytest.raises(InvalidShape, match="Event"):
78
+ build(_shape(max_rows=3), require_statistics=False)
79
+
80
+
81
+ def test_no_ceiling_costs_no_query() -> None:
82
+ """A declaration that does not ask is not charged for the answer."""
83
+ from django.db import connection
84
+ from django.test.utils import CaptureQueriesContext
85
+
86
+ with CaptureQueriesContext(connection) as without:
87
+ build(_shape(max_rows=None), require_statistics=False)
88
+
89
+ counted = [q for q in without.captured_queries if "count(" in q["sql"].lower()]
90
+ assert counted == []
91
+
92
+
93
+ @pytest.mark.parametrize("bad", [0, -1, -100])
94
+ def test_a_ceiling_below_one_is_refused_at_declaration(bad: int) -> None:
95
+ with pytest.raises(InvalidShape, match="max_rows"):
96
+ Projection(EventSession, per=Event, copying=TemplateSession, max_rows=bad)
97
+
98
+
99
+ def test_a_ceiling_that_is_not_a_whole_number_is_refused() -> None:
100
+ """`True` is an int as far as isinstance is concerned, and a ceiling of one
101
+ row is never what a caller meant by it."""
102
+ with pytest.raises(InvalidShape, match="max_rows"):
103
+ Projection(EventSession, per=Event, copying=TemplateSession, max_rows=True)
104
+
105
+
106
+ def _raw(*, max_rows: int) -> Shape:
107
+ """The escape hatch, whose count has to wrap rather than count a join.
108
+
109
+ This package cannot know what a caller's statement is one row per, so the
110
+ only honest count is the caller's own select with `count(*)` around it.
111
+ """
112
+ return Shape(
113
+ Table(Template, rows=4, name=Constant("t")),
114
+ Table(
115
+ TemplateSession,
116
+ rows=40,
117
+ template=FanOut(Zipf(1.1)),
118
+ title=Skew({"morning": 0.5, "evening": 0.5}),
119
+ minutes=Sequential(15, 1),
120
+ ),
121
+ Table(Event, rows=20, template=FanOut(Uniform(1, 4)), name=Constant("e")),
122
+ Projection(
123
+ EventSession,
124
+ columns=("id", "event", "title", "minutes", "channel"),
125
+ sql=(
126
+ "SELECT row_number() OVER (ORDER BY e.id, t.id), e.id, t.title, %s, %s "
127
+ f"FROM {Event._meta.db_table} e "
128
+ f"JOIN {TemplateSession._meta.db_table} t "
129
+ "ON t.template_id = e.template_id"
130
+ ),
131
+ params=(99, "import"),
132
+ reads=(Event, TemplateSession),
133
+ max_rows=max_rows,
134
+ ),
135
+ seed=3,
136
+ )
137
+
138
+
139
+ def test_a_raw_projection_is_counted_by_wrapping_the_callers_select() -> None:
140
+ with pytest.raises(InvalidShape, match="max_rows=3"):
141
+ build(_raw(max_rows=3), require_statistics=False)
142
+
143
+ assert EventSession.objects.count() == 0
144
+
145
+
146
+ def test_a_raw_projection_under_its_ceiling_builds() -> None:
147
+ build(_raw(max_rows=100_000), require_statistics=False)
148
+
149
+ assert EventSession.objects.count() > 0
@@ -168,7 +168,13 @@ def test_it_also_undoes_a_world_it_opened_the_transaction_for() -> None:
168
168
  # and stays a constant. Off PostgreSQL it is one DELETE per declared table --
169
169
  # two here -- which is a property of the *declaration* and not of the factor, so
170
170
  # the curve below still has the loader's own shape and only its intercept moved.
171
- _POSTGRES_STATEMENTS = 17
171
+ #
172
+ # And once more for the sequence reset that runs *after* the rollback, which is
173
+ # one statement per declared table -- two here, again a property of the
174
+ # declaration rather than the factor. PostgreSQL only: Django emits no sequence
175
+ # reset for a SQLite table without AUTOINCREMENT, which is why the portable
176
+ # constant did not move with it.
177
+ _POSTGRES_STATEMENTS = 19
172
178
  _PORTABLE_STATEMENTS = 12
173
179
  _ROWS_PER_INSERT = 1000
174
180
 
@@ -189,8 +195,8 @@ def test_building_a_world_costs_the_same_on_postgres_at_every_factor() -> None:
189
195
  # The half that makes a capture around the block merely wrong rather than
190
196
  # catastrophic: COPY is not a wrapped statement, so the overhead is the
191
197
  # TRUNCATE, the emptiness check, the statistics-target read, the parent key
192
- # read, the sequence reset, the ANALYZE and the savepoints -- none of which
193
- # depend on how many rows there are.
198
+ # read, the two sequence resets, the ANALYZE and the savepoints -- none of
199
+ # which depend on how many rows there are.
194
200
  shape = _graph(companies=10, sessions=_ROWS_PER_INSERT)
195
201
 
196
202
  assert _statements(shape, 1, DEFAULT_DB_ALIAS) == _POSTGRES_STATEMENTS