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.
Files changed (70) hide show
  1. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/CHANGELOG.md +35 -1
  2. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/PKG-INFO +21 -5
  3. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/README.md +20 -4
  4. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/__init__.py +4 -0
  5. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/build.py +30 -5
  6. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/__init__.py +2 -1
  7. django_data_shape-0.2.0/django_data_shape/distributions/zipf.py +37 -0
  8. django_data_shape-0.2.0/django_data_shape/fan_out.py +82 -0
  9. django_data_shape-0.2.0/django_data_shape/fan_out_plan.py +75 -0
  10. django_data_shape-0.2.0/django_data_shape/generate_rows.py +60 -0
  11. django_data_shape-0.2.0/django_data_shape/order_tables.py +65 -0
  12. django_data_shape-0.2.0/django_data_shape/resolve_fan_out.py +121 -0
  13. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/table.py +49 -13
  14. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/version.py +1 -1
  15. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/docs/index.md +9 -8
  16. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/docs/reference.md +4 -0
  17. django_data_shape-0.2.0/docs/relations.md +118 -0
  18. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/mkdocs.yml +1 -0
  19. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/pyproject.toml +1 -1
  20. django_data_shape-0.2.0/tests/distributions/test_zipf.py +40 -0
  21. django_data_shape-0.2.0/tests/test_build_graph.py +175 -0
  22. django_data_shape-0.2.0/tests/test_fan_out.py +42 -0
  23. django_data_shape-0.2.0/tests/test_order_tables.py +46 -0
  24. django_data_shape-0.2.0/tests/test_resolve_fan_out.py +141 -0
  25. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_table.py +20 -2
  26. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/testapp/models.py +26 -0
  27. django_data_shape-0.1.1/django_data_shape/generate_rows.py +0 -43
  28. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/CODEOWNERS +0 -0
  29. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/SECURITY.md +0 -0
  30. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/dependabot.yml +0 -0
  31. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/release.yml +0 -0
  32. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/tests.yml +0 -0
  33. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.github/workflows/upstream-drift.yml +0 -0
  34. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.gitignore +0 -0
  35. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/.pre-commit-config.yaml +0 -0
  36. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/CLAUDE.md +0 -0
  37. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/LICENSE +0 -0
  38. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/Makefile +0 -0
  39. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/build_result.py +0 -0
  40. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/bounded.py +0 -0
  41. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/constant.py +0 -0
  42. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/distribution.py +0 -0
  43. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/sequential.py +0 -0
  44. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/skew.py +0 -0
  45. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/distributions/uniform.py +0 -0
  46. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/invalid_shape.py +0 -0
  47. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/py.typed +0 -0
  48. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/require_postgres.py +0 -0
  49. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/shape.py +0 -0
  50. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/shape_not_empty.py +0 -0
  51. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/table_result.py +0 -0
  52. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/unsupported_backend.py +0 -0
  53. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/django_data_shape/utils.py +0 -0
  54. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/scripts/release-publish.sh +0 -0
  55. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/__init__.py +0 -0
  56. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/conftest.py +0 -0
  57. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/conftest_settings.py +0 -0
  58. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/__init__.py +0 -0
  59. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_constant.py +0 -0
  60. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_sequential.py +0 -0
  61. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_skew.py +0 -0
  62. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/distributions/test_uniform.py +0 -0
  63. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_build.py +0 -0
  64. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_generate_rows.py +0 -0
  65. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_require_postgres.py +0 -0
  66. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_shape.py +0 -0
  67. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_utils.py +0 -0
  68. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/test_version.py +0 -0
  69. {django_data_shape-0.1.1 → django_data_shape-0.2.0}/tests/testapp/__init__.py +0 -0
  70. {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.1.1...HEAD
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.1.1
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. This release covers single tables. Foreign-key fan-out as a distribution,
102
- physical placement, per-group invariants and template-database reuse are the
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. This release covers single tables. Foreign-key fan-out as a distribution,
62
- physical placement, per-group invariants and template-database reuse are the
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
- for table in shape.tables:
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
- loaded = _load(connection, table, shape.seed)
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],
@@ -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)