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