Skip to content

Cross-table after (spec 0.5.0), a warning that suggests it, and integer keys 1..N - #49

Open
jarichb wants to merge 12 commits into
mainfrom
claude/parent-dates-and-ordered-ids
Open

jarichb wants to merge 12 commits into
mainfrom
claude/parent-dates-and-ordered-ids

Conversation

@jarichb

@jarichb jarichb commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What changes

1. after can name a parent's column. after: customers.created_at on orders.order_date keeps every order on or after the created_at of the customer its foreign key points at. after now takes a column of the same row (as before), a parent's column as <table>.<column> (the last . separates the column, so raw.customers.created_at works), or a list of either, all of which the value is not before: after: [orders.ordered_at, products.launched_at], after: [placed_at, customers.created_at]. A date against a timestamp compares by day. A null FK constrains nothing.

  • Reaching the parent: exactly one one-column foreign key onto the parent's primary key or a unique column. Errors name what is wrong: no such table, no such column, not a date, no FK to that table, only a composite FK, an FK onto a column that is no key, the column's own table (a self-reference isn't followed; name the column without a table), two tables' afters naming each other, a parent generated after the child (FK cycle broken at that key), and several FKs to the parent:
    tables.messages.columns.sent_at.generate.after: names users.created_at, and messages reaches users through more than one foreign key (sender_id, receiver_id): after cannot tell which users row it means
  • Keeping the child's shape: a late row takes another parent created by then, drawn from the parents the column already points at, so the date keeps its growth/seasonality/business hours. Only a row with none (or a 1:1 FK) moves its date, within the column's own shape and never past as_of. A mixed list places the value after both. Holds after when fills and composite-key repair, and on later days (a same-day parent lands earlier that day).
  • Spec 0.5.0: a list or a parent's column in after is new vocabulary, so a document using it says model2data: 0.5.0; a 0.2–0.4 document using it gets an error naming the version to write. DBML with such a note converts to 0.5.0. $id → /spec/0.5.0/; PUBLISHING.md lists the 0.5.0 files.
  • dbt: with hint tests on, each parent column writes model2data_not_before_parent (macro in macros/model2data_parent_tests.sql, written only when needed). The defects report checks it; late_arriving prefers rows that stay clear of their parents.

2. A warning that suggests the hint. Where a table reaches a parent through one FK, the parent has a created_at-like date, and no after of the table names that parent, validation (and every run) warns:
tables.orders.columns.order_date: can fall before customers.created_at (the customers row it points at through customer_id); add after: customers.created_at to keep it after.
The Issue carries suggestion = Suggestion(path="tables.orders.columns.order_date.generate.after", value="customers.created_at", spec="0.5.0" when the document is older); value is a tuple when it extends an existing after. No warning for a parent without such a date, self-references, two FKs to one parent, or when an after already names it. Generation itself is unchanged by the warning.

3. Integer primary keys number the rows 1..N, rows in key order (unchanged from the earlier commits of this PR).

The bundled examples (ecommerce, ecommerce_daily, ecommerce_training, advanced_features, saas_platform, and the spec's coffee_webshop) use the hint where it is plainly right and now say 0.5.0.

Determinism

Ids change for every model with an integer pk; dates change only for models that use a parent's column in after. CHANGELOG (Unreleased) says so; determinism digests and the old pinned frames are re-recorded.

Coffee-webshop story (studio example, local copy with after: customers.created_at on orders.order_date; 2,000 customers / 10,000 orders, as_of 2026-10-07, seeds 1–3)

without the hint with the hint
orders before their customer 54–55% 0%
gift season vs May, orders/day 3.1–3.5x 3.1–3.5x
Sep 2026 vs Nov 2025 1.14x 1.14x
top fifth of customers' share (skew: 0.8) ~66% ~80%

Tests

tests/test_parent_dates.py (56 tests: generation, lists, mixed lists, stage chain, 1:1, last-day window, when fix-up, days, every validation error, the cycle checks, the generator's own checks, the warning and its suggestion, DBML/writer versions, dbt tests and the defects check, keys 1..N); a real dbt build passes the parent test clean and fails it on a customer created after its order. Full suite 2232 passed; ruff, ruff format, ty clean.

🤖 Generated with Claude Code

jarichb and others added 5 commits October 7, 2026 22:35
A table's single-column integer primary key was a shuffled sample starting
at 0, so the first customers came out as 4251, 4557, 1941. It now numbers
the rows 1..N like an identity column, and the rows come out in that order.
A key drawn in an explicit min/max range or with a distribution, and an
integer key that is also a foreign key (a one-to-one child keyed by its
parent), keeps its draw and is put in ascending order: the key is drawn
before any other column of the row, so sorting it sorts the rows. Text,
uuid and composite keys are unchanged.

Later days already continue from the largest key; an empty table now starts
at 1 rather than 0.

This is a deliberate output change under the per-version determinism
promise: the key no longer draws from the table's stream, so every column
after it moves. The pinned frames of earlier releases are pinned again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Half the orders of the ecommerce example were placed before their customer
signed up. A table's creation column -- its first date or timestamp without
`after` named for the created stage (created_at, signup_date, start_date),
else its first named for no stage (order_date) -- is now kept on or after
the creation column of every parent row its foreign keys point at, through
a foreign key onto the parent's primary or unique key; with several
parents, after the latest. A date and a timestamp compare by day. Self
references, null foreign keys, parents without a date and foreign keys
that break a cycle are not followed.

The child keeps its own shape: a row dated before its parent takes another
parent created by then, drawn from the parents the column already points
at, so its date keeps the growth, seasonality and business hours it was
drawn with. Only a row with no such parent (or a one-to-one, which cannot
change parent) moves its date, drawn again from the column's day weights
and hours cut to the part of the window on or after the parent's, never
past as_of. Moving dates alone would have flattened the coffee webshop's
gift-season peak (3.1x May before, 0.6x after); taking older parents keeps
it (3.0-3.5x) at the cost of a steeper skew (top fifth of customers: 66%
of orders before, about 80% after).

A composite-key repair or a `when` fill that dates a row before its parent
afterwards moves only that row's date and places its later dates again.
On later days a new row whose parent was inserted the same day lands later
that day. `after_parent: false` on the creation column turns the rule off
for the table, and the generator rejects it anywhere else.

The late_arriving defect prefers rows that stay clear of their parents
once moved back, so it keeps breaking only the incremental model; a review
test moves to a seed whose late rows still show what it checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
With hint tests on, each foreign key a creation date follows writes
`model2data_not_before_parent` on the child's creation column: it joins
the child to the parent model through the foreign key and returns the rows
dated before the parent row, by day for a date against a timestamp. A key
several parent rows share counts its earliest, so a duplicate_keys defect
stays the unique test's to report. The macro ships in
macros/model2data_parent_tests.sql, written only when a project has such a
test, so other projects are what they were.

`hint_tests_for` takes the model's refs beside bare tables, and the defects
report checks the test the way its SQL reads, so a defect that dates a row
before its parent lists it in EXPECTED_FAILURES.md. A dbt run confirms the
test passes on clean data and fails on a customer created after its order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
`after_parent` is new optional vocabulary, so by the spec's own rule it is
a minor version: allowed only in a document that says `model2data: 0.5.0`,
and an error naming the version to write in a 0.2, 0.3 or 0.4 document,
which otherwise read exactly as before. It is a boolean on the table's
creation column; anywhere else the reader names the column that is. A
writer keeps the version a document was read with and writes 0.5.0 once
it uses the hint; DBML with an `after_parent` note converts to 0.5.0.

The spec README says what 0.5.0 also says of every document's data, as a
minor release may before 1.0.0: a child's creation date is not before its
parents' (References), and a one-column integer primary key numbers the
rows 1..n in order (Keys). The schema's $id moves to /spec/0.5.0/,
PUBLISHING.md lists the 0.5.0 files of engine 1.15.0 beside the older
ones, and the guide pages name 0.5.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The changelog's Unreleased section says what 1.15.0 changes and which
models generate different bytes for the same seed (every model with an
integer primary key; where the parent rule applies, its children too). The
README, LLMS.md and the time-shapes, distributions and dbt-project pages
describe the rule, the opt-out and the new dbt test. The determinism
digests are recorded again for these deliberate changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@codecov

codecov Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.65398% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
model2data/generate/timeline.py 95.55% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

jarichb and others added 3 commits October 8, 2026 09:17
Tests reach the paths the rule's patch left uncovered: an empty child and
an all-null creation column, a creation date that `when` fills after the
rule ran (the row moves again and its later dates follow), the defects
check with no parent frame or no rows, DBML with an `after_parent` note
converting to 0.5.0, a model given the hint in code being written as
0.5.0, and an enum named like a date beside the creation column.

Branches no input reaches are removed rather than kept: a datetime64
column (generation stores text and dates), a parent frame or foreign key
that is missing (a rule only names generated parents), the guards around
already-unboxed keys, a window start or end a parent can never pass, a
rule without links, a null check `NaT` comparisons already answer, and a
parent-less dbt test (`dbt_tests` always names its parent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A child's date no longer follows its parents' on its own, and the
`after_parent` opt-out is gone. Instead `after` names what a date follows:
a column of the same row as before, a parent's column as
`<table>.<column>`, or a list of either. `after: customers.created_at` on
orders.order_date keeps each order on or after the created_at of the
customer its foreign key points at.

The parent is reached through exactly one one-column foreign key onto
its key; validation names what is wrong otherwise (no such table or
column, not a date, no foreign key to it, only a composite one, one onto
a column that is no key, the column's own table, and two foreign keys to
the parent, named), and refuses tables whose afters name each other's
columns. The generator refuses a parent drawn after the child.

The technique that kept the stories stays: a late row takes an older
parent from those the column points at, and only a row with none (or a
one-to-one) moves its date within the column's own shape, never past
as_of. A list mixing the row's own columns places the value after both.
The fix-ups after `when` and composite-key repair, the later days, the
by-day comparison of a date with a timestamp, the dbt test
`model2data_not_before_parent` (one per parent column) and the
late_arriving preference all follow the hint now.

Validation warns where a child's first date can fall before a parent's
created_at-like date and no `after` names that parent, with the hint to
add as a structured `Issue.suggestion` (path, value, and the spec
version an older document needs) for a one-click fix. No warning for a
parent without such a date, a self-reference, or two foreign keys to one
parent.

A list or a parent's column in `after` is spec 0.5.0, gated as before.
The bundled examples (and the spec's coffee-webshop example) use it where
it is plainly right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The changelog's Unreleased section, the README, LLMS.md and the
time-shapes, distributions and dbt-project pages describe `after` naming
a parent's column, its errors and the warning that suggests it, in place
of the automatic rule. Dates change only for models that use it (the
examples now do); ids change for every model with an integer primary key.
The determinism digests are recorded again for these deliberate changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jarichb jarichb changed the title Children created after their parents; integer keys 1..N in key order (spec 0.5.0) Cross-table after (spec 0.5.0), a warning that suggests it, and integer keys 1..N Oct 8, 2026
jarichb and others added 4 commits October 8, 2026 22:12
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Spec 0.5.0 in the summary, the hint line, when to use it, how to act on the warning, and the two-foreign-keys case that is not built yet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant