Skip to main content

Relational tables with each

Use it when one record really stands for several rows — a customer with a handful of orders, an invoice with line items, a post with tags — and you want them as separate rows in a child table, each carrying a foreign key back to the parent, all from a single config.

The each attribute repeats a <line> once per element of a list. One record turns into N output rows — a normalized table ready for a database. Pointed at a <pool> instead, it prints the pool record by record, so a lookup table comes out of the same config as the rows that reference it.

note

Example outputs below are illustrative. The exact values a generator emits can shift between core versions and seeds; the counts and the structural rules (which rows appear, which stay empty, that keys are unique) are what the feature guarantees.

One run, four parents and their children, exactly as the rows come out.
  • Athe parent rows, each with its own key
  • Bthe child rows — every one of them carries its parent key, so nothing is orphaned

At a glance​

WhereWhat
Applies on<line>
ValueThe name of a sequence whose generator has repeat, or of a <pool>
EffectThe line is emitted once per element of that list, or once per record of that pool

A target sequence must carry repeat — that's what makes it a list to iterate over. Point each at anything else and TDC tells you why (see the last section below).

The problem: a list crammed into one record​

A customer placed three orders. On the record they sit as a list:

1;VIP;8648,7170,7063

That's no good for a database. You need an orders table where each order is its own row, linked back to the customer. One record has to produce three rows — not one row with a list in it.

The tool​

Give the orders their own repeat list, then put each on the order line so it fires once per order:

<env count="4" seed="each-demo" inject="${{%}}">
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="Tier"><gen type="text" value="VIP,standard" percent="50,50"/></sequence>
<sequence name="VipOrders" parent="Tier.VIP">
<gen type="number" value="1000..9999" repeat="2..3"/>
</sequence>
<sequence name="StdOrders" parent="Tier.standard">
<gen type="number" value="100..999" repeat="0..2"/>
</sequence>
</env>
<block>
<line><data>INSERT INTO customers VALUES (${{Id}}, '${{Name}}', '${{Tier}}');</data></line>
<line each="VipOrders"><data>INSERT INTO orders VALUES (${{_item_id}}, ${{Id}}, ${{VipOrders}});</data></line>
<line each="StdOrders"><data>INSERT INTO orders VALUES (${{_item_id}}, ${{Id}}, ${{StdOrders}});</data></line>
</block>

What you get​

./run each-demo.tdc
INSERT INTO customers VALUES (1, 'James', 'standard');
INSERT INTO orders VALUES (4, 1, 433);
INSERT INTO orders VALUES (5, 1, 474);
INSERT INTO customers VALUES (2, 'Michael', 'standard');
INSERT INTO customers VALUES (3, 'John', 'VIP');
INSERT INTO orders VALUES (11, 3, 2460);
INSERT INTO orders VALUES (12, 3, 5137);
INSERT INTO orders VALUES (13, 3, 7717);
INSERT INTO customers VALUES (4, 'Robert', 'VIP');
INSERT INTO orders VALUES (16, 4, 5249);
INSERT INTO orders VALUES (17, 4, 2324);

The order line is written once in the config, yet it prints as many times as the customer has orders. Customer 2 (Michael) drew zero orders, so there are no order rows for him at all and no empty placeholder left behind. The two VIP customers draw from VipOrders and the standard customers from StdOrders, and each row iterates the right list because parent already settled which list is active.

Why/when: this is the one-config way to emit a parent table and its child table together, correctly linked — no post-processing, no second run, no scripting to explode the list.

What's visible inside an each line​

Inside a line that carries each, the iterated sequence name refers to the current element, and two extra built-ins become available:

TokenWhat it means
${{VipOrders}}the current element. Outside the each line, the same name is the whole joined list
${{_item}}position within the record: 1, 2, 3
${{_item_id}}a global, unique number across the whole run — your primary key
everything elseas usual: ${{Id}}, ${{_count}}, any other sequence

This is exactly why the foreign key works: ${{Id}} still means the customer on every order row, not the element. If iterating rebound Id to the element, every order would point at the wrong customer. The parent's columns stay fixed; only the iterated name and _item / _item_id advance.

The walked name also carries the line's positions:

TokenWhat it means
${{VipOrders._item}}the number of this line among the lines the each prints: 1, 2, 3
${{VipOrders._first}}true on the first line printed, false on the rest
${{VipOrders._last}}true on the last line printed, false on the rest

They are named after the walked list, so they never mix with the run's own _first and _last, which mark the first and last record. _last on a walked list is what puts a comma between elements and not after the final one: <data if="!VipOrders._last">,</data>.

An if= on an each line, and on every <data> inside it, is asked of each element, with the element in scope — <line each="VipOrders" if="VipOrders > 5000"> prints only the large orders. The positions count the lines that survive the filter, so VipOrders._last is the last line actually printed.

