django-data-shape 0.19.0__tar.gz → 0.20.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 (180) hide show
  1. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/CHANGELOG.md +25 -1
  2. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/PKG-INFO +1 -1
  3. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/projection.py +44 -0
  4. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/sql_value.py +2 -1
  5. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/version.py +1 -1
  6. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/projections.md +22 -0
  7. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/pyproject.toml +1 -1
  8. django_data_shape-0.20.0/tests/test_projection_sql_percent.py +98 -0
  9. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/CODEOWNERS +0 -0
  10. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/SECURITY.md +0 -0
  11. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/dependabot.yml +0 -0
  12. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/release.yml +0 -0
  13. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/tests.yml +0 -0
  14. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/upstream-drift.yml +0 -0
  15. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.gitignore +0 -0
  16. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.pre-commit-config.yaml +0 -0
  17. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/CLAUDE.md +0 -0
  18. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/LICENSE +0 -0
  19. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/Makefile +0 -0
  20. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/README.md +0 -0
  21. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/__init__.py +0 -0
  22. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/apply_statistics_targets.py +0 -0
  23. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/build.py +0 -0
  24. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/build_result.py +0 -0
  25. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/canonical.py +0 -0
  26. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/check_constraints.py +0 -0
  27. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/check_invariants.py +0 -0
  28. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/children_per_parent.py +0 -0
  29. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/clone_database.py +0 -0
  30. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivation_queried_database.py +0 -0
  31. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/__init__.py +0 -0
  32. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/after.py +0 -0
  33. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/aligned.py +0 -0
  34. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/copied.py +0 -0
  35. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derivation.py +0 -0
  36. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derived.py +0 -0
  37. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/given.py +0 -0
  38. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/offset.py +0 -0
  39. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/per_parent.py +0 -0
  40. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/product.py +0 -0
  41. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/scope.py +0 -0
  42. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/__init__.py +0 -0
  43. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/ascending.py +0 -0
  44. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/bounded.py +0 -0
  45. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/categorical.py +0 -0
  46. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/constant.py +0 -0
  47. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/distinct.py +0 -0
  48. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/distribution.py +0 -0
  49. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/sequential.py +0 -0
  50. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/skew.py +0 -0
  51. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/uniform.py +0 -0
  52. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/zipf.py +0 -0
  53. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/drop_database.py +0 -0
  54. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out.py +0 -0
  55. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out_plan.py +0 -0
  56. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out_sizes.py +0 -0
  57. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/__init__.py +0 -0
  58. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
  59. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
  60. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
  61. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/generate_rows.py +0 -0
  62. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/infer_key_strategy.py +0 -0
  63. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invalid_shape.py +0 -0
  64. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invariant.py +0 -0
  65. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invariant_violated.py +0 -0
  66. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/__init__.py +0 -0
  67. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/disjoint.py +0 -0
  68. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_function.py +0 -0
  69. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_strategy.py +0 -0
  70. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/md5_keys.py +0 -0
  71. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/sequential_keys.py +0 -0
  72. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/sql_keys.py +0 -0
  73. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/uuid_keys.py +0 -0
  74. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/order_derivations.py +0 -0
  75. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/order_tables.py +0 -0
  76. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/paired.py +0 -0
  77. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/paired_plan.py +0 -0
  78. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/py.typed +0 -0
  79. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/refuse_queries.py +0 -0
  80. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/require_clone_strategy.py +0 -0
  81. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/require_postgres.py +0 -0
  82. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/resolve_fan_out.py +0 -0
  83. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/resolve_paired.py +0 -0
  84. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scale_protocol.py +0 -0
  85. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scaled_shape.py +0 -0
  86. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scaled_world.py +0 -0
  87. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape.py +0 -0
  88. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_digest.py +0 -0
  89. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_from_factory.py +0 -0
  90. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_not_empty.py +0 -0
  91. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/table.py +0 -0
  92. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/table_result.py +0 -0
  93. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/template_database.py +0 -0
  94. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/unhashable_shape.py +0 -0
  95. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/unsupported_backend.py +0 -0
  96. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/utils.py +0 -0
  97. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/world_changed.py +0 -0
  98. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/derivations.md +0 -0
  99. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/index.md +0 -0
  100. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/invariants.md +0 -0
  101. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/keys.md +0 -0
  102. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/pytest.md +0 -0
  103. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/reference.md +0 -0
  104. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/relations.md +0 -0
  105. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/statistics.md +0 -0
  106. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/mkdocs.yml +0 -0
  107. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/scripts/check-changelog +0 -0
  108. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/scripts/release-publish.sh +0 -0
  109. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/__init__.py +0 -0
  110. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/conftest.py +0 -0
  111. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/conftest_settings.py +0 -0
  112. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/__init__.py +0 -0
  113. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_after.py +0 -0
  114. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_after_units.py +0 -0
  115. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_aligned.py +0 -0
  116. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
  117. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_derived.py +0 -0
  118. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_given.py +0 -0
  119. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_per_parent.py +0 -0
  120. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/__init__.py +0 -0
  121. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_constant.py +0 -0
  122. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_sequential.py +0 -0
  123. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_skew.py +0 -0
  124. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_uniform.py +0 -0
  125. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_zipf.py +0 -0
  126. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/__init__.py +0 -0
  127. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_key_function.py +0 -0
  128. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_md5_keys.py +0 -0
  129. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_sequential_keys.py +0 -0
  130. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_uuid_keys.py +0 -0
  131. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/scale_protocol_consumers.py +0 -0
  132. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/scale_protocol_impostors.py +0 -0
  133. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_apply_statistics_targets.py +0 -0
  134. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build.py +0 -0
  135. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_derivations.py +0 -0
  136. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_graph.py +0 -0
  137. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_invariants.py +0 -0
  138. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_keys.py +0 -0
  139. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_m2m.py +0 -0
  140. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_portable.py +0 -0
  141. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_projection.py +0 -0
  142. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_check_constraints.py +0 -0
  143. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_children_per_parent.py +0 -0
  144. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_documentation.py +0 -0
  145. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fan_out.py +0 -0
  146. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fan_out_sizes.py +0 -0
  147. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fixtures.py +0 -0
  148. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_generate_rows.py +0 -0
  149. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_infer_key_strategy.py +0 -0
  150. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_invariant.py +0 -0
  151. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_order_derivations.py +0 -0
  152. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_order_tables.py +0 -0
  153. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired.py +0 -0
  154. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired_parents.py +0 -0
  155. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired_plan.py +0 -0
  156. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection.py +0 -0
  157. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection_ceiling.py +0 -0
  158. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection_values.py +0 -0
  159. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_reference_documents_the_public_surface.py +0 -0
  160. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_refuse_queries.py +0 -0
  161. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_require_clone_strategy.py +0 -0
  162. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_require_postgres.py +0 -0
  163. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_resolve_fan_out.py +0 -0
  164. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_resolve_paired.py +0 -0
  165. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scale_protocol.py +0 -0
  166. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_projection_ceiling.py +0 -0
  167. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_shape.py +0 -0
  168. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world.py +0 -0
  169. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world_over_a_session_world.py +0 -0
  170. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world_restores_sequences.py +0 -0
  171. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape.py +0 -0
  172. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape_digest.py +0 -0
  173. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape_from_factory.py +0 -0
  174. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_table.py +0 -0
  175. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_template_database.py +0 -0
  176. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_utils.py +0 -0
  177. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_version.py +0 -0
  178. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/testapp/__init__.py +0 -0
  179. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/testapp/models.py +0 -0
  180. {django_data_shape-0.19.0 → django_data_shape-0.20.0}/uv.lock +0 -0
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.0] — 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **A lone `%` in a `sql=` statement is refused at declaration time.** The
15
+ statement is run with its parameters, and an empty parameter sequence is still
16
+ a sequence, so a `%` that is not a placeholder is read as the start of one:
17
+ the failure came from `psycopg/cursor.py` at build time, naming the driver and
18
+ nothing about the shape. A caller who passed no parameters at all had no model
19
+ that explained it, and the modulo operator is the reason anyone writes a bare
20
+ `%` in the first place.
21
+
22
+ A valid statement can never contain one, so nothing legal is refused -- which
23
+ is the whole reason this can be a refusal rather than an escape. `sql=` takes
24
+ `params=`, so pyformat is its interface and `%%` stays the spelling for the
25
+ operator.
26
+
27
+ `SqlValue` on the derived path escapes instead, and the two are not
28
+ inconsistent: there the caller supplies no parameters and has no reason to
29
+ know one exists. Both docstrings and the projections guide now say which is
30
+ which, at both call sites, because the asymmetry is exactly what a reader
31
+ finding one of them will next be confused by.
32
+
10
33
  ## [0.19.0] — 2026-09-05
