Перейти к основному содержимому

Связанные таблицы через each

Когда пригодится — когда одна запись на самом деле означает несколько строк: клиент с горсткой заказов, счёт с позициями, пост с тегами — и вам нужно, чтобы они легли отдельными строками в дочернюю таблицу, каждая со ссылкой (внешним ключом) на родителя, и всё это из одного конфига.

Атрибут each повторяет <line> по одному разу на каждый элемент списка. Одна карточка превращается в N строк вывода — нормализованную таблицу, готовую для базы данных.

примечание

Примеры вывода ниже — иллюстративные. Конкретные значения, которые выдаёт генератор, могут меняться от версии ядра и от сида; а вот количество строк и структурные правила (какие строки появляются, какие остаются пустыми, что ключи уникальны) — именно это функция и гарантирует.

Один прогон: четыре родителя и их потомки ровно в том виде, в каком выходят строки.
  • Aстроки-родители, у каждой свой ключ
  • Bстроки-потомки — каждая несёт ключ своего родителя, поэтому сирот не бывает

Коротко

ГдеЧто
Применяется на<line>
ЗначениеИмя последовательности, у генератора которой стоит repeat
ЭффектСтрока выводится по одному разу на каждый элемент этого списка

У целевой последовательности обязан стоять 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>

Что получилось

./run each-demo.tdc
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.

Про номера заказов

Посмотрите на первичные ключи: 4, 5, потом 11, 12, 13, потом 16, 17. Они растут, но с пропусками.

Так сделано осознанно. _item_id вычисляется из номера карточки, поэтому строку можно получить, не зная соседних — а именно это и позволяет многопоточности --jobs давать байт в байт тот же результат, что и однопоточный прогон. Плата — пропуски там, где у карточки заказов меньше максимума. Для первичного ключа это совершенно нормально: в настоящих базах номера тоже редко идут подряд.

Уникальность при этом железная, в том числе между несколькими списками: StdOrders занял 4, 5, а VipOrders11, 12, 13, и эти два пространства ключей никогда не пересекаются. На большей проверке 2000 карточек дали 3501 заказ с 3501 различным ключом и ноль заказов, указывающих на несуществующего клиента.

Зачем/когда: полагайтесь на _item_id как на стабильный, уникальный, безопасный для многопоточности суррогатный ключ. Берите _item, когда нужен порядковый номер внутри карточки (1, 2, 3, заново стартующий на каждом родителе).

Жизненный цикл, а не просто список

Строки одной записи не обязаны быть мешком несвязанных значений. Они могут быть историей: заказ идёт 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>
./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

Каждый заказ проходит допустимый путь — отгруженный был сначала оплачен, отменённый не отгружался, — а исходы ложатся ровно в тех долях, которые объявил <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>
./run steps.tdc (3 заказа)
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именованный <data> — это колонка для Parquet, а Parquet собирает колонки по карточке, а не по обходимой строкеTDC209
./run broken.tdc (each на не-списке)
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), где одна карточка должна стать несколькими физическими строками.

Смотрите также