About the order numbers​

Look at the primary keys: 4, 5, then 11, 12, 13, then 16, 17. They rise, but with gaps.

That's deliberate. _item_id is computed from the record's number, so a row can be produced without knowing its neighbors — which is what keeps --jobs parallelism byte-for-byte identical to a single-threaded run. The price is a gap wherever a record has fewer orders than the maximum. For a primary key that's perfectly fine — real databases rarely have gap-free IDs either.

Uniqueness is still ironclad, including across multiple lists — StdOrders uses 4, 5 while VipOrders uses 11, 12, 13, and the two ranges never collide. In a larger check, 2000 records produced 3501 orders with 3501 distinct keys, and zero orders pointing at a nonexistent customer.

Why/when: trust _item_id as a stable, unique, parallel-safe surrogate key. Reach for _item instead when you want a per-record sequence number (1, 2, 3 that restarts on every parent).

A whole pool, record by record​

A <pool> is a small table built once, before the rows, and the rows draw their members from it. each with the pool's name prints that table: one line per record, in pool order, with every field read as ${{Pool.field}}. That is the lookup table the rows' foreign keys point at:

<env count="4" seed="visits" local="en">
<pool name="Doctors" count="3">
<sequence name="id"><gen type="increment" value="1"/></sequence>
<sequence name="last"><gen type="template" value="person.lastName"/></sequence>
</pool>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Doc"><gen type="pool" value="Doctors"/></sequence>
<before>
<line each="Doctors"><data>INSERT INTO doctors VALUES (${{Doctors.id}}, '${{Doctors.last | sql}}');</data></line>
</before>
</env>
<block>
<line><data>INSERT INTO visits VALUES (${{Id}}, ${{Doc.id}});</data></line>
</block>
./run visits.tdc
INSERT INTO doctors VALUES (1, 'Smith');
INSERT INTO doctors VALUES (2, 'Johnson');
INSERT INTO doctors VALUES (3, 'Williams');
INSERT INTO visits VALUES (1, 1);
INSERT INTO visits VALUES (2, 3);
INSERT INTO visits VALUES (3, 2);
INSERT INTO visits VALUES (4, 2);

Every doctor a visit names exists in doctors, because both come from the same pool. The pool is the same on every row, so a line walking it prints the whole table each time it runs: put it in <before> or <after>, or in a run with count="1", to print it once.

A field that draws from another pool is a record one level down, and reads the same way: ${{Doctors.dept.name}}. The positions work as on a list — Doctors._last is the last record printed — and so does a filter:

<line><data>[</data></line>
<line each="Doctors" if="Doctors.shift == day"><data> ${{Doctors.last | json}}</data><data if="!Doctors._last">,</data></line>
<line><data>]</data></line>
./run positions.tdc
[
"Johnson",
"Smith"
]

The pool holds five doctors, and the last of them works nights, so the filter drops that record. Doctors._last falls on Smith, the last record printed, and the array closes without a trailing comma. Output formats → JSON builds a whole document out of two pools this way.

Several lines per element: <group>​

each on a <line> repeats one line. When an element needs several — a department, then its doctors, then a closing bracket — wrap the lines in a <group>. <group each="…"> prints everything inside it once per element, in order, with that element in scope for every line:

<block>
<line><data>[</data></line>
<group each="Depts">
<line><data> {"name": ${{Depts.name | json}}, "doctors": [</data></line>
<group each="Doctors" if="Doctors.dept.id == Depts.id">
<line><data> {"name": ${{Doctors.last | json}},</data></line>
<line><data> "languages": ${{Doctors.languages | json:;}}}</data><data if="!Doctors._last">,</data></line>
</group>
<line><data> ]}</data><data if="!Depts._last">,</data></line>
</group>
<line><data>]</data></line>
</block>

The Depts and Doctors pools are the ones from Output formats → Two tables in one document.

./run nested.tdc
[
{"name": "Cardiology", "doctors": [
  {"name": "Williams",
   "languages": ["English", "Polish"]}
]},
{"name": "Neurology", "doctors": [
  {"name": "Smith",
   "languages": ["English"]},
  {"name": "Johnson",
   "languages": ["English"]}
]}
]

The outer group prints three lines for each department. Inside it, ${{Depts.name}} is the department being printed, so the inner group, which walks all the doctors, keeps the ones whose dept.id matches. Each level counts its own positions: Doctors._last is the last doctor printed in this department and starts over in the next one, and Depts._last is the last department. That is why every comma lands where JSON needs it. A group can hold lines and other groups, as deep as the document goes.

Without each, a group's if= keeps or drops all of its lines at once — one condition instead of the same one copied onto every line:

