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.
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/CHANGELOG.md +25 -1
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/PKG-INFO +1 -1
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/projection.py +44 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/sql_value.py +2 -1
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/projections.md +22 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/pyproject.toml +1 -1
- django_data_shape-0.20.0/tests/test_projection_sql_percent.py +98 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/release.yml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/tests.yml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.github/workflows/upstream-drift.yml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.gitignore +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/CLAUDE.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/LICENSE +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/Makefile +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/README.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/apply_statistics_targets.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/build.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/build_result.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/canonical.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/check_constraints.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/check_invariants.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/children_per_parent.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/clone_database.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivation_queried_database.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/after.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/aligned.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/copied.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derivation.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derived.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/given.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/offset.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/per_parent.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/product.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/scope.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/ascending.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/categorical.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/distinct.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/skew.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/uniform.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/zipf.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/drop_database.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out_plan.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fan_out_sizes.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/generate_rows.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/infer_key_strategy.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invalid_shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invariant.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invariant_violated.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/disjoint.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_function.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_strategy.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/md5_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/sequential_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/sql_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/uuid_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/order_derivations.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/order_tables.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/paired.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/paired_plan.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/refuse_queries.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/require_clone_strategy.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/require_postgres.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/resolve_fan_out.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/resolve_paired.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scale_protocol.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scaled_shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/scaled_world.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_digest.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_from_factory.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_not_empty.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/table.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/table_result.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/template_database.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/unhashable_shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/unsupported_backend.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/utils.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/world_changed.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/derivations.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/index.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/invariants.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/keys.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/pytest.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/reference.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/relations.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/docs/statistics.md +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/mkdocs.yml +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/scripts/check-changelog +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/conftest.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/conftest_settings.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_after.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_after_units.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_aligned.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_derived.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_given.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_per_parent.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_zipf.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_key_function.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_md5_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_sequential_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/keys/test_uuid_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/scale_protocol_consumers.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/scale_protocol_impostors.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_apply_statistics_targets.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_derivations.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_graph.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_invariants.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_keys.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_m2m.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_portable.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_build_projection.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_check_constraints.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_children_per_parent.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_documentation.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fan_out.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fan_out_sizes.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_fixtures.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_generate_rows.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_infer_key_strategy.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_invariant.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_order_derivations.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_order_tables.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired_parents.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_paired_plan.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection_ceiling.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_projection_values.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_reference_documents_the_public_surface.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_refuse_queries.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_require_clone_strategy.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_require_postgres.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_resolve_fan_out.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_resolve_paired.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scale_protocol.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_projection_ceiling.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world_over_a_session_world.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world_restores_sequences.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape_digest.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_shape_from_factory.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_table.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_template_database.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_utils.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_version.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/testapp/models.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
@@ -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.
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/apply_statistics_targets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/check_constraints.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/children_per_parent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/__init__.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/after.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/aligned.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/copied.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derivation.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/derived.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/given.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/offset.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/per_parent.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/product.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/derivations/scope.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/__init__.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/ascending.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/bounded.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/categorical.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/constant.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/distinct.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/sequential.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/skew.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/uniform.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/distributions/zipf.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/__init__.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/scale_fixture.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/fixtures/shape_fixture.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/infer_key_strategy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/invariant_violated.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_function.py
RENAMED
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/key_strategy.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/keys/sequential_keys.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/order_derivations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/require_clone_strategy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/shape_from_factory.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/template_database.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/django_data_shape/unsupported_backend.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/derivations/test_canonical_arithmetic.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/distributions/test_sequential.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_apply_statistics_targets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_projection_ceiling.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_data_shape-0.19.0 → django_data_shape-0.20.0}/tests/test_scaled_world_restores_sequences.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|