django-data-shape 0.1.1__tar.gz → 0.2.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.1.1 → django_data_shape-0.2.0}/CHANGELOG.md +35 -1
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/PKG-INFO +21 -5
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/README.md +20 -4
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/__init__.py +4 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/build.py +30 -5
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/__init__.py +2 -1
- django_data_shape-0.2.0/django_data_shape/distributions/zipf.py +37 -0
- django_data_shape-0.2.0/django_data_shape/fan_out.py +82 -0
- django_data_shape-0.2.0/django_data_shape/fan_out_plan.py +75 -0
- django_data_shape-0.2.0/django_data_shape/generate_rows.py +60 -0
- django_data_shape-0.2.0/django_data_shape/order_tables.py +65 -0
- django_data_shape-0.2.0/django_data_shape/resolve_fan_out.py +121 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/table.py +49 -13
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/version.py +1 -1
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/docs/index.md +9 -8
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/docs/reference.md +4 -0
- django_data_shape-0.2.0/docs/relations.md +118 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/mkdocs.yml +1 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/pyproject.toml +1 -1
- django_data_shape-0.2.0/tests/distributions/test_zipf.py +40 -0
- django_data_shape-0.2.0/tests/test_build_graph.py +175 -0
- django_data_shape-0.2.0/tests/test_fan_out.py +42 -0
- django_data_shape-0.2.0/tests/test_order_tables.py +46 -0
- django_data_shape-0.2.0/tests/test_resolve_fan_out.py +141 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_table.py +20 -2
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/testapp/models.py +26 -0
- django_data_shape-0.1.1/django_data_shape/generate_rows.py +0 -43
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/CODEOWNERS +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/SECURITY.md +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/dependabot.yml +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/release.yml +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/tests.yml +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/upstream-drift.yml +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.gitignore +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.pre-commit-config.yaml +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/CLAUDE.md +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/LICENSE +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/Makefile +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/build_result.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/bounded.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/constant.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/distribution.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/sequential.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/skew.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/uniform.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/invalid_shape.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/py.typed +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/require_postgres.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/shape.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/shape_not_empty.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/table_result.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/unsupported_backend.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/utils.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/scripts/release-publish.sh +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/__init__.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/conftest.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/conftest_settings.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/__init__.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_constant.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_sequential.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_skew.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_uniform.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_build.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_generate_rows.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_require_postgres.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_shape.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_utils.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_version.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/testapp/__init__.py +0 -0
- {django_data_shape-0.1.1 → django_data_shape-0.2.0}/uv.lock +0 -0
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] — 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `FanOut`, which declares how a foreign key's children spread across their
|
|
14
|
+
parents: a size distribution, a `childless` share for parents with no children
|
|
15
|
+
at all, a `null` share for nullable columns, and `placement`.
|
|
16
|
+
- `Zipf`, the heavy-tailed weight distribution fan-out is realistically drawn
|
|
17
|
+
from. A table where every parent has ten children is not merely tidy -- it is
|
|
18
|
+
the one shape in which the planner is never wrong, because its `n_distinct`
|
|
19
|
+
average is the truth.
|
|
20
|
+
- Tables load in dependency order, and a cycle of fan-outs is refused by name.
|
|
21
|
+
|
|
22
|
+
### The two representation decisions
|
|
23
|
+
- **Fan-out reads the parent's real keys rather than assuming the dense `1..N`
|
|
24
|
+
range this package assigns.** The case that matters is the hybrid: a project
|
|
25
|
+
builds its fifty companies with the ORM, where the row count is small and the
|
|
26
|
+
ORM is the right tool, and asks this package only for the two million orders.
|
|
27
|
+
Referential integrity then holds by construction, because every key emitted
|
|
28
|
+
came out of the parent table.
|
|
29
|
+
- **A fan-out is a partition of the child key range, not a per-child draw.**
|
|
30
|
+
Parent `j` owns rows `[start, end)`. A per-child draw cannot be inverted, and
|
|
31
|
+
"which children belong to parent T" is what a mirrored collection needs. The
|
|
32
|
+
childless tail and `placement` both fall out of the partition for free.
|
|
33
|
+
|
|
34
|
+
### Notes
|
|
35
|
+
- `placement` defaults to `arrival`. Emitting children parent by parent gives a
|
|
36
|
+
perfectly clustered table that no production system has and that flatters
|
|
37
|
+
every index scan over the foreign key.
|
|
38
|
+
- A self-referential fan-out is refused: it would read keys from a table still
|
|
39
|
+
empty at load time. Self-referential trees are their own feature.
|
|
40
|
+
- A relation needs a `FanOut` and a plain column refuses one, in both
|
|
41
|
+
directions.
|
|
42
|
+
|
|
10
43
|
## [0.1.1] — 2026-09-01
|
|
11
44
|
|
|
12
45
|
### Added
|
|
@@ -75,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
75
108
|
into `COPY FROM STDIN`, which psycopg 2 cannot do without materialising them
|
|
76
109
|
first.
|
|
77
110
|
|
|
78
|
-
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.
|
|
111
|
+
[Unreleased]: https://github.com/Artui/django-data-shape/compare/v0.2.0...HEAD
|
|
112
|
+
[0.2.0]: https://github.com/Artui/django-data-shape/compare/v0.1.1...v0.2.0
|
|
79
113
|
[0.1.1]: https://github.com/Artui/django-data-shape/compare/v0.1.0...v0.1.1
|
|
80
114
|
[0.1.0]: https://github.com/Artui/django-data-shape/compare/v0.0.0...v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: django-data-shape
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -96,12 +96,28 @@ build(shape)
|
|
|
96
96
|
past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
|
|
97
97
|
It raises on any backend that is not PostgreSQL rather than degrading quietly.
|
|
98
98
|
|
|
99
|
+
## Relations
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
Table(
|
|
103
|
+
Order,
|
|
104
|
+
rows=2_000_000,
|
|
105
|
+
# A distribution, not a number: giving every parent ten children is the one
|
|
106
|
+
# shape in which the planner is never wrong, because its n_distinct average
|
|
107
|
+
# is then the truth.
|
|
108
|
+
company=FanOut(Zipf(1.2), childless=0.35),
|
|
109
|
+
...
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The parents can be rows this package built or rows your own code did -- their
|
|
114
|
+
real keys are read, not assumed, so the ORM can own the small tables while this
|
|
115
|
+
owns the large ones.
|
|
116
|
+
|
|
99
117
|
## Status
|
|
100
118
|
|
|
101
|
-
Early.
|
|
102
|
-
|
|
103
|
-
releases after it; declaring a relation raises today rather than generating ids
|
|
104
|
-
that point at nothing.
|
|
119
|
+
Early. Single tables and the model graph. Derived fields, collections copied
|
|
120
|
+
along a join, per-group invariants and template-database reuse come next.
|
|
105
121
|
|
|
106
122
|
Full documentation: <https://artui.github.io/django-data-shape/>
|
|
107
123
|
|
|
@@ -56,12 +56,28 @@ build(shape)
|
|
|
56
56
|
past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
|
|
57
57
|
It raises on any backend that is not PostgreSQL rather than degrading quietly.
|
|
58
58
|
|
|
59
|
+
## Relations
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
Table(
|
|
63
|
+
Order,
|
|
64
|
+
rows=2_000_000,
|
|
65
|
+
# A distribution, not a number: giving every parent ten children is the one
|
|
66
|
+
# shape in which the planner is never wrong, because its n_distinct average
|
|
67
|
+
# is then the truth.
|
|
68
|
+
company=FanOut(Zipf(1.2), childless=0.35),
|
|
69
|
+
...
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The parents can be rows this package built or rows your own code did -- their
|
|
74
|
+
real keys are read, not assumed, so the ORM can own the small tables while this
|
|
75
|
+
owns the large ones.
|
|
76
|
+
|
|
59
77
|
## Status
|
|
60
78
|
|
|
61
|
-
Early.
|
|
62
|
-
|
|
63
|
-
releases after it; declaring a relation raises today rather than generating ids
|
|
64
|
-
that point at nothing.
|
|
79
|
+
Early. Single tables and the model graph. Derived fields, collections copied
|
|
80
|
+
along a join, per-group invariants and template-database reuse come next.
|
|
65
81
|
|
|
66
82
|
Full documentation: <https://artui.github.io/django-data-shape/>
|
|
67
83
|
|
|
@@ -8,6 +8,8 @@ from django_data_shape.distributions.distribution import Distribution
|
|
|
8
8
|
from django_data_shape.distributions.sequential import Sequential
|
|
9
9
|
from django_data_shape.distributions.skew import Skew
|
|
10
10
|
from django_data_shape.distributions.uniform import Uniform
|
|
11
|
+
from django_data_shape.distributions.zipf import Zipf
|
|
12
|
+
from django_data_shape.fan_out import FanOut
|
|
11
13
|
from django_data_shape.invalid_shape import InvalidShape
|
|
12
14
|
from django_data_shape.shape import Shape
|
|
13
15
|
from django_data_shape.shape_not_empty import ShapeNotEmpty
|
|
@@ -21,6 +23,7 @@ __all__ = [
|
|
|
21
23
|
"BuildResult",
|
|
22
24
|
"Constant",
|
|
23
25
|
"Distribution",
|
|
26
|
+
"FanOut",
|
|
24
27
|
"InvalidShape",
|
|
25
28
|
"Sequential",
|
|
26
29
|
"Shape",
|
|
@@ -29,6 +32,7 @@ __all__ = [
|
|
|
29
32
|
"Table",
|
|
30
33
|
"TableResult",
|
|
31
34
|
"Uniform",
|
|
35
|
+
"Zipf",
|
|
32
36
|
"UnsupportedBackend",
|
|
33
37
|
"__version__",
|
|
34
38
|
"build",
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, cast
|
|
6
6
|
|
|
7
7
|
from django.core.management.color import no_style
|
|
8
8
|
from django.db import DEFAULT_DB_ALIAS, connections, transaction
|
|
9
|
+
from django.db.models import Model
|
|
9
10
|
|
|
10
11
|
from django_data_shape.build_result import BuildResult
|
|
12
|
+
from django_data_shape.fan_out import FanOut
|
|
13
|
+
from django_data_shape.fan_out_plan import FanOutPlan
|
|
11
14
|
from django_data_shape.generate_rows import generate_rows
|
|
15
|
+
from django_data_shape.order_tables import order_tables
|
|
12
16
|
from django_data_shape.require_postgres import require_postgres
|
|
17
|
+
from django_data_shape.resolve_fan_out import resolve_fan_out
|
|
13
18
|
from django_data_shape.shape import Shape
|
|
14
19
|
from django_data_shape.shape_not_empty import ShapeNotEmpty
|
|
15
20
|
from django_data_shape.table import Table
|
|
@@ -41,15 +46,35 @@ def build(shape: Shape, using: str = DEFAULT_DB_ALIAS) -> BuildResult:
|
|
|
41
46
|
# -- fix the shape, run it again -- fails on a duplicate key rather than on
|
|
42
47
|
# the original problem.
|
|
43
48
|
with transaction.atomic(using=using):
|
|
44
|
-
|
|
49
|
+
# Parents first. Not because the database insists -- Django's foreign
|
|
50
|
+
# keys are deferred, so any order commits -- but because a fan-out reads
|
|
51
|
+
# its parent's real keys, and a table with no rows yet has none.
|
|
52
|
+
for table in order_tables(shape.tables):
|
|
45
53
|
_require_empty(connection, table)
|
|
46
|
-
|
|
54
|
+
plans = _resolve(connection, table, shape.seed)
|
|
55
|
+
loaded = _load(connection, table, shape.seed, plans)
|
|
47
56
|
_reset_sequence(connection, table)
|
|
48
57
|
_analyze(connection, table)
|
|
49
58
|
results.append(TableResult(table=table.db_table, rows=loaded))
|
|
50
59
|
return BuildResult(tables=tuple(results))
|
|
51
60
|
|
|
52
61
|
|
|
62
|
+
def _resolve(connection: Any, table: Table, seed: int) -> dict[str, FanOutPlan]:
|
|
63
|
+
"""Partition each declared relation over the parent keys that exist."""
|
|
64
|
+
plans: dict[str, FanOutPlan] = {}
|
|
65
|
+
for name, field in table.relations():
|
|
66
|
+
plans[name] = resolve_fan_out(
|
|
67
|
+
cast("FanOut", table.fields[name]),
|
|
68
|
+
cast("type[Model]", field.related_model),
|
|
69
|
+
table.rows,
|
|
70
|
+
seed,
|
|
71
|
+
table.db_table,
|
|
72
|
+
name,
|
|
73
|
+
connection,
|
|
74
|
+
)
|
|
75
|
+
return plans
|
|
76
|
+
|
|
77
|
+
|
|
53
78
|
def _require_empty(connection: Any, table: Table) -> None:
|
|
54
79
|
"""Refuse to build on top of rows that are already there.
|
|
55
80
|
|
|
@@ -72,7 +97,7 @@ def _require_empty(connection: Any, table: Table) -> None:
|
|
|
72
97
|
# API reached through Django's cursor wrapper, and it appears on no Django base
|
|
73
98
|
# class, so annotating the real wrapper type would mean asserting the checker
|
|
74
99
|
# out of the way on every line that uses it.
|
|
75
|
-
def _load(connection: Any, table: Table, seed: int) -> int:
|
|
100
|
+
def _load(connection: Any, table: Table, seed: int, plans: dict[str, FanOutPlan]) -> int:
|
|
76
101
|
"""Stream generated rows into the table with ``COPY FROM STDIN``.
|
|
77
102
|
|
|
78
103
|
``bulk_create`` is the obvious alternative and is roughly an order of
|
|
@@ -105,7 +130,7 @@ def _load(connection: Any, table: Table, seed: int) -> int:
|
|
|
105
130
|
# block never learns it needs a rollback, so the next query inside it
|
|
106
131
|
# fails with "current transaction is aborted" instead of a Django error.
|
|
107
132
|
with connection.wrap_database_errors, cursor.copy(statement) as copy:
|
|
108
|
-
for row in generate_rows(table, seed):
|
|
133
|
+
for row in generate_rows(table, seed, plans):
|
|
109
134
|
copy.write_row(
|
|
110
135
|
(
|
|
111
136
|
row[0],
|
{django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/__init__.py
RENAMED
|
@@ -6,5 +6,6 @@ from django_data_shape.distributions.distribution import Distribution
|
|
|
6
6
|
from django_data_shape.distributions.sequential import Sequential
|
|
7
7
|
from django_data_shape.distributions.skew import Skew
|
|
8
8
|
from django_data_shape.distributions.uniform import Uniform
|
|
9
|
+
from django_data_shape.distributions.zipf import Zipf
|
|
9
10
|
|
|
10
|
-
__all__ = ["Bounded", "Constant", "Distribution", "Sequential", "Skew", "Uniform"]
|
|
11
|
+
__all__ = ["Bounded", "Constant", "Distribution", "Sequential", "Skew", "Uniform", "Zipf"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Heavy-tailed weights: a few very large, a long tail of very small."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Zipf:
|
|
11
|
+
"""Positive weights following a power law of exponent ``s``.
|
|
12
|
+
|
|
13
|
+
The distribution fan-out is realistically drawn from, and the reason
|
|
14
|
+
declaring fan-out is worth doing at all. A customer table where every
|
|
15
|
+
customer has ten orders is not merely tidy, it is the one shape in which the
|
|
16
|
+
planner is never wrong: its ``n_distinct`` average *is* the truth, so a join
|
|
17
|
+
estimate cannot miss. Give the head a thousand orders and the tail one, and
|
|
18
|
+
the same estimate is out by orders of magnitude in both directions -- which
|
|
19
|
+
is what production looks like and what a test database has to reproduce.
|
|
20
|
+
|
|
21
|
+
Inverse transform of a Pareto: ``(1 - draw) ** (-1 / s)``. Larger ``s``
|
|
22
|
+
means a lighter tail; values near 1 are the classic Zipf regime.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, s: float = 1.2) -> None:
|
|
26
|
+
if not math.isfinite(s) or s <= 0:
|
|
27
|
+
raise InvalidShape(f"Zipf needs a positive finite exponent, got s={s}.")
|
|
28
|
+
self._s = s
|
|
29
|
+
|
|
30
|
+
def value(self, row: int, draw: float) -> object:
|
|
31
|
+
# ``1 - draw`` rather than ``draw`` so the singularity sits at the top of
|
|
32
|
+
# the interval: draw is in [0, 1), so 1 - draw is in (0, 1] and never
|
|
33
|
+
# zero, which keeps the power finite for every possible draw.
|
|
34
|
+
return (1.0 - draw) ** (-1.0 / self._s)
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return f"Zipf({self._s!r})"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""How a foreign key's children are spread across their parents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_data_shape.distributions.distribution import Distribution
|
|
6
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
7
|
+
|
|
8
|
+
PLACEMENTS = ("arrival", "grouped")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FanOut:
|
|
12
|
+
"""A distribution over how many children each parent has.
|
|
13
|
+
|
|
14
|
+
Declared as a shape over an **already fixed** child row count, never as a
|
|
15
|
+
multiplier. Rows times fan-out makes cardinality emergent, and a table whose
|
|
16
|
+
size is emergent is a table nothing can assert about.
|
|
17
|
+
|
|
18
|
+
``sizes`` supplies a relative weight per parent -- ``Zipf()`` for the
|
|
19
|
+
realistic heavy tail, ``Uniform(1, 10)`` for something flatter. The weights
|
|
20
|
+
are normalised, so their scale is irrelevant and only their spread matters.
|
|
21
|
+
|
|
22
|
+
``childless`` is the share of parents with **no** children at all. It is
|
|
23
|
+
called out separately because it is the case hand-written fixtures always
|
|
24
|
+
omit and the one that changes what a join does: a parent nobody references
|
|
25
|
+
is the difference between an inner and an outer join returning the same
|
|
26
|
+
thing and returning different things.
|
|
27
|
+
|
|
28
|
+
``null`` is the share of children whose foreign key is NULL, which is only
|
|
29
|
+
meaningful on a nullable column. **It thins the partition uniformly after**
|
|
30
|
+
it is computed, so ``sizes`` describes the pre-null spread. Stated because
|
|
31
|
+
the alternative -- partitioning only the non-null children -- would make the
|
|
32
|
+
declared distribution mean something subtly different from what it says.
|
|
33
|
+
|
|
34
|
+
``placement`` decides where children sit physically, and it is not
|
|
35
|
+
cosmetic. Emitting them parent by parent gives a perfectly clustered table
|
|
36
|
+
that no production system has and that flatters every index scan; the
|
|
37
|
+
default interleaves them the way rows really arrive.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
sizes: Distribution,
|
|
43
|
+
*,
|
|
44
|
+
childless: float = 0.0,
|
|
45
|
+
null: float = 0.0,
|
|
46
|
+
placement: str = "arrival",
|
|
47
|
+
) -> None:
|
|
48
|
+
for name, share in (("childless", childless), ("null", null)):
|
|
49
|
+
if not 0.0 <= share < 1.0:
|
|
50
|
+
raise InvalidShape(
|
|
51
|
+
f"FanOut {name} is a share of the whole and must be in [0, 1), got {share}."
|
|
52
|
+
)
|
|
53
|
+
if placement not in PLACEMENTS:
|
|
54
|
+
raise InvalidShape(
|
|
55
|
+
f"FanOut placement must be one of {', '.join(PLACEMENTS)}, got {placement!r}."
|
|
56
|
+
)
|
|
57
|
+
self._sizes = sizes
|
|
58
|
+
self._childless = childless
|
|
59
|
+
self._null = null
|
|
60
|
+
self._placement = placement
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def sizes(self) -> Distribution:
|
|
64
|
+
return self._sizes
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def childless(self) -> float:
|
|
68
|
+
return self._childless
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def null(self) -> float:
|
|
72
|
+
return self._null
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def placement(self) -> str:
|
|
76
|
+
return self._placement
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"FanOut({self._sizes!r}, childless={self._childless!r}, "
|
|
81
|
+
f"null={self._null!r}, placement={self._placement!r})"
|
|
82
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A fan-out resolved against real parent keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
from math import gcd
|
|
7
|
+
|
|
8
|
+
from django_data_shape.utils import draw
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FanOutPlan:
|
|
12
|
+
"""Which parent owns each child row, decided once and answered in O(1).
|
|
13
|
+
|
|
14
|
+
A **partition of the child key range**: parent ``j`` owns rows
|
|
15
|
+
``[starts[j], starts[j + 1])``. That representation is the whole point, and
|
|
16
|
+
it was chosen over the obvious alternative -- drawing a parent per child --
|
|
17
|
+
because a per-child draw **cannot be inverted**. Asking "which children
|
|
18
|
+
belong to parent T" is what a mirrored collection needs, and against a draw
|
|
19
|
+
the only answer is to index every row.
|
|
20
|
+
|
|
21
|
+
Two consequences fall out for free. A childless parent is one whose range is
|
|
22
|
+
empty, so the tail everybody forgets is representable rather than
|
|
23
|
+
approximated. And physical placement becomes a pure question of the order
|
|
24
|
+
rows are *emitted* in, entirely separate from which parent owns them -- the
|
|
25
|
+
same split this design keeps finding between one order and another.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
keys: list[int],
|
|
31
|
+
starts: list[int],
|
|
32
|
+
rows: int,
|
|
33
|
+
null_stream: int,
|
|
34
|
+
null_share: float,
|
|
35
|
+
interleave: bool,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._keys = keys
|
|
38
|
+
self._starts = starts
|
|
39
|
+
self._rows = rows
|
|
40
|
+
self._null_stream = null_stream
|
|
41
|
+
self._null_share = null_share
|
|
42
|
+
self._stride = _stride(rows) if interleave else 1
|
|
43
|
+
|
|
44
|
+
def key_for(self, row: int) -> int | None:
|
|
45
|
+
"""The parent key for one child row, or None where the key is null."""
|
|
46
|
+
if self._null_share and draw(self._null_stream, row) < self._null_share:
|
|
47
|
+
return None
|
|
48
|
+
slot = (row * self._stride) % self._rows
|
|
49
|
+
# bisect_right, not left: a parent with an empty range shares its start
|
|
50
|
+
# with the next one, and bisect_right steps past every duplicate to the
|
|
51
|
+
# last parent whose range actually begins at or below the slot. That is
|
|
52
|
+
# what makes a childless parent unreachable rather than special-cased.
|
|
53
|
+
return self._keys[bisect.bisect_right(self._starts, slot) - 1]
|
|
54
|
+
|
|
55
|
+
def sizes(self) -> list[int]:
|
|
56
|
+
"""How many children each parent ended up with, in parent-key order."""
|
|
57
|
+
bounds = [*self._starts, self._rows]
|
|
58
|
+
return [bounds[i + 1] - bounds[i] for i in range(len(self._keys))]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stride(rows: int) -> int:
|
|
62
|
+
"""A multiplier that walks every slot exactly once, scattering as it goes.
|
|
63
|
+
|
|
64
|
+
``row * stride % rows`` is a bijection precisely when the two are coprime,
|
|
65
|
+
so children land in an order unrelated to their parent without buffering a
|
|
66
|
+
permutation or holding any state. Starting near the golden ratio of ``rows``
|
|
67
|
+
gives the low-discrepancy spread that makes consecutive children come from
|
|
68
|
+
unrelated parents, which is what "arrival order" means physically.
|
|
69
|
+
"""
|
|
70
|
+
if rows < 3:
|
|
71
|
+
return 1
|
|
72
|
+
candidate = max(2, int(rows * 0.6180339887498949))
|
|
73
|
+
while gcd(candidate, rows) != 1:
|
|
74
|
+
candidate += 1
|
|
75
|
+
return candidate
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Turning a declared table into the tuples COPY will consume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from django_data_shape.distributions.distribution import Distribution
|
|
10
|
+
from django_data_shape.fan_out_plan import FanOutPlan
|
|
11
|
+
from django_data_shape.table import Table
|
|
12
|
+
from django_data_shape.utils import draw, field_stream
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_rows(
|
|
16
|
+
table: Table, seed: int, plans: Mapping[str, FanOutPlan] | None = None
|
|
17
|
+
) -> Iterator[tuple[Any, ...]]:
|
|
18
|
+
"""Yield one tuple per row: the primary key, then each declared column.
|
|
19
|
+
|
|
20
|
+
Rows, not model instances. The ORM is the wrong tool at these counts -- the
|
|
21
|
+
difference between a load measured in seconds and one measured in minutes --
|
|
22
|
+
and nothing here needs an instance, because no ``save`` will run and no
|
|
23
|
+
signal should fire.
|
|
24
|
+
|
|
25
|
+
Primary keys are a dense ``1..N`` because this package assigns them, which is
|
|
26
|
+
what lets a child's foreign key be satisfied without a lookup and what makes
|
|
27
|
+
a self-referential tree acyclic by construction. It also obliges the caller
|
|
28
|
+
to reset the sequence afterwards; see ``build``.
|
|
29
|
+
|
|
30
|
+
``plans`` carries the resolved fan-out for each relation column. Resolving
|
|
31
|
+
happens outside this function because it has to read the parent's real keys
|
|
32
|
+
out of the database, and keeping the query there leaves generation itself
|
|
33
|
+
backend-neutral and testable without a connection.
|
|
34
|
+
|
|
35
|
+
A generator rather than a list: a million tuples is real memory, psycopg
|
|
36
|
+
writes them one at a time anyway, and materialising the set would buy
|
|
37
|
+
nothing but peak RSS.
|
|
38
|
+
"""
|
|
39
|
+
plans = plans or {}
|
|
40
|
+
# Each column is reduced to one callable of the row index before the loop
|
|
41
|
+
# starts. At a million rows the loop body runs a million times per column, so
|
|
42
|
+
# the branch between a fan-out and a value distribution is worth deciding
|
|
43
|
+
# once rather than a million times.
|
|
44
|
+
emit: list[Callable[[int], object]] = []
|
|
45
|
+
for name, _field in table.columns():
|
|
46
|
+
plan = plans.get(name)
|
|
47
|
+
if plan is not None:
|
|
48
|
+
emit.append(plan.key_for)
|
|
49
|
+
continue
|
|
50
|
+
distribution = cast("Distribution", table.fields[name])
|
|
51
|
+
emit.append(
|
|
52
|
+
partial(_from_distribution, distribution, field_stream(seed, table.db_table, name))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for row in range(table.rows):
|
|
56
|
+
yield (row + 1, *(produce(row) for produce in emit))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _from_distribution(distribution: Distribution, stream: int, row: int) -> object:
|
|
60
|
+
return distribution.value(row, draw(stream, row))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Deciding which table to load first."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from django.db.models import Model
|
|
8
|
+
|
|
9
|
+
from django_data_shape.fan_out import FanOut
|
|
10
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
11
|
+
from django_data_shape.table import Table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def order_tables(tables: tuple[Table, ...]) -> tuple[Table, ...]:
|
|
15
|
+
"""Parents before children, for the reason that is easy to get wrong.
|
|
16
|
+
|
|
17
|
+
Not because the database demands it. Django creates every PostgreSQL foreign
|
|
18
|
+
key ``DEFERRABLE INITIALLY DEFERRED``, so inside one transaction the checks
|
|
19
|
+
fire at commit and any order would satisfy them.
|
|
20
|
+
|
|
21
|
+
The real reason is that a fan-out **reads the parent's keys**, and a table
|
|
22
|
+
with no rows yet has none. Ordering is therefore a correctness requirement
|
|
23
|
+
of this package's own design rather than of the schema -- which is worth
|
|
24
|
+
stating, because the schema is where a reader would look for it and would
|
|
25
|
+
find nothing.
|
|
26
|
+
|
|
27
|
+
A cycle is refused by name. Two tables that each fan out over the other
|
|
28
|
+
cannot both be loaded second, and no amount of deferring changes that.
|
|
29
|
+
"""
|
|
30
|
+
by_model = {table.model: table for table in tables}
|
|
31
|
+
ordered: list[Table] = []
|
|
32
|
+
state: dict[Table, int] = {}
|
|
33
|
+
|
|
34
|
+
def visit(table: Table, trail: tuple[Table, ...]) -> None:
|
|
35
|
+
mark = state.get(table, 0)
|
|
36
|
+
if mark == 2:
|
|
37
|
+
return
|
|
38
|
+
if mark == 1:
|
|
39
|
+
names = " -> ".join(t.model.__name__ for t in (*trail, table))
|
|
40
|
+
raise InvalidShape(
|
|
41
|
+
f"These tables fan out over each other in a cycle: {names}. One of them has to "
|
|
42
|
+
"be loaded first, and a cycle means neither can be."
|
|
43
|
+
)
|
|
44
|
+
state[table] = 1
|
|
45
|
+
for name, declared in table.fields.items():
|
|
46
|
+
if not isinstance(declared, FanOut):
|
|
47
|
+
continue
|
|
48
|
+
# cast, not a guard: related_model is Optional on the generic field
|
|
49
|
+
# descriptor, but Table has already refused a FanOut on anything that
|
|
50
|
+
# is not a relation. Branching on None here would add a path no test
|
|
51
|
+
# could reach, which is how 100% branch coverage stops being
|
|
52
|
+
# achievable honestly.
|
|
53
|
+
parent = cast("type[Model]", table.model._meta.get_field(name).related_model)
|
|
54
|
+
# A parent outside the shape is a table the caller built themselves,
|
|
55
|
+
# which is the supported hybrid: the ORM for the small tables, this
|
|
56
|
+
# package for the large ones. Its keys get read at resolve time.
|
|
57
|
+
declared_parent = by_model.get(parent)
|
|
58
|
+
if declared_parent is not None and declared_parent is not table:
|
|
59
|
+
visit(declared_parent, (*trail, table))
|
|
60
|
+
state[table] = 2
|
|
61
|
+
ordered.append(table)
|
|
62
|
+
|
|
63
|
+
for table in tables:
|
|
64
|
+
visit(table, ())
|
|
65
|
+
return tuple(ordered)
|