django-data-shape 0.16.0__tar.gz → 0.17.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.16.0 → django_data_shape-0.17.0}/CHANGELOG.md +67 -1
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/PKG-INFO +1 -1
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/__init__.py +6 -0
- django_data_shape-0.17.0/django_data_shape/derivations/copied.py +46 -0
- django_data_shape-0.17.0/django_data_shape/derivations/offset.py +65 -0
- django_data_shape-0.17.0/django_data_shape/derivations/product.py +67 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scaled_world.py +56 -2
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/table.py +43 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/utils.py +42 -1
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/derivations.md +32 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/reference.md +6 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/pyproject.toml +1 -1
- django_data_shape-0.17.0/tests/derivations/test_after_units.py +77 -0
- django_data_shape-0.17.0/tests/derivations/test_canonical_arithmetic.py +165 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fixtures.py +21 -12
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scaled_world.py +12 -5
- django_data_shape-0.17.0/tests/test_scaled_world_over_a_session_world.py +95 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_utils.py +46 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/testapp/models.py +22 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/release.yml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/tests.yml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.github/workflows/upstream-drift.yml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.gitignore +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/CLAUDE.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/LICENSE +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/Makefile +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/README.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/apply_statistics_targets.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/build.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/build_result.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/canonical.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/check_constraints.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/check_invariants.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/children_per_parent.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/clone_database.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivation_queried_database.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/after.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/aligned.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/derivation.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/derived.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/given.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/per_parent.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/derivations/scope.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/ascending.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/categorical.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/distinct.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/skew.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/uniform.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/distributions/zipf.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/drop_database.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out_plan.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fan_out_sizes.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/scale_fixture.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/shape_fixture.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/fixtures/skip_unless_postgres.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/generate_rows.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/infer_key_strategy.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invalid_shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invariant.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/invariant_violated.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/disjoint.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/key_function.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/key_strategy.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/md5_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/sequential_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/sql_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/keys/uuid_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/order_derivations.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/order_tables.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/paired.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/paired_plan.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/projection.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/refuse_queries.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/require_clone_strategy.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/require_postgres.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/resolve_fan_out.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/resolve_paired.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scale_protocol.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/scaled_shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_digest.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_from_factory.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/shape_not_empty.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/table_result.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/template_database.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/unhashable_shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/unsupported_backend.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/django_data_shape/world_changed.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/index.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/invariants.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/keys.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/projections.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/pytest.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/relations.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/docs/statistics.md +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/mkdocs.yml +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/scripts/check-changelog +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/conftest.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/conftest_settings.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_after.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_aligned.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_derived.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_given.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/derivations/test_per_parent.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/distributions/test_zipf.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_key_function.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_md5_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_sequential_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/keys/test_uuid_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/scale_protocol_consumers.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/scale_protocol_impostors.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_apply_statistics_targets.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_derivations.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_graph.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_invariants.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_keys.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_m2m.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_portable.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_build_projection.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_check_constraints.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_children_per_parent.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_documentation.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fan_out.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_fan_out_sizes.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_generate_rows.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_infer_key_strategy.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_invariant.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_order_derivations.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_order_tables.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_paired.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_paired_plan.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_projection.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_reference_documents_the_public_surface.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_refuse_queries.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_require_clone_strategy.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_require_postgres.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_resolve_fan_out.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_resolve_paired.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scale_protocol.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_scaled_shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape_digest.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_shape_from_factory.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_table.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_template_database.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/test_version.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.16.0 → django_data_shape-0.17.0}/uv.lock +0 -0
|
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.0] — 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`Product`, `Offset` and `Copied`: the commonest arithmetic, said as data.**
|
|
15
|
+
They compute nothing `Derived` could not. They exist because of what `Derived`
|
|
16
|
+
costs: it takes a callable, a callable cannot be honestly digested, and a
|
|
17
|
+
shape holding one is refused by `template_database`. So a column as ordinary
|
|
18
|
+
as `total = quantity * unit_price` took a whole declaration out of the reuse
|
|
19
|
+
that turns a forty-second build into a hundred-millisecond clone -- and
|
|
20
|
+
`build()` kept working, so nothing said what it had cost.
|
|
21
|
+
|
|
22
|
+
The refusal stays; it is right. These three are pure data, implement
|
|
23
|
+
`Canonical`, and hash. `Derived` remains the answer for computation that
|
|
24
|
+
really is code.
|
|
25
|
+
|
|
26
|
+
`Offset` is also the same-row half of `After`, which is parent-scoped only.
|
|
27
|
+
Its gap is fixed rather than spread across a window, because a due date thirty
|
|
28
|
+
days after an issue date is a term and not a distribution.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **A scaled world can now be built over a session world.** The two pytest
|
|
33
|
+
surfaces this package ships wanted the same tables in any application with one
|
|
34
|
+
model graph, and were mutually exclusive: the session world holds its rows for
|
|
35
|
+
the whole run, so the scaled build met a table that was not empty and refused.
|
|
36
|
+
The documented answer -- give the two different models -- is not available to a
|
|
37
|
+
project whose plan assertions and growth assertions are about the same flow,
|
|
38
|
+
because that is what the application is.
|
|
39
|
+
|
|
40
|
+
It was also order-dependent, which is what made it worth fixing rather than
|
|
41
|
+
documenting better: a suite whose growth tests happened to be collected first
|
|
42
|
+
passed, and the same tests named in the other order failed.
|
|
43
|
+
|
|
44
|
+
`scaled_world` now empties the tables its shape declares before building.
|
|
45
|
+
**Nothing is snapshotted, and nothing needs to be**: this happens inside the
|
|
46
|
+
atomic block it already rolls back, so whatever was there comes back when the
|
|
47
|
+
block ends. A table whose keys are `Disjoint` is left alone, mirroring the
|
|
48
|
+
exemption `build` already makes, so the documented hybrid -- parents made by
|
|
49
|
+
your code, children made here -- keeps working.
|
|
50
|
+
|
|
51
|
+
`build()` keeps its refusal. It has no transaction of its own to undo and
|
|
52
|
+
would be destroying rows for good.
|
|
53
|
+
|
|
54
|
+
The two statement-count constants moved: one TRUNCATE on PostgreSQL, one
|
|
55
|
+
DELETE per declared table elsewhere. Both are properties of the declaration
|
|
56
|
+
rather than the factor, so what those tests pin -- fixed on PostgreSQL at
|
|
57
|
+
every factor, a curve off it -- is unchanged.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **`After` across a parent column of a different kind is refused.**
|
|
62
|
+
`date + timedelta` is a `date`, so a `DateTimeField` child whose parent column
|
|
63
|
+
was a `DateField` was filled with dates: `COPY` accepted them and they landed
|
|
64
|
+
as naive midnights. A world whose orders went on sale in 2024 had shows
|
|
65
|
+
starting in 1900, and the only signal was Django's own per-row
|
|
66
|
+
`RuntimeWarning` in the middle of a build that prints thousands of lines.
|
|
67
|
+
|
|
68
|
+
Both field types are on `_meta` and both are known before a row is generated,
|
|
69
|
+
so this belonged in the class of things already refused at declaration time.
|
|
70
|
+
Only `After` is checked, deliberately: `After` *is* `parent + offset` written
|
|
71
|
+
into this column, so the two describe the same quantity by construction, while
|
|
72
|
+
a `Derived` reading a parent column may legitimately convert -- turning a
|
|
73
|
+
timestamp into a count of days is an ordinary thing to declare.
|
|
74
|
+
|
|
10
75
|
## [0.16.0] — 2026-09-04
|
|
11
76
|
|
|
12
77
|
### Fixed
|
|
@@ -1050,7 +1115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1050
1115
|
into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
|
|
1051
1116
|
first.
|
|
1052
1117
|
|
|
1053
|
-
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.
|
|
1118
|
+
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.17.0...HEAD
|
|
1119
|
+
[0.17.0]: https://github.com/Artui/django-data-shape/compare/v0.16.0...v0.17.0
|
|
1054
1120
|
[0.16.0]: https://github.com/Artui/django-data-shape/compare/v0.15.0...v0.16.0
|
|
1055
1121
|
[0.15.0]: https://github.com/Artui/django-data-shape/compare/v0.14.0...v0.15.0
|
|
1056
1122
|
[0.14.0]: https://github.com/Artui/django-data-shape/compare/v0.13.0...v0.14.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: django-data-shape
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.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
|
|
@@ -11,10 +11,13 @@ from django_data_shape.clone_database import clone_database
|
|
|
11
11
|
from django_data_shape.derivation_queried_database import DerivationQueriedDatabase
|
|
12
12
|
from django_data_shape.derivations.after import After
|
|
13
13
|
from django_data_shape.derivations.aligned import Aligned
|
|
14
|
+
from django_data_shape.derivations.copied import Copied
|
|
14
15
|
from django_data_shape.derivations.derivation import Derivation
|
|
15
16
|
from django_data_shape.derivations.derived import Derived
|
|
16
17
|
from django_data_shape.derivations.given import Given
|
|
18
|
+
from django_data_shape.derivations.offset import Offset
|
|
17
19
|
from django_data_shape.derivations.per_parent import PerParent
|
|
20
|
+
from django_data_shape.derivations.product import Product
|
|
18
21
|
from django_data_shape.derivations.scope import Scope
|
|
19
22
|
from django_data_shape.distributions.ascending import Ascending
|
|
20
23
|
from django_data_shape.distributions.bounded import Bounded
|
|
@@ -67,6 +70,7 @@ __all__ = [
|
|
|
67
70
|
"Categorical",
|
|
68
71
|
"ChildrenPerParent",
|
|
69
72
|
"Constant",
|
|
73
|
+
"Copied",
|
|
70
74
|
"Derivation",
|
|
71
75
|
"DerivationQueriedDatabase",
|
|
72
76
|
"Derived",
|
|
@@ -81,8 +85,10 @@ __all__ = [
|
|
|
81
85
|
"KeyFunction",
|
|
82
86
|
"Md5Keys",
|
|
83
87
|
"KeyStrategy",
|
|
88
|
+
"Offset",
|
|
84
89
|
"Paired",
|
|
85
90
|
"PerParent",
|
|
91
|
+
"Product",
|
|
86
92
|
"Projection",
|
|
87
93
|
"ScaleProtocol",
|
|
88
94
|
"Scope",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""A column taken verbatim from the parent's."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_data_shape.derivations.scope import Scope
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Copied:
|
|
9
|
+
"""``relation.field``, unchanged.
|
|
10
|
+
|
|
11
|
+
A ticket's face value is the unit price of the order it belongs to; a line's
|
|
12
|
+
currency is its invoice's. There is no arithmetic at all, which is what made
|
|
13
|
+
needing a lambda for it -- and losing the template-database cache with it --
|
|
14
|
+
annoying rather than merely unfortunate. See
|
|
15
|
+
:class:`~django_data_shape.derivations.product.Product`.
|
|
16
|
+
|
|
17
|
+
Worth saying what this is *not*, because the two are easy to confuse. A
|
|
18
|
+
denormalised copy is a column with its own statistics: the planner sees a
|
|
19
|
+
distribution over the child table rather than a join, which is the whole
|
|
20
|
+
reason schemas carry such columns and the reason a shaped database has to
|
|
21
|
+
reproduce them. Reading the parent's column through the join is a different
|
|
22
|
+
query with a different plan.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, source: str) -> None:
|
|
26
|
+
self._source = source
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def scope(self) -> Scope:
|
|
30
|
+
return Scope.PARENT
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def sources(self) -> tuple[str, ...]:
|
|
34
|
+
return (self._source,)
|
|
35
|
+
|
|
36
|
+
def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
|
|
37
|
+
return sources[0]
|
|
38
|
+
|
|
39
|
+
def canonical(self) -> object:
|
|
40
|
+
return ("Copied", self._source)
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"Copied({self._source!r})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["Copied"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A fixed distance past another column of the same row."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from django_data_shape.derivations.scope import Scope
|
|
8
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Offset:
|
|
12
|
+
"""``source + by``, where ``source`` is another column of this row.
|
|
13
|
+
|
|
14
|
+
The same-row half of :class:`~django_data_shape.derivations.after.After`,
|
|
15
|
+
which is parent-scoped only. A show goes on sale and then happens; an
|
|
16
|
+
invoice is issued and then falls due. Both columns are on the same row, and
|
|
17
|
+
until this existed the only way to say so was a lambda -- which cost the
|
|
18
|
+
whole shape its template-database cache. See
|
|
19
|
+
:class:`~django_data_shape.derivations.product.Product` for why that
|
|
20
|
+
mattered.
|
|
21
|
+
|
|
22
|
+
The gap is **fixed**, which is the difference from ``After``: that one
|
|
23
|
+
spreads a gap across ``within`` using the column's own draw, because a
|
|
24
|
+
child's distance from its parent is a real distribution. A due date thirty
|
|
25
|
+
days after an issue date is not a distribution, it is a term. Where the
|
|
26
|
+
spread is wanted on the same row, ``Derived`` still takes a callable.
|
|
27
|
+
|
|
28
|
+
``by`` is in the column's own units -- a ``timedelta`` for a datetime
|
|
29
|
+
column, a number for a numeric one -- and anything supporting
|
|
30
|
+
``source + by`` works.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, source: str, *, by: Any) -> None:
|
|
34
|
+
zero = by * 0
|
|
35
|
+
if by < zero:
|
|
36
|
+
raise InvalidShape(
|
|
37
|
+
f"Offset needs a non-negative by, got {by!r}. A column that lands "
|
|
38
|
+
"*before* another is that other column's Offset, declared the other "
|
|
39
|
+
"way round -- which keeps the two readable in the order they happen."
|
|
40
|
+
)
|
|
41
|
+
self._source = source
|
|
42
|
+
self._by = by
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def scope(self) -> Scope:
|
|
46
|
+
return Scope.ROW
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def sources(self) -> tuple[str, ...]:
|
|
50
|
+
return (self._source,)
|
|
51
|
+
|
|
52
|
+
def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
|
|
53
|
+
# cast for the same reason After does: the column's type is the model's
|
|
54
|
+
# business, and a guard here is a branch no declaration reaches.
|
|
55
|
+
source = cast("Any", sources[0])
|
|
56
|
+
return source + self._by
|
|
57
|
+
|
|
58
|
+
def canonical(self) -> object:
|
|
59
|
+
return ("Offset", self._source, self._by)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"Offset({self._source!r}, by={self._by!r})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["Offset"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The product of two columns of the same row."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from django_data_shape.derivations.scope import Scope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Product:
|
|
11
|
+
"""``left * right``, read from two other columns of this row.
|
|
12
|
+
|
|
13
|
+
One of three derivations that exist because
|
|
14
|
+
:class:`~django_data_shape.derivations.derived.Derived` -- the only shipped
|
|
15
|
+
face that can read another column of the same row -- takes a callable, and
|
|
16
|
+
a callable cannot be digested. A shape holding one is refused by
|
|
17
|
+
:func:`~django_data_shape.template_database.template_database`, so a column
|
|
18
|
+
as ordinary as ``total = quantity * unit_price`` excluded the whole
|
|
19
|
+
declaration from the reuse that turns a forty-second build into a
|
|
20
|
+
hundred-millisecond clone.
|
|
21
|
+
|
|
22
|
+
That refusal is right and stays: two lambdas share a name, and identical
|
|
23
|
+
bytecode returns something else when a constant it reads is edited in
|
|
24
|
+
another module. What was wrong is what it excluded. These three say the
|
|
25
|
+
commonest arithmetic as **data**, so they implement
|
|
26
|
+
:class:`~django_data_shape.canonical.Canonical` and the shape hashes::
|
|
27
|
+
|
|
28
|
+
Table(
|
|
29
|
+
Order,
|
|
30
|
+
rows=2_000_000,
|
|
31
|
+
quantity=Aligned("basket", Uniform(1, 8, places=0)),
|
|
32
|
+
unit_price=Aligned("basket", Uniform(1500, 25000, places=0)),
|
|
33
|
+
total=Product("quantity", "unit_price"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
``Derived`` is unchanged and remains the answer for computation that really
|
|
37
|
+
is code. The line between them is whether the declaration can be written
|
|
38
|
+
down: a product of two named columns can, a lambda cannot.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, left: str, right: str) -> None:
|
|
42
|
+
self._left = left
|
|
43
|
+
self._right = right
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def scope(self) -> Scope:
|
|
47
|
+
return Scope.ROW
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def sources(self) -> tuple[str, ...]:
|
|
51
|
+
return (self._left, self._right)
|
|
52
|
+
|
|
53
|
+
def value(self, row: int, draw: float, sources: tuple[object, ...]) -> object:
|
|
54
|
+
# cast rather than a narrowing check, as the other derivations do: what
|
|
55
|
+
# a column holds is decided by the model, not by this package, and a
|
|
56
|
+
# runtime guard would be a branch no declaration can reach.
|
|
57
|
+
left, right = (cast("Any", source) for source in sources)
|
|
58
|
+
return left * right
|
|
59
|
+
|
|
60
|
+
def canonical(self) -> object:
|
|
61
|
+
return ("Product", self._left, self._right)
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
return f"Product({self._left!r}, {self._right!r})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["Product"]
|
|
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Iterator
|
|
6
6
|
from contextlib import contextmanager
|
|
7
7
|
|
|
8
|
-
from django.db import DEFAULT_DB_ALIAS, transaction
|
|
8
|
+
from django.db import DEFAULT_DB_ALIAS, connections, transaction
|
|
9
9
|
|
|
10
10
|
from django_data_shape.build import build
|
|
11
|
+
from django_data_shape.keys.disjoint import Disjoint
|
|
11
12
|
from django_data_shape.scaled_shape import scaled_shape
|
|
12
13
|
from django_data_shape.shape import Shape
|
|
13
14
|
|
|
@@ -81,8 +82,10 @@ def scaled_world(shape: Shape, factor: int, *, using: str = DEFAULT_DB_ALIAS) ->
|
|
|
81
82
|
not drawn -- so the only visible effect is that a row created by the ORM
|
|
82
83
|
after a world is torn down gets a larger id than it otherwise would.
|
|
83
84
|
"""
|
|
85
|
+
scaled = scaled_shape(shape, factor)
|
|
84
86
|
with transaction.atomic(using=using):
|
|
85
|
-
|
|
87
|
+
_empty_declared_tables(scaled, using)
|
|
88
|
+
result = build(scaled, using=using, require_statistics=False)
|
|
86
89
|
yield result.rows
|
|
87
90
|
# Rolling back on the way out rather than raising and swallowing an
|
|
88
91
|
# exception to get there: set_rollback is the supported way to leave an
|
|
@@ -90,3 +93,54 @@ def scaled_world(shape: Shape, factor: int, *, using: str = DEFAULT_DB_ALIAS) ->
|
|
|
90
93
|
# never reaches this line and does not need to -- atomic rolls back for
|
|
91
94
|
# exactly that case already.
|
|
92
95
|
transaction.set_rollback(True, using=using)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _empty_declared_tables(shape: Shape, using: str) -> None:
|
|
99
|
+
"""Clear the tables ``shape`` declares, inside the caller's transaction.
|
|
100
|
+
|
|
101
|
+
A world at a factor is the declared shape at that size and nothing else, so
|
|
102
|
+
rows already in those tables are not this world's -- and emptying them is
|
|
103
|
+
what lets a scaled world be built over a session world, which is the one
|
|
104
|
+
composition of this package's two pytest surfaces that an application with a
|
|
105
|
+
single model graph needs and could not have.
|
|
106
|
+
|
|
107
|
+
**Nothing is snapshotted, and nothing needs to be.** This runs inside the
|
|
108
|
+
atomic block ``scaled_world`` already rolls back, so whatever was there
|
|
109
|
+
comes back when the block ends -- the session world included. That is the
|
|
110
|
+
whole reason the fix is four lines rather than a save-and-restore: the
|
|
111
|
+
database was already going to undo this.
|
|
112
|
+
|
|
113
|
+
Emptying rather than refusing is safe *only* here, and that is why it lives
|
|
114
|
+
in this function and not in ``build``. A bare ``build()`` keeps its refusal,
|
|
115
|
+
because it has no transaction of its own to undo and would be destroying
|
|
116
|
+
rows for good.
|
|
117
|
+
|
|
118
|
+
A table whose keys are :class:`~django_data_shape.keys.disjoint.Disjoint`
|
|
119
|
+
is left alone, mirroring the exemption ``build`` makes for the same reason:
|
|
120
|
+
those keys cannot collide with a caller's rows, so the hybrid this package
|
|
121
|
+
documents -- parents made by your code, children made here -- must keep
|
|
122
|
+
working. Deleting the parents would break it in a new way.
|
|
123
|
+
"""
|
|
124
|
+
connection = connections[using]
|
|
125
|
+
tables = [
|
|
126
|
+
table.db_table
|
|
127
|
+
for table in shape.tables
|
|
128
|
+
if not isinstance(getattr(table, "keys", None), Disjoint)
|
|
129
|
+
]
|
|
130
|
+
if not tables:
|
|
131
|
+
return
|
|
132
|
+
quoted = ", ".join(connection.ops.quote_name(name) for name in tables)
|
|
133
|
+
with connection.cursor() as cursor:
|
|
134
|
+
if connection.vendor == "postgresql":
|
|
135
|
+
# TRUNCATE is transactional on PostgreSQL, so it rolls back with the
|
|
136
|
+
# rest of the block. CASCADE covers a foreign key from a table this
|
|
137
|
+
# shape does not declare; RESTART IDENTITY is deliberately omitted,
|
|
138
|
+
# because a sequence reset is not transactional and would leave the
|
|
139
|
+
# counter behind the rows that came back.
|
|
140
|
+
cursor.execute(f"TRUNCATE {quoted} CASCADE")
|
|
141
|
+
else:
|
|
142
|
+
# DELETE rather than TRUNCATE off PostgreSQL: SQLite has no
|
|
143
|
+
# TRUNCATE, and the loads here are small enough that the difference
|
|
144
|
+
# does not matter.
|
|
145
|
+
for name in reversed(tables):
|
|
146
|
+
cursor.execute(f"DELETE FROM {connection.ops.quote_name(name)}")
|
|
@@ -8,6 +8,7 @@ from typing import Any, cast
|
|
|
8
8
|
|
|
9
9
|
from django.db.models import Field, Model
|
|
10
10
|
|
|
11
|
+
from django_data_shape.derivations.after import After
|
|
11
12
|
from django_data_shape.derivations.derivation import Derivation
|
|
12
13
|
from django_data_shape.derivations.per_parent import PerParent
|
|
13
14
|
from django_data_shape.derivations.scope import Scope
|
|
@@ -26,6 +27,7 @@ from django_data_shape.utils import (
|
|
|
26
27
|
check_not_inherited,
|
|
27
28
|
check_statistics_target,
|
|
28
29
|
has_db_default,
|
|
30
|
+
offsettable_kind,
|
|
29
31
|
primary_key_field,
|
|
30
32
|
)
|
|
31
33
|
|
|
@@ -370,6 +372,47 @@ class Table:
|
|
|
370
372
|
f"field named {field_name}. Its concrete fields are: "
|
|
371
373
|
f"{', '.join(sorted(available))}."
|
|
372
374
|
)
|
|
375
|
+
if isinstance(declared, After):
|
|
376
|
+
self._check_offset_units(name, source, parent, field_name, known[name])
|
|
377
|
+
|
|
378
|
+
def _check_offset_units(
|
|
379
|
+
self,
|
|
380
|
+
name: str,
|
|
381
|
+
source: str,
|
|
382
|
+
parent: type[Model],
|
|
383
|
+
field_name: str,
|
|
384
|
+
column: Field[Any, Any],
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Refuse an ``After`` whose parent column is a different kind of thing.
|
|
387
|
+
|
|
388
|
+
Only ``After`` is checked, and deliberately not every parent-scoped
|
|
389
|
+
derivation: ``After`` *is* ``parent + offset`` written into this column,
|
|
390
|
+
so the two columns describe the same quantity by construction. A
|
|
391
|
+
``Derived`` reading a parent column may legitimately convert -- turning
|
|
392
|
+
a timestamp into a count of days is an ordinary thing to declare -- and
|
|
393
|
+
a check that could not tell the two apart would refuse it.
|
|
394
|
+
|
|
395
|
+
The failure this prevents is silent rather than loud, which is why it
|
|
396
|
+
is worth a refusal at declaration time. ``date + timedelta`` is a
|
|
397
|
+
``date``; the load accepts it, the rows arrive as naive midnights, and
|
|
398
|
+
the only complaint is Django's own per-row warning in the middle of a
|
|
399
|
+
build that prints thousands of lines.
|
|
400
|
+
"""
|
|
401
|
+
parent_field = cast("Field[Any, Any]", parent._meta.get_field(field_name))
|
|
402
|
+
parent_kind = offsettable_kind(parent_field)
|
|
403
|
+
column_kind = offsettable_kind(column)
|
|
404
|
+
if parent_kind is None or column_kind is None or parent_kind == column_kind:
|
|
405
|
+
return
|
|
406
|
+
raise InvalidShape(
|
|
407
|
+
f"{self.model.__name__}.{name} is After({source!r}), which writes that parent "
|
|
408
|
+
f"column plus an offset into this one -- but {parent.__name__}.{field_name} is a "
|
|
409
|
+
f"{type(parent_field).__name__} and {self.model.__name__}.{name} is a "
|
|
410
|
+
f"{type(column).__name__}. Adding an offset to a "
|
|
411
|
+
f"{type(parent_field).__name__} produces a {parent_kind}, so this column would be "
|
|
412
|
+
f"filled with the wrong kind of value and the load would accept it. Read a "
|
|
413
|
+
f"{type(column).__name__} on the parent, or fill this column with a distribution "
|
|
414
|
+
"of its own."
|
|
415
|
+
)
|
|
373
416
|
|
|
374
417
|
def _check_group_sources(self, name: str, declared: Derivation) -> None:
|
|
375
418
|
"""Refuse a group-scoped column whose groups are not there to be grouped by.
|
|
@@ -5,7 +5,17 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
from django.db.models import
|
|
8
|
+
from django.db.models import (
|
|
9
|
+
DateField,
|
|
10
|
+
DateTimeField,
|
|
11
|
+
DecimalField,
|
|
12
|
+
DurationField,
|
|
13
|
+
Field,
|
|
14
|
+
FloatField,
|
|
15
|
+
IntegerField,
|
|
16
|
+
Model,
|
|
17
|
+
TimeField,
|
|
18
|
+
)
|
|
9
19
|
from django.db.models.fields import NOT_PROVIDED
|
|
10
20
|
|
|
11
21
|
from django_data_shape.invalid_shape import InvalidShape
|
|
@@ -191,3 +201,34 @@ def check_not_inherited(model: type[Model]) -> None:
|
|
|
191
201
|
f"{parents} on its own for the columns that live in its table, and make the rows of "
|
|
192
202
|
f"{model.__name__} another way."
|
|
193
203
|
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def offsettable_kind(field: Field[Any, Any]) -> str | None:
|
|
207
|
+
"""What kind of thing this column holds, for the purpose of adding to it.
|
|
208
|
+
|
|
209
|
+
``After`` writes ``parent + offset`` into a column, so the parent's column
|
|
210
|
+
and the child's have to be the same kind of thing. They are not
|
|
211
|
+
interchangeable even when Python will add them: ``date + timedelta`` is a
|
|
212
|
+
``date``, so a ``DateTimeField`` filled from a ``DateField`` parent gets
|
|
213
|
+
dates, ``COPY`` accepts them, and they land as naive midnights.
|
|
214
|
+
|
|
215
|
+
``DateTimeField`` is checked before ``DateField`` because it *subclasses*
|
|
216
|
+
it -- the one ordering in this function that is load-bearing, and the reason
|
|
217
|
+
an ``isinstance`` chain is used rather than a mapping keyed on the class.
|
|
218
|
+
|
|
219
|
+
Returns ``None`` for a column this package has no opinion about, and the
|
|
220
|
+
caller then declines to judge rather than inventing a refusal: a custom
|
|
221
|
+
field that adds cleanly to an offset is a legitimate thing to declare, and
|
|
222
|
+
refusing it would cost a caller a working shape to buy nothing.
|
|
223
|
+
"""
|
|
224
|
+
if isinstance(field, DateTimeField):
|
|
225
|
+
return "datetime"
|
|
226
|
+
if isinstance(field, DateField):
|
|
227
|
+
return "date"
|
|
228
|
+
if isinstance(field, TimeField):
|
|
229
|
+
return "time"
|
|
230
|
+
if isinstance(field, DurationField):
|
|
231
|
+
return "duration"
|
|
232
|
+
if isinstance(field, (IntegerField, FloatField, DecimalField)):
|
|
233
|
+
return "number"
|
|
234
|
+
return None
|
|
@@ -35,6 +35,9 @@ different places, and the place is the only thing that varies:
|
|
|
35
35
|
| `After` | `parent` | `"relation.field"` on the parent row | an order opened after its account signed up |
|
|
36
36
|
| `Given` | `parent` | `"relation.field"` on the parent row | severity skewed by the account's plan |
|
|
37
37
|
| `Aligned` | `rank` | a shared rank the declaration names | the whales are the same whales |
|
|
38
|
+
| `Product` | `row` | two other columns of this row | `total = quantity * unit_price` |
|
|
39
|
+
| `Offset` | `row` | one other column of this row | a show starts 75 days after it goes on sale |
|
|
40
|
+
| `Copied` | `parent` | `"relation.field"`, unchanged | a ticket's face value is its order's unit price |
|
|
38
41
|
|
|
39
42
|
`Derived` is the mechanism and the other three are shorthand over it. The scope
|
|
40
43
|
is a parameter, so a correlation nobody shipped a face for is still declarable:
|
|
@@ -43,6 +46,35 @@ is a parameter, so a correlation nobody shipped a face for is still declarable:
|
|
|
43
46
|
region = Derived("account.country", compute=region_of, scope="parent")
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
## Why three of them say arithmetic as data
|
|
50
|
+
|
|
51
|
+
`Product`, `Offset` and `Copied` compute nothing `Derived` could not. They exist
|
|
52
|
+
because of what `Derived` costs, and the cost is not obvious from the
|
|
53
|
+
declaration.
|
|
54
|
+
|
|
55
|
+
`Derived` takes a **callable**, and a callable cannot be honestly digested: two
|
|
56
|
+
lambdas share a name, and identical bytecode returns something else when a
|
|
57
|
+
constant it reads is edited in another module. So a shape holding one is refused
|
|
58
|
+
by [`template_database`](statistics.md) -- and that refusal is right, because a
|
|
59
|
+
cache key that agreed while the data changed would serve a stale database to a
|
|
60
|
+
suite that then passes.
|
|
61
|
+
|
|
62
|
+
What was wrong is what it excluded. `total = quantity * unit_price` is about as
|
|
63
|
+
ordinary as a column gets, and declaring it with a lambda took the whole shape
|
|
64
|
+
out of the reuse that turns a forty-second build into a hundred-millisecond
|
|
65
|
+
clone. `build()` kept working, so nothing said what it had cost.
|
|
66
|
+
|
|
67
|
+
These three are pure data -- a product of two named columns, a fixed offset, a
|
|
68
|
+
parent column unchanged -- so they implement `Canonical` and the shape hashes.
|
|
69
|
+
Reach for `Derived` when the computation really is code; reach for these when it
|
|
70
|
+
can be written down.
|
|
71
|
+
|
|
72
|
+
`Offset` is also the same-row half of `After`, which is parent-scoped only. The
|
|
73
|
+
difference beyond scope is that `After` spreads its gap across `within` using
|
|
74
|
+
the column's own draw, because a child's distance from its parent is a real
|
|
75
|
+
distribution; `Offset`'s gap is fixed, because a due date thirty days after an
|
|
76
|
+
issue date is a term rather than a distribution.
|
|
77
|
+
|
|
46
78
|
That is why they are one thing. Built separately, "custom creation logic" and
|
|
47
79
|
"correlate across a relation" become two vocabularies overlapping on the
|
|
48
80
|
interesting half -- and the half a consumer asks for first is the within-row one,
|
|
@@ -76,6 +76,12 @@
|
|
|
76
76
|
|
|
77
77
|
::: django_data_shape.derivations.aligned.Aligned
|
|
78
78
|
|
|
79
|
+
::: django_data_shape.derivations.product.Product
|
|
80
|
+
|
|
81
|
+
::: django_data_shape.derivations.offset.Offset
|
|
82
|
+
|
|
83
|
+
::: django_data_shape.derivations.copied.Copied
|
|
84
|
+
|
|
79
85
|
## Keys
|
|
80
86
|
|
|
81
87
|
::: django_data_shape.keys.key_strategy.KeyStrategy
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``After`` writes the parent's value plus an offset, so the two columns have
|
|
2
|
+
to be the same kind of thing.
|
|
3
|
+
|
|
4
|
+
``date + timedelta`` is a ``date``. A ``DateTimeField`` child filled across an
|
|
5
|
+
edge whose parent column is a ``DateField`` was therefore filled with dates:
|
|
6
|
+
``COPY`` accepted them, they landed as naive midnights, and the only signal was
|
|
7
|
+
Django's own per-row ``RuntimeWarning`` in the middle of a build.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as dt
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from django_data_shape import After, Constant, FanOut, InvalidShape, Sequential, Table, Zipf
|
|
17
|
+
from tests.testapp.models import Performance, Ticket
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_a_date_parent_under_a_datetime_child_is_refused() -> None:
|
|
21
|
+
with pytest.raises(InvalidShape) as excinfo:
|
|
22
|
+
Table(
|
|
23
|
+
Performance,
|
|
24
|
+
rows=10,
|
|
25
|
+
playhouse=FanOut(Zipf()),
|
|
26
|
+
starts_at=After("playhouse.opened_on", within=dt.timedelta(days=1)),
|
|
27
|
+
doors_on=Constant(dt.date(2026, 1, 1)),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
message = str(excinfo.value)
|
|
31
|
+
assert "Performance.starts_at" in message
|
|
32
|
+
assert "playhouse.opened_on" in message
|
|
33
|
+
# Both kinds named, because a reader who has to look them up has been given
|
|
34
|
+
# an error that knows more than it says.
|
|
35
|
+
assert "DateField" in message
|
|
36
|
+
assert "DateTimeField" in message
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_datetime_parent_under_a_date_child_is_refused() -> None:
|
|
40
|
+
"""The other direction truncates rather than misdating, and is still wrong."""
|
|
41
|
+
with pytest.raises(InvalidShape, match="doors_on"):
|
|
42
|
+
Table(
|
|
43
|
+
Performance,
|
|
44
|
+
rows=10,
|
|
45
|
+
playhouse=FanOut(Zipf()),
|
|
46
|
+
starts_at=Sequential(
|
|
47
|
+
dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), dt.timedelta(hours=1)
|
|
48
|
+
),
|
|
49
|
+
doors_on=After("playhouse.announced_at", within=dt.timedelta(days=1)),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_matching_kinds_are_accepted() -> None:
|
|
54
|
+
"""A `DateField` after a `DateField` is exactly what the edge is for."""
|
|
55
|
+
Table(
|
|
56
|
+
Performance,
|
|
57
|
+
rows=10,
|
|
58
|
+
playhouse=FanOut(Zipf()),
|
|
59
|
+
starts_at=Sequential(
|
|
60
|
+
dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), dt.timedelta(hours=1)
|
|
61
|
+
),
|
|
62
|
+
doors_on=After("playhouse.opened_on", within=dt.timedelta(days=1)),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_the_documented_datetime_case_still_builds() -> None:
|
|
67
|
+
"""The example in `After`'s own docstring, unchanged."""
|
|
68
|
+
Table(
|
|
69
|
+
Ticket,
|
|
70
|
+
rows=10,
|
|
71
|
+
account=FanOut(Zipf()),
|
|
72
|
+
opened_at=After("account.signed_up_at", within=dt.timedelta(days=365)),
|
|
73
|
+
severity=Constant("low"),
|
|
74
|
+
quantity=Constant(1),
|
|
75
|
+
unit_price=Constant("1.00"),
|
|
76
|
+
total=Constant("1.00"),
|
|
77
|
+
)
|