<block>
<line><data>#${{Id}} ${{Kind}}</data></line>
<group if="Kind == visit">
<line><data> room ${{Room}}</data></line>
<line><data> check-in at the front desk</data></line>
</group>
</block>
./run contacts.tdc
#1 call
#2 visit
room 122
check-in at the front desk
#3 visit
room 116
check-in at the front desk
#4 call

A line inside a group keeps its own if= and may walk a list of its own with each. A group's if= can read the element it walks but not its positions, which count the elements that if= keeps (TDC215). A typed column — <data name="…"> — cannot sit anywhere inside a group (TDC209): the group prints its lines several times or not at all, and a column has one cell per record.

A lifecycle, not just a list​

A record's rows do not have to be a bag of unrelated values. They can be a history: an order that goes created → paid → shipped → delivered, one row per step, and never a step out of sequence.

Two pieces do it. A <mix> picks which path this order takes, and if on each <line> decides whether that step belongs to the path — so the steps are written in the order they happen, and only the lines of the chosen path fire:

<env count="20" seed="lifecycle" local="en">
<sequence name="OrderId"><gen type="increment" value="1000"/></sequence>

<mix name="Outcome" percent="60,25,15">
<case><gen type="text" value="delivered"/></case>
<case><gen type="text" value="refunded"/></case>
<case><gen type="text" value="cancelled"/></case>
</mix>
</env>
<block>
<line><data>${{OrderId}},1,created</data></line>

<line if="Outcome.delivered"><data>${{OrderId}},2,paid</data></line>
<line if="Outcome.delivered"><data>${{OrderId}},3,shipped</data></line>
<line if="Outcome.delivered"><data>${{OrderId}},4,delivered</data></line>

<line if="Outcome.refunded"><data>${{OrderId}},2,paid</data></line>
<line if="Outcome.refunded"><data>${{OrderId}},3,refunded</data></line>

<line if="Outcome.cancelled"><data>${{OrderId}},2,cancelled</data></line>
</block>
./run lifecycle.tdc
1000,1,created
1000,2,paid
1000,3,shipped
1000,4,delivered
1001,1,created
1001,2,paid
1001,3,refunded
1002,1,created
1002,2,paid
1002,3,shipped
1002,4,delivered
1003,1,created
1003,2,paid
1003,3,shipped
1003,4,delivered
1004,1,created
1004,2,paid
1004,3,refunded

Every order takes a legal path — a shipped order was paid first, a cancelled one never shipped — and the outcomes land in the exact proportions <mix> declared. The step number is written into the line rather than counted, because the lines of one path are known when the config is written.

The shorter spelling, and when it is the right one

One sequence can hold the whole path — <gen type="text" value="created,paid,shipped,delivered" repeat="4" order="sequential"/> — with each to unroll it. A fixed repeat on a walked list takes N values off the walk per row, so a list of four read four at a time is the whole path on every record:

<sequence name="Order"><gen type="increment" value="1000"/></sequence>
<sequence name="Step"><gen type="text" value="created,paid,shipped,delivered" repeat="4" order="sequential"/></sequence>
...
<line each="Step"><data>${{Order}},${{Step}}</data></line>
./run steps.tdc (3 orders)
1000,created
1000,paid
1000,shipped
1000,delivered
1001,created
1001,paid

Use it when every record follows the SAME path. The <mix> above is for when they do not: there, one order is cancelled and another delivered, in declared proportions, and no walked list can say that. See order= / cycle= for the walk's rules and the two shapes it refuses.

Why it is built this way. A status column that changed by "looking at the previous row" would force the run to be computed in order, from the first row. Choosing a whole path up front and unrolling it keeps every record independent — so this works on the streaming engines and in parallel, unchanged.

The same shape covers anything with a fixed vocabulary of steps: a support ticket (open → assigned → resolved), a delivery, a moderation queue, an onboarding funnel.

Where it won't work​

each is strict about what it can iterate, and fails loudly rather than guessing:

WhatWhyError
each on a sequence with no repeatthere's nothing to iterate overTDC207
each on a name that is neither a sequence nor a poolnothing by that name exists, so the line would print nothing at allTDC206
<data name="…"> inside an each line or a <group>a named <data> is a column for Parquet, and Parquet collects columns per record, not per iterated rowTDC209
Orders._last in the if= of the same each line or groupthe positions count the lines that if= lets through, so they cannot decide which lines those areTDC215
./run broken.tdc (each on a non-list)
error[TDC207]: each="Tier" — that sequence holds one value, not a list
note: Add repeat= to its <gen>, e.g. <gen … repeat="1..5"/>, or drop each=.

For Parquet output you don't need each at all: a repeat list stays a real list inside the column, which is already the right shape for a columnar file. each is the tool for text formats — SQL, CSV, JSON lines — where one record must become several physical rows.

See also​