django-data-shape 0.18.0__tar.gz → 0.19.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 (179) hide show
  1. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/CHANGELOG.md +105 -1
  2. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/PKG-INFO +1 -1
  3. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/__init__.py +2 -0
  4. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/paired.py +48 -4
  5. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/projection.py +71 -0
  6. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/resolve_paired.py +77 -6
  7. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/scaled_shape.py +12 -3
  8. django_data_shape-0.19.0/django_data_shape/sql_value.py +112 -0
  9. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/version.py +1 -1
  10. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/projections.md +61 -0
  11. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/reference.md +2 -0
  12. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/relations.md +53 -0
  13. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/pyproject.toml +1 -1
  14. django_data_shape-0.19.0/tests/test_paired_parents.py +147 -0
  15. django_data_shape-0.19.0/tests/test_projection_values.py +153 -0
  16. django_data_shape-0.19.0/tests/test_scaled_projection_ceiling.py +103 -0
  17. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/CODEOWNERS +0 -0
  18. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/SECURITY.md +0 -0
  19. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/dependabot.yml +0 -0
  20. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/workflows/release.yml +0 -0
  21. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/workflows/tests.yml +0 -0
  22. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.github/workflows/upstream-drift.yml +0 -0
  23. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.gitignore +0 -0
  24. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/.pre-commit-config.yaml +0 -0
  25. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/CLAUDE.md +0 -0
  26. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/LICENSE +0 -0
  27. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/Makefile +0 -0
  28. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/README.md +0 -0
  29. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/apply_statistics_targets.py +0 -0
  30. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/build.py +0 -0
  31. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/build_result.py +0 -0
  32. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/canonical.py +0 -0
  33. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/check_constraints.py +0 -0
  34. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/check_invariants.py +0 -0
  35. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/children_per_parent.py +0 -0
  36. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/clone_database.py +0 -0
  37. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivation_queried_database.py +0 -0
  38. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/__init__.py +0 -0
  39. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/after.py +0 -0
  40. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/aligned.py +0 -0
  41. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/copied.py +0 -0
  42. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/derivation.py +0 -0
  43. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/derived.py +0 -0
  44. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/given.py +0 -0
  45. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/offset.py +0 -0
  46. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/per_parent.py +0 -0
  47. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/product.py +0 -0
  48. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/derivations/scope.py +0 -0
  49. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/__init__.py +0 -0
  50. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/ascending.py +0 -0
  51. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/bounded.py +0 -0
  52. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/categorical.py +0 -0
  53. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/constant.py +0 -0
  54. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/distinct.py +0 -0
  55. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/distribution.py +0 -0
  56. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/sequential.py +0 -0
  57. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/skew.py +0 -0
  58. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/uniform.py +0 -0
  59. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/distributions/zipf.py +0 -0
  60. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/drop_database.py +0 -0
  61. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fan_out.py +0 -0
  62. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fan_out_plan.py +0 -0
  63. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fan_out_sizes.py +0 -0
  64. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fixtures/__init__.py +0 -0
  65. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  66. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  67. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  68. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/generate_rows.py +0 -0
  69. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/infer_key_strategy.py +0 -0
  70. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/invalid_shape.py +0 -0
  71. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/invariant.py +0 -0
  72. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/invariant_violated.py +0 -0
  73. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/__init__.py +0 -0
  74. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/disjoint.py +0 -0
  75. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/key_function.py +0 -0
  76. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/key_strategy.py +0 -0
  77. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/md5_keys.py +0 -0
  78. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/sequential_keys.py +0 -0
  79. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/sql_keys.py +0 -0
  80. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/keys/uuid_keys.py +0 -0
  81. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/order_derivations.py +0 -0
  82. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/order_tables.py +0 -0
  83. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/paired_plan.py +0 -0
  84. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/py.typed +0 -0
  85. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/refuse_queries.py +0 -0
  86. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/require_clone_strategy.py +0 -0
  87. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/require_postgres.py +0 -0
  88. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/resolve_fan_out.py +0 -0
  89. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/scale_protocol.py +0 -0
  90. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/scaled_world.py +0 -0
  91. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/shape.py +0 -0
  92. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/shape_digest.py +0 -0
  93. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/shape_from_factory.py +0 -0
  94. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/shape_not_empty.py +0 -0
  95. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/table.py +0 -0
  96. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/table_result.py +0 -0
  97. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/template_database.py +0 -0
  98. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/unhashable_shape.py +0 -0
  99. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/unsupported_backend.py +0 -0
  100. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/utils.py +0 -0
  101. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/django_data_shape/world_changed.py +0 -0
  102. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/derivations.md +0 -0
  103. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/index.md +0 -0
  104. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/invariants.md +0 -0
  105. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/keys.md +0 -0
  106. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/pytest.md +0 -0
  107. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/docs/statistics.md +0 -0
  108. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/mkdocs.yml +0 -0
  109. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/scripts/check-changelog +0 -0
  110. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/scripts/release-publish.sh +0 -0
  111. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/__init__.py +0 -0
  112. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/conftest.py +0 -0
  113. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/conftest_settings.py +0 -0
  114. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/__init__.py +0 -0
  115. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_after.py +0 -0
  116. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_after_units.py +0 -0
  117. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_aligned.py +0 -0
  118. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
  119. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_derived.py +0 -0
  120. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_given.py +0 -0
  121. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/derivations/test_per_parent.py +0 -0
  122. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/__init__.py +0 -0
  123. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/test_constant.py +0 -0
  124. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/test_sequential.py +0 -0
  125. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/test_skew.py +0 -0
  126. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/test_uniform.py +0 -0
  127. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/distributions/test_zipf.py +0 -0
  128. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/keys/__init__.py +0 -0
  129. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/keys/test_key_function.py +0 -0
  130. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/keys/test_md5_keys.py +0 -0
  131. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/keys/test_sequential_keys.py +0 -0
  132. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/keys/test_uuid_keys.py +0 -0
  133. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/scale_protocol_consumers.py +0 -0
  134. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/scale_protocol_impostors.py +0 -0
  135. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_apply_statistics_targets.py +0 -0
  136. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build.py +0 -0
  137. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_derivations.py +0 -0
  138. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_graph.py +0 -0
  139. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_invariants.py +0 -0
  140. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_keys.py +0 -0
  141. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_m2m.py +0 -0
  142. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_portable.py +0 -0
  143. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_build_projection.py +0 -0
  144. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_check_constraints.py +0 -0
  145. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_children_per_parent.py +0 -0
  146. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_documentation.py +0 -0
  147. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_fan_out.py +0 -0
  148. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_fan_out_sizes.py +0 -0
  149. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_fixtures.py +0 -0
  150. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_generate_rows.py +0 -0
  151. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_infer_key_strategy.py +0 -0
  152. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_invariant.py +0 -0
  153. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_order_derivations.py +0 -0
  154. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_order_tables.py +0 -0
  155. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_paired.py +0 -0
  156. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_paired_plan.py +0 -0
  157. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_projection.py +0 -0
  158. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_projection_ceiling.py +0 -0
  159. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  160. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_refuse_queries.py +0 -0
  161. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_require_clone_strategy.py +0 -0
  162. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_require_postgres.py +0 -0
  163. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_resolve_fan_out.py +0 -0
  164. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_resolve_paired.py +0 -0
  165. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_scale_protocol.py +0 -0
  166. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_scaled_shape.py +0 -0
  167. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_scaled_world.py +0 -0
  168. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_scaled_world_over_a_session_world.py +0 -0
  169. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_scaled_world_restores_sequences.py +0 -0
  170. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_shape.py +0 -0
  171. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_shape_digest.py +0 -0
  172. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_shape_from_factory.py +0 -0
  173. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_table.py +0 -0
  174. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_template_database.py +0 -0
  175. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_utils.py +0 -0
  176. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/test_version.py +0 -0
  177. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/testapp/__init__.py +0 -0
  178. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/tests/testapp/models.py +0 -0
  179. {django_data_shape-0.18.0 → django_data_shape-0.19.0}/uv.lock +0 -0
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.0] — 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **`Paired(..., parents=)`: an edge narrowed to a subset of the partner table.**
15
+
16
+ Two through tables over one partner model chose independently -- every
17
+ mechanism here computes a column from the row index and nothing else -- so
18
+ they overlapped by construction, and any rule of the form *these two
19
+ relationships must not overlap* was unsatisfiable however the shape was
20
+ written. Declared as an invariant, such a rule correctly fired and rolled
21
+ every build back with nothing a declaration could change.
22
+
23
+ The family is larger than it looks: reviewer-is-not-author,
24
+ approver-is-not-requester, auditor-is-not-audited. It is every
25
+ separation-of-duties constraint there is.
26
+
27
+ It behaves as `FanOut(parents=)` does, which gained the same narrowing in
28
+ 0.13.0 for the same class of problem one primitive over: keys are read through
29
+ the database as a predicate rather than filtered afterwards, named partners
30
+ are put back in declaration order so the weights do not follow a sort order
31
+ nobody wrote down, and a named key matching no row is **refused** rather than
32
+ silently dropped.
33
+
34
+ **Narrowing can make a declaration impossible that was fine over the whole
35
+ table**, because what binds is the busiest group rather than the product. The
36
+ existing build-time refusal already says so and now says it about the narrowed
37
+ set.
38
+
39
+ - **`Projection(..., values={...})` and `SqlValue`: a projected column of the
40
+ table's own.**
41
+
42
+ A projection copies a column from the source it names or takes the model's
43
+ own default, and a projected table's **measure** column is neither -- the
44
+ score on a review, the amount on a generated line, the reading on a sample.
45
+
46
+ Leaving it to a model default was legal and was the wrong answer *for this
47
+ package specifically*: one value across every projected row is
48
+ `n_distinct = 1`, the exact shape a planner cannot use. A library whose whole
49
+ purpose is planner realism was building tables it had made unplannable, and
50
+ the declaration looked correct.
51
+
52
+ `sql=` already answered this and answered it expensively -- it replaces the
53
+ whole `SELECT`, so the join stops being derived from the model graph and can
54
+ drift from it afterwards, the copied columns are written by hand, and the key
55
+ strategy has to be spelled in SQL. `values=` gives up none of that:
56
+
57
+ ```python
58
+ Projection(
59
+ ReviewScore,
60
+ per=Review,
61
+ copying=Criterion,
62
+ values={"score": SqlValue("({per}.id * 31 + {source}.id * 17) % 5 + 1")},
63
+ )
64
+ ```
65
+
66
+ `{per}` and `{source}` are substituted with the aliases the derived statement
67
+ uses. They are placeholders rather than the aliases themselves because the
68
+ aliases are this package's private business.
69
+
70
+ **It takes SQL rather than a distribution, and that is a decision.** A
71
+ `Distribution` computes from `draw(stream, row)`, which is SplitMix64 --
72
+ expressible in PostgreSQL only through `numeric` modular arithmetic and casts
73
+ across the sign boundary, where one mistake gives a declaration two meanings
74
+ depending on which statement filled the table. That is the divergence
75
+ `SqlKeys` exists to refuse, and it is not worth buying convenience with.
76
+
77
+ Declaring an expression for a column the source already carries, or for a
78
+ column the model does not have, or alongside `sql=`, is refused at declaration
79
+ time.
80
+
81
+ Two things the expression carries that the declaration should not have to. A
82
+ literal `%` is escaped, because the statement is executed with bound
83
+ parameters and an unescaped one is an incomplete placeholder to psycopg and to
84
+ Django's SQLite wrapper alike -- the paramstyle is no more the declaration's
85
+ business than the join's aliases are. And the expression is the one part of a
86
+ shape that is **not portable**, which is documented rather than papered over:
87
+ `mod(x, 5)` is an integer on PostgreSQL and a REAL on SQLite, so `%` is the
88
+ spelling the examples use.
89
+
90
+ ## [0.18.1] — 2026-09-05
91
+
92
+ ### Fixed
93
+
94
+ - **`scaled_shape` now multiplies a projection's `max_rows` by the factor.**
95
+ It passed a `Projection` through untouched, which is right for its *size* --
96
+ that grows because the tables it reads did -- and wrong for a declared
97
+ ceiling, which is a number in the same units and did not move. A ceiling sized
98
+ for the world as written therefore refused every growth assertion, because
99
+ those build the same declaration at a larger factor.
100
+
101
+ The consumer that asked for `max_rows` in 0.18.0 hit this on the first run:
102
+ 10,000 declared against a world of 3,188 rows, and 29,078 at factor 10.
103
+
104
+ Multiplying is the arithmetic rather than an approximation of one, and the
105
+ reason is the same one that makes the size need no factor: every table scales,
106
+ parents included, so a parent has the same number of children at every factor
107
+ and the projection is a sum over `factor` times as many parents of an
108
+ unchanged per-parent product.
109
+
110
+ A declaration with no ceiling is still passed through by identity.
111
+
10
112
  ## [0.18.0] — 2026-09-05
