django-data-shape 0.20.0__tar.gz → 0.22.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.20.0 → django_data_shape-0.22.0}/.github/workflows/release.yml +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.github/workflows/tests.yml +5 -5
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.github/workflows/upstream-drift.yml +79 -7
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/CHANGELOG.md +387 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/CLAUDE.md +19 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/PKG-INFO +10 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/README.md +9 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/__init__.py +36 -32
- django_data_shape-0.22.0/django_data_shape/backends/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/backends}/require_clone_strategy.py +2 -2
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/backends}/require_postgres.py +1 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/backends}/unsupported_backend.py +1 -1
- django_data_shape-0.22.0/django_data_shape/databases/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/databases}/clone_database.py +16 -4
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/databases}/drop_database.py +13 -1
- django_data_shape-0.22.0/django_data_shape/databases/require_quotable_name.py +45 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/databases}/shape_digest.py +7 -7
- django_data_shape-0.22.0/django_data_shape/databases/template_database.py +993 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/databases}/unhashable_shape.py +1 -1
- django_data_shape-0.22.0/django_data_shape/databases/unusable_base.py +33 -0
- django_data_shape-0.22.0/django_data_shape/declaration/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/check_constraints.py +9 -9
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/order_derivations.py +1 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/order_tables.py +6 -6
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/projection.py +121 -13
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/shape.py +12 -12
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/shape_from_factory.py +1 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/sql_value.py +55 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/table.py +7 -7
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/after.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/derived.py +2 -2
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/given.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/offset.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/per_parent.py +2 -2
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/product.py +2 -2
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/categorical.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/distinct.py +2 -2
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/skew.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/uniform.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/zipf.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/fixtures/scale_fixture.py +24 -12
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/fixtures/shape_fixture.py +20 -12
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/fixtures/skip_unless_postgres.py +3 -3
- django_data_shape-0.22.0/django_data_shape/generation/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/generation}/generate_rows.py +4 -4
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/generation}/refuse_queries.py +1 -1
- django_data_shape-0.22.0/django_data_shape/invariants/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/invariants}/check_invariants.py +3 -3
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/invariants}/invariant.py +2 -2
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/invariants}/invariant_violated.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/disjoint.py +7 -0
- django_data_shape-0.22.0/django_data_shape/keys/is_disjoint.py +31 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/key_function.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/md5_keys.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/sequential_keys.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/sql_keys.py +1 -1
- django_data_shape-0.22.0/django_data_shape/loading/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/loading}/apply_statistics_targets.py +58 -5
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/loading}/build.py +44 -31
- django_data_shape-0.22.0/django_data_shape/relations/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/fan_out.py +2 -2
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/fan_out_sizes.py +17 -16
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/paired.py +3 -3
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/resolve_fan_out.py +15 -4
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/resolve_paired.py +4 -4
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/world_changed.py +2 -2
- django_data_shape-0.22.0/django_data_shape/scaling/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/scaling}/scale_protocol.py +1 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/scaling}/scaled_shape.py +5 -5
- django_data_shape-0.22.0/django_data_shape/scaling/scaled_world.py +867 -0
- django_data_shape-0.22.0/django_data_shape/scaling/shape_referenced.py +41 -0
- django_data_shape-0.22.0/django_data_shape/types/__init__.py +1 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/types}/build_result.py +1 -1
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/types}/canonical.py +2 -2
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/types}/children_per_parent.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/utils.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/keys.md +7 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/projections.md +96 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/pytest.md +133 -18
- django_data_shape-0.22.0/docs/reference.md +143 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/relations.md +7 -6
- django_data_shape-0.22.0/docs/statistics.md +506 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/pyproject.toml +1 -1
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/backends}/test_require_clone_strategy.py +1 -1
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/backends}/test_require_postgres.py +1 -1
- django_data_shape-0.22.0/tests/databases/test_require_quotable_name.py +102 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/databases}/test_shape_digest.py +1 -1
- django_data_shape-0.22.0/tests/databases/test_template_database.py +2078 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_order_derivations.py +1 -1
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_order_tables.py +1 -1
- django_data_shape-0.22.0/tests/declaration/test_projection_values_references.py +195 -0
- django_data_shape-0.22.0/tests/declaration/test_projection_values_uuid.py +153 -0
- django_data_shape-0.22.0/tests/distributions/__init__.py +0 -0
- django_data_shape-0.22.0/tests/fixtures/__init__.py +0 -0
- django_data_shape-0.22.0/tests/generation/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/generation}/test_generate_rows.py +2 -2
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/generation}/test_refuse_queries.py +1 -1
- django_data_shape-0.22.0/tests/invariants/__init__.py +0 -0
- django_data_shape-0.22.0/tests/keys/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/keys}/test_infer_key_strategy.py +1 -1
- django_data_shape-0.22.0/tests/keys/test_is_disjoint.py +89 -0
- django_data_shape-0.22.0/tests/loading/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_apply_statistics_targets.py +180 -6
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build.py +25 -0
- django_data_shape-0.22.0/tests/relations/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_paired_plan.py +2 -2
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_resolve_fan_out.py +1 -1
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_resolve_paired.py +2 -2
- django_data_shape-0.22.0/tests/scaling/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scale_protocol.py +3 -3
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scaled_world.py +139 -7
- django_data_shape-0.22.0/tests/scaling/test_scaled_world_disjoint_advice.py +166 -0
- django_data_shape-0.22.0/tests/scaling/test_scaled_world_leaves_undeclared_tables_alone.py +672 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scaled_world_over_a_session_world.py +44 -1
- django_data_shape-0.22.0/tests/scaling/test_scaled_world_over_disjoint_tables.py +482 -0
- django_data_shape-0.22.0/tests/scaling/test_scaled_world_pending_trigger_events.py +382 -0
- django_data_shape-0.22.0/tests/test_package_layout.py +77 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/test_reference_documents_the_public_surface.py +1 -1
- django_data_shape-0.22.0/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/testapp/models.py +246 -0
- django_data_shape-0.22.0/tests/types/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/uv.lock +74 -74
- django_data_shape-0.20.0/django_data_shape/scaled_world.py +0 -176
- django_data_shape-0.20.0/django_data_shape/template_database.py +0 -281
- django_data_shape-0.20.0/docs/reference.md +0 -139
- django_data_shape-0.20.0/docs/statistics.md +0 -274
- django_data_shape-0.20.0/tests/test_template_database.py +0 -443
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.gitignore +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/LICENSE +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/Makefile +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/declaration}/invalid_shape.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/aligned.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/copied.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/derivation.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/derivations/scope.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/ascending.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/fixtures/__init__.py +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/generation}/derivation_queried_database.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/__init__.py +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/keys}/infer_key_strategy.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/key_strategy.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/keys/uuid_keys.py +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/loading}/shape_not_empty.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/fan_out_plan.py +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/relations}/paired_plan.py +0 -0
- {django_data_shape-0.20.0/django_data_shape → django_data_shape-0.22.0/django_data_shape/types}/table_result.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/derivations.md +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/index.md +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/docs/invariants.md +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/mkdocs.yml +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/scripts/check-changelog +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/__init__.py +0 -0
- {django_data_shape-0.20.0/tests/derivations → django_data_shape-0.22.0/tests/backends}/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/conftest.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/conftest_settings.py +0 -0
- {django_data_shape-0.20.0/tests/distributions → django_data_shape-0.22.0/tests/databases}/__init__.py +0 -0
- {django_data_shape-0.20.0/tests/keys → django_data_shape-0.22.0/tests/declaration}/__init__.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_check_constraints.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_projection.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_projection_ceiling.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_projection_sql_percent.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_projection_values.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_shape.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_shape_from_factory.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/declaration}/test_table.py +0 -0
- {django_data_shape-0.20.0/tests/testapp → django_data_shape-0.22.0/tests/derivations}/__init__.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_after.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_after_units.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_aligned.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_canonical_arithmetic.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_derived.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_given.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/derivations/test_per_parent.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/distributions/test_zipf.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/fixtures}/test_fixtures.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/invariants}/test_invariant.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/keys/test_key_function.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/keys/test_md5_keys.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/keys/test_sequential_keys.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/keys/test_uuid_keys.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_derivations.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_graph.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_invariants.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_keys.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_m2m.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_portable.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/loading}/test_build_projection.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_fan_out.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_fan_out_sizes.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_paired.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/relations}/test_paired_parents.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/scale_protocol_consumers.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/scale_protocol_impostors.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scaled_projection_ceiling.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scaled_shape.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/scaling}/test_scaled_world_restores_sequences.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/test_documentation.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/test_utils.py +0 -0
- {django_data_shape-0.20.0 → django_data_shape-0.22.0}/tests/test_version.py +0 -0
- {django_data_shape-0.20.0/tests → django_data_shape-0.22.0/tests/types}/test_children_per_parent.py +0 -0
|
@@ -56,7 +56,7 @@ jobs:
|
|
|
56
56
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
57
57
|
with:
|
|
58
58
|
fetch-depth: 0
|
|
59
|
-
- uses: astral-sh/setup-uv@
|
|
59
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
60
60
|
with:
|
|
61
61
|
enable-cache: true
|
|
62
62
|
- name: Install
|
|
@@ -20,7 +20,7 @@ jobs:
|
|
|
20
20
|
runs-on: ubuntu-latest
|
|
21
21
|
steps:
|
|
22
22
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
23
|
-
- uses: astral-sh/setup-uv@
|
|
23
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
24
24
|
with:
|
|
25
25
|
enable-cache: true
|
|
26
26
|
- name: Install
|
|
@@ -37,7 +37,7 @@ jobs:
|
|
|
37
37
|
runs-on: ubuntu-latest
|
|
38
38
|
steps:
|
|
39
39
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
40
|
-
- uses: astral-sh/setup-uv@
|
|
40
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
41
41
|
with:
|
|
42
42
|
enable-cache: true
|
|
43
43
|
- name: Install with docs group
|
|
@@ -79,7 +79,7 @@ jobs:
|
|
|
79
79
|
PGDATABASE: django_data_shape
|
|
80
80
|
steps:
|
|
81
81
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
82
|
-
- uses: astral-sh/setup-uv@
|
|
82
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
83
83
|
with:
|
|
84
84
|
enable-cache: true
|
|
85
85
|
# A floor is a claim about the *oldest* version that works, and a lockfile
|
|
@@ -199,7 +199,7 @@ jobs:
|
|
|
199
199
|
|
|
200
200
|
steps:
|
|
201
201
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
202
|
-
- uses: astral-sh/setup-uv@
|
|
202
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
203
203
|
with:
|
|
204
204
|
enable-cache: true
|
|
205
205
|
- name: Install Python ${{ matrix.python-version }}
|
|
@@ -257,7 +257,7 @@ jobs:
|
|
|
257
257
|
PGDATABASE: django_data_shape
|
|
258
258
|
steps:
|
|
259
259
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
260
|
-
- uses: astral-sh/setup-uv@
|
|
260
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
261
261
|
with:
|
|
262
262
|
enable-cache: true
|
|
263
263
|
- name: Install Python 3.13
|
|
@@ -41,9 +41,38 @@ jobs:
|
|
|
41
41
|
resolve-latest:
|
|
42
42
|
name: resolve unpinned + test
|
|
43
43
|
runs-on: ubuntu-latest
|
|
44
|
+
# The same service tests.yml gives its own jobs. Without it this workflow
|
|
45
|
+
# resolved the newest of everything, installed it, and then failed every
|
|
46
|
+
# Postgres-backed test on `connection refused` -- so it has never once
|
|
47
|
+
# reported on upstream drift, only on its own missing database. A job that
|
|
48
|
+
# cannot pass cannot fail informatively either: the issue it opens each week
|
|
49
|
+
# names a resolve that was never the problem.
|
|
50
|
+
services:
|
|
51
|
+
postgres:
|
|
52
|
+
image: postgres:17
|
|
53
|
+
env:
|
|
54
|
+
POSTGRES_USER: postgres
|
|
55
|
+
POSTGRES_PASSWORD: postgres
|
|
56
|
+
POSTGRES_DB: django_data_shape
|
|
57
|
+
ports:
|
|
58
|
+
- 5432:5432
|
|
59
|
+
options: >-
|
|
60
|
+
--health-cmd pg_isready
|
|
61
|
+
--health-interval 10s
|
|
62
|
+
--health-timeout 5s
|
|
63
|
+
--health-retries 5
|
|
64
|
+
# Mirrors tests.yml. The unpinned resolve includes psycopg, so a release of
|
|
65
|
+
# it that breaks the connection is exactly the drift this is here to find,
|
|
66
|
+
# and it can only be found against a real server.
|
|
67
|
+
env:
|
|
68
|
+
PGHOST: localhost
|
|
69
|
+
PGPORT: "5432"
|
|
70
|
+
PGUSER: postgres
|
|
71
|
+
PGPASSWORD: postgres
|
|
72
|
+
PGDATABASE: django_data_shape
|
|
44
73
|
steps:
|
|
45
74
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
46
|
-
- uses: astral-sh/setup-uv@
|
|
75
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
47
76
|
with:
|
|
48
77
|
enable-cache: false
|
|
49
78
|
|
|
@@ -63,27 +92,70 @@ jobs:
|
|
|
63
92
|
run: git --no-pager diff --stat uv.lock || true
|
|
64
93
|
|
|
65
94
|
- name: Install
|
|
95
|
+
id: install
|
|
66
96
|
run: uv sync --all-groups --all-extras
|
|
67
97
|
|
|
68
98
|
- name: Show the resolved versions
|
|
69
99
|
run: uv pip list
|
|
70
100
|
|
|
101
|
+
# A resolve can succeed by backing off. When the newest release of a
|
|
102
|
+
# direct dependency conflicts with something else this project requires,
|
|
103
|
+
# uv takes the newest version that does resolve and logs an ordinary
|
|
104
|
+
# "Updated" line, so the suite below passes against a release nobody
|
|
105
|
+
# chose. That is how django-ag-ui's run went green with pydantic-ai-slim
|
|
106
|
+
# at 2.46 and 2.54 out: from 2.47 its [ag-ui] extra caps ag-ui-protocol
|
|
107
|
+
# below the 1.0 django-ag-ui requires. This compares every direct
|
|
108
|
+
# dependency with PyPI, fails on one held below a release that has been
|
|
109
|
+
# out longer than the Dependabot cooldown, and asks uv what holds it. A
|
|
110
|
+
# ceiling declared in pyproject.toml is honoured, which is how a hold is
|
|
111
|
+
# accepted on purpose. One copy for every repo, pinned like any action.
|
|
112
|
+
- name: Check no direct dependency is held back
|
|
113
|
+
id: held-back
|
|
114
|
+
uses: Artui/held-back-check@b64bbb88fd63c9c96675caba477cce13714de93e # v0.1.0
|
|
115
|
+
|
|
116
|
+
# Runs whether or not the check above failed: they answer different
|
|
117
|
+
# questions, and a hold is no reason to stop measuring what did resolve.
|
|
71
118
|
- name: Test
|
|
119
|
+
id: test
|
|
120
|
+
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
|
|
72
121
|
run: uv run pytest
|
|
73
122
|
|
|
74
123
|
- name: Open or update an issue on failure
|
|
75
|
-
|
|
124
|
+
# Main only. A dispatch on any other branch is a trial of that branch's
|
|
125
|
+
# manifest, and an issue saying the unpinned resolve is failing would be
|
|
126
|
+
# filed against the wrong tree.
|
|
127
|
+
if: failure() && github.ref == 'refs/heads/main'
|
|
128
|
+
env:
|
|
129
|
+
HELD_BACK: ${{ steps.held-back.outcome }}
|
|
130
|
+
TESTS: ${{ steps.test.outcome }}
|
|
131
|
+
REPORT: ${{ steps.held-back.outputs.report }}
|
|
76
132
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
77
133
|
with:
|
|
78
134
|
script: |
|
|
135
|
+
const fs = require("fs");
|
|
79
136
|
const title = "upstream drift: the unpinned resolve is failing";
|
|
137
|
+
const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
138
|
+
const sections = [];
|
|
139
|
+
if (process.env.TESTS === "failure") {
|
|
140
|
+
sections.push([
|
|
141
|
+
"The weekly unpinned resolve failed. A dependency released a version",
|
|
142
|
+
"that `pyproject.toml` admits and the suite does not survive.",
|
|
143
|
+
"",
|
|
144
|
+
"The `git diff --stat uv.lock` step in that run names what moved.",
|
|
145
|
+
].join("\n"));
|
|
146
|
+
}
|
|
147
|
+
if (process.env.HELD_BACK === "failure") {
|
|
148
|
+
sections.push(fs.existsSync(process.env.REPORT)
|
|
149
|
+
? fs.readFileSync(process.env.REPORT, "utf8")
|
|
150
|
+
: "The held-back check failed before writing its report; the run's log says why.");
|
|
151
|
+
}
|
|
152
|
+
if (!sections.length) {
|
|
153
|
+
sections.push("A step before the suite failed; the run's log names it.");
|
|
154
|
+
}
|
|
80
155
|
const body = [
|
|
81
|
-
|
|
82
|
-
"that `pyproject.toml` admits and the suite does not survive.",
|
|
83
|
-
"",
|
|
84
|
-
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
|
156
|
+
...sections,
|
|
85
157
|
"",
|
|
86
|
-
|
|
158
|
+
`Run: ${run}`,
|
|
87
159
|
"",
|
|
88
160
|
"Fix forward (adopt the new version) or add a floor/ceiling that says",
|
|
89
161
|
"why, but do not silence the job — it is the control that lets this",
|
|
@@ -7,6 +7,390 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.0] — 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`template_database(shape, base=...)` starts a template from a database
|
|
15
|
+
that is already migrated.** A template was always built into an empty
|
|
16
|
+
database that `migrate` replayed the whole history into, and on a project
|
|
17
|
+
with a long one that replay is most of the cost: a consumer with about 330
|
|
18
|
+
migrations measured thirteen and a half minutes of `migrate` against about a
|
|
19
|
+
minute to build a 4.4-million-row shape, paid again by every template a
|
|
20
|
+
change to the declaration makes. With a base, the template starts as
|
|
21
|
+
`CREATE DATABASE ... TEMPLATE <base>`: the tables `migrate` builds for apps
|
|
22
|
+
without migrations are rebuilt from the models, and everything else the base
|
|
23
|
+
holds is carried as it is. Apps with migrations are brought forward by their
|
|
24
|
+
history: `migrate --run-syncdb` still runs over the copy, applying whatever
|
|
25
|
+
the base has yet to and firing `post_migrate`. Apps without migrations have
|
|
26
|
+
no history, so the tables `run_syncdb` makes for them -- their managed
|
|
27
|
+
models' tables and the many-to-many tables Django creates for those models'
|
|
28
|
+
relations, read the way `run_syncdb` reads them, router and models module
|
|
29
|
+
included, and found under the name PostgreSQL stores, which cuts a
|
|
30
|
+
`db_table` longer than 63 bytes -- are dropped from the copy first and made
|
|
31
|
+
again from the current models, as in an empty database. `run_syncdb` never alters an existing table, so a table the
|
|
32
|
+
base made from an older model would otherwise survive under a key naming the
|
|
33
|
+
new one. The base's rows in those tables do not carry over; the rest of its
|
|
34
|
+
rows, an unmanaged model's table and the tables of an app no longer installed
|
|
35
|
+
are carried. A foreign key or view in the base that points into a rebuilt
|
|
36
|
+
table stops the drop, which runs without `CASCADE` so that nothing is
|
|
37
|
+
removed silently, and is refused as `UnusableBase` from the copy. So is a
|
|
38
|
+
rebuilt table the connecting role may not drop: `DROP TABLE` needs ownership
|
|
39
|
+
of the table or of its schema, and a copy keeps every owner the base gave, so
|
|
40
|
+
the message names each such table, its owner and the remedies -- connect as
|
|
41
|
+
the owner or a member of it that inherits its privileges, reassign the tables
|
|
42
|
+
in the base, or pass `base=None`. The check runs in the copy rather than the
|
|
43
|
+
base, because the role that makes the copy owns it and so holds the
|
|
44
|
+
privileges of `pg_database_owner`, which owns `public` from PostgreSQL 15: a
|
|
45
|
+
role owning none of those tables can still drop them from its own copy, and
|
|
46
|
+
is not refused.
|
|
47
|
+
`base=None`, the default, behaves and keys exactly as before.
|
|
48
|
+
|
|
49
|
+
**A base behind the migrations on disk is migrated forward in the copy; only
|
|
50
|
+
a history migrating cannot repair is refused**, with the new `UnusableBase`,
|
|
51
|
+
before anything is created. Migrating forward always ends at the checkout's schema for the
|
|
52
|
+
apps with migrations, whatever prefix of their history the base holds, and
|
|
53
|
+
the others are rebuilt, so the key stays sound without the
|
|
54
|
+
base's applied migrations entering it -- the same reason Django's own
|
|
55
|
+
PostgreSQL `TEST: {"TEMPLATE": ...}` can migrate a copy forward. A base far
|
|
56
|
+
behind pays its `migrate` once per key, never more than building from empty,
|
|
57
|
+
and the base a project has most often, one behind because a branch added a
|
|
58
|
+
migration, builds rather than being refused. *Ahead* is an applied migration
|
|
59
|
+
of an installed app that no migration on disk is or replaces: of the
|
|
60
|
+
histories a base can record, the one migrating cannot bring to the
|
|
61
|
+
checkout's schema, since a branch
|
|
62
|
+
migration that adds only an index would otherwise build silently and skew
|
|
63
|
+
plan assertions. The rows a squash leaves behind while it still lists them in
|
|
64
|
+
`replaces`, once all of them are applied, and the rows of an app that is no
|
|
65
|
+
longer installed, are not ahead. The message names up to three migrations
|
|
66
|
+
and both remedies: `manage.py migrate <app> --prune` for rows left by
|
|
67
|
+
squashed migrations deleted after their squash's `replaces` was removed, and
|
|
68
|
+
migrating the base back or recreating it for a migration from another
|
|
69
|
+
branch. Where `--prune` would decline, because a squash on disk still lists
|
|
70
|
+
in `replaces` a migration the base records as applied and that is gone from
|
|
71
|
+
disk -- one of the app being pruned from Django 5.1, of any app before -- it
|
|
72
|
+
names the squash and says to finish it first, and nowhere else, since
|
|
73
|
+
finishing a squash whose replaced files are still on disk splits the app
|
|
74
|
+
into two leaf migrations. Two
|
|
75
|
+
histories `migrate` would mishandle are refused too: a squash applied to the
|
|
76
|
+
base only in part, when a replaced migration it has yet to apply is gone
|
|
77
|
+
from disk -- Django then runs neither the squash nor the rest of what it
|
|
78
|
+
replaces, so the template would silently lack them, read off the graph
|
|
79
|
+
Django's loader builds, so that from Django 6.0 a squash of a squash is
|
|
80
|
+
judged over everything under it -- and a base holding the tables of an app
|
|
81
|
+
with a migration on disk that it records no applied migration for, which
|
|
82
|
+
`migrate` would try to create again: one with no `django_migrations` table,
|
|
83
|
+
one restored from `pg_dump --schema-only`, which brings that table back
|
|
84
|
+
empty, or one made before the app had migrations. An app whose migrations
|
|
85
|
+
would create nothing, such as a `0001_initial` holding only
|
|
86
|
+
`SeparateDatabaseAndState` state operations, is refused the same way,
|
|
87
|
+
although `migrate` would accept it. A base that does not
|
|
88
|
+
exist, one that does not accept connections, a name holding a double quote
|
|
89
|
+
-- which Django's quoting cannot carry intact, so the database checked and
|
|
90
|
+
the database copied could differ -- and a template this package made or the
|
|
91
|
+
partial of one are each refused by name rather than failing inside Django or
|
|
92
|
+
the server. Only a name the package generates counts as a template, so a
|
|
93
|
+
database of your own whose name starts `data_shape_` is not mistaken for one.
|
|
94
|
+
|
|
95
|
+
The key also takes the base's name and its database oid, so dropping and
|
|
96
|
+
recreating a base -- the usual way one restored from a dump is refreshed,
|
|
97
|
+
with the rows of `django_migrations` alongside the schema -- is a new
|
|
98
|
+
template. The base is checked on a cache hit too. Rows
|
|
99
|
+
the base holds become template content; changing them in place, by hand or
|
|
100
|
+
by migrating the base, neither of which moves its name or oid, is not seen
|
|
101
|
+
by the key, and is stated beside the
|
|
102
|
+
existing `RunSQL` gap with the same remedy, `drop_database`. One case no
|
|
103
|
+
check can see is stated too: a migration regenerated under a name the base
|
|
104
|
+
has already applied is skipped by `migrate`, so the copy keeps the version
|
|
105
|
+
the base ran; recreating the base is the remedy. Nor can one more: SQL an
|
|
106
|
+
applied migration of an app with migrations ran against a table of an app
|
|
107
|
+
without them -- an index, a trigger, a policy, a grant or a comment -- is
|
|
108
|
+
lost when the copy rebuilds that table, and recreating the base does not
|
|
109
|
+
help, so that template is built from empty with `base=None`. The process's
|
|
110
|
+
own connection is closed before the copy, so a project whose test database
|
|
111
|
+
is the base can pass it as one.
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
|
|
115
|
+
- **Every module the package owns now lives in a subpackage named for a
|
|
116
|
+
concern.** The root held forty-five modules against four to eight in the
|
|
117
|
+
sibling libraries, and no rule had been broken: the structural rules all
|
|
118
|
+
governed a *file* -- one exported symbol, named after it, fully annotated --
|
|
119
|
+
and none governed a directory, so a package could satisfy every one of them
|
|
120
|
+
and still put everything it does in one place. The root now holds
|
|
121
|
+
`__init__.py`, `version.py` and `utils.py`.
|
|
122
|
+
|
|
123
|
+
The new directories are `declaration/` (what a user writes, and everything
|
|
124
|
+
that refuses it before a row exists), `relations/` (fan-out and paired edges,
|
|
125
|
+
declared and resolved against the keys that exist), `generation/`, `loading/`,
|
|
126
|
+
`invariants/`, `databases/`, `backends/`, `scaling/` and `types/`.
|
|
127
|
+
`infer_key_strategy` moved into the existing `keys/`. There is no
|
|
128
|
+
`exceptions/`: an exception stays beside the code that raises it, so
|
|
129
|
+
`DerivationQueriedDatabase` sits with the guard that raises it and
|
|
130
|
+
`UnsupportedBackend` with the two backend gates.
|
|
131
|
+
|
|
132
|
+
**No public import changes.** `__all__` is unchanged, name for name and in the
|
|
133
|
+
same order, and every symbol is still imported the same way -- the only paths
|
|
134
|
+
that moved are the ones a caller was never meant to reach into. The
|
|
135
|
+
`django_data_shape.fixtures` surface is untouched.
|
|
136
|
+
|
|
137
|
+
- **`tests/test_package_layout.py` gates the layout**, asserting that the root
|
|
138
|
+
holds only the allowlisted modules and that no module name is used at two
|
|
139
|
+
levels. Prose is what failed here: a convention that names only files is
|
|
140
|
+
obeyed exactly as far as files go, and the drift it permitted was invisible
|
|
141
|
+
for twenty-one releases.
|
|
142
|
+
|
|
143
|
+
### Fixed
|
|
144
|
+
|
|
145
|
+
- **A scaled world no longer empties tables it does not declare, including
|
|
146
|
+
parents reached through a chain of foreign keys.** On PostgreSQL the emptying
|
|
147
|
+
was `TRUNCATE ... CASCADE`, and `CASCADE` follows foreign keys by schema,
|
|
148
|
+
transitively, whatever the rows hold. In an application whose billing points
|
|
149
|
+
back at its clubs -- a section belongs to a club, a fee to a section, and a
|
|
150
|
+
club references its selected fee -- a shape declaring only `Section` emptied
|
|
151
|
+
the clubs too. A fan-out narrowed to a club the caller had made, with
|
|
152
|
+
`parents=`, was then refused with `InvalidShape` for naming a key that was
|
|
153
|
+
"not a row of" the club table, blaming the caller's key for the package's own
|
|
154
|
+
statement; without `parents=` nothing was refused, and tables the caller had
|
|
155
|
+
filled were silently empty for the life of the block.
|
|
156
|
+
|
|
157
|
+
A world now removes the rows of its declared tables and nothing else, and no
|
|
158
|
+
statement it issues changes a table its shape does not declare, even inside
|
|
159
|
+
the block. On PostgreSQL it reads, from `pg_constraint`, every table
|
|
160
|
+
referencing a declared table that holds rows -- the set `CASCADE` would have
|
|
161
|
+
taken -- and when none of those holds rows it issues one `TRUNCATE` that
|
|
162
|
+
lists them all, without `CASCADE`: nothing outside the declaration loses a
|
|
163
|
+
row, and a list that ever missed a table would be refused by PostgreSQL
|
|
164
|
+
rather than silently widened. That is the route a session world under a
|
|
165
|
+
scaled world over the same graph takes every time. Otherwise each declared
|
|
166
|
+
table is emptied by `DELETE`, children first, and every undeclared row stays
|
|
167
|
+
where it was. A `DELETE` fires row-level `DELETE` triggers, which `TRUNCATE`
|
|
168
|
+
did not: a trigger on a declared table runs inside the world, and what it
|
|
169
|
+
writes elsewhere is rolled back with the block, while a `BEFORE DELETE`
|
|
170
|
+
trigger that keeps its rows leaves the build refusing the table with
|
|
171
|
+
`ShapeNotEmpty`. A world over empty tables issues no emptying statement and
|
|
172
|
+
fires no foreign-key checks.
|
|
173
|
+
|
|
174
|
+
A declared table with `Disjoint` keys is still left alone and built beside --
|
|
175
|
+
unless it holds rows and has a foreign key into a declared table the world
|
|
176
|
+
empties, directly or through another such table. Then it is emptied too, since
|
|
177
|
+
its rows are declared rows and cannot outlive the parents they point at. In
|
|
178
|
+
0.21.0 `CASCADE` emptied such a table on PostgreSQL, along with everything
|
|
179
|
+
else referencing the declared tables, and off PostgreSQL its rows were left
|
|
180
|
+
pointing at parents the world had deleted. The foreign keys that decide it
|
|
181
|
+
are the models', so a `ForeignKey(db_constraint=False)` pulls its table in as
|
|
182
|
+
well. A `Disjoint` table pointing at nothing the world empties keeps its
|
|
183
|
+
rows, which over a session world declaring it are the session's, and the
|
|
184
|
+
world builds beside them with keys of its own. `UuidKeys` and `Md5Keys` make
|
|
185
|
+
each key from the row and a stream derived from the seed, which scaling
|
|
186
|
+
keeps, so in 0.21.0 a world over a session world built from the same shape
|
|
187
|
+
made the session's keys and failed on the primary key. Every `Disjoint`
|
|
188
|
+
table a world builds is now handed a stream of the world's own, never the
|
|
189
|
+
one `build()` uses for the same seed and table, so inside a world its keys
|
|
190
|
+
are not the ones `build()` gives the same declaration. That stream does not
|
|
191
|
+
depend on the factor, so row *i* has one key at every factor, as an integer
|
|
192
|
+
key does, and a world opened inside another over the same table draws from
|
|
193
|
+
another stream again. Foreign keys into such a table need nothing, since a
|
|
194
|
+
fan-out reads its parents' keys from the table. And a world now reads
|
|
195
|
+
`Disjoint` as `build()` does: a key strategy implementing the protocol whose
|
|
196
|
+
`is_disjoint_from_existing_rows()` answers no is emptied like any other,
|
|
197
|
+
where the world used to leave its rows in place for the build to refuse with
|
|
198
|
+
`ShapeNotEmpty`.
|
|
199
|
+
|
|
200
|
+
**One behaviour changes: a scaled world declaring a parent over rows whose
|
|
201
|
+
undeclared children reference it is now refused**, where 0.21.0 silently
|
|
202
|
+
emptied them on PostgreSQL and left them pointing at nothing elsewhere.
|
|
203
|
+
Removing the parent's rows would leave those references pointing at nothing
|
|
204
|
+
or take the children along, so the world raises the new `ShapeReferenced`
|
|
205
|
+
before removing anything, naming each reference as
|
|
206
|
+
`referencing_table.column -> declared_table` and the ways out: declare that
|
|
207
|
+
table in the shape too, so its rows are the world's, or do not create those
|
|
208
|
+
rows in the test. Where following it would end the refusal, it also offers
|
|
209
|
+
`Disjoint` keys, so the world builds beside the rows already there: only
|
|
210
|
+
where every declared table it names has a primary key that accepts the UUIDs
|
|
211
|
+
`UuidKeys` and `Md5Keys` make, so never an integer key or one that is itself
|
|
212
|
+
a foreign key, and only where none of them is a projected table, has
|
|
213
|
+
`Disjoint` keys already, or has a foreign key into another table being
|
|
214
|
+
emptied, which would pull it back into the emptying. A scaled
|
|
215
|
+
world over part of a session world's graph meets it the same way, and the
|
|
216
|
+
remedy is the same. A foreign key left null is not a reference, and only a
|
|
217
|
+
foreign key the database enforces is seen: `ForeignKey(db_constraint=False)`
|
|
218
|
+
and `GenericForeignKey` are invisible to it. The refusal is made on every
|
|
219
|
+
backend: PostgreSQL's catalogue answers it there, and Django's introspection
|
|
220
|
+
everywhere else. It runs before any `DELETE` on both, which is what keeps a
|
|
221
|
+
`DELETE` inside the declaration: one can reach past its table through a
|
|
222
|
+
database-level `ON DELETE`, such as the one Django 6.1's `DB_CASCADE`
|
|
223
|
+
creates, but by then no row outside the tables being emptied references
|
|
224
|
+
them, so all it can reach is declared rows being removed anyway. Introspection reports each column of a
|
|
225
|
+
composite foreign key on its own, so off PostgreSQL such a key counts as soon
|
|
226
|
+
as any column is set; Django never creates one.
|
|
227
|
+
|
|
228
|
+
The `parents=` refusal names the one way a key can still vanish inside a
|
|
229
|
+
world: a parent table declared in the same shape, which the world empties
|
|
230
|
+
before building. The `scaled_world`, `scale_fixture` and `shape_fixture`
|
|
231
|
+
docstrings and the pytest page describe the rule, where the two fixtures'
|
|
232
|
+
docstrings still said a scaled world could not build over a session world at
|
|
233
|
+
all.
|
|
234
|
+
|
|
235
|
+
- **A scaled world builds over child rows the caller wrote earlier in the same
|
|
236
|
+
transaction.** On PostgreSQL it failed on the way in with `cannot TRUNCATE
|
|
237
|
+
"..." because it has pending trigger events`. Django creates PostgreSQL
|
|
238
|
+
foreign keys `DEFERRABLE INITIALLY DEFERRED`, so a child row leaves its
|
|
239
|
+
foreign-key check queued until commit, and PostgreSQL refuses to truncate a
|
|
240
|
+
table with checks still pending. An insert into a parent alone queues nothing,
|
|
241
|
+
which is why only a caller who wrote a child met it; the usual way in is a
|
|
242
|
+
factory's `SubFactory` in a test's own setup.
|
|
243
|
+
|
|
244
|
+
`scaled_world` now fires the pending checks before a `TRUNCATE`. That changes
|
|
245
|
+
only *when* they run: a row that genuinely violates a constraint now raises
|
|
246
|
+
`IntegrityError` at world entry, naming the constraint, rather than when the
|
|
247
|
+
enclosing transaction next checks. The `DELETE` route the entry above adds,
|
|
248
|
+
taken on PostgreSQL when an undeclared table holds rows, fires them after its
|
|
249
|
+
`DELETE` statements instead, so a row outside the declaration that breaks a
|
|
250
|
+
constraint raises at entry on either route. The two routes part on an orphan
|
|
251
|
+
the caller wrote in a declared table: the `TRUNCATE` route raises for it,
|
|
252
|
+
while the `DELETE` route removes it first, PostgreSQL skips the check of a row
|
|
253
|
+
that is gone, and the world builds. Once the checks are fired, on either
|
|
254
|
+
route, every deferrable constraint is deferred for the rest of the block, one
|
|
255
|
+
declared `INITIALLY IMMEDIATE` included; the mode is transaction state, so
|
|
256
|
+
the rollback of the world's own block restores the caller's. Off PostgreSQL
|
|
257
|
+
nothing is fired, because nothing there refuses a pending check.
|
|
258
|
+
|
|
259
|
+
**`build()` sets a declared statistics target over checks still queued in its
|
|
260
|
+
transaction**, where PostgreSQL refused the `ALTER TABLE ... SET STATISTICS`
|
|
261
|
+
with `cannot ALTER TABLE "..." because it has pending trigger events`. A table
|
|
262
|
+
with `Disjoint` keys is built beside the rows already there, so a row the
|
|
263
|
+
caller wrote into it earlier in the transaction left its check queued on the
|
|
264
|
+
very table the build then altered -- and a scaled world over such a table
|
|
265
|
+
empties nothing, so it met the same refusal. Each row a world's `DELETE`
|
|
266
|
+
removes from a referenced table queues a check too. `apply_statistics_targets`
|
|
267
|
+
now fires the pending checks before its first `ALTER TABLE`, and only for a
|
|
268
|
+
table that declares a target, so a table declaring none issues no statement
|
|
269
|
+
more. A row that breaks a constraint raises `IntegrityError` there, naming
|
|
270
|
+
it. Firing them ends with `SET CONSTRAINTS ALL DEFERRED`, so a build inside a
|
|
271
|
+
transaction of the caller's leaves every deferrable constraint deferred until
|
|
272
|
+
that transaction ends.
|
|
273
|
+
|
|
274
|
+
The docstring claimed this package never issues a destructive statement
|
|
275
|
+
against a table it did not fill. Beside `CASCADE` that was untrue. What holds
|
|
276
|
+
now, and what the docstring says, is that no statement a world issues changes
|
|
277
|
+
a table its shape does not declare: the only statement naming an undeclared
|
|
278
|
+
table is a `TRUNCATE` of tables that hold no rows.
|
|
279
|
+
|
|
280
|
+
The PostgreSQL statement count a capture around a world sees over empty
|
|
281
|
+
tables is nineteen, the same at every factor; over a session world declaring
|
|
282
|
+
the same tables, emptying them first adds five more where another table
|
|
283
|
+
references them, four where none does, also the same at every factor. The
|
|
284
|
+
figure quoted in the `scaled_world` and `scale_fixture` docstrings
|
|
285
|
+
and in the pytest page had drifted from the measured one in all three places;
|
|
286
|
+
they now quote both, and a test holds them to it. The `execute_wrapper` figure
|
|
287
|
+
the `scaled_world` docstring quotes beside it, seventeen -- two fewer, because
|
|
288
|
+
`COPY` reaches the debug cursor's log but not the wrapper hook -- is now
|
|
289
|
+
measured and held to the prose the same way.
|
|
290
|
+
|
|
291
|
+
- **The `ShapeNotEmpty` message and the pytest page describe the refusal the
|
|
292
|
+
package makes.** Since 0.17.0 a scaled world empties the tables it declares
|
|
293
|
+
inside the transaction it rolls back, so it builds over a session world's
|
|
294
|
+
rows -- yet the message still said a scaled world cannot build over them, and
|
|
295
|
+
the pytest page's "The two fixtures do not share a table" still called that
|
|
296
|
+
composition refused and prescribed different models. The message now names
|
|
297
|
+
the causes that remain: a second build over a session world's rows, another
|
|
298
|
+
`shape_fixture` or `build()` called directly, and a template started from a
|
|
299
|
+
base database that holds rows in a declared table. Its remedies are a scaled
|
|
300
|
+
world, different models, or emptying the table, in the base if that is where
|
|
301
|
+
the rows came from. The page's section is now "A scaled world can sit over a
|
|
302
|
+
session world", quoting the new message, and a test holds the quotation to
|
|
303
|
+
the message raised.
|
|
304
|
+
|
|
305
|
+
- **Statistics targets are looked up under the name Django created the table
|
|
306
|
+
with.** `build()` read each column's current target through `regclass`,
|
|
307
|
+
binding the model's raw `db_table`, and `regclass` input is parsed as an
|
|
308
|
+
identifier: a capital letter folded to lower case and a dot split the name
|
|
309
|
+
into a schema and a table. A model whose `db_table` held either stopped the
|
|
310
|
+
build with "relation does not exist" before a row was written, or, where a
|
|
311
|
+
table of the folded name existed, was checked against that table's targets,
|
|
312
|
+
so a shape its own column could not record went through unrefused. The name
|
|
313
|
+
is now quoted by the connection, as Django's `CREATE TABLE` quoted it, which
|
|
314
|
+
also resolves a `db_table` written already quoted, such as
|
|
315
|
+
`'"schema"."table"'`.
|
|
316
|
+
|
|
317
|
+
- **`drop_database` and `clone_database` refuse a database name holding a
|
|
318
|
+
double quote**, with `ValueError`, before any statement. Django's
|
|
319
|
+
`quote_name` wraps a name in double quotes without escaping one inside it,
|
|
320
|
+
and passes a name already wrapped in them through unchanged, so a statement
|
|
321
|
+
could name a different database from the one the same string names as a
|
|
322
|
+
value: `drop_database('"x"')` looked up a database literally named `"x"`,
|
|
323
|
+
reported that it was not there, and dropped `x`, and a quote followed by a
|
|
324
|
+
clause made the clause part of the statement. `template_database` already
|
|
325
|
+
refused such a base; all three now share one check, and no name this package
|
|
326
|
+
makes holds a double quote.
|
|
327
|
+
|
|
328
|
+
## [0.21.0] — 2026-09-06
|
|
329
|
+
|
|
330
|
+
### Added
|
|
331
|
+
|
|
332
|
+
- **`values=` expressions can name one another, as `{values.<name>}`.** A
|
|
333
|
+
projected table's measure columns are usually related to each other -- a
|
|
334
|
+
requested amount and an approved one, a quantity and a total, an amount and
|
|
335
|
+
the rate derived from it -- and an expression could not say so. The
|
|
336
|
+
relationship had to be restated from whatever both columns were computed from,
|
|
337
|
+
with coefficients chosen so that it happens to hold: a reader could only find
|
|
338
|
+
the invariant by doing the arithmetic, and nothing rechecked it when either
|
|
339
|
+
expression was edited.
|
|
340
|
+
|
|
341
|
+
The name is dotted rather than a bare `{requested_amount}` because `{per}` and
|
|
342
|
+
`{source}` already occupy that space and a model is entitled to a column
|
|
343
|
+
called `per`. Only `values=` entries are referenceable -- a copied column is
|
|
344
|
+
already reachable as `{source}.name`, and the primary key is the
|
|
345
|
+
`row_number()` window itself and is reachable nowhere.
|
|
346
|
+
|
|
347
|
+
**It is substitution, not sharing**, which is what the spelling says: the
|
|
348
|
+
referenced expression is written out again, parenthesised, and the database
|
|
349
|
+
evaluates it once per reference. That is free for a deterministic expression,
|
|
350
|
+
and every expression here already had to be deterministic -- `template_database`
|
|
351
|
+
reuses a database keyed on the declaration and nothing else. What a reference
|
|
352
|
+
adds is that a volatile expression now disagrees with itself *within one
|
|
353
|
+
build*, so the documentation pairs the feature with an `Invariant` rather than
|
|
354
|
+
leaving the rule stated and unchecked.
|
|
355
|
+
|
|
356
|
+
A subquery computing each expression once was declined: the key is a
|
|
357
|
+
`row_number()` window whose outer `ORDER BY` decides where rows physically
|
|
358
|
+
land, so nesting moves the one thing this package exists to control -- and a
|
|
359
|
+
shape using no reference would have had its statement changed to buy a feature
|
|
360
|
+
it does not use. Substitution leaves every existing shape's SQL byte-identical.
|
|
361
|
+
|
|
362
|
+
A reference that names nothing, and a cycle, are refused at declaration time
|
|
363
|
+
and name the path. A name that is a *copied* column is refused separately and
|
|
364
|
+
hands over `{source}.name`, because that is a mistake about the spelling
|
|
365
|
+
rather than about the column.
|
|
366
|
+
|
|
367
|
+
### Fixed
|
|
368
|
+
|
|
369
|
+
- **The documented `SqlValue` example was a type error on UUID-keyed models.**
|
|
370
|
+
`({per}.id * 31 + {source}.id * 17) % 5 + 1` works on Django's default
|
|
371
|
+
`BigAutoField` and has no operator at all on a schema whose models carry
|
|
372
|
+
`id = UUIDField(primary_key=True)` -- which a shared abstract base makes an
|
|
373
|
+
ordinary layout rather than an unusual one. So the first thing such a reader
|
|
374
|
+
copied out of the documentation did not run, and it failed from inside a
|
|
375
|
+
generated statement at build time rather than at declaration.
|
|
376
|
+
|
|
377
|
+
The guide and the docstring now carry a worked example for UUID keys, and it
|
|
378
|
+
is **executed by the suite** rather than asserted: the documentation's Python
|
|
379
|
+
blocks are only parsed, and an expression is a string literal that parses
|
|
380
|
+
perfectly, which is exactly how this shipped. Two parts of the replacement are
|
|
381
|
+
load-bearing and fail rarely enough to reach production -- `::bigint` before
|
|
382
|
+
`abs`, because `hashtext` returns `int4` and `abs(-2147483648)` is `integer
|
|
383
|
+
out of range`; and `abs` at all, because PostgreSQL's `%` keeps the sign of
|
|
384
|
+
the dividend, so a measure column would hold negatives and every plan over it
|
|
385
|
+
would still look fine.
|
|
386
|
+
|
|
387
|
+
- **The projections guide implied UUID keys were settled by one sentence about
|
|
388
|
+
`sql=`.** That sentence is about the *projected table's own* primary key. The
|
|
389
|
+
tables named by `per=` and `copying=` may be keyed however they like, and the
|
|
390
|
+
care an expression over one of them needs is a different subject that the page
|
|
391
|
+
did not cover -- so a reader with UUID keys throughout read a paragraph that
|
|
392
|
+
appeared to address them and was sent onward into the broken example.
|
|
393
|
+
|
|
10
394
|
## [0.20.0] — 2026-09-05
|
|
11
395
|
|
|
12
396
|
### Added
|
|
@@ -1298,7 +1682,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1298
1682
|
into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
|
|
1299
1683
|
first.
|
|
1300
1684
|
|
|
1301
|
-
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.
|
|
1685
|
+
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.22.0...HEAD
|
|
1686
|
+
[0.22.0]: https://github.com/Artui/django-data-shape/compare/v0.21.0...v0.22.0
|
|
1687
|
+
[0.21.0]: https://github.com/Artui/django-data-shape/compare/v0.20.0...v0.21.0
|
|
1302
1688
|
[0.20.0]: https://github.com/Artui/django-data-shape/compare/v0.19.0...v0.20.0
|
|
1303
1689
|
[0.19.0]: https://github.com/Artui/django-data-shape/compare/v0.18.1...v0.19.0
|
|
1304
1690
|
[0.18.1]: https://github.com/Artui/django-data-shape/compare/v0.18.0...v0.18.1
|
|
@@ -51,6 +51,25 @@ make docs-build # mkdocs build --strict
|
|
|
51
51
|
- `__init__.py` is the only re-export point, and holds no logic.
|
|
52
52
|
- Every annotated module starts with `from __future__ import annotations`. Ruff's
|
|
53
53
|
`required-imports` enforces this rather than leaving it to memory.
|
|
54
|
+
- **The package root is a table of contents, not a drawer.** It holds
|
|
55
|
+
`__init__.py`, `version.py` and `utils.py`; everything this package does lives
|
|
56
|
+
in a subpackage named for a concern. Those are `declaration/` (what a user
|
|
57
|
+
writes and what refuses it), `relations/`, `generation/`, `loading/`,
|
|
58
|
+
`invariants/`, `databases/`, `backends/`, `scaling/`, the vocabulary
|
|
59
|
+
directories `derivations/`, `distributions/` and `keys/`, the value carriers in
|
|
60
|
+
`types/`, and the pytest surface in `fixtures/`. Three modules on one concern
|
|
61
|
+
earn a directory; below three they join the neighbouring concern rather than
|
|
62
|
+
sitting at the root. A name says what the code does, so `helpers/`, `core/`,
|
|
63
|
+
`common/` and `misc/` are refused -- each is the flat root again one level
|
|
64
|
+
down. An exception lives beside the code that raises it, or, where many
|
|
65
|
+
modules raise it, in the subpackage whose failure it names, which is why there
|
|
66
|
+
is no `exceptions/`.
|
|
67
|
+
|
|
68
|
+
This rule is asserted in `tests/test_package_layout.py` rather than trusted,
|
|
69
|
+
because prose is what failed the first time: the six rules above all govern a
|
|
70
|
+
*file*, every one of them was satisfied, and forty-two modules still piled up
|
|
71
|
+
at the package root. The test gates both halves -- the root allowlist, and
|
|
72
|
+
that no module name is used at two levels.
|
|
54
73
|
|
|
55
74
|
## The rules the design rests on
|
|
56
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: django-data-shape
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.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
|
|
@@ -268,6 +268,15 @@ clone_database(template, "test_myapp", replace=True)
|
|
|
268
268
|
|
|
269
269
|
The template is named after a content hash of the declaration, the schema, the
|
|
270
270
|
relevant settings and this package's version, so a stale one is never asked for.
|
|
271
|
+
A project with a long migration history can start templates from a database it
|
|
272
|
+
already keeps migrated, `template_database(shape, base="myproject_base")`, rather
|
|
273
|
+
than replaying every migration into an empty one. Apps with migrations are
|
|
274
|
+
migrated forward in the copy, which always ends at the checkout's schema; the
|
|
275
|
+
tables `migrate` builds for apps without them are rebuilt from the models, so the
|
|
276
|
+
base's rows in those do not carry over; and everything else the base holds, its
|
|
277
|
+
other rows and its unmanaged tables among them, is carried as it is. Only a
|
|
278
|
+
history no forward migration can fix, such as a base ahead of the migrations on
|
|
279
|
+
disk, is refused.
|
|
271
280
|
A shape holding a `Derived` or a `KeyFunction` is refused rather than hashed --
|
|
272
281
|
there is no honest digest of a callable, and every way of guessing one agrees
|
|
273
282
|
while the data has changed. See
|
|
@@ -225,6 +225,15 @@ clone_database(template, "test_myapp", replace=True)
|
|
|
225
225
|
|
|
226
226
|
The template is named after a content hash of the declaration, the schema, the
|
|
227
227
|
relevant settings and this package's version, so a stale one is never asked for.
|
|
228
|
+
A project with a long migration history can start templates from a database it
|
|
229
|
+
already keeps migrated, `template_database(shape, base="myproject_base")`, rather
|
|
230
|
+
than replaying every migration into an empty one. Apps with migrations are
|
|
231
|
+
migrated forward in the copy, which always ends at the checkout's schema; the
|
|
232
|
+
tables `migrate` builds for apps without them are rebuilt from the models, so the
|
|
233
|
+
base's rows in those do not carry over; and everything else the base holds, its
|
|
234
|
+
other rows and its unmanaged tables among them, is carried as it is. Only a
|
|
235
|
+
history no forward migration can fix, such as a base ahead of the migrations on
|
|
236
|
+
disk, is refused.
|
|
228
237
|
A shape holding a `Derived` or a `KeyFunction` is refused rather than hashed --
|
|
229
238
|
there is no honest digest of a callable, and every way of guessing one agrees
|
|
230
239
|
while the data has changed. See
|