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