Связанные таблицы через each
Когда пригодится — когда одна запись на самом деле означает несколько строк: клиент с горсткой заказов, счёт с позициями, пост с тегами — и вам нужно, чтобы они легли отдельными строками в дочернюю таблицу, каждая со ссылкой (внешним ключом) на родителя, и всё это из одного конфига.
Атрибут each повторяет
<line> по одному разу на каждый элемент
списка. Одна карточка превращается в N строк вывода — нормализованную таблицу,
готовую для базы данных. Если навести его на <pool>, он печатает пул
запись за записью — и справочник выходит из того же конфига, что и строки, которые на него
ссылаются.
Примеры вывода ниже — иллюстративные. Конкретные значения, которые выдаёт генератор, могут меняться от версии ядра и от сида; а вот количество строк и структурные правила (какие строки появляются, какие остаются пустыми, что ключи уникальны) — именно это функция и гарантирует.
- Aстроки-родители, у каждой свой ключ
- Bстроки-потомки — каждая несёт ключ своего родителя, поэтому сирот не бывает
Коротко
| Где | Что |
|---|---|
| Применяется на | <line> |
| Значение | Имя последовательности, у генератора которой стоит repeat, или <pool> |
| Эффект | Строка выводится по одному разу на каждый элемент этого списка или на каждую запись этого пула |
У целевой последовательности обязан стоять repeat — именно
он делает её списком, который можно обойти. Наведите each на что-то другое — и TDC
объяснит, в чём дело (см. последний раздел ниже).
Задача: список, втиснутый в одну карточку
Клиент сделал три заказа. В карточке они лежат списком:
1;VIP;8648,7170,7063
Для базы так не годится. Нужна таблица orders, где каждый заказ — своя строка
со ссылкой на клиента. Одна карточка должна дать три строки, а не одну строку со
списком внутри.
Инструмент
Дайте заказам собственный список через repeat,
а на строку с заказом поставьте each — и она сработает по разу на каждый заказ:
<env count="4" seed="each-demo" inject="${{%}}" local="ru">
<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,обычный" 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.обычный">
<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>
Что получилось
INSERT INTO customers VALUES (1, 'Александр', 'обычный'); INSERT INTO orders VALUES (4, 1, 433); INSERT INTO orders VALUES (5, 1, 474); INSERT INTO customers VALUES (2, 'Андрей', 'обычный'); INSERT INTO customers VALUES (3, 'Сергей', '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, 'Владимир', 'VIP'); INSERT INTO orders VALUES (16, 4, 5249); INSERT INTO orders VALUES (17, 4, 2324);
Строка с заказом написана в конфиге один раз, а печатается столько раз, сколько
заказов у клиента. У клиента №2 (Андрей) выпало ноль заказов — поэтому строк
с его заказами нет вовсе, и никакой пустой заглушки не осталось. Два VIP-клиента
берут заказы из VipOrders, обычные — из StdOrders, и на каждой строке обходится
именно нужный список, потому что parent уже отобрал,
какой список активен.
Зачем/когда: это способ одним конфигом вывести родительскую таблицу и её дочернюю таблицу вместе, правильно связанными — без постобработки, без второго прогона, без скриптов, разворачивающих список.
Что видно внутри строки с each
Внутри строки под each имя обходимой последовательности означает текущий элемент, плюс
есть два дополнительных встроенных значения:
| Запись | Что означает |
|---|---|
${{VipOrders}} | текущий элемент. Вне строки с each то же имя — это весь склеенный список |
${{_item}} | номер внутри карточки: 1, 2, 3 |
${{_item_id}} | сквозной уникальный номер по всему прогону — ваш первичный ключ |
| остальное | как обычно: ${{Id}}, ${{_count}}, любая другая последовательность |
Именно поэтому внешний ключ работает: ${{Id}} на каждой строке заказа по-прежнему
означает клиента, а не элемент. Если бы обход перепривязывал Id к элементу,
каждый заказ ссылался бы не туда. Колонки родителя остаются неизменными; двигаются
только имя обходимого списка и _item / _item_id.
Ещё имя обходимого списка несёт позиции строки:
| Запись | Что означает |
|---|---|
${{VipOrders._item}} | номер этой строки среди строк, которые печатает each: 1, 2, 3 |
${{VipOrders._first}} | true на первой напечатанной строке, false на остальных |
${{VipOrders._last}} | true на последней напечатанной строке, false на остальных |
Они названы по обходимому списку и поэтому не путаются с собственными
_first и _last прогона — те отмечают первую и последнюю
карточку. _last у списка — это то, что ставит запятую между элементами, но не после
последнего: <data if="!VipOrders._last">,</data>.
if= на строке с each — и на каждом <data>
внутри неё — проверяется для каждого элемента, с элементом в области видимости:
<line each="VipOrders" if="VipOrders > 5000"> печатает только крупные заказы. Позиции
считают строки, прошедшие фильтр, так что VipOrders._last — последняя строка, которая
действительно напечатана.
Про номера заказов
Посмотрите на первичные ключи: 4, 5, потом 11, 12, 13, потом 16, 17. Они
растут, но с пропусками.
Так сделано осознанно. _item_id вычисляется из номера карточки, поэтому строку можно
получить, не зная соседних — а именно это и позволяет многопоточности
--jobs давать байт в байт тот же результат, что и однопоточный
прогон. Плата — пропуски там, где у карточки заказов меньше максимума. Для первичного
ключа это совершенно нормально: в настоящих базах номера тоже редко идут подряд.
Уникальность при этом железная, в том числе между несколькими списками: StdOrders
занял 4, 5, а VipOrders — 11, 12, 13, и эти два пространства ключей никогда не
пересекаются. На большей проверке 2000 карточек дали 3501 заказ с 3501 различным ключом
и ноль заказов, указывающих на несуществующего клиента.
Зачем/когда: полагайтесь на _item_id как на стабильный, уникальный, безопасный
для многопоточности суррогатный ключ. Берите _item, когда нужен порядковый номер
внутри карточки (1, 2, 3, заново стартующий на каждом родителе).
Пул целиком, запись за записью
<pool> — маленькая таблица, которая строится один раз, до строк, а
строки берут из неё записи. each с именем пула печатает эту таблицу: строку на запись, в
порядке пула, а каждое поле читается как ${{Pool.field}}. Это и есть справочник, на
который указывают внешние ключи строк:
<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>
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);
Каждый врач, на которого ссылается визит, есть в doctors: оба берутся из одного пула. Пул
одинаков на каждой строке, поэтому строка, которая его обходит, печатает всю таблицу при
каждом запуске. Чтобы напечатать её один раз, поставьте строку в
<before> или <after> либо в прогон с
count="1".
Поле, которое берёт запись из другого пула, — это запись уровнем ниже, и читается оно так же:
${{Doctors.dept.name}}. Позиции работают как у списка — Doctors._last означает последнюю
напечатанную запись, — и фильтр тоже:
<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>
[ "Johnson", "Smith" ]
В пуле пять врачей, и последний работает по ночам, поэтому фильтр его отбрасывает.
Doctors._last выпадает на Smith — последнюю напечатанную запись, — и массив закрывается
без висящей запятой. В Форматах вывода → JSON так же
из двух пулов собирается целый документ.
Несколько строк на элемент: <group>
each на <line> повторяет одну строку. Если элементу нужно несколько — отделение, потом
его врачи, потом закрывающая скобка, — оберните строки в <group>. <group each="…"> печатает
всё, что внутри, по одному разу на элемент, по порядку, и элемент виден каждой строке:
<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>
Пулы Depts и Doctors — те же, что в
Форматах вывода → Две таблицы в одном документе.
[
{"name": "Cardiology", "doctors": [
{"name": "Williams",
"languages": ["English", "Polish"]}
]},
{"name": "Neurology", "doctors": [
{"name": "Smith",
"languages": ["English"]},
{"name": "Johnson",
"languages": ["English"]}
]}
]Внешняя группа печатает три строки на каждое отделение. Внутри неё ${{Depts.name}} — это
отделение, которое сейчас печатается, поэтому внутренняя группа, обходящая всех врачей,
оставляет тех, у кого dept.id совпадает. Каждый уровень считает свои позиции: Doctors._last —
последний напечатанный врач этого отделения, и в следующем счёт начинается заново, а
Depts._last — последнее отделение. Поэтому каждая запятая стоит там, где её ждёт JSON. Группа
может держать строки и другие группы — на любую глубину документа.
Без each условие if= группы оставляет или отбрасывает все её строки сразу — одно условие
вместо того же самого, скопированного в каждую строку:
<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>
#1 call #2 visit room 122 check-in at the front desk #3 visit room 116 check-in at the front desk #4 call
Строка внутри группы сохраняет свой if= и может обходить собственный список через each.
if= группы может читать обходимый элемент, но не его позиции: они считают элементы, которые
этот if= оставил (TDC215). Типизированной колонки — <data name="…"> — внутри группы быть не
может нигде (TDC209): группа печатает свои строки несколько раз или ни разу, а у колонки одна
ячейка на карточку.
Жизненный цикл, а не просто список
Строки одной записи не обязаны быть мешком несвязанных значений. Они могут быть
историей: заказ идёт created → paid → shipped → delivered, по строке на шаг, и ни
один шаг не встаёт не на своё место.
Работают две вещи. <mix> выбирает, каким путём пойдёт этот заказ, а
if на каждой <line> решает, принадлежит ли
шаг этому пути — так шаги записаны в том порядке, в каком происходят, и срабатывают
только строки выбранного пути:
<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>
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
Каждый заказ проходит допустимый путь — отгруженный был сначала оплачен, отменённый
не отгружался, — а исходы ложатся ровно в тех долях, которые объявил <mix>. Номер шага
записан прямо в строке, а не считается, потому что строки одного пути известны уже когда
пишется конфиг.
Одна последовательность может держать весь путь:
<gen type="text" value="created,paid,shipped,delivered" repeat="4" order="sequential"/>
плюс each, чтобы её развернуть. Фиксированный repeat на
идущем по порядку списке берёт с хода N значений на строку, поэтому список из четырёх,
читаемый по четыре, — это весь путь на каждой записи:
<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>
1000,created 1000,paid 1000,shipped 1000,delivered 1001,created 1001,paid
Берите её, когда каждая запись идёт ОДНИМ и тем же путём. <mix> выше — для случая,
когда пути разные: там один заказ отменён, другой доставлен, в объявленных долях, и
никакой идущий по порядку список этого не скажет. Правила хода и две формы, которые он
отклоняет, — в order= / cycle=.
легальным путём — отгруженный сначала оплачен, отменённый никогда
не отгружался, — а исходы ложатся ровно в те доли, которые объявил <mix>. Из трёх строк
на запись срабатывает только одна: parent оставляет две другие пустыми.
Почему сделано именно так. Столбец статуса, который менялся бы «глядя на предыдущую строку», заставил бы считать прогон по порядку, с первой строки. Выбор целого пути заранее и его разворачивание оставляют каждую запись независимой — поэтому это работает и на потоковых движках, и в параллель, без изменений.
Та же форма годится на всё, где шаги берутся из фиксированного словаря: тикет поддержки
(open → assigned → resolved), доставка, очередь модерации, онбординг.
Где не сработает
each строг к тому, что он может обойти, и падает громко, а не гадает:
| Что | Почему | Ошибка |
|---|---|---|
each на последовательности без repeat | обходить нечего | TDC207 |
each на имени, которое не последовательность и не пул | с таким именем ничего нет, и строка не напечатала бы вообще ничего | TDC206 |
<data name="…"> внутри строки с each или внутри <group> | именованный <data> — это колонка для Parquet, а Parquet собирает колонки по карточке, а не по обходимой строке | TDC209 |
Orders._last в if= той же строки с each или той же группы | позиции считают строки, которые пропустил if=, поэтому решать, какие это строки, они не могут | TDC215 |
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=.
Для вывода в Parquet each вообще не нужен: список из
repeat остаётся настоящим списком внутри колонки —
а это уже правильная форма для колоночного файла. each — инструмент для текстовых
форматов (SQL, CSV, JSON lines), где одна карточка должна стать несколькими физическими
строками.
Смотрите также
- Иерархические зависимости —
parent, который решает, какой список активен на каждой строке. - Согласованные и связанные данные — другой способ связать таблицы, через общий родительский справочник.
repeat/separator— как последовательность вообще становится списком.- Встроенные значения —
_item,_item_id,_countи их родня. - Пулы — таблицы, которые может напечатать строка с
each, и как строки берут из них записи.