11
113
 
12
114
  ### Added
@@ -1173,7 +1275,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1173
1275
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1174
1276
  first.
1175
1277
 
1176
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.18.0...HEAD
1278
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.19.0...HEAD
1279
+ [0.19.0]: https://github.com/Artui/django-data-shape/compare/v0.18.1...v0.19.0
1280
+ [0.18.1]: https://github.com/Artui/django-data-shape/compare/v0.18.0...v0.18.1
1177
1281
  [0.18.0]: https://github.com/Artui/django-data-shape/compare/v0.17.1...v0.18.0
1178
1282
  [0.17.1]: https://github.com/Artui/django-data-shape/compare/v0.17.0...v0.17.1
1179
1283
  [0.17.0]: https://github.com/Artui/django-data-shape/compare/v0.16.0...v0.17.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -52,6 +52,7 @@ from django_data_shape.shape import Shape
52
52
  from django_data_shape.shape_digest import shape_digest
53
53
  from django_data_shape.shape_from_factory import shape_from_factory
54
54
  from django_data_shape.shape_not_empty import ShapeNotEmpty
55
+ from django_data_shape.sql_value import SqlValue
55
56
  from django_data_shape.table import Table
56
57
  from django_data_shape.table_result import TableResult
57
58
  from django_data_shape.template_database import template_database
