django-data-shape 0.1.0__py3-none-any.whl
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/__init__.py +33 -0
- django_data_shape/build.py +144 -0
- django_data_shape/build_result.py +27 -0
- django_data_shape/distributions/__init__.py +9 -0
- django_data_shape/distributions/constant.py +26 -0
- django_data_shape/distributions/distribution.py +24 -0
- django_data_shape/distributions/sequential.py +31 -0
- django_data_shape/distributions/skew.py +71 -0
- django_data_shape/distributions/uniform.py +50 -0
- django_data_shape/generate_rows.py +43 -0
- django_data_shape/invalid_shape.py +18 -0
- django_data_shape/py.typed +0 -0
- django_data_shape/require_postgres.py +46 -0
- django_data_shape/shape.py +42 -0
- django_data_shape/shape_not_empty.py +17 -0
- django_data_shape/table.py +207 -0
- django_data_shape/table_result.py +18 -0
- django_data_shape/unsupported_backend.py +18 -0
- django_data_shape/utils.py +45 -0
- django_data_shape/version.py +7 -0
- django_data_shape-0.1.0.dist-info/METADATA +110 -0
- django_data_shape-0.1.0.dist-info/RECORD +24 -0
- django_data_shape-0.1.0.dist-info/WHEEL +4 -0
- django_data_shape-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A realistically shaped test database from Django models."""
|
|
2
|
+
|
|
3
|
+
from django_data_shape.build import build
|
|
4
|
+
from django_data_shape.build_result import BuildResult
|
|
5
|
+
from django_data_shape.distributions.constant import Constant
|
|
6
|
+
from django_data_shape.distributions.distribution import Distribution
|
|
7
|
+
from django_data_shape.distributions.sequential import Sequential
|
|
8
|
+
from django_data_shape.distributions.skew import Skew
|
|
9
|
+
from django_data_shape.distributions.uniform import Uniform
|
|
10
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
11
|
+
from django_data_shape.shape import Shape
|
|
12
|
+
from django_data_shape.shape_not_empty import ShapeNotEmpty
|
|
13
|
+
from django_data_shape.table import Table
|
|
14
|
+
from django_data_shape.table_result import TableResult
|
|
15
|
+
from django_data_shape.unsupported_backend import UnsupportedBackend
|
|
16
|
+
from django_data_shape.version import __version__
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BuildResult",
|
|
20
|
+
"Constant",
|
|
21
|
+
"Distribution",
|
|
22
|
+
"InvalidShape",
|
|
23
|
+
"Sequential",
|
|
24
|
+
"Shape",
|
|
25
|
+
"ShapeNotEmpty",
|
|
26
|
+
"Skew",
|
|
27
|
+
"Table",
|
|
28
|
+
"TableResult",
|
|
29
|
+
"Uniform",
|
|
30
|
+
"UnsupportedBackend",
|
|
31
|
+
"__version__",
|
|
32
|
+
"build",
|
|
33
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Turning a declaration into a database the planner can reason about."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.core.management.color import no_style
|
|
8
|
+
from django.db import DEFAULT_DB_ALIAS, connections, transaction
|
|
9
|
+
|
|
10
|
+
from django_data_shape.build_result import BuildResult
|
|
11
|
+
from django_data_shape.generate_rows import generate_rows
|
|
12
|
+
from django_data_shape.require_postgres import require_postgres
|
|
13
|
+
from django_data_shape.shape import Shape
|
|
14
|
+
from django_data_shape.shape_not_empty import ShapeNotEmpty
|
|
15
|
+
from django_data_shape.table import Table
|
|
16
|
+
from django_data_shape.table_result import TableResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build(shape: Shape, using: str = DEFAULT_DB_ALIAS) -> BuildResult:
|
|
20
|
+
"""Generate, load, reset sequences and analyze every table in ``shape``.
|
|
21
|
+
|
|
22
|
+
The order of those steps is the whole function, and it is not
|
|
23
|
+
interchangeable. Loading rows into a table that was analyzed while empty
|
|
24
|
+
leaves the planner holding statistics from the old contents and applying
|
|
25
|
+
them to the new row count -- a worse lie than having no statistics at all,
|
|
26
|
+
and the one that produced a thirty-thousand-fold misestimate in the
|
|
27
|
+
measurements this package was designed from. So the ``ANALYZE`` is here, at
|
|
28
|
+
the end, owned by the library rather than left to the caller to remember.
|
|
29
|
+
|
|
30
|
+
That is also why a bare ``ANALYZE`` is in this release at all, when the
|
|
31
|
+
statistics work proper -- per-column targets, and caching a built database
|
|
32
|
+
as a template -- comes later. A loader that leaves its table unanalyzed
|
|
33
|
+
ships the exact state this package exists to condemn.
|
|
34
|
+
"""
|
|
35
|
+
connection = connections[using]
|
|
36
|
+
require_postgres(connection, "Building a shape")
|
|
37
|
+
|
|
38
|
+
results: list[TableResult] = []
|
|
39
|
+
# One transaction around every table. Without it a shape whose second table
|
|
40
|
+
# fails leaves the first committed and analyzed, and the natural next action
|
|
41
|
+
# -- fix the shape, run it again -- fails on a duplicate key rather than on
|
|
42
|
+
# the original problem.
|
|
43
|
+
with transaction.atomic(using=using):
|
|
44
|
+
for table in shape.tables:
|
|
45
|
+
_require_empty(connection, table)
|
|
46
|
+
loaded = _load(connection, table, shape.seed)
|
|
47
|
+
_reset_sequence(connection, table)
|
|
48
|
+
_analyze(connection, table)
|
|
49
|
+
results.append(TableResult(table=table.db_table, rows=loaded))
|
|
50
|
+
return BuildResult(tables=tuple(results))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _require_empty(connection: Any, table: Table) -> None:
|
|
54
|
+
"""Refuse to build on top of rows that are already there.
|
|
55
|
+
|
|
56
|
+
The keys this package assigns start at 1 every time, so a second build over
|
|
57
|
+
the same table collides on the primary key. That surfaced as a bare
|
|
58
|
+
UniqueViolation naming an index, which tells the reader nothing about what
|
|
59
|
+
they did or what to do instead.
|
|
60
|
+
"""
|
|
61
|
+
with connection.cursor() as cursor:
|
|
62
|
+
cursor.execute(f"SELECT EXISTS (SELECT 1 FROM {connection.ops.quote_name(table.db_table)})")
|
|
63
|
+
row = cursor.fetchone()
|
|
64
|
+
if row[0]:
|
|
65
|
+
raise ShapeNotEmpty(
|
|
66
|
+
f"{table.db_table} already holds rows, and this package assigns primary keys from 1, "
|
|
67
|
+
"so building over them would collide. Empty the table first."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# The connection is typed loosely on purpose. ``cursor.copy()`` is psycopg 3's
|
|
72
|
+
# API reached through Django's cursor wrapper, and it appears on no Django base
|
|
73
|
+
# class, so annotating the real wrapper type would mean asserting the checker
|
|
74
|
+
# out of the way on every line that uses it.
|
|
75
|
+
def _load(connection: Any, table: Table, seed: int) -> int:
|
|
76
|
+
"""Stream generated rows into the table with ``COPY FROM STDIN``.
|
|
77
|
+
|
|
78
|
+
``bulk_create`` is the obvious alternative and is roughly an order of
|
|
79
|
+
magnitude too slow at the row counts that make a plan meaningful. ``COPY``
|
|
80
|
+
is also why the generator yields tuples rather than model instances: there
|
|
81
|
+
is no instance to build, and no ``save`` to run.
|
|
82
|
+
|
|
83
|
+
Each declared value is passed through its field's ``get_db_prep_save``
|
|
84
|
+
first, and that is not a formality. Skipping it is how a naive datetime got
|
|
85
|
+
written five hours away from where ``save()`` would have put it under a
|
|
86
|
+
non-UTC ``TIME_ZONE`` -- silently, on the exact column ``Sequential`` exists
|
|
87
|
+
to make realistic -- and how a ``JSONField`` failed to load at all. The
|
|
88
|
+
generator stays backend-neutral because the preparation happens here rather
|
|
89
|
+
than inside it.
|
|
90
|
+
|
|
91
|
+
Returns the number of rows the database actually took, not the number
|
|
92
|
+
declared. They are the same today and stop being so once deduplicated
|
|
93
|
+
many-to-many edges arrive.
|
|
94
|
+
"""
|
|
95
|
+
quote = connection.ops.quote_name
|
|
96
|
+
pk_column = table.model._meta.pk.column
|
|
97
|
+
columns = [quote(pk_column)] + [quote(field.column) for _, field in table.columns()]
|
|
98
|
+
statement = f"COPY {quote(table.db_table)} ({', '.join(columns)}) FROM STDIN"
|
|
99
|
+
prepare = [field.get_db_prep_save for _, field in table.columns()]
|
|
100
|
+
|
|
101
|
+
with connection.cursor() as cursor:
|
|
102
|
+
# ``copy`` is not in Django's WRAP_ERROR_ATTRS, so without this a
|
|
103
|
+
# Postgres error escapes as a raw psycopg exception: the caller cannot
|
|
104
|
+
# catch django.db.IntegrityError, and -- worse -- an enclosing atomic
|
|
105
|
+
# block never learns it needs a rollback, so the next query inside it
|
|
106
|
+
# fails with "current transaction is aborted" instead of a Django error.
|
|
107
|
+
with connection.wrap_database_errors, cursor.copy(statement) as copy:
|
|
108
|
+
for row in generate_rows(table, seed):
|
|
109
|
+
copy.write_row(
|
|
110
|
+
(
|
|
111
|
+
row[0],
|
|
112
|
+
*(
|
|
113
|
+
prep(value, connection)
|
|
114
|
+
for prep, value in zip(prepare, row[1:], strict=True)
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return int(cursor.rowcount)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reset_sequence(connection: Any, table: Table) -> None:
|
|
122
|
+
"""Move the identity sequence past the keys this package just assigned.
|
|
123
|
+
|
|
124
|
+
Skipping this is the first bug the design invites: rows exist at ids 1..N
|
|
125
|
+
while the sequence still starts at 1, so the very first ``objects.create()``
|
|
126
|
+
inside a test raises ``IntegrityError`` on a primary key that is already
|
|
127
|
+
taken. Django's own backend operation is used rather than a hand-written
|
|
128
|
+
``setval`` because it already knows how the column's sequence is named.
|
|
129
|
+
"""
|
|
130
|
+
with connection.cursor() as cursor:
|
|
131
|
+
for statement in connection.ops.sequence_reset_sql(no_style(), [table.model]):
|
|
132
|
+
cursor.execute(statement)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _analyze(connection: Any, table: Table) -> None:
|
|
136
|
+
"""Populate the statistics the planner reads.
|
|
137
|
+
|
|
138
|
+
Rows alone change nothing: without this the planner falls back to a default
|
|
139
|
+
selectivity and commits to it, which is how a two-million-row table gets
|
|
140
|
+
bitmap-scanned through an index for a value matching 98% of it. Measured at
|
|
141
|
+
81 ms on that table, because ``ANALYZE`` samples rather than scans.
|
|
142
|
+
"""
|
|
143
|
+
with connection.cursor() as cursor:
|
|
144
|
+
cursor.execute(f"ANALYZE {connection.ops.quote_name(table.db_table)}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""What a completed build reports back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from django_data_shape.table_result import TableResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class BuildResult:
|
|
12
|
+
"""The outcome of building a shape, table by table.
|
|
13
|
+
|
|
14
|
+
Returned rather than logged because the counts are worth asserting on: the
|
|
15
|
+
row count a shape declares and the row count a table holds are the same
|
|
16
|
+
number today, but they stop being the same as soon as deduplication enters
|
|
17
|
+
the picture with many-to-many edges. Reporting achieved counts from the
|
|
18
|
+
start means that release changes what this says, not what callers have to
|
|
19
|
+
start checking.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
tables: tuple[TableResult, ...]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def rows(self) -> int:
|
|
26
|
+
"""Rows loaded across every table."""
|
|
27
|
+
return sum(result.rows for result in self.tables)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The declared value distributions."""
|
|
2
|
+
|
|
3
|
+
from django_data_shape.distributions.constant import Constant
|
|
4
|
+
from django_data_shape.distributions.distribution import Distribution
|
|
5
|
+
from django_data_shape.distributions.sequential import Sequential
|
|
6
|
+
from django_data_shape.distributions.skew import Skew
|
|
7
|
+
from django_data_shape.distributions.uniform import Uniform
|
|
8
|
+
|
|
9
|
+
__all__ = ["Constant", "Distribution", "Sequential", "Skew", "Uniform"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The same value in every row."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Constant:
|
|
7
|
+
"""Every row gets ``value``.
|
|
8
|
+
|
|
9
|
+
Present because a column that never varies is a real shape, not a missing
|
|
10
|
+
declaration: a tenant id on a single-tenant fixture, or a flag that is false
|
|
11
|
+
for every row in the dataset under test. Declaring it says so, where leaving
|
|
12
|
+
it out would mean the field simply had no distribution.
|
|
13
|
+
|
|
14
|
+
It also has a planner consequence worth knowing: a single-valued column has
|
|
15
|
+
exactly one most-common value at frequency 1.0, so a filter on it is either
|
|
16
|
+
everything or nothing, and an index on it is never usable.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, value: object) -> None:
|
|
20
|
+
self._value = value
|
|
21
|
+
|
|
22
|
+
def value(self, row: int, draw: float) -> object:
|
|
23
|
+
return self._value
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"Constant({self._value!r})"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The one thing every declared value has in common."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Distribution(Protocol):
|
|
9
|
+
"""Produces the value of one field for one row.
|
|
10
|
+
|
|
11
|
+
Both arguments are supplied to every implementation because the two kinds of
|
|
12
|
+
distribution need different halves: a categorical or numeric one consumes
|
|
13
|
+
``draw`` and ignores the row, while a monotonic one consumes ``row`` and
|
|
14
|
+
ignores the draw. Passing both keeps the protocol single-method, and a
|
|
15
|
+
single-method protocol is what allows a distribution to be a plain object
|
|
16
|
+
rather than a class hierarchy.
|
|
17
|
+
|
|
18
|
+
``draw`` is uniform in [0, 1) and depends only on the field and the row, so
|
|
19
|
+
an implementation must not carry state between calls. One that did would
|
|
20
|
+
make the same shape produce different data depending on generation order,
|
|
21
|
+
which is the property the placement work in a later release depends on.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def value(self, row: int, draw: float) -> object: ...
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""A column that advances with the row, instead of scattering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Sequential:
|
|
9
|
+
"""``start`` plus ``row`` steps: monotonic, and correlated with the key.
|
|
10
|
+
|
|
11
|
+
The point is the correlation, not the convenience. Postgres records a
|
|
12
|
+
correlation statistic per column and costs an index scan differently
|
|
13
|
+
depending on it, so a timestamp column filled with shuffled dates plans
|
|
14
|
+
differently from one that advances the way real rows arrive. Shuffling is
|
|
15
|
+
the easy thing to do by accident and it is wrong in a way that only shows up
|
|
16
|
+
in plan choice.
|
|
17
|
+
|
|
18
|
+
Works for anything supporting ``start + row * step``, which covers numbers
|
|
19
|
+
and ``datetime`` with a ``timedelta``. It ignores ``draw`` entirely: this is
|
|
20
|
+
the one distribution whose value is a function of position alone.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, start: Any, step: Any) -> None:
|
|
24
|
+
self._start = start
|
|
25
|
+
self._step = step
|
|
26
|
+
|
|
27
|
+
def value(self, row: int, draw: float) -> object:
|
|
28
|
+
return self._start + row * self._step
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"Sequential({self._start!r}, {self._step!r})"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""A categorical column with a declared imbalance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Skew:
|
|
13
|
+
"""Values drawn from a weighted set, in a fixed order.
|
|
14
|
+
|
|
15
|
+
This is the distribution the package exists for. A status column that is 98%
|
|
16
|
+
one value is what decides whether an index on it is usable at all, and it is
|
|
17
|
+
the thing a fixtures loop never expresses -- ten rows with one of each says
|
|
18
|
+
the opposite of what production says.
|
|
19
|
+
|
|
20
|
+
``Mapping[Any, float]`` rather than ``dict[object, float]``: ``dict`` is
|
|
21
|
+
invariant in both parameters, so a caller's prepared ``dict[str, float]`` --
|
|
22
|
+
the obvious way to build one of these outside a call -- would be rejected by
|
|
23
|
+
a type checker for no reason a reader could act on. ``Mapping`` is covariant
|
|
24
|
+
in its value type, so integer counts are accepted too.
|
|
25
|
+
|
|
26
|
+
Weights are relative and need not sum to 1: the readable form is often
|
|
27
|
+
counts, and normalising here is cheaper than making every caller do it. They
|
|
28
|
+
must be positive, because a zero-weight value is a value that never appears,
|
|
29
|
+
which is better said by leaving it out than by declaring it and meaning not.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, weights: Mapping[Any, float]) -> None:
|
|
33
|
+
if not weights:
|
|
34
|
+
raise InvalidShape("Skew needs at least one value; an empty distribution has none.")
|
|
35
|
+
# ``not (w > 0)`` rather than ``w <= 0`` because NaN compares False to
|
|
36
|
+
# both, so the obvious spelling let it through -- and a NaN weight makes
|
|
37
|
+
# every cumulative bound NaN, so no draw ever matches and the fallthrough
|
|
38
|
+
# returns the last value for every row. The declared distribution comes
|
|
39
|
+
# out inverted, silently.
|
|
40
|
+
bad = sorted(repr(v) for v, w in weights.items() if not (w > 0) or not math.isfinite(w))
|
|
41
|
+
if bad:
|
|
42
|
+
raise InvalidShape(
|
|
43
|
+
"Skew weights must be positive and finite, and these are not: "
|
|
44
|
+
+ ", ".join(bad)
|
|
45
|
+
+ ". A value that never occurs is said by omitting it."
|
|
46
|
+
)
|
|
47
|
+
total = sum(weights.values())
|
|
48
|
+
# The cumulative bounds are precomputed once rather than per row: this
|
|
49
|
+
# runs a million times or more, and the alternative is re-summing the
|
|
50
|
+
# weights inside the hot loop.
|
|
51
|
+
self._values: list[object] = []
|
|
52
|
+
self._bounds: list[float] = []
|
|
53
|
+
running = 0.0
|
|
54
|
+
for value, weight in weights.items():
|
|
55
|
+
running += weight
|
|
56
|
+
self._values.append(value)
|
|
57
|
+
self._bounds.append(running / total)
|
|
58
|
+
self._weights = dict(weights)
|
|
59
|
+
|
|
60
|
+
def value(self, row: int, draw: float) -> object:
|
|
61
|
+
for value, bound in zip(self._values, self._bounds, strict=True):
|
|
62
|
+
if draw < bound:
|
|
63
|
+
return value
|
|
64
|
+
# Reachable only when floating-point accumulation leaves the final bound
|
|
65
|
+
# a hair below 1.0, since a draw is guaranteed below 1.0 by construction.
|
|
66
|
+
# The last value is the correct answer there, and falling through to it
|
|
67
|
+
# is cheaper than renormalising on every draw.
|
|
68
|
+
return self._values[-1]
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return f"Skew({self._weights!r})"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A numeric column spread evenly across a range."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Uniform:
|
|
12
|
+
"""Values spread evenly between ``low`` and ``high``.
|
|
13
|
+
|
|
14
|
+
Deliberately the least interesting distribution in the package, and named
|
|
15
|
+
plainly so it reads as a choice. Most real columns are not uniform, and a
|
|
16
|
+
uniform declaration on a column that matters is usually a placeholder
|
|
17
|
+
somebody meant to come back to.
|
|
18
|
+
|
|
19
|
+
``places`` rounds the result. Not because the column would reject the
|
|
20
|
+
unrounded value -- Postgres rounds a float to a ``numeric(10, 2)`` happily,
|
|
21
|
+
and only overflowing the declared precision is an error -- but because a
|
|
22
|
+
money column whose values carry full binary float noise is not what the
|
|
23
|
+
application would ever have written, and this package's whole claim is that
|
|
24
|
+
the loaded rows are ones it could have. Rounding to ``Decimal`` rather than
|
|
25
|
+
``float`` keeps the value exact on the way into ``COPY``; ``places=0`` is
|
|
26
|
+
how a plain integer column is filled.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, low: float, high: float, places: int | None = None) -> None:
|
|
30
|
+
# isfinite first: NaN compares False to every ordering, so ``high <= low``
|
|
31
|
+
# accepts a NaN bound and then returns NaN for every row.
|
|
32
|
+
if not math.isfinite(low) or not math.isfinite(high):
|
|
33
|
+
raise InvalidShape(f"Uniform needs finite bounds, got low={low}, high={high}.")
|
|
34
|
+
if high <= low:
|
|
35
|
+
raise InvalidShape(f"Uniform needs high greater than low, got low={low}, high={high}.")
|
|
36
|
+
if places is not None and places < 0:
|
|
37
|
+
raise InvalidShape(f"Uniform places cannot be negative, got {places}.")
|
|
38
|
+
self._low = low
|
|
39
|
+
self._high = high
|
|
40
|
+
self._places = places
|
|
41
|
+
|
|
42
|
+
def value(self, row: int, draw: float) -> object:
|
|
43
|
+
raw = self._low + draw * (self._high - self._low)
|
|
44
|
+
if self._places is None:
|
|
45
|
+
return raw
|
|
46
|
+
return round(Decimal(repr(raw)), self._places)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
places = "" if self._places is None else f", places={self._places}"
|
|
50
|
+
return f"Uniform({self._low!r}, {self._high!r}{places})"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Turning a declared table into the tuples COPY will consume."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django_data_shape.table import Table
|
|
9
|
+
from django_data_shape.utils import draw, field_stream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generate_rows(table: Table, seed: int) -> Iterator[tuple[Any, ...]]:
|
|
13
|
+
"""Yield one tuple per row: the primary key, then each declared column.
|
|
14
|
+
|
|
15
|
+
Rows, not model instances. The ORM is the wrong tool at these counts -- it
|
|
16
|
+
is the difference between a load measured in seconds and one measured in
|
|
17
|
+
minutes -- and nothing here needs a model instance, because no ``save`` will
|
|
18
|
+
run and no signal should fire.
|
|
19
|
+
|
|
20
|
+
Primary keys are a dense ``1..N`` because this package assigns them. That is
|
|
21
|
+
what will let a child row's foreign key be satisfied by construction with no
|
|
22
|
+
lookup, and it is why a self-referential tree is acyclic for free. It also
|
|
23
|
+
obliges the caller to reset the sequence afterwards; see ``build``.
|
|
24
|
+
|
|
25
|
+
A generator rather than a list: a million rows of tuples is real memory, and
|
|
26
|
+
psycopg writes them one at a time anyway, so materialising the whole set
|
|
27
|
+
would buy nothing but peak RSS.
|
|
28
|
+
"""
|
|
29
|
+
columns = table.columns()
|
|
30
|
+
# Stream ids are derived once per field rather than per row. At a million
|
|
31
|
+
# rows this loop runs a million times per column, so anything hoistable out
|
|
32
|
+
# of it is worth hoisting.
|
|
33
|
+
streams = [field_stream(seed, table.db_table, name) for name, _ in columns]
|
|
34
|
+
distributions = [table.fields[name] for name, _ in columns]
|
|
35
|
+
|
|
36
|
+
for row in range(table.rows):
|
|
37
|
+
yield (
|
|
38
|
+
row + 1,
|
|
39
|
+
*(
|
|
40
|
+
distribution.value(row, draw(stream, row))
|
|
41
|
+
for distribution, stream in zip(distributions, streams, strict=True)
|
|
42
|
+
),
|
|
43
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Raised when a declaration cannot describe a database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidShape(Exception):
|
|
7
|
+
"""A shape declaration is contradictory, incomplete or unsatisfiable.
|
|
8
|
+
|
|
9
|
+
Its own type, and raised as early as the contradiction can be seen -- at
|
|
10
|
+
declaration time wherever possible, rather than at load time. The reason is
|
|
11
|
+
the package's own bar: a generated database that is wrong is worse than one
|
|
12
|
+
that refuses to exist, because the test suite it feeds will assert on data
|
|
13
|
+
that could never occur and pass or fail for reasons unrelated to the code.
|
|
14
|
+
|
|
15
|
+
Every message names the model, the field or the constraint at fault. An
|
|
16
|
+
error that says only that something is inconsistent leaves the reader to
|
|
17
|
+
re-derive what this code already knew.
|
|
18
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The backend gate, in one place and testable without a database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django_data_shape.unsupported_backend import UnsupportedBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_postgres(connection: Any, operation: str) -> None:
|
|
11
|
+
"""Refuse anything but PostgreSQL, naming what was refused and why.
|
|
12
|
+
|
|
13
|
+
Takes the connection and reads ``vendor`` off it rather than importing a
|
|
14
|
+
backend or opening a cursor, which is what lets every refusal path in this
|
|
15
|
+
package be covered by passing an object with a vendor. A degradation path
|
|
16
|
+
reachable only by running the whole suite on the backend it refuses is a
|
|
17
|
+
path the coverage gate cannot see, and this package gates coverage on
|
|
18
|
+
Postgres precisely because that is where its real work happens.
|
|
19
|
+
"""
|
|
20
|
+
# The driver is read off the connection, exactly like ``vendor`` above, and
|
|
21
|
+
# for the same reason: a refusal that could only be covered by installing
|
|
22
|
+
# the driver it refuses is a refusal the coverage gate cannot see. Django
|
|
23
|
+
# sets ``Database`` to the driver module -- ``psycopg`` on 3, ``psycopg2``
|
|
24
|
+
# on 2 -- so a stub can supply it and this branch is testable everywhere.
|
|
25
|
+
driver = getattr(connection.Database, "__name__", "")
|
|
26
|
+
if connection.vendor == "postgresql" and driver != "psycopg":
|
|
27
|
+
# Django 6.1 still ships the psycopg 2 fallback, so this is a live
|
|
28
|
+
# configuration rather than a legacy one. Without this check the vendor
|
|
29
|
+
# gate passes and the load fails deep inside the package with
|
|
30
|
+
# "'psycopg2.extensions.cursor' object has no attribute 'copy'" -- a
|
|
31
|
+
# traceback pointing here, which a user would reasonably file as a bug
|
|
32
|
+
# in this package rather than as a missing driver.
|
|
33
|
+
raise UnsupportedBackend(
|
|
34
|
+
f"{operation} needs psycopg 3; connection '{connection.alias}' is using "
|
|
35
|
+
f"{driver or 'an unknown driver'}. "
|
|
36
|
+
"Rows are streamed straight into COPY FROM STDIN, which psycopg 2 cannot do without "
|
|
37
|
+
"materialising them first -- the cost this package exists to avoid. Install the "
|
|
38
|
+
"'postgres' extra: pip install django-data-shape[postgres]."
|
|
39
|
+
)
|
|
40
|
+
if connection.vendor != "postgresql":
|
|
41
|
+
raise UnsupportedBackend(
|
|
42
|
+
f"{operation} needs PostgreSQL; connection '{connection.alias}' is "
|
|
43
|
+
f"{connection.vendor}. Generation and cardinality are backend-neutral, but "
|
|
44
|
+
"COPY loading and planner statistics are not, and a shaped database whose plans "
|
|
45
|
+
"mean nothing is worse than no shaped database at all."
|
|
46
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A whole declared database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
6
|
+
from django_data_shape.table import Table
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Shape:
|
|
10
|
+
"""The tables to build, and the seed that makes them reproducible.
|
|
11
|
+
|
|
12
|
+
A declaration and nothing else: it holds no connection, opens nothing, and
|
|
13
|
+
has no ``build`` method. Building lives in a separate function on purpose,
|
|
14
|
+
because a shape has to stay inert data for two things that come later --
|
|
15
|
+
hashing it into a template-database cache key, and emitting one from a real
|
|
16
|
+
database's statistics. An object that could act would be an object with
|
|
17
|
+
state worth not hashing.
|
|
18
|
+
|
|
19
|
+
The seed is part of the declaration rather than an argument to the build,
|
|
20
|
+
for the same reason: two builds of the same shape must produce byte-identical
|
|
21
|
+
databases, and a seed passed at build time would let them differ while the
|
|
22
|
+
declaration claimed they could not.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *tables: Table, seed: int = 0) -> None:
|
|
26
|
+
if not tables:
|
|
27
|
+
raise InvalidShape("A shape needs at least one table.")
|
|
28
|
+
seen: dict[str, Table] = {}
|
|
29
|
+
for table in tables:
|
|
30
|
+
key = table.db_table
|
|
31
|
+
if key in seen:
|
|
32
|
+
raise InvalidShape(
|
|
33
|
+
f"{key} is declared twice. One table gets one row count and one set of "
|
|
34
|
+
"distributions; two declarations would silently mean whichever came last."
|
|
35
|
+
)
|
|
36
|
+
seen[key] = table
|
|
37
|
+
self.tables = tables
|
|
38
|
+
self.seed = seed
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
names = ", ".join(table.model.__name__ for table in self.tables)
|
|
42
|
+
return f"Shape({names}, seed={self.seed})"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Raised when a table already holds rows a build would collide with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ShapeNotEmpty(Exception):
|
|
7
|
+
"""The destination table is not empty, so the assigned keys would collide.
|
|
8
|
+
|
|
9
|
+
Its own type rather than a reused one because the caller's remedy is
|
|
10
|
+
specific and nothing else in this package shares it: empty the table, then
|
|
11
|
+
build. Raised before any row is written, so a build that fails this way has
|
|
12
|
+
changed nothing.
|
|
13
|
+
|
|
14
|
+
The alternative was letting the database report it, which it did -- as a
|
|
15
|
+
unique-violation naming an index. That says what went wrong at the storage
|
|
16
|
+
layer and nothing about what the caller did or what to do instead.
|
|
17
|
+
"""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""One model's declared shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from django.db.models import Field, IntegerField, Model
|
|
8
|
+
from django.db.models.fields import NOT_PROVIDED
|
|
9
|
+
|
|
10
|
+
from django_data_shape.distributions.constant import Constant
|
|
11
|
+
from django_data_shape.distributions.distribution import Distribution
|
|
12
|
+
from django_data_shape.invalid_shape import InvalidShape
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Table:
|
|
16
|
+
"""How many rows of one model, and how each column is distributed.
|
|
17
|
+
|
|
18
|
+
Field distributions are given as keyword arguments because that is the form
|
|
19
|
+
a reader scans fastest. ``fields=`` is the escape hatch, and it is not
|
|
20
|
+
optional politeness: a model may legitimately have a column called ``rows``
|
|
21
|
+
or ``model``, and Python's own argument binding would silently hand that
|
|
22
|
+
keyword to this signature instead. Without the mapping form those models
|
|
23
|
+
would simply be undeclarable.
|
|
24
|
+
|
|
25
|
+
Every refusal below happens here, at declaration time, rather than during
|
|
26
|
+
the load. A shape that cannot describe a database should say so before it
|
|
27
|
+
has spent a minute generating rows, and the message should name the field --
|
|
28
|
+
a reader who has to re-derive which column was meant has been given an error
|
|
29
|
+
that knows more than it says.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
model: type[Model],
|
|
35
|
+
rows: int,
|
|
36
|
+
fields: dict[str, Distribution] | None = None,
|
|
37
|
+
**field_distributions: Distribution,
|
|
38
|
+
) -> None:
|
|
39
|
+
if rows < 0:
|
|
40
|
+
raise InvalidShape(f"{model.__name__} cannot have {rows} rows.")
|
|
41
|
+
|
|
42
|
+
declared: dict[str, Distribution] = dict(fields or {})
|
|
43
|
+
overlap = sorted(set(declared) & set(field_distributions))
|
|
44
|
+
if overlap:
|
|
45
|
+
raise InvalidShape(
|
|
46
|
+
f"{model.__name__} declares {', '.join(overlap)} twice, once in fields= and "
|
|
47
|
+
"once as a keyword. Use one or the other."
|
|
48
|
+
)
|
|
49
|
+
declared.update(field_distributions)
|
|
50
|
+
|
|
51
|
+
self.model = model
|
|
52
|
+
self.rows = rows
|
|
53
|
+
self.fields = declared
|
|
54
|
+
self._validate()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def db_table(self) -> str:
|
|
58
|
+
return str(self.model._meta.db_table)
|
|
59
|
+
|
|
60
|
+
def columns(self) -> tuple[tuple[str, Field[Any, Any]], ...]:
|
|
61
|
+
"""The declared fields, in a stable order, with their model fields.
|
|
62
|
+
|
|
63
|
+
Sorted by name rather than left in declaration order: the order decides
|
|
64
|
+
the column list of the ``COPY`` statement, and a shape whose generated
|
|
65
|
+
SQL changes when two keyword arguments are swapped would hash to a
|
|
66
|
+
different cache key for no reason.
|
|
67
|
+
"""
|
|
68
|
+
meta = self.model._meta
|
|
69
|
+
# cast because get_field's return type covers reverse relations too,
|
|
70
|
+
# which _validate has already ruled out for every name reaching here.
|
|
71
|
+
return tuple(
|
|
72
|
+
(name, cast("Field[Any, Any]", meta.get_field(name))) for name in sorted(self.fields)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _validate(self) -> None:
|
|
76
|
+
meta = self.model._meta
|
|
77
|
+
known = {field.name: field for field in meta.concrete_fields}
|
|
78
|
+
|
|
79
|
+
unknown = sorted(name for name in self.fields if name not in known)
|
|
80
|
+
if unknown:
|
|
81
|
+
raise InvalidShape(
|
|
82
|
+
f"{self.model.__name__} has no field named {', '.join(unknown)}. "
|
|
83
|
+
f"Its concrete fields are: {', '.join(sorted(known))}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
pk_fields = [field for field in known.values() if field.primary_key]
|
|
87
|
+
for field in pk_fields:
|
|
88
|
+
# The dense 1..N range this package assigns is integers, and nothing
|
|
89
|
+
# downstream converts it. Given a CharField primary key the load
|
|
90
|
+
# used to succeed and write "1", "2", "3" -- values the application
|
|
91
|
+
# could never produce, with a whole statistics picture built on top
|
|
92
|
+
# of them. Refusing is the only honest answer until a key strategy
|
|
93
|
+
# can be declared per table.
|
|
94
|
+
if not isinstance(field, IntegerField):
|
|
95
|
+
raise InvalidShape(
|
|
96
|
+
f"{self.model.__name__}.{field.name} is a "
|
|
97
|
+
f"{type(field).__name__} primary key, and this package assigns primary keys "
|
|
98
|
+
"itself as a dense 1..N integer range. Only integer primary keys are "
|
|
99
|
+
"supported."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
pk_names = {name for name, field in known.items() if field.primary_key}
|
|
103
|
+
declared_pk = sorted(pk_names & set(self.fields))
|
|
104
|
+
if declared_pk:
|
|
105
|
+
raise InvalidShape(
|
|
106
|
+
f"{self.model.__name__}.{', '.join(declared_pk)} is the primary key, which this "
|
|
107
|
+
"package assigns itself as a dense 1..N range. That is what lets a foreign key "
|
|
108
|
+
"be satisfied without a lookup, so it is not available to declare."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Relations are the next release's work, and generating a foreign key
|
|
112
|
+
# column from a value distribution would produce ids pointing at rows
|
|
113
|
+
# that may not exist. Refusing is the only honest answer until fan-out
|
|
114
|
+
# can be declared as a distribution over the parents.
|
|
115
|
+
relations = sorted(
|
|
116
|
+
name for name, field in known.items() if field.is_relation and name in self.fields
|
|
117
|
+
)
|
|
118
|
+
if relations:
|
|
119
|
+
raise InvalidShape(
|
|
120
|
+
f"{self.model.__name__}.{', '.join(relations)} is a relation, and relations are "
|
|
121
|
+
"not supported yet. Declaring fan-out as a distribution is the next release."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._resolve_defaults(known)
|
|
125
|
+
|
|
126
|
+
def _resolve_defaults(self, known: dict[str, Field[Any, Any]]) -> None:
|
|
127
|
+
"""Decide what happens to every field the caller did not declare.
|
|
128
|
+
|
|
129
|
+
The subtlety that makes this its own method: **a Django ``default=`` is
|
|
130
|
+
not a database default.** It is applied by ``save()``, and this package
|
|
131
|
+
never calls ``save()`` -- it streams tuples into ``COPY``. So a column
|
|
132
|
+
that is ``NOT NULL`` with a Python-level default has nothing behind it
|
|
133
|
+
at the database level, and omitting it from the load fails on a
|
|
134
|
+
not-null violation rather than quietly taking the default.
|
|
135
|
+
|
|
136
|
+
Filling it in with the same value ``save()`` would have written keeps
|
|
137
|
+
the caller's mental model true instead of making them restate a default
|
|
138
|
+
they already declared on the model.
|
|
139
|
+
"""
|
|
140
|
+
undeclared = [
|
|
141
|
+
(name, field)
|
|
142
|
+
for name, field in known.items()
|
|
143
|
+
if name not in self.fields and not field.primary_key
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Declaring a relation is refused in _validate; omitting a required one
|
|
147
|
+
# used to be accepted and then fail inside COPY with a not-null
|
|
148
|
+
# violation. Both directions have to refuse, or the contract only holds
|
|
149
|
+
# for the callers who tried the unsupported thing explicitly.
|
|
150
|
+
required_relations = sorted(
|
|
151
|
+
name
|
|
152
|
+
for name, field in undeclared
|
|
153
|
+
if field.is_relation and not field.null and not field.has_default()
|
|
154
|
+
)
|
|
155
|
+
if required_relations:
|
|
156
|
+
raise InvalidShape(
|
|
157
|
+
f"{self.model.__name__}.{', '.join(required_relations)} is a relation that "
|
|
158
|
+
"cannot be null, and relations are not supported yet, so this shape cannot be "
|
|
159
|
+
"built. Declaring fan-out as a distribution is the next release."
|
|
160
|
+
)
|
|
161
|
+
undeclared = [(name, field) for name, field in undeclared if not field.is_relation]
|
|
162
|
+
|
|
163
|
+
# A callable default is refused rather than guessed. This package cannot
|
|
164
|
+
# know whether it varies per row -- ``uuid4`` does, ``dict`` does not --
|
|
165
|
+
# and both readings produce data the application would never have
|
|
166
|
+
# written: one duplicates a value meant to be unique, the other invents
|
|
167
|
+
# variation where the model promised none.
|
|
168
|
+
callables = sorted(
|
|
169
|
+
name for name, field in undeclared if field.has_default() and callable(field.default)
|
|
170
|
+
)
|
|
171
|
+
if callables:
|
|
172
|
+
raise InvalidShape(
|
|
173
|
+
f"{self.model.__name__}.{', '.join(callables)} has a callable default, which "
|
|
174
|
+
"this package will not call on your behalf: it cannot tell a per-row default "
|
|
175
|
+
"from a shared one, and guessing either way writes rows the application never "
|
|
176
|
+
"would. Declare a distribution for it."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
missing: list[str] = []
|
|
180
|
+
for name, field in undeclared:
|
|
181
|
+
if field.has_default():
|
|
182
|
+
self.fields[name] = Constant(field.get_default())
|
|
183
|
+
elif field.null or self._has_db_default(field):
|
|
184
|
+
continue
|
|
185
|
+
else:
|
|
186
|
+
missing.append(name)
|
|
187
|
+
|
|
188
|
+
if missing:
|
|
189
|
+
raise InvalidShape(
|
|
190
|
+
f"{self.model.__name__}.{', '.join(sorted(missing))} cannot be null and has no "
|
|
191
|
+
"default, so it has to be declared. A column left to chance is the column whose "
|
|
192
|
+
"selectivity the plan then depends on."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _has_db_default(field: Field[Any, Any]) -> bool:
|
|
197
|
+
"""Whether the database itself will supply a value.
|
|
198
|
+
|
|
199
|
+
``db_default`` arrived in Django 5.0 and this package supports 4.2, so
|
|
200
|
+
the attribute cannot be assumed to exist. Unlike ``default``, this one
|
|
201
|
+
is real DDL, which is why a column carrying it can be left out of the
|
|
202
|
+
``COPY`` entirely.
|
|
203
|
+
"""
|
|
204
|
+
return getattr(field, "db_default", NOT_PROVIDED) is not NOT_PROVIDED
|
|
205
|
+
|
|
206
|
+
def __repr__(self) -> str:
|
|
207
|
+
return f"Table({self.model.__name__}, rows={self.rows})"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""What one table's load actually produced."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TableResult:
|
|
10
|
+
"""Rows loaded into one table.
|
|
11
|
+
|
|
12
|
+
Carries the database table name rather than the model, because this is what
|
|
13
|
+
a caller prints or asserts on, and because the two stop being one-to-one as
|
|
14
|
+
soon as through tables are generated alongside the models that declare them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
table: str
|
|
18
|
+
rows: int
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Raised when the connection cannot do what was asked of it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnsupportedBackend(Exception):
|
|
7
|
+
"""The database backend cannot support the operation requested.
|
|
8
|
+
|
|
9
|
+
Separate from :class:`~django_data_shape.invalid_shape.InvalidShape`
|
|
10
|
+
because nothing is wrong with the declaration: the same shape is valid, and
|
|
11
|
+
would build, against Postgres. Only the destination is unsuitable.
|
|
12
|
+
|
|
13
|
+
Raised rather than warned, and never quietly degraded to a slower path. The
|
|
14
|
+
whole claim of this package is that the loaded database is one the planner
|
|
15
|
+
can reason about; a backend without ``COPY`` or column statistics cannot
|
|
16
|
+
produce that, and silently producing something else would be the failure
|
|
17
|
+
mode the package was written to expose.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Helpers used across more than one module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
_MASK64 = (1 << 64) - 1
|
|
8
|
+
_GOLDEN = 0x9E3779B97F4A7C15
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def field_stream(seed: int, table: str, field: str) -> int:
|
|
12
|
+
"""A stable 64-bit stream id for one table's one field.
|
|
13
|
+
|
|
14
|
+
Derived once per field rather than per row, so the cost of a real hash is
|
|
15
|
+
paid a handful of times instead of millions. ``hash()`` is deliberately not
|
|
16
|
+
used: it is salted per interpreter run, which would make a seeded shape
|
|
17
|
+
reproduce only within a single process.
|
|
18
|
+
"""
|
|
19
|
+
digest = hashlib.blake2b(f"{table}.{field}".encode(), digest_size=8).digest()
|
|
20
|
+
return (int.from_bytes(digest, "big") ^ seed) & _MASK64
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def draw(stream: int, row: int) -> float:
|
|
24
|
+
"""A uniform float in [0, 1) for one field of one row.
|
|
25
|
+
|
|
26
|
+
Deterministic in ``(stream, row)`` alone, which is the property the whole
|
|
27
|
+
design rests on: a value does not depend on how many rows were generated
|
|
28
|
+
before it, so rows can later be emitted in an order different from the one
|
|
29
|
+
they were assigned in. That separation is what lets physical placement be
|
|
30
|
+
declared without buffering a group in memory.
|
|
31
|
+
|
|
32
|
+
SplitMix64's finalizer rather than ``random.Random``: seeding a Mersenne
|
|
33
|
+
Twister per value costs far more than the value is worth at these row
|
|
34
|
+
counts, and this needs no sequential state to be reproducible.
|
|
35
|
+
"""
|
|
36
|
+
z = (stream + (row + 1) * _GOLDEN) & _MASK64
|
|
37
|
+
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & _MASK64
|
|
38
|
+
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & _MASK64
|
|
39
|
+
z = z ^ (z >> 31)
|
|
40
|
+
# 53 bits over 2**53 rather than 64 bits over 2**64: the latter can round up
|
|
41
|
+
# to exactly 1.0 for the top 1024 finalizer outputs, and the finalizer is a
|
|
42
|
+
# bijection so all of them are reachable. That would break the [0, 1)
|
|
43
|
+
# contract every distribution is written against. This is the same
|
|
44
|
+
# construction the standard library uses for ``random.random()``.
|
|
45
|
+
return (z >> 11) / 9007199254740992.0
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-data-shape
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Project-URL: Homepage, https://github.com/Artui/django-data-shape
|
|
6
|
+
Project-URL: Repository, https://github.com/Artui/django-data-shape
|
|
7
|
+
Project-URL: Issues, https://github.com/Artui/django-data-shape/issues
|
|
8
|
+
Author-email: Artur Veres <artur8118@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,fixtures,performance,postgres,query-planner,test-data,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Framework :: Django :: 6.0
|
|
20
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
|
+
Classifier: Framework :: Pytest
|
|
22
|
+
Classifier: Intended Audience :: Developers
|
|
23
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Programming Language :: Python
|
|
26
|
+
Classifier: Programming Language :: Python :: 3
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
32
|
+
Classifier: Topic :: Database
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Classifier: Topic :: Software Development :: Testing
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Requires-Dist: django>=4.2
|
|
37
|
+
Provides-Extra: postgres
|
|
38
|
+
Requires-Dist: psycopg[binary]>=3.2.0; extra == 'postgres'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# django-data-shape
|
|
42
|
+
|
|
43
|
+
[](https://github.com/Artui/django-data-shape/actions/workflows/tests.yml)
|
|
44
|
+
[](https://pypi.org/project/django-data-shape/)
|
|
45
|
+
[](https://pypi.org/project/django-data-shape/)
|
|
46
|
+
[](https://pypi.org/project/django-data-shape/)
|
|
47
|
+
[](https://artui.github.io/django-data-shape/)
|
|
48
|
+
[](https://github.com/Artui/django-data-shape/actions/workflows/tests.yml)
|
|
49
|
+
[](https://github.com/astral-sh/ruff)
|
|
50
|
+
[](LICENSE)
|
|
51
|
+
|
|
52
|
+
A realistically shaped test database from Django models.
|
|
53
|
+
|
|
54
|
+
Declare the shape of your data -- cardinality, value skew, foreign-key fan-out as
|
|
55
|
+
a distribution with a long tail, and where related rows physically sit -- then
|
|
56
|
+
load it by `COPY` and `ANALYZE` it, so the query planner makes the same choices
|
|
57
|
+
it will make in production.
|
|
58
|
+
|
|
59
|
+
It exists because a plan over ten rows is a lie, and because the loop it replaces
|
|
60
|
+
is not merely smaller: uniform fan-out makes the planner always right, and
|
|
61
|
+
generating children parent-by-parent clusters them perfectly, which flatters
|
|
62
|
+
every index scan. A test database can be wrong in the flattering direction, and
|
|
63
|
+
usually is.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install django-data-shape[postgres]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Use
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import datetime
|
|
75
|
+
|
|
76
|
+
from django_data_shape import Sequential, Shape, Skew, Table, Uniform, build
|
|
77
|
+
|
|
78
|
+
shape = Shape(
|
|
79
|
+
Table(
|
|
80
|
+
Order,
|
|
81
|
+
rows=1_000_000,
|
|
82
|
+
status=Skew({"complete": 0.98, "pending": 0.015, "cancelled": 0.005}),
|
|
83
|
+
total=Uniform(0, 500, places=2),
|
|
84
|
+
created_at=Sequential(
|
|
85
|
+
datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
|
|
86
|
+
datetime.timedelta(seconds=3),
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
seed=1234,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
build(shape)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`build()` generates the rows, loads them with `COPY`, moves the identity sequence
|
|
96
|
+
past the keys it assigned, and runs `ANALYZE` so the planner can see the shape.
|
|
97
|
+
It raises on any backend that is not PostgreSQL rather than degrading quietly.
|
|
98
|
+
|
|
99
|
+
## Status
|
|
100
|
+
|
|
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.
|
|
105
|
+
|
|
106
|
+
Full documentation: <https://artui.github.io/django-data-shape/>
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
django_data_shape/__init__.py,sha256=_IVSrdleAm0zSEg0CZRlsPzMLWp7XYt5w56xx5SC8cQ,1107
|
|
2
|
+
django_data_shape/build.py,sha256=rCzCPBmQafifRS6wB_9oOpquoa74Y0BV3asr1ZK95Nw,7042
|
|
3
|
+
django_data_shape/build_result.py,sha256=z_I8DeVO9YC-pOztXAz6dk-1iFho2gf1J_3JeGU4-Kc,871
|
|
4
|
+
django_data_shape/generate_rows.py,sha256=weXqNSA-ANKfbZe9ub66RoMPvKVRBxlOshTIFXaD_gI,1821
|
|
5
|
+
django_data_shape/invalid_shape.py,sha256=xC9sUqiLo5EkDMvFoBpqeqIiZTOlDQyDl5AXPIetqOU,804
|
|
6
|
+
django_data_shape/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
django_data_shape/require_postgres.py,sha256=S5y3PPwfa0DpRskXamFzt-L0F0P7yQm0W2hlM6UCEwY,2618
|
|
8
|
+
django_data_shape/shape.py,sha256=2hcASfhoRHNz6JsE_m4T2tH4p7NpHZl741gc8Dmp85E,1727
|
|
9
|
+
django_data_shape/shape_not_empty.py,sha256=0fNK9IjiBiZ14gWPEnXVwNfTdTBE-CVTtyjX06oSZAw,712
|
|
10
|
+
django_data_shape/table.py,sha256=uzViWZogjpA1qRR-thomG2kuIh4zR2aC8E6ohDyPrpg,9553
|
|
11
|
+
django_data_shape/table_result.py,sha256=f5M3irpbCy4m0sUSwc0iVDhCL5zXXmLAnQ7tiC-bxrc,478
|
|
12
|
+
django_data_shape/unsupported_backend.py,sha256=38lea85ttw8yLs83i8yU0AtdI3la4wkH3oAidrNK3j4,799
|
|
13
|
+
django_data_shape/utils.py,sha256=3rdIL0KTBRATL6fyoIEjO0YVhlv2ezJF4fTxygr_AhM,2001
|
|
14
|
+
django_data_shape/version.py,sha256=IGt2-6r71nTeUfPEK_x1lNeMEBrz0uYGAfUvfX0lTVs,152
|
|
15
|
+
django_data_shape/distributions/__init__.py,sha256=_qvSk36zHtq33S0ewBJCZjlQR9eaWWeXJOThnj2MhIQ,426
|
|
16
|
+
django_data_shape/distributions/constant.py,sha256=lwEcfqrtaAdRdIrClnw-nT5UswUKSFQT2ZiLp773Tlk,889
|
|
17
|
+
django_data_shape/distributions/distribution.py,sha256=gjcpRo2eFY2UaUDJwHzbgN8nHK4W3ijxZW2m1Lb6H4M,1009
|
|
18
|
+
django_data_shape/distributions/sequential.py,sha256=Ag7sbIURwG3_47sKfJVnGSNs6rEX-Mw-_x4j3UH9FCQ,1170
|
|
19
|
+
django_data_shape/distributions/skew.py,sha256=KbjYPxUUMPr_r0bMlpTVyho5Hh5WDPLLReRDpxcbzYA,3297
|
|
20
|
+
django_data_shape/distributions/uniform.py,sha256=p-RC0AENioKBCJjEEYscH1Cmgg7cPlYAjwl7Q20asIo,2245
|
|
21
|
+
django_data_shape-0.1.0.dist-info/METADATA,sha256=qgtGCYr9JuILW_4MirC7KzIwliLZ41jU0FkFJsqfnEw,4693
|
|
22
|
+
django_data_shape-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
23
|
+
django_data_shape-0.1.0.dist-info/licenses/LICENSE,sha256=0rCDnUPa87Fm67SxYMrdqEdLV6UiMENH6NCPLZ_KT5M,1068
|
|
24
|
+
django_data_shape-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artur Veres
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|