Форматы вывода (CSV, JSON, SQL…)
В TDC нет фиксированного списка экспортёров, и это осознанно.
Блок вывода собирает текст:
<before> / <after>
обрамляют всю сборку, <block> — одна запись, а
<data> — сырой текст. Поэтому один и тот же движок
выдаёт CSV, JSON, SQL, YAML, NDJSON и что угодно ещё, собранное из символов.
Плата за эту свободу в том, что правила синтаксиса формата держите вы. TDC не знает, что
вы собираете JSON, и не поставит за вас ни запятую, ни кавычку, ни null — но и не поставит
их там, где не надо. Этот гайд проходит три самых частых формата и ровно те места, где каждый
из них ломается, а затем показывает, как собрать любую другую форму.
Выводы ниже иллюстративны — конкретные значения зависят от версии ядра и сида. Важна форма
каждого формата, а не имена и числа. Проверяйте готовый файл настоящим парсером
(python -m json.tool, sqlite3, любой CSV-ридер), а не глазами: каждая поломка ниже при
беглом просмотре выглядит нормально.
CSV
CSV — самая дружелюбная цель: заголовок один раз, дальше по строке на запись. Кусаются две вещи — место заголовка и любое значение, где встречается разделитель или кавычка.
Заголовок — один раз, через <before>
Заголовку место в <before>: он печатается ровно
один раз, над всеми записями, и неважно, count у вас 3 или 3 миллиона.
<tdc>
<env count="3" seed="demo">
<before><line><data>id,name,category</data></line></before>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="text" value="Ручка,Кружка,Блокнот"/></sequence>
<sequence name="Cat"><gen type="text" value="Офис,Кухня,Офис"/></sequence>
</env>
<block>
<line><data>${{Id}},${{Name}},${{Cat}}</data></line>
</block>
</tdc>
id,name,category 1,Блокнот,Офис 2,Кружка,Офис 3,Ручка,Кухня
Заголовок в <block> повторился бы на каждой строке; в <before> он печатается один раз.
Так же используйте <after> для итоговой строки в конце или закрывающей метки.
csv — значение с запятой или кавычкой
Задача. Название товара вроде Набор ножей, 3 шт содержит разделитель полей. Записанная
как есть, эта запятая становится разрывом колонки: у строки появляется лишнее поле,
категории съезжают в цены, и — хуже всего — файл всё равно открывается без ошибки. Ущерб
вылезает только когда файл читает программа.
<block>
<line><data>${{Id}},${{Name}},${{Cat}},${{Price}}</data></line>
</block>
# глазами всё нормально: 7,"Набор ножей, 3 шт",Посуда,3200 # что на самом деле видит CSV-ридер — 5 полей вместо 4: 7 | Набор ножей | 3 шт | Посуда | 3200
Инструмент. Фильтр csv берёт поле в кавычки и удваивает
внутренние — ровно как требует RFC 4180. Кавычит он каждое поле, на которое
поставлен, а не только те, что иначе развалились бы: закавыченное поле всегда валидный
CSV, и читатель разницы не заметит. Ставьте его на каждое текстовое
поле, пришедшее извне вашего конфига:
<block>
<line><data>${{Id}},${{Name | csv}},${{Cat}},${{Price}}</data></line>
</block>
id,name,category,price 7,"Набор ножей, 3 шт",Посуда,3200 8,"Кофе ""Арабика"" 250 г",Бакалея,540 9,"Кружка",Кухня,410
Запятые и кавычки теперь надёжно живут внутри полей в кавычках; ридер получает четыре колонки в каждой строке. Применяйте, когда значение может нести запятую, кавычку или перенос строки — а это почти всегда так у имён, названий и адресов, вытянутых из настоящего файла данных.
Другой разделитель
Разделитель — это просто символ, который вы печатаете. Нужны точки с запятой (частый выбор,
когда значение и так содержит запятые) или табы? Поменяйте текст в <data> и заголовок под
него — переключать никакой режим не надо.
<env count="3" seed="demo">
<before><line><data>id;name;category</data></line></before>
<!-- последовательности Id / Name / Cat, без изменений -->
</env>
<block><line><data>${{Id}};${{Name}};${{Cat}}</data></line></block>
id;name;category 1;Ручка;Офис 2;Кружка;Кухня
(Атрибут delimiter — это про другое: он говорит генератору
file, как читать входной CSV. На выходе вы просто пишете нужный
символ.)
JSON
У JSON в TDC тоже нет «режима» — это текст, как и CSV. Но у него четыре правила синтаксиса, которые простой сборщик текста радостно нарушит. Вот все четыре и лекарство для каждого.
Массив без висящей запятой
Задача. Три объекта — ещё не JSON, пока что-то не соединит их в массив. Напрашивающийся ход — запятая в конце каждого объекта — ставит запятую и после последнего, а её не прощает ни один парсер:
"age": 21 }, ] JSONDecodeError: Expecting value: line 17 column 1
Два чистых решения.
Первое — условный кусок. Второй <data if="!_last">
печатает запятую в каждой записи, кроме последней (_last — встроенный флаг «последняя
запись»), и JSON закрывается корректно:
<tdc>
<env count="3" seed="demo" local="ru">
<before><line><data>[</data></line></before>
<after><line><data>]</data></line></after>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="template" value="person.male.firstName"/></sequence>
</env>
<block>
<line><data> {"id": ${{Id}}, "name": "${{Name}}"}</data><data if="!_last">,</data></line>
</block>
</tdc>
[
{"id": 1, "name": "Владимир"},
{"id": 2, "name": "Сергей"},
{"id": 3, "name": "Александр"}
]Второе — отдельная фикстура. <delimiter_block>
печатает свой текст между записями — count - 1 раз, никогда после последней, — а это ровно
и есть «запятая между элементами массива»:
<env count="3" seed="demo">
<before><line><data>[</data></line></before>
<delimiter_block><line><data>,</data></line></delimiter_block>
<after><line><data>]</data></line></after>
...
</env>
Берите if="!_last", когда запятая едет на той же строке, что и объект; тянитесь к
<delimiter_block>, когда запись занимает несколько строк и разделителю нужна своя. Запятая
одна на строке выглядит непривычно, но это валидный JSON — переносы строк между элементами
массива стандарту безразличны. Хотите красиво — пропустите файл через jq ..
Кавычки ставите вы
Обратите внимание на кавычки выше: у "${{Name}}" они есть, у ${{Id}} — нет. TDC про типы
JSON ничего не знает — кавычки ставите вы, и именно так число остаётся числом, а строка строкой.
<line><data> "id": ${{Id}}, "zip": "${{Zip}}", "age": ${{Age}}</data></line>
"id": 1, "zip": "101000", "age": 34
zip в кавычках намеренно: почтовый индекс — не число (арифметику с ним не делают, а ведущий
ноль пропал бы). id и age остаются без кавычек, чтобы разбираться как целые.
Вложенность — это просто ещё строки
Вложенному объекту не нужен особый синтаксис — это ещё несколько строк <data> с нужными
скобками и отступами:
<line><data> "address": {</data></line>
<line><data> "city": "${{City}}",</data></line>
<line><data> "zip": "${{Zip}}"</data></line>
<line><data> },</data></line>
"address": {
"city": "Москва",
"zip": "101000"
},Массив внутри объекта
Несколько значений в одном поле даёт repeat: он склеивает их
в одну ячейку через separator. Фильтр
json с этим разделителем снова режет ячейку и
пишет JSON-массив — вместе с кавычками и скобками:
<sequence name="Tags">
<gen type="text" value="sale,new,gift,vip" repeat="0..3" separator=";"/>
</sequence>
<line><data> "tags": ${{Tags | json:;}},</data></line>
"tags": ["gift", "vip"], "tags": [], "tags": ["sale"],
Запись без тегов получает [], а не [""]. Пишите тот разделитель, через который склеена
колонка: ${{Tags | json}} без него печатает весь список одной строкой, и check
предупреждает об этом (TDC312). Для массива чисел фильтр не
нужен — кавычек у чисел нет, хватает separator="," и [${{Scores}}]. И учтите:
repeat берёт каждое значение независимо, так что один тег может
выпасть в строке дважды; если так не нужно, добавьте distinct="true" в <gen>.
null — это слово, а не пустота
Задача. Для необязательного поля напрашивается missing,
но missing даёт пустое значение, а в JSON пустоты не существует:
<gen type="number" value="1..100" missing="0.5"/>
{"id": 1, "score": }
JSONDecodeError: Expecting value: line 1 column 20(В CSV та же пустота как раз правильна — пустая ячейка означает «нет значения». Форматы разные.)
Инструмент. Напишите слово null явно, как одну из веток <mix>,
которая заодно делает долю точной — на 50 000 строк вы получите ровно 10 000 null, а не
«примерно 20%»:
<mix name="Score" percent="80,20">
<case><gen type="number" value="1..100"/></case>
<case><data>null</data></case>
</mix>
{"id": 1, "score": 56}
{"id": 2, "score": null}
{"id": 3, "score": 12}Для строкового поля фильтр json сам
пишет null вместо пустого значения, так что missing работает как есть:
<sequence name="Note"><gen type="text" value="fragile,urgent" missing="0.5"/></sequence>
...
<line><data>{"id": ${{Id}}, "note": ${{Note | json}}}</data></line>
{"id": 1, "note": "fragile"}
{"id": 2, "note": null}
{"id": 3, "note": "urgent"}Кавычка из чужих данных
Последняя поломка приходит из значений, которые вы не контролируете, — из имени, вытянутого из
файла. Товар Кофе "Арабика" 250 г попадает в строку как есть, и вторая
кавычка закрывает её раньше времени:
{"name": "Кофе "Арабика" 250 г"}
JSONDecodeError: Expecting ',' delimiterФильтр json пишет значение как строку JSON:
сам ставит кавычки и экранирует то, что требует JSON, — внутреннюю кавычку, обратный слэш,
табуляцию, перевод строки:
<data>{"name": ${{Name | json}}}</data>
{"name": "Кофе \"Арабика\" 250 г"}
{"name": "Кофе \"Робуста\" 250 г"}Это разбирается обратно в исходную строку, кавычки на месте. Обратите внимание: вокруг
${{Name | json}} кавычек в конфиге нет — фильтр приносит свои.
Когда записей много: NDJSON
У массива есть потолок: чтобы прочитать один объект, парсер обязан загрузить весь файл, так
что миллион записей — это гигабайты в памяти. Отраслевой ответ — NDJSON: один объект на
строку, без обёртки и без запятых. Его читают pandas, ClickHouse, BigQuery и jq.
Достаточно убрать три фикстуры и уложить каждую запись в одну строку:
<block><line>
<data>{"id": ${{Id}}, "name": "${{Name}}", "city": "${{City}}", "score": ${{Score}}}</data>
</line></block>
{"id": 1, "name": "Пётр", "city": "Москва", "score": 3}
{"id": 2, "name": "Дмитрий", "city": "Париж", "score": null}
{"id": 3, "name": "Андрей", "city": "Москва", "score": 12}Потребитель читает файл по строке за раз, и памяти нужно на одну запись, а не на весь файл — это формат выбора начиная с нескольких сотен тысяч строк. Как TDC пишет такое на диск, не держа файл в памяти, — см. Большие выгрузки и потоковая запись.
Вложенный список внутри записи
Запись, которая несёт собственный список — заказ и его позиции, пациент и его визиты, —
печатает его через each, по строке на элемент. Запятая
ставится после каждого элемента, кроме последнего, а какой последний, говорит сам обходимый
список: Sku._last.
<tdc>
<env count="2" seed="orders" local="en">
<sequence name="OrderId"><gen type="increment" value="5001"/></sequence>
<sequence name="Sku"><gen type="regex" value="SKU-[0-9]{4}" repeat="1..3" separator="|"/></sequence>
<before><line><data>[</data></line></before>
<after><line><data>]</data></line></after>
<before_block><line><data> { "order": ${{OrderId}}, "lines": [</data></line></before_block>
<after_block><line><data> ] }</data></line></after_block>
<delimiter_block><line><data> ,</data></line></delimiter_block>
</env>
<block>
<line each="Sku"><data> { "sku": ${{Sku | json}}, "qty": ${{Sku._item}} }</data><data if="!Sku._last">,</data></line>
</block>
</tdc>
[
{ "order": 5001, "lines": [
{ "sku": "SKU-3547", "qty": 1 },
{ "sku": "SKU-8121", "qty": 2 },
{ "sku": "SKU-7610", "qty": 3 }
] }
,
{ "order": 5002, "lines": [
{ "sku": "SKU-4917", "qty": 1 },
{ "sku": "SKU-8482", "qty": 2 }
] }
]repeat="1..3" даёт каждой записи своё количество элементов, и запятые идут следом — их всегда
на одну меньше, чем строк, включая запись, где элемент один. В строке с each ${{Sku}} — это
текущий элемент, поэтому | json берёт его без разделителя. Если запятая должна стоять на
отдельной строке, используйте <delimiter_line>: она
печатается между строками, которые даёт each.
${{Name}} в фикстуре раскрывается и читает ту строку, рядом с которой фикстура стоит, — поэтому
собственные поля записи живут в <before_block> прямо у открывающей скобки, а элементы массива —
в <block>. Чего фикстура держать не может, так это <gen> (TDC131): генератор там выдал бы
константу, похожую на разыгранное значение.
Две таблицы в одном документе
JSON-документ со справочниками — отделения и врачи, которые в них работают, — собирается из
пулов. <line each="Depts"> печатает пул запись за записью, поле
читается как ${{Depts.name}}, а поле, взятое из другого пула, — уровнем ниже:
${{Doctors.dept.id}}.
<tdc>
<env count="1" seed="clinic" local="en">
<pool name="Depts" count="2">
<sequence name="id"><gen type="increment" value="1"/></sequence>
<sequence name="name"><gen type="text" value="Cardiology,Neurology" order="sequential"/></sequence>
</pool>
<pool name="Doctors" count="3">
<sequence name="id"><gen type="increment" value="1"/></sequence>
<sequence name="last"><gen type="template" value="person.lastName"/></sequence>
<sequence name="dept"><gen type="pool" value="Depts"/></sequence>
<sequence name="languages"><gen type="text" value="English,Spanish,Polish" repeat="1..2" separator=";" distinct="true"/></sequence>
</pool>
</env>
<block>
<line><data>{</data></line>
<line><data> "departments": [</data></line>
<line each="Depts"><data> {"id": ${{Depts.id}}, "name": ${{Depts.name | json}}}</data><data if="!Depts._last">,</data></line>
<line><data> ],</data></line>
<line><data> "doctors": [</data></line>
<line each="Doctors"><data> {"id": ${{Doctors.id}}, "name": ${{Doctors.last | json}}, "dept": ${{Doctors.dept.id}}, "languages": ${{Doctors.languages | json:;}}}</data><data if="!Doctors._last">,</data></line>
<line><data> ]</data></line>
<line><data>}</data></line>
</block>
</tdc>
{
"departments": [
{"id": 1, "name": "Cardiology"},
{"id": 2, "name": "Neurology"}
],
"doctors": [
{"id": 1, "name": "Smith", "dept": 2, "languages": ["English"]},
{"id": 2, "name": "Johnson", "dept": 2, "languages": ["English"]},
{"id": 3, "name": "Williams", "dept": 1, "languages": ["English", "Polish"]}
]
}Каждый dept указывает на существующее отделение: врачи берут его из того же пула, который
печатает документ. count="1" печатает документ один раз; пул одинаков на каждой строке,
поэтому при большем числе строк те же таблицы повторились бы. Двадцать таблиц — это двадцать
пулов и двадцать строк с each, никаких других конструкций не нужно.
Записи внутри записей
Если в каждой записи лежит список собственных записей — отделение и его врачи, — печатайте её
через <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>
[
{"name": "Cardiology", "doctors": [
{"name": "Williams",
"languages": ["English", "Polish"]}
]},
{"name": "Neurology", "doctors": [
{"name": "Smith",
"languages": ["English"]},
{"name": "Johnson",
"languages": ["English"]}
]}
]Doctors._last считает врачей одного отделения и в следующем начинает заново, поэтому каждый
внутренний массив закрывается без висящей запятой, а Depts._last делает то же для внешнего.
SQL
Для SQL вы выдаёте команды, которые база выполняет напрямую — TDC ни к чему не подключается, он
пишет файл команд, который вы заливаете через sqlite3, psql или миграцию. Две заботы о
формате: экранировать апострофы и обернуть сборку в схему и транзакцию.
sql — апостроф в значении
Задача. В SQL строковый литерал ограничен одинарными кавычками, так что фамилия вроде
O'Brien закрывает строку раньше времени, и команда не выполнится:
INSERT INTO users (id, last) VALUES (2, 'O'Brien'); Error: near "Brien": syntax error
Инструмент. Фильтр sql удваивает апостроф — так SQL экранирует его
внутри литерала:
<tdc>
<env count="3" seed="demo">
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Last"><gen type="text" value="Chisholm,O'Brien,Foster" order="sequential"/></sequence>
</env>
<block>
<line><data>INSERT INTO users (id, last) VALUES (${{Id}}, '${{Last | sql}}');</data></line>
</block>
</tdc>
INSERT INTO users (id, last) VALUES (1, 'Chisholm'); INSERT INTO users (id, last) VALUES (2, 'O''Brien'); INSERT INTO users (id, last) VALUES (3, 'Foster');
'O''Brien' загружается обратно как O'Brien. В штатных пакетах данных апострофов не встречается —
поэтому пример задаёт свой список; но стоит подключить свой набор имён — и они появятся
(ирландские и итальянские фамилии ими полны), — поэтому ставьте sql на каждое текстовое поле,
идущее в команду. Стоит это ничего, а спасает от упавшей заливки позже.
Схема сверху, данные снизу — <before> / <after>
CREATE TABLE выполняется один раз, поэтому ему место в
<before>, который печатается впереди всех строк:
<env count="3" seed="shop" local="ru">
<before>
<line><data>CREATE TABLE customers (</data></line>
<line><data> id INTEGER PRIMARY KEY,</data></line>
<line><data> name TEXT NOT NULL,</data></line>
<line><data> city TEXT NOT NULL</data></line>
<line><data>);</data></line>
</before>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="First"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="Last"><gen type="template" value="person.male.lastName"/></sequence>
<sequence name="City"><gen type="text" value="Москва,Париж,Берлин" percent="50,30,20"/></sequence>
</env>
<block><line>
<data>INSERT INTO customers VALUES (${{Id}}, '${{First}} ${{Last | sql}}', '${{City}}');</data>
</line></block>
CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL ); INSERT INTO customers VALUES (1, 'Александр Иванов', 'Берлин'); INSERT INTO customers VALUES (2, 'Владимир Кузнецов', 'Париж'); INSERT INTO customers VALUES (3, 'Сергей Смирнов', 'Москва');
Обернуть в одну транзакцию
Тысяча голых INSERT — это тысяча транзакций, а значит медленно. Оберните сборку в
BEGIN / COMMIT, чтобы она загрузилась как одна, добавив по строке в каждую фикстуру:
<env count="3" seed="demo">
<before>… схема …<line><data>BEGIN;</data></line></before>
<after><line><data>COMMIT;</data></line></after>
<!-- последовательности, без изменений -->
</env>
Затем заливаете:
# SQLite sqlite3 shop.db < shop.sql # PostgreSQL — тот же файл psql -f shop.sql
Для связанных таблиц — заказов, которые ссылаются на существующих клиентов, с всегда валидным
внешним ключом — генерируйте по строке на элемент списка через
each; полный приём — в
Связные и реляционные данные.
Что угодно ещё
Те же три части собирают любую текстовую форму — вы управляете каждым символом:
- YAML —
-перед каждым элементом списка, отступ в два пробела для вложенности. - Таблица Markdown — заголовок с разделителем
|плюс строка-правило|---|в<before>, строки с|в<block>. - Отчёт фиксированной ширины — дополняйте поля до заданной ширины фильтрами
mask/ срезами и выравнивайте колонки.
<env count="3" seed="demo">
<before>
<line><data>| id | name | city |</data></line>
<line><data>|----|------|--------|</data></line>
</before>
<!-- последовательности, без изменений -->
</env>
<block><line><data>| ${{Id}} | ${{Name}} | ${{City}} |</data></line></block>
| id | name | city | |----|------|--------| | 1 | Иван | Москва | | 2 | Пётр | Казань |
TDC лишь подставляет значения — формат таков, каким вы его написали.
Смотрите также
- Вывод и форматирование —
<block>,<line>,<data>, фикстуры и условиеif. - Маски и регистр — фильтры экранирования
csv/sql/replaceцеликом. - Чтение файлов и CSV — тяните свои значения из файла генератором
file. - Связные и реляционные данные — связанные таблицы, внешние ключи и
атрибуты
each/weight/row. - Большие выгрузки и потоковая запись — миллионы строк и NDJSON на диске.