11
34
 
12
35
  ### Added
@@ -1275,7 +1298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1275
1298
  into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
1276
1299
  first.
1277
1300
 
1278
- [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.19.0...HEAD
1301
+ [Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.20.0...HEAD
1302
+ [0.20.0]: https://github.com/Artui/django-data-shape/compare/v0.19.0...v0.20.0
1279
1303
  [0.19.0]: https://github.com/Artui/django-data-shape/compare/v0.18.1...v0.19.0
1280
1304
  [0.18.1]: https://github.com/Artui/django-data-shape/compare/v0.18.0...v0.18.1
1281
1305
  [0.18.0]: https://github.com/Artui/django-data-shape/compare/v0.17.1...v0.18.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-data-shape
3
- Version: 0.19.0
3
+ Version: 0.20.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
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  from collections import Counter
6
7
  from collections.abc import Mapping, Sequence
7
8
  from copy import copy
@@ -29,6 +30,12 @@ from django_data_shape.utils import (
29
30
  _PER = "per"
30
31
  _SOURCE = "src"
31
32
 
33
+ # Every pyformat shape psycopg 3 and Django's SQLite wrapper accept: a doubled
34
+ # `%%`, a positional placeholder (psycopg also takes `%b` and `%t`), and a named
35
+ # one. Written as what is *allowed* rather than as what is wrong, because the
36
+ # set of legal placeholders is short and closed and the set of mistakes is not.
37
+ _PLACEHOLDER = re.compile(r"%(?:%|[sbt]|\([^)]*\)[sbt])")
38
+
32
39
 
33
40
  class Projection:
34
41
  """One row per pair, copied along a join, by ``INSERT ... SELECT``.
@@ -543,6 +550,7 @@ class Projection:
543
550
  "what this package would use to write the key column, and here the statement "
544
551
  f"writes it: put {pk_field.name} in columns= and produce it in the select."
545
552
  )
553
+ _refuse_a_lone_percent(self._model, cast("str", self._sql))
546
554
  if columns is None:
547
555
  raise InvalidShape(
548
556
  f"{self._model.__name__} declares sql= without columns=. The select's columns "
@@ -647,6 +655,42 @@ class Projection:
647
655
  )
648
656
 
649
657
 
658
+ def _refuse_a_lone_percent(model: type[Model], sql: str) -> None:
659
+ """Refuse a ``%`` that is neither a placeholder nor doubled, here rather than at build.
660
+
661
+ ``sql=`` is a **parameterised** statement -- the caller writes ``%s`` and
662
+ passes ``params=``, which is the documented interface -- so pyformat is part
663
+ of that contract and a literal ``%`` has to be doubled. That is correct and
664
+ is not what this refusal changes.
665
+
666
+ What it changes is where the mistake surfaces. The statement reaches the
667
+ driver with a parameter sequence, and an empty sequence is still a sequence,
668
+ so an unescaped ``%`` is an incomplete placeholder: the exception comes from
669
+ ``psycopg/cursor.py``, at build time, naming nothing in the shape. A caller
670
+ who passed no parameters at all has no model that explains it, and the
671
+ modulo operator is the reason anyone writes a bare ``%`` in the first place.
672
+
673
+ A valid statement can never contain one, so nothing legal is refused -- the
674
+ check is the whole reason this can be a refusal rather than an escape.
675
+ ``SqlValue`` on the derived path *does* escape, and the two are not
676
+ inconsistent: there the caller supplies no parameters and has no reason to
677
+ know one exists.
678
+ """
679
+ position = 0
680
+ while (found := sql.find("%", position)) != -1:
681
+ placeholder = _PLACEHOLDER.match(sql, found)
682
+ if placeholder is None:
683
+ raise InvalidShape(
684
+ f"{model.__name__} declares sql= containing a lone '%' in "
685
+ f"{sql[max(0, found - 12) : found + 8].strip()!r}. The statement is run with "
686
+ "its parameters, so a '%' that is not a placeholder is read as the start of "
687
+ "one and the driver refuses it far from here. Write '%%' for the modulo "
688
+ "operator. (A derived projection's SqlValue needs no escaping, because there "
689
+ "you supply no parameters and the package escapes for you.)"
690
+ )
691
+ position = placeholder.end()
692
+
693
+
650
694
  def _addressable(model: type[Model]) -> dict[str, Field[Any, Any]]:
651
695
  """Every concrete field under both names a caller might reasonably use.
652
696
 
@@ -63,7 +63,8 @@ class SqlValue:
63
63
  has to be both.
64
64
 
65
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
66
+ applies rather than asking for -- unlike ``sql=``, which takes ``params=``
67
+ and so owns its own placeholders; a lone ``%`` is refused there instead. The statement is executed with bound
67
68
  parameters, so a literal ``%`` in it is an incomplete placeholder to both
68
69
  psycopg and Django's SQLite wrapper, and the declaration fails at execution
69
70
  with a paramstyle error naming nothing in the shape. The paramstyle is this
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__: str = "0.19.0"
5
+ __version__: str = "0.20.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -299,6 +299,28 @@ package owns the keys, and a statement it did not write has to say what they are
299
299
  rather than leave them to a sequence whose current value is not part of any
300
300
  declaration.
301
301
 
302
+ ### One thing is inspected: a lone `%`
303
+
304
+ The statement is run **with its parameters**, and an empty parameter sequence is
305
+ still a sequence — so a `%` that is not a placeholder is read as the start of one
306
+ and the driver refuses it, at build time, with a message naming `psycopg` and
307
+ nothing about the shape. Write `%%` for the modulo operator:
308
+
309
+ ```python
310
+ sql = "SELECT row_number() OVER (ORDER BY e.id), e.id, e.id %% 5 FROM app_event e"
311
+ ```
312
+
313
+ That is the pyformat contract and it is deliberate here: `sql=` takes `params=`,
314
+ so the placeholders are the interface. The declaration is checked for a lone `%`
315
+ only so the refusal arrives where the mistake is, rather than one layer down. A
316
+ valid statement can never contain one, so nothing legal is refused.
317
+
318
+ `SqlValue` on the derived path is the other way round and that is not an
319
+ inconsistency: there you supply no parameters and have no reason to know one
320
+ exists, so `%` is escaped for you. The rule is the same in both places — the
321
+ paramstyle belongs to whoever wrote the statement, and on the derived path that
322
+ is this package.
323
+
302
324
  Nothing else about the select is inspected — that is what an escape hatch is —
303
325
  but the build still gives it the emptiness check, the sequence reset, the
304
326
  `ANALYZE` and the transaction.
@@ -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.19.0"
173
+ current_version = "0.20.0"
174
174
  allow_dirty = false
175
175
  commit = false
176
176
  tag = false
@@ -0,0 +1,98 @@
1
+ """A lone ``%`` in a supplied select, refused where the declaration is.
2
+
3
+ ``sql=`` is a parameterised statement -- the caller writes ``%s`` and passes
4
+ ``params=``, which is the documented interface -- so pyformat is part of that
5
+ contract and a literal ``%`` has to be doubled. That is correct and stays.
6
+
7
+ What was wrong is where it was discovered. The statement reaches the driver with
8
+ a parameter sequence, so an unescaped ``%`` is an incomplete placeholder, and
9
+ the exception comes from ``psycopg/cursor.py`` at build time naming nothing in
10
+ the shape. A caller who passed no parameters at all has no model that explains
11
+ it.
12
+
13
+ The asymmetry with ``SqlValue`` is deliberate and the refusal says so: on the
14
+ derived path the caller supplies no parameters and has no reason to know one
15
+ exists, so ``render()`` escapes for them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pytest
21
+
22
+ from django_data_shape import Constant, FanOut, InvalidShape, Projection, Shape, Table, Uniform
23
+ from tests.testapp.models import Event, EventSession, Template
24
+
25
+ pytestmark = pytest.mark.django_db
26
+
27
+
28
+ def _projection(sql: str, params: tuple[object, ...] = ()) -> Projection:
29
+ return Projection(
30
+ EventSession,
31
+ columns=("id", "event", "title"),
32
+ sql=sql,
33
+ params=params,
34
+ reads=(Event,),
35
+ )
36
+
37
+
38
+ def test_a_lone_percent_is_refused_at_declaration_time() -> None:
39
+ with pytest.raises(InvalidShape) as caught:
40
+ _projection("SELECT e.id, e.id, e.name FROM testapp_event e WHERE e.id % 5 = 0")
41
+
42
+ message = str(caught.value)
43
+ assert "EventSession" in message, "the refusal has to name the declaration"
44
+ assert "%%" in message, "and say what to write instead"
45
+ # Named, so the refusal cannot be mistaken for a complaint about the
46
+ # placeholders the same statement is allowed to carry.
47
+ assert "e.id % 5" in message
48
+
49
+
50
+ def test_the_refusal_explains_why_sqlvalue_differs() -> None:
51
+ """The asymmetry is the thing a reader will next be confused by."""
52
+ with pytest.raises(InvalidShape) as caught:
53
+ _projection("SELECT e.id, e.id, e.name FROM testapp_event e WHERE e.id % 5 = 0")
54
+
55
+ assert "SqlValue" in str(caught.value)
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "fragment",
60
+ [
61
+ "e.name <> %s",
62
+ "e.name <> %(name)s",
63
+ "e.name <> %s AND e.id % 5 = 0",
64
+ ],
65
+ )
66
+ def test_a_placeholder_is_not_a_lone_percent(fragment: str) -> None:
67
+ """Every pyformat shape survives, including one beside a doubled operator."""
68
+ sql = f"SELECT e.id, e.id, e.name FROM testapp_event e WHERE {fragment.replace('% 5', '%% 5')}"
69
+ params = ("x",) if "%s" in fragment or "%(name)s" in fragment else ()
70
+
71
+ projection = _projection(sql, params)
72
+
73
+ assert projection.model is EventSession
74
+
75
+
76
+ def test_a_doubled_percent_still_reaches_the_database_as_an_operator() -> None:
77
+ """The refusal teaches a spelling that has to work, so it is run."""
78
+ shape = Shape(
79
+ Table(Template, rows=3, name=Constant("t")),
80
+ Table(Event, rows=12, template=FanOut(Uniform(1, 3)), name=Constant("e")),
81
+ Projection(
82
+ EventSession,
83
+ columns=("id", "event", "title", "minutes", "channel"),
84
+ sql=(
85
+ "SELECT row_number() OVER (ORDER BY e.id), e.id, "
86
+ "cast(e.id %% 5 AS varchar(200)), 30, 'web' FROM testapp_event e"
87
+ ),
88
+ reads=(Event,),
89
+ ),
90
+ seed=5,
91
+ )
92
+
93
+ from django_data_shape import build
94
+
95
+ build(shape, require_statistics=False)
96
+
97
+ titles = {title for title in EventSession.objects.values_list("title", flat=True)}
98
+ assert len(titles) > 1, "the operator has to have run, not been sent as a placeholder"