@@ -98,6 +99,7 @@ __all__ = [
98
99
  "ShapeNotEmpty",
99
100
  "Skew",
100
101
  "SqlKeys",
102
+ "SqlValue",
101
103
  "Table",
102
104
  "TableResult",
103
105
  "UnhashableShape",
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Sequence
6
+
5
7
  from django_data_shape.distributions.distribution import Distribution
6
8
  from django_data_shape.invalid_shape import InvalidShape
7
9
 
@@ -56,7 +58,13 @@ class Paired:
56
58
  derived shape nobody quotes is a shape nobody can check.
57
59
  """
58
60
 
59
- def __init__(self, relation: str, weights: Distribution) -> None:
61
+ def __init__(
62
+ self,
63
+ relation: str,
64
+ weights: Distribution,
65
+ *,
66
+ parents: Sequence[object] | None = None,
67
+ ) -> None:
60
68
  if not isinstance(relation, str) or not relation:
61
69
  raise InvalidShape(
62
70
  f"Paired needs the name of the fan-out it is paired with, got {relation!r}. "
@@ -65,6 +73,7 @@ class Paired:
65
73
  )
66
74
  self._relation = relation
67
75
  self._weights = weights
76
+ self._parents = None if parents is None else tuple(parents)
68
77
 
69
78
  @property
70
79
  def relation(self) -> str:
@@ -74,9 +83,44 @@ class Paired:
74
83
  def weights(self) -> Distribution:
75
84
  return self._weights
76
85
 
86
+ @property
87
+ def parents(self) -> tuple[object, ...] | None:
88
+ """The partner keys this edge may use, or ``None`` for all of them.
89
+
90
+ The same narrowing
91
+ :class:`~django_data_shape.fan_out.FanOut` takes, and it is here for the
92
+ one thing a fan-out's version cannot do: **two through tables over the
93
+ same partner model cannot otherwise be made disjoint.**
94
+
95
+ Every mechanism in this package computes a column from the row index
96
+ alone, so two ``Paired`` declarations over one partner table choose
97
+ independently and overlap by construction. Any rule of the form *these
98
+ two relationships must not overlap* is then violated however the shape
99
+ is written -- a reviewer who is also an author, an approver who is also
100
+ the requester, an auditor auditing their own team. That family is every
101
+ separation-of-duties constraint there is, and it was inexpressible.
102
+
103
+ Narrowing the two sides to disjoint subsets is what makes it
104
+ expressible, and it is also how the domains themselves work: a venue
105
+ keeps a reviewer board, an organisation separates approvers from
106
+ requesters. The declaration says the thing the business already says.
107
+
108
+ Keys are read through the database as a predicate rather than filtered
109
+ afterwards, and a named key matching no row is refused rather than
110
+ silently dropped -- both for the reasons the fan-out's version gives.
111
+ """
112
+ return self._parents
113
+
77
114
  def canonical(self) -> object:
78
- """The fan-out it pairs with and the weights over partners. See ``Canonical``."""
79
- return (self._relation, self._weights)
115
+ """The fan-out it pairs with, the weights, and any narrowing. See ``Canonical``.
116
+
117
+ ``parents`` is part of the digest because it changes which rows exist:
118
+ two declarations differing only in their subset build different
119
+ databases, and a cache key that agreed would serve one for the other.
120
+ """
121
+ return (self._relation, self._weights, self._parents)
80
122
 
81
123
  def __repr__(self) -> str:
82
- return f"Paired({self._relation!r}, {self._weights!r})"
124
+ if self._parents is None:
125
+ return f"Paired({self._relation!r}, {self._weights!r})"
126
+ return f"Paired({self._relation!r}, {self._weights!r}, parents={list(self._parents)!r})"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections import Counter
6
6
  from collections.abc import Mapping, Sequence
7
+ from copy import copy
7
8
  from types import MappingProxyType
8
9
  from typing import Any, cast
9
10
 
@@ -13,6 +14,7 @@ from django_data_shape.infer_key_strategy import infer_key_strategy
13
14
  from django_data_shape.invalid_shape import InvalidShape
14
15
  from django_data_shape.keys.key_strategy import KeyStrategy
15
16
  from django_data_shape.keys.sql_keys import SqlKeys
17
+ from django_data_shape.sql_value import SqlValue
16
18
  from django_data_shape.utils import (
17
19
  check_not_inherited,
18
20
  check_statistics_target,
@@ -178,6 +180,7 @@ class Projection:
178
180
  keys: KeyStrategy | None = None,
179
181
  statistics: Mapping[str, int] | None = None,
180
182
  max_rows: int | None = None,
183
+ values: Mapping[str, SqlValue] | None = None,
181
184
  ) -> None:
182
185
  self._model = model
183
186
  self._per = per
@@ -197,6 +200,8 @@ class Projection:
197
200
  "nothing is already refused."
198
201
  )
199
202
  self._max_rows = max_rows
203
+ self._values = dict(values or {})
204
+ self._expressions: tuple[tuple[str, SqlValue], ...] = ()
200
205
  self._copied: tuple[tuple[str, str, str], ...] = ()
201
206
  self._literals: tuple[tuple[str, object], ...] = ()
202
207
  self._columns: tuple[str, ...] = ()
@@ -207,6 +212,16 @@ class Projection:
207
212
  # runs it first: under multi-table inheritance ``_meta.concrete_fields``
208
213
  # spans two tables and every column decision below would be made about
209
214
  # the wrong one.
215
+ if self._values and sql is not None:
216
+ # `sql=` is the whole SELECT, so there is nowhere for an expression
217
+ # to be put and no join for it to be written over. Two ways of
218
+ # saying one column is the over-determination refused everywhere
219
+ # else in this package.
220
+ raise InvalidShape(
221
+ f"{model.__name__} declares values= and sql= together. `sql=` is the entire "
222
+ "SELECT the insert reads from, so an expression has nowhere to go -- write it "
223
+ "into that statement instead."
224
+ )
210
225
  check_not_inherited(model)
211
226
  pk_field = primary_key_field(model)
212
227
  if sql is None:
@@ -302,6 +317,31 @@ class Projection:
302
317
  defaults = tuple(value for _column, value in self._literals)
303
318
  return f"{into} {self._select(quote, seed)}", defaults
304
319
 
320
+ def scaled(self, factor: int) -> Projection:
321
+ """This declaration at another size, which for a projection is its ceiling.
322
+
323
+ A projection has no row count to multiply -- its size is
324
+ ``count(per JOIN copying)``, so scaling the tables it reads scales it
325
+ without anything being said. ``max_rows`` is different: it is a declared
326
+ number in the same units as that size, and a ceiling that does not move
327
+ with the factor fires on the first growth assertion.
328
+
329
+ **Multiplying it by the factor is the right arithmetic rather than an
330
+ approximation of one**, and for the same reason the count needs no
331
+ factor. ``scaled_shape`` scales *every* table, parents included, so a
332
+ parent has the same number of children at every factor; the projection
333
+ is then a sum over ``factor`` times as many parents of an unchanged
334
+ per-parent product, which is ``factor`` times the original.
335
+
336
+ Returns ``self`` when there is no ceiling, so a declaration that did not
337
+ ask for one keeps the identity it always had here.
338
+ """
339
+ if self._max_rows is None:
340
+ return self
341
+ scaled = copy(self)
342
+ scaled._max_rows = self._max_rows * factor
343
+ return scaled
344
+
305
345
  def count_statement(self, connection: Any) -> tuple[str, tuple[object, ...]]:
306
346
  """How many rows the insert would write, without writing them.
307
347
 
@@ -346,6 +386,13 @@ class Projection:
346
386
  key = cast("SqlKeys", self._keys).key_sql(field_stream(seed, self.db_table, ":key"), row)
347
387
  values = [key]
348
388
  values.extend(f"{quote(alias)}.{quote(column)}" for _target, alias, column in self._copied)
389
+ # Between the copied columns and the bound literals, matching the order
390
+ # `_columns` was built in -- the two lists are one statement and a
391
+ # disagreement between them would write every column into the wrong slot.
392
+ values.extend(
393
+ expression.render(quote(_PER), quote(_SOURCE))
394
+ for _target, expression in self._expressions
395
+ )
349
396
  values.extend("%s" for _column, _value in self._literals)
350
397
  per_link, source_link = self._join
351
398
  return (
@@ -404,6 +451,7 @@ class Projection:
404
451
  source_pk = _column(primary_key_field(source))
405
452
 
406
453
  copied: list[tuple[str, str, str]] = []
454
+ expressions: list[tuple[str, SqlValue]] = []
407
455
  literals: list[tuple[str, object]] = []
408
456
  callables: list[str] = []
409
457
  missing: list[str] = []
@@ -418,6 +466,8 @@ class Projection:
418
466
  copied.append((_column(field), _PER, _column(primary_key_field(per))))
419
467
  elif field.is_relation and field.related_model is source:
420
468
  copied.append((_column(field), _SOURCE, source_pk))
469
+ elif field.name in self._values:
470
+ expressions.append((_column(field), self._values[field.name]))
421
471
  elif field.name in available:
422
472
  copied.append((_column(field), _SOURCE, _column(available[field.name])))
423
473
  elif field.has_default() and callable(field.default):
@@ -429,6 +479,21 @@ class Projection:
429
479
  else:
430
480
  missing.append(field.name)
431
481
 
482
+ declared = {field.name for field in self._model._meta.concrete_fields}
483
+ unknown = sorted(set(self._values) - declared)
484
+ if unknown:
485
+ raise InvalidShape(
486
+ f"{self._model.__name__} has no column(s) {', '.join(unknown)}, named in "
487
+ f"values=. Its columns are: {', '.join(sorted(declared))}."
488
+ )
489
+ answered = sorted(name for name in self._values if name in available)
490
+ if answered:
491
+ raise InvalidShape(
492
+ f"{self._model.__name__}.{', '.join(answered)} is named in values= and is also "
493
+ f"a column {source.__name__} carries under the same name, so the declaration "
494
+ "says two things about one column. Rename it, or drop it from values= and let "
495
+ "it be copied."
496
+ )
432
497
  if callables:
433
498
  raise InvalidShape(
434
499
  f"{self._model.__name__}.{', '.join(callables)} has a callable default, which "
@@ -446,10 +511,12 @@ class Projection:
446
511
  )
447
512
 
448
513
  self._copied = tuple(copied)
514
+ self._expressions = tuple(expressions)
449
515
  self._literals = tuple(literals)
450
516
  self._columns = (
451
517
  _column(pk_field),
452
518
  *(column for column, _alias, _source in copied),
519
+ *(column for column, _expression in expressions),
453
520
  *(column for column, _value in literals),
454
521
  )
455
522
 
@@ -555,6 +622,10 @@ class Projection:
555
622
  self._columns,
556
623
  self._sql,
557
624
  self._params,
625
+ # The expressions decide the values in the columns they fill, so two
626
+ # declarations differing only here build different databases and a
627
+ # key that agreed would serve one for the other.
628
+ tuple((column, expression.canonical()) for column, expression in self._expressions),
558
629
  # In the key although it writes no column, because it decides load
559
630
  # order and load order reaches the data: a raw statement selecting
560
631
  # from a table built before it and after it returns different rows.
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import math
6
6
  import numbers
7
7
  from decimal import Decimal
8
- from typing import Any
8
+ from typing import Any, cast
9
9
 
10
10
  from django.db.models import Model
11
11
 
@@ -52,7 +52,9 @@ def resolve_paired(
52
52
  is resolved, so this is the one structural refusal that has to wait for the
53
53
  build.
54
54
  """
55
- keys = _read_partner_keys(partner, connection)
55
+ keys = _read_partner_keys(partner, connection, paired.parents)
56
+ _require_every_named_partner(paired.parents, keys, partner, table, field)
57
+ keys = _in_declared_order(paired.parents, keys)
56
58
  sizes = plan.sizes()
57
59
  if not keys and rows:
58
60
  raise InvalidShape(
@@ -84,23 +86,92 @@ def resolve_paired(
84
86
  )
85
87
 
86
88
 
87
- def _read_partner_keys(partner: type[Model], connection: Any) -> list[int]:
89
+ def _read_partner_keys(
90
+ partner: type[Model], connection: Any, named: tuple[object, ...] | None = None
91
+ ) -> list[int]:
88
92
  """The partner's real keys, queried rather than assumed.
89
93
 
90
94
  The same correction a fan-out carries: a project may have built the partner
91
95
  table with the ORM, so its keys are whatever the sequence handed out and a
92
96
  child pointing at ``1..N`` would point at nothing.
97
+
98
+ ``named`` narrows through the database as a predicate rather than as a
99
+ filter applied afterwards, so an edge table pinned to one half of a partner
100
+ table reads that half instead of every key it is about to ignore. The keys
101
+ go out as parameters, so a value from a caller never reaches the statement
102
+ as text.
93
103
  """
94
104
  pk_column = partner._meta.pk.column
95
105
  quote = connection.ops.quote_name
96
- with connection.cursor() as cursor:
97
- cursor.execute(
106
+ statement = (
107
+ f"SELECT {quote(pk_column)} FROM {quote(partner._meta.db_table)} "
108
+ f"ORDER BY {quote(pk_column)}"
109
+ )
110
+ params: tuple[object, ...] = ()
111
+ if named is not None:
112
+ placeholders = ", ".join(["%s"] * len(named)) or "NULL"
113
+ statement = (
98
114
  f"SELECT {quote(pk_column)} FROM {quote(partner._meta.db_table)} "
99
- f"ORDER BY {quote(pk_column)}"
115
+ f"WHERE {quote(pk_column)} IN ({placeholders}) ORDER BY {quote(pk_column)}"
100
116
  )
117
+ params = tuple(named)
118
+ with connection.cursor() as cursor:
119
+ cursor.execute(statement, params)
101
120
  return [row[0] for row in cursor.fetchall()]
102
121
 
103
122
 
123
+ def _require_every_named_partner(
124
+ named: tuple[object, ...] | None,
125
+ keys: list[int],
126
+ partner: type[Model],
127
+ table: str,
128
+ field: str,
129
+ ) -> None:
130
+ """Every key ``parents=`` named has to be a row, or the narrowing is a lie.
131
+
132
+ The database does the narrowing, so a key matching nothing simply does not
133
+ come back -- and the edges that would have used it go to whatever else was
134
+ named instead, or the table comes out empty if it was the only one. Both are
135
+ silent, and both produce a world the declaration does not describe.
136
+
137
+ The same refusal a fan-out makes, and the likely causes are the same: a key
138
+ from another test, a factory row that was rolled back, or a list built from
139
+ a queryset filtered differently from the one in the reader's head. None of
140
+ them is visible in the shape.
141
+ """
142
+ if named is None:
143
+ return
144
+ missing = [key for key in named if key not in set(keys)]
145
+ if not missing:
146
+ return
147
+ raise InvalidShape(
148
+ f"{table}.{field} names partner key(s) {missing!r} in parents=, and "
149
+ f"{partner.__name__} has no row with them. The narrowing runs in the database, so "
150
+ "a key that matches nothing is not an error there -- the edges that would have used "
151
+ "it go to the other named partners, or this table comes out empty. Load the partner "
152
+ "first, or name keys that exist."
153
+ )
154
+
155
+
156
+ def _in_declared_order(named: tuple[object, ...] | None, keys: list[int]) -> list[int]:
157
+ """Put named partners back in the order the declaration named them.
158
+
159
+ Weights below are assigned by *position*, and keys arrive ordered by primary
160
+ key because that is how they are read -- so without this the popularity of a
161
+ partner would follow the sort order of values nobody wrote down. The fan-out
162
+ carries the same correction for the same reason, and there it was measured:
163
+ one declaration gave the first-named parent 5, 11 or 79 rows across twelve
164
+ builds, because the sort order moved with the keys.
165
+ """
166
+ if named is None:
167
+ return keys
168
+ present = set(keys)
169
+ # cast rather than a narrowing check: `named` holds whatever key type the
170
+ # partner's primary key is, and membership in `present` is what proves each
171
+ # one is a key this table actually read back.
172
+ return [cast("int", key) for key in named if key in present]
173
+
174
+
104
175
  def _weights(paired: Paired, keys: list[int], seed: int, table: str, field: str) -> list[float]:
105
176
  """A weight per partner, scattered across the key range rather than ordered.
106
177
 
@@ -71,14 +71,23 @@ def scaled_shape(shape: Shape, factor: int) -> Shape:
71
71
  because a message about a row count the caller never wrote is a message that
72
72
  knows more than it says.
73
73
 
74
- **A :class:`~django_data_shape.projection.Projection` passes through
75
- untouched, and needs no factor of its own.** It has no declared row count to
74
+ **A :class:`~django_data_shape.projection.Projection` needs no factor for
75
+ its size, and one for its ceiling.** It has no declared row count to
76
76
  multiply: its size is ``count(per JOIN copying)``, so scaling the tables it
77
77
  reads scales it by exactly the same amount without anything being said.
78
78
  That is the determined-not-distributed property paying for itself -- a
79
79
  mirroring vocabulary with a row count in it would have needed a rule here,
80
80
  and would have had to pick between scaling the count and scaling the thing
81
81
  the count was derived from.
82
+
83
+ ``max_rows`` **is** multiplied, and the same reasoning is why. A ceiling is
84
+ a declared number in the same units as that size, so one that stayed put
85
+ would fire on the first growth assertion -- which is exactly what a consumer
86
+ hit within a run of asking for the feature. Because every table scales,
87
+ parents included, a parent has the same number of children at every factor
88
+ and the projection is a sum over ``factor`` times as many parents of an
89
+ unchanged per-parent product: linear, so multiplying the ceiling is the
90
+ arithmetic rather than an approximation of it.
82
91
  """
83
92
  # A whole number, checked rather than rounded. Rounding would let each table
84
93
  # decide its own size at a fractional factor, so two tables in one shape
@@ -94,7 +103,7 @@ def scaled_shape(shape: Shape, factor: int) -> Shape:
94
103
 
95
104
  try:
96
105
  tables = tuple(
97
- table
106
+ table.scaled(factor)
98
107
  if isinstance(table, Projection)
99
108
  # ``fields`` rather than keyword arguments, because a model may have
100
109
  # a column named ``rows`` or ``model``. Everything else is forwarded
@@ -0,0 +1,112 @@
1
+ """A projected column written as an expression the database evaluates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django_data_shape.invalid_shape import InvalidShape
6
+
7
+ _PER_PLACEHOLDER = "{per}"
8
+ _SOURCE_PLACEHOLDER = "{source}"
9
+
10
+
11
+ class SqlValue:
12
+ """One projected column's value, as SQL over the join the projection derives.
13
+
14
+ A projection copies a column from the source it names, or takes the model's
15
+ own default, and those are the only two answers it has. A projected table's
16
+ **measure** column is neither: the score on a review, the amount on a
17
+ generated line, the reading on a sample. It belongs to the projected row and
18
+ to nothing the source carries.
19
+
20
+ Leaving it to the model default is legal and is the wrong answer *for this
21
+ package specifically*: one value across every projected row is
22
+ ``n_distinct = 1``, which is the exact shape a planner cannot use. A library
23
+ whose whole purpose is planner realism would then be building a table it had
24
+ made unplannable, and the declaration would look correct.
25
+
26
+ ``sql=`` already answers this and answers it expensively: it replaces the
27
+ whole ``SELECT``, so the join stops being derived from the model graph and
28
+ can drift from it afterwards, the copied columns are written out by hand,
29
+ and the key strategy has to be spelled in SQL. ``values=`` gives up none of
30
+ that and writes one expression for the one column that needs one::
31
+
32
+ Projection(
33
+ ReviewScore,
34
+ per=Review,
35
+ copying=Criterion,
36
+ values={"score": SqlValue("({per}.id * 31 + {source}.id * 17) % 5 + 1")},
37
+ )
38
+
39
+ ``{per}`` and ``{source}`` are substituted with the aliases the derived
40
+ statement uses, quoted for the connection. They are placeholders rather than
41
+ the aliases themselves because the aliases are this package's private
42
+ business: a declaration that spelled them would break the day they changed,
43
+ and a reader could not tell which side was which.
44
+
45
+ **It is SQL rather than a distribution, and that is a decision worth
46
+ stating.** A :class:`~django_data_shape.distributions.distribution.Distribution`
47
+ computes from ``draw(stream, row)``, which is SplitMix64 -- expressible in
48
+ PostgreSQL only through ``numeric`` modular arithmetic and casts across the
49
+ sign boundary, where a single mistake gives one declaration two meanings
50
+ depending on which statement filled the table. That is the divergence
51
+ :class:`~django_data_shape.keys.sql_keys.SqlKeys` exists to refuse, and it
52
+ is not worth buying convenience with. An expression the caller wrote is
53
+ honest about being the caller's.
54
+
55
+ **It is the one part of a shape that is not portable, and it cannot be
56
+ made portable.** Everything else here is a declaration this package
57
+ compiles per backend; an expression is SQL the database evaluates as
58
+ written. ``mod(x, 5)`` returns an integer on PostgreSQL and a REAL on
59
+ SQLite, so one declaration writes ``5`` into one database and ``5.0`` into
60
+ the other -- which is why the example above uses ``%``, integer on both.
61
+ Nothing here can detect that: the expression is opaque until the database
62
+ reads it. Write it for the backend the shape is built on, and cast when it
63
+ has to be both.
64
+
65
+ ``%`` is passed through as the operator, which takes an escape this package
66
+ applies rather than asking for. The statement is executed with bound
67
+ parameters, so a literal ``%`` in it is an incomplete placeholder to both
68
+ psycopg and Django's SQLite wrapper, and the declaration fails at execution
69
+ with a paramstyle error naming nothing in the shape. The paramstyle is this
70
+ package's private business for the same reason the join's aliases are: a
71
+ declaration that spelled it would be spelling how the statement happens to
72
+ be run.
73
+
74
+ The expression is inert data, so a shape holding one still digests.
75
+ """
76
+
77
+ def __init__(self, expression: str) -> None:
78
+ if not isinstance(expression, str) or not expression.strip():
79
+ raise InvalidShape(
80
+ f"SqlValue needs a SQL expression, got {expression!r}. An empty one would "
81
+ "produce a statement the database refuses, at a point far from the "
82
+ "declaration that caused it."
83
+ )
84
+ self._expression = expression
85
+
86
+ @property
87
+ def expression(self) -> str:
88
+ return self._expression
89
+
90
+ def render(self, per_alias: str, source_alias: str) -> str:
91
+ """The expression with the join's aliases substituted and ``%`` escaped.
92
+
93
+ The escape is last so it also covers a ``%`` inside a quoted alias,
94
+ and it is unconditional because the statement always carries a
95
+ parameter sequence -- an empty one is still a sequence, and both
96
+ drivers interpolate on anything that is not ``None``.
97
+ """
98
+ return (
99
+ self._expression.replace(_PER_PLACEHOLDER, per_alias)
100
+ .replace(_SOURCE_PLACEHOLDER, source_alias)
101
+ .replace("%", "%%")
102
+ )
103
+
104
+ def canonical(self) -> object:
105
+ """The expression itself, which is what decides the rows. See ``Canonical``."""
106
+ return ("SqlValue", self._expression)
107
+
108
+ def __repr__(self) -> str:
109
+ return f"SqlValue({self._expression!r})"
110
+
111
+
112
+ __all__ = ["SqlValue"]
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.18.0"
5
+ __version__: str = "0.19.0"
6
6
 
7
7
  __all__ = ["__version__"]