Projections¶
Some tables are not distributed at all. They are copied.
An Event is created from a Template, and its EventSession rows mirror that
template's TemplateSession rows one for one. How many sessions an event has is
not a shape anybody chose: it is count(TemplateSession WHERE template =
event.template). A Projection declares that.
from django_data_shape import Constant, FanOut, Projection, Shape, Table, Uniform, Zipf, build
build(
Shape(
Table(Template, rows=500, name=Constant("t")),
Table(
TemplateSession,
rows=4_000,
template=FanOut(Zipf(1.1)),
title=Constant("session"),
minutes=Uniform(15, 90),
),
Table(Event, rows=200_000, template=FanOut(Zipf()), name=Constant("e")),
Projection(EventSession, per=Event, copying=TemplateSession),
)
)
That reads as one EventSession per Event, copying TemplateSession, and it
is the whole declaration. The join, the column list and the primary keys are all
derived from the model graph, and the table is filled by a single
INSERT ... SELECT over the tables already built.
Why a projection rather than a way of saying "mirror this"¶
Three reasons, and the first is the one that decides it.
It is what the real system already collapses into at scale. One event built from a template is a service call. A million of them is one statement. A projection is that statement, so a test database is built the way the production table would be backfilled rather than the way one row is created — and that is also why this is not the per-row creation hook this package declines.
It needs no new distribution machinery. A mirroring mode on FanOut would
need an inverted fan-out, a cardinality derived from another table, and a way to
say "as many as over there" — three new vocabulary items to express something
that is not a distribution in the first place.
It reproduces a correlation Postgres cannot see. Sessions-per-event is
correlated with the template, so every event built from a big template has many
sessions and every event from a small one has few. A plain
FanOut(Zipf()) on EventSession.event would draw that count independently and
hand the planner a join selectivity real data never has. Postgres has no
cross-table statistics, so it cannot see the correlation either way — which means
declaring it does not fix an estimate. It builds the database in which the wrong
estimate is reproducible.
The cardinality is determined, not declared¶
There is no rows= on a Projection. The row count is decided by the tables it
copies from, and declaring it as well would be the same over-determination this
package refuses everywhere else.
The achieved count comes back in the BuildResult, like every other table's:
result = build(shape)
sessions = next(table for table in result.tables if table.table == "app_eventsession")
print(sessions.rows)
It also means a projection needs no scale factor of its own. scaled_shape
passes it through untouched, and it grows because the tables it reads did.
It grows as a product, which is the part that surprises¶
A projection is one row per pair along the join, so its size is the inner product of the two sides. When both of them fan out over the same parents, the busy parents multiply:
Table(Venue, rows=40, name=Constant("alhambra"))
Table(VenueSeat, rows=24_000, venue=FanOut(Zipf(1.1)), number=Sequential(1, 1))
Table(Show, rows=4_000, venue=FanOut(Zipf(1.15)), title=Constant("an evening with"))
Projection(ShowSeat, per=Show, copying=VenueSeat)
boxoffice_order 300,000 <- the largest number in the declaration
boxoffice_showseat 2,413,223 <- eight times bigger, declared nowhere
Raise either declared count by four and the projection grows by sixteen.
That is the model working as intended -- it is what reproduces a correlation a
FanOut on the child would destroy -- but it does mean the largest table in a
database can be the one nobody wrote a number for.
max_rows bounds it, before the insert rather than after¶
The count is taken first and compared, so a declaration that has run away costs a scan of the join rather than the time to write every row of it. The refusal names the number it would have written and the tables the join is over, because the surprise is never the ceiling:
The projection into boxoffice_showseat would insert 12,058,114 rows, which is
over its declared max_rows=3,000,000. Nothing was written. A projection has no
row count of its own: its size is one row per pair along the join it copies,
over Show, VenueSeat. That size is a product, so when both sides of the join fan
out over the same parents the busy parents multiply and raising either declared
count by four grows this by sixteen. Either the ceiling is too low, or one of
those counts moved further than it looked.
scaled_shape multiplies the ceiling by the factor, for the same reason the
size needs no factor at all: every table scales, parents included, so a parent
has the same number of children at every factor and the projection is a sum over
factor times as many parents of an unchanged per-parent product. A ceiling
that stayed put would fire on the first growth assertion.
A declaration that does not set one is not charged for the answer -- no count is taken. There is no default ceiling and there will not be one: how many rows is too many is a judgement about size, which this package does not make on your behalf anywhere else either. What it can do is act on your number, early.
What gets derived¶
The join. per and copying are joined through a model they both reach in
one step — here both have a foreign key to Template. per's own primary key
counts as a step of length zero, so a source that points straight at per is the
same case. Exactly one such link has to exist; zero and several are both refused
by name.
through= — which model the join runs on¶
Several links is the common case, not the exception, and the reason is a pattern most Django schemas have:
class BaseModel(models.Model):
created_by = models.ForeignKey(User, ...)
updated_by = models.ForeignKey(User, ...)
class Meta:
abstract = True
An abstract base like that makes any two models in the schema joinable, so
the derivation has candidates everywhere and can settle nothing — not for one
pair, for every pair. through= says which model the join is on:
The refusal names it, and names a model that can actually resolve the join rather than the first one alphabetically — an audit model is reached by two edges from each side, so it is never a usable answer even though it is a real candidate.
Where a model is reached by more than one edge from a side, through= cannot
help: both edges satisfy it, so naming it leaves the same choice. The refusal
says so and sends you to sql=, which is the only place a specific pair of
columns can be written.
The columns, in this order, for every column but the primary key:
| The column | Gets |
|---|---|
the foreign key to per |
that row's key |
a foreign key to copying |
the copied row's key |
a column named the same as one on copying |
that column's value |
a column with a plain default= |
that default, as a bound parameter |
a nullable column, or one with db_default |
nothing — it is left out |
| anything else | a refusal naming the column |
The copy is by name, so the two columns have to hold compatible types; the database says so if they do not.
A default= is written rather than left out because a Django default is applied
by save() and is not DDL — the same reason Table fills one in. A callable
default is refused, and a projection has a second reason on top of Table's:
the rows are made by one statement and never pass through Python, so there is no
per-row moment at which the callable could be called.
Where the rows land, and why there is no placement=¶
The statement orders by per's key and then the copied row's. That is
deterministic — two builds of one shape have to agree — and it is also the honest
physical layout.
FanOut has to choose between arrival and grouped because a parent's
children really do arrive over time, so emitting them contiguously is a lie. A
copied collection is different: one event's sessions are written in one
transaction, and the events themselves arrive in key order. Grouped is
arrival order here, so there is nothing to choose.
Keys¶
A projected table's keys come from the same place as any other table's: the key strategy on the declaration. The only extra requirement is that the strategy can say itself in SQL, because a projection has no declared row count to enumerate in Python and the rows never pass through it.
SequentialKeys — the strategy inferred for an integer primary key — can:
row + 1, over a row index the database computes with row_number(). UuidKeys
and KeyFunction cannot, and are refused by name at declaration time. That is a
refusal rather than an approximation on purpose: computing a different hash in
SQL from the one Python computes would give one strategy two meanings depending
on which statement filled the table.
A UUID-keyed table can still be projected — with sql=, producing the keys in
the statement.
That is about the projected table's own primary key, and only that. The
tables named by per= and copying= may be keyed however they like: a
projection reads them, and reading a UUID key needs nothing special. What does
need care is an expression over one — see
the note on UUID keys below,
because integer arithmetic on a UUID is a type error rather than a refusal this
package can make.
values= — a column of the projected table's own¶
A projection copies a column from the source it names, or takes the model's own default. Those are its only two answers, and a projected table's measure column is neither — the score on a review, the amount on a generated line, the reading on a sample. It belongs to the projected row and to nothing the source carries.
Leaving it to a model default is legal and is the wrong answer here
specifically: one value across every projected row is n_distinct = 1, the
exact shape a planner cannot use. A package whose whole purpose is planner
realism would be building a table it had made unplannable, and the declaration
would look correct.
Projection(
ReviewScore,
per=Review,
copying=Criterion,
values={"score": SqlValue("({per}.id * 31 + {source}.id * 17) % 5 + 1")},
)
{per} and {source} are substituted with the aliases the derived statement
uses, quoted for the connection. They are placeholders rather than the aliases
themselves because the aliases are this package's private business: a
declaration that spelled them would break the day they changed, and a reader
could not tell which side was which. A literal % is escaped for the same
reason -- the statement is executed with bound parameters, so an unescaped one
is an incomplete placeholder to psycopg and to Django's SQLite wrapper alike,
and the paramstyle is no more the declaration's business than the aliases are.
An expression over a UUID key needs a hash¶
The example above multiplies id by an integer, which works on Django's default
BigAutoField and is a type error on a schema whose models carry
id = UUIDField(primary_key=True) — a shared abstract base doing exactly that is
an ordinary Django layout, not an unusual one. PostgreSQL has no uuid * integer
operator, and because an expression is opaque to this package the refusal comes
from the driver at build time, naming neither the shape nor the column.
Per-row variation over a UUID has to come from a hash:
Projection(
ReviewScore,
per=Review,
copying=Criterion,
values={
"score": SqlValue("abs(hashtext({per}.id::text || {source}.id::text)::bigint) % 5 + 1")
},
)
Every part of that is load-bearing, and two of them fail rarely enough to reach production:
::bigintgoes beforeabs, becausehashtextreturnsint4andabs(-2147483648)isinteger out of range. That is one value in four billion, which is to say it is a crash your suite will not find and a consumer will.absis there because PostgreSQL's%keeps the sign of the dividend, sohashtext(...) % 5runs from-4to4. A measure column would hold negatives and every plan over it would still look fine.
{values.x} — one column named from another¶
A projected table's measure columns are usually related to each other: a
requested amount and an approved one, a quantity and a total, an amount and the
rate derived from it. An expression can name another entry in the same values=
dict, and the relationship is then stated where a reader will look for it:
values = {
"requested_amount": SqlValue("abs(hashtext({per}.id::text)::bigint) % 500 + 100"),
"approved_amount": SqlValue("{values.requested_amount} * 8 / 10"),
}
Without it, both columns have to be restated from whatever they were both
computed from, with coefficients picked so that approved <= requested happens
to hold — an invariant a reader can only find by doing the arithmetic, and one
that nothing rechecks when either expression is edited.
The name is dotted rather than a bare {requested_amount} because {per} and
{source} already occupy that space and a model is entitled to a column called
per. Only values= entries are referenceable: a copied column is already
reachable as {source}.name, and the primary key is the row_number() window
itself and is reachable nowhere at all. A name that resolves to neither, and a
cycle, are refused at declaration time and name the path.
It is substitution, not sharing¶
{values.x} names the declaration, and what it splices in is that expression
written out again — parenthesised, so 1 + 1 referenced from x * 3 is 6 and
not 4. The database evaluates the referenced expression once per reference.
For a deterministic expression that costs arithmetic nobody measures, and every
expression here has to be deterministic anyway: template_database reuses a
database keyed on the declaration and nothing else, so a volatile expression is
already a stale-data bug waiting for the second run. What a reference adds is
that the two copies are two different values within one build, so the
relationship the declaration appears to state is not the one the rows hold.
State the relationship in values= and net it with an
Invariant:
Invariant(
"an approved amount never exceeds the amount requested",
Shipment,
violated_by=Q(approved_amount__gt=F("requested_amount")),
)
That pairing is the answer to "nothing rechecks it": the declaration says what the relationship is, and the invariant fails the build if it ever stops being true — whether because an expression was edited, or because one of them was not deterministic after all.
The expression is the one part of a shape that is not portable¶
Everything else here is a declaration compiled per backend. An expression is SQL
the database evaluates as written, and nothing can inspect it: mod(x, 5)
returns an integer on PostgreSQL and a REAL on SQLite, so one declaration writes
5 into one database and 5.0 into the other. That is why the example above
spells modulo as %, which is integer on both. Write the expression for the
backend the shape is built on, and cast where it has to be both.
The escape hatch below answers the same question and answers it expensively —
sql= replaces the whole SELECT, so the join stops being derived from the
model graph, the copied columns are written out by hand, and the key strategy
has to be spelled in SQL. values= gives up none of that.
Why SQL and not a distribution¶
A Distribution computes from draw(stream, row), which is SplitMix64 —
expressible in PostgreSQL only through numeric modular arithmetic and casts
across the sign boundary, where one mistake gives a declaration two meanings
depending on which statement filled the table. That is exactly the divergence
SqlKeys exists to refuse, and it is not worth buying convenience
with. An expression you wrote is honest about being yours.
The escape hatch¶
For anything shaped oddly — a filter, an aggregate, a three-way join, a window —
supply the SELECT yourself and name the columns it produces.
Projection(
EventSession,
columns=("id", "event", "title", "minutes", "channel"),
sql=(
"SELECT row_number() OVER (ORDER BY e.id, t.id), e.id, t.title, t.minutes, %s "
"FROM app_event e "
"JOIN app_templatesession t ON t.template_id = e.template_id "
"WHERE t.title <> %s"
),
params=("web", "hidden"),
)
The columns are field names, checked against the model, so a typo is refused
here rather than by the database. A relation may be spelled either way —
event and event_id name the same column — because what an INSERT lists is
the second, and that is what the refusal asking for columns= describes. The primary key has to be among them: this
package owns the keys, and a statement it did not write has to say what they are
rather than leave them to a sequence whose current value is not part of any
declaration.
One thing is inspected: a lone %¶
The statement is run with its parameters, and an empty parameter sequence is
still a sequence — so a % that is not a placeholder is read as the start of one
and the driver refuses it, at build time, with a message naming psycopg and
nothing about the shape. Write %% for the modulo operator:
That is the pyformat contract and it is deliberate here: sql= takes params=,
so the placeholders are the interface. The declaration is checked for a lone %
only so the refusal arrives where the mistake is, rather than one layer down. A
valid statement can never contain one, so nothing legal is refused.
SqlValue on the derived path is the other way round and that is not an
inconsistency: there you supply no parameters and have no reason to know one
exists, so % is escaped for you. The rule is the same in both places — the
paramstyle belongs to whoever wrote the statement, and on the derived path that
is this package.
Nothing else about the select is inspected — that is what an escape hatch is —
but the build still gives it the emptiness check, the sequence reset, the
ANALYZE and the transaction.
reads= — what the statement selects from¶
Nothing here parses SQL, so a statement of your own is opaque and is ordered as
late as the rest of the declaration allows. That is the right default until
something fans out over the table it fills: the projection then has to run
before that table, and may find the tables it selects from still empty.
reads= says what it needs, and puts it back in the graph precisely — after
what it reads, before what reads it.
Projection(
EventSession,
columns=("id", "event", "title", "minutes", "channel"),
sql="SELECT row_number() OVER (ORDER BY e.id), e.id, e.name, 1, %s FROM app_event e",
params=("web",),
reads=(Event,),
)
It is part of the declaration the template-database cache keys on, because the
same statement run before and after a table selects different rows. A derived
projection has no use for it and is refused for asking: per= and copying=
already are the answer.
Load order¶
A projection is ordered like everything else: after the tables it reads, before anything that reads it. So the order the declarations are written in does not matter, and a projected table may itself be a fan-out parent.
Shape(
Table(Attendance, rows=1_000_000, session=FanOut(Zipf()), name=Constant("a")),
Projection(EventSession, per=Event, copying=TemplateSession),
...,
)
A raw sql= projection without reads= is the exception: it names nothing, so
"run it last" is a preference rather than an edge. It goes after everything
where nothing needs it, and directly before whatever does.
That distinction is the fix for a refusal that was simply wrong. Expressed as
edges to every other declaration, the preference met a table fanning out over
the projection and reported Attendance -> EventSession -> Attendance — a cycle
in a chain, with no way for the caller to correct it. A preference cannot
contradict a declared edge; an edge can, and did.
Two declarations that really do read each other are still refused by name, the
same way two tables fanning out over each other are — and the refusal happens
when the Shape is built, because which table can be filled first is decided by
the declarations and nothing else.
Refusals¶
A projection that inserts no rows fails the build. An empty projected table is not a smaller world — it is a declared table left out of the database, and every test reading it then passes or fails for a reason unrelated to the code. The usual causes are an input that is empty, and a join that matches nothing.
The rest are refused at declaration time, before a connection is opened: an
ambiguous join, a model with no edge to per or more than one, a column nothing
can fill, a callable default, and a key strategy with no SQL form.
What is still Postgres-only¶
Nothing about a projection, and everything about what it is for. The statement
is ordinary SQL and runs anywhere, so build(..., require_statistics=False)
fills a projected table on SQLite exactly as it fills any other. What SQLite does
not get is the ANALYZE — and therefore a plan worth reading. The line is the
same one this package draws everywhere: generation and cardinality are
backend-neutral, planner realism is not.