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

Связанные таблицы через 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>

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

./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.

Ещё имя обходимого списка несёт позиции строки:

ЗаписьЧто означает
${{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>
./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);

Каждый врач, на которого ссылается визит, есть в 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>
./run positions.tdc
[
"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 — те же, что в Форматах вывода → Две таблицы в одном документе.

./run nested.tdc
[
{"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>
./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

Строка внутри группы сохраняет свой 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>
./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 или внутри <group>именованный <data> — это колонка для Parquet, а Parquet собирает колонки по карточке, а не по обходимой строкеTDC209
Orders._last в if= той же строки с each или той же группыпозиции считают строки, которые пропустил if=, поэтому решать, какие это строки, они не могутTDC215
./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), где одна карточка должна стать несколькими физическими строками.

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