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

Форматы вывода (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>
./run out.tdc -o out.csv
id,name,category
1,Блокнот,Офис
2,Кружка,Офис
3,Ручка,Кухня

Заголовок в <block> повторился бы на каждой строке; в <before> он печатается один раз. Так же используйте <after> для итоговой строки в конце или закрывающей метки.

csv — значение с запятой или кавычкой​

Задача. Название товара вроде Набор ножей, 3 шт содержит разделитель полей. Записанная как есть, эта запятая становится разрывом колонки: у строки появляется лишнее поле, категории съезжают в цены, и — хуже всего — файл всё равно открывается без ошибки. Ущерб вылезает только когда файл читает программа.

<block>
<line><data>${{Id}},${{Name}},${{Cat}},${{Price}}</data></line>
</block>
./run out.tdc (запятая внутри значения)
# глазами всё нормально:
7,"Набор ножей, 3 шт",Посуда,3200

# что на самом деле видит CSV-ридер — 5 полей вместо 4:

7 | Набор ножей | 3 шт | Посуда | 3200

Инструмент. Фильтр csv берёт поле в кавычки и удваивает внутренние — ровно как требует RFC 4180. Кавычит он каждое поле, на которое поставлен, а не только те, что иначе развалились бы: закавыченное поле всегда валидный CSV, и читатель разницы не заметит. Ставьте его на каждое текстовое поле, пришедшее извне вашего конфига:

<block>
<line><data>${{Id}},${{Name | csv}},${{Cat}},${{Price}}</data></line>
</block>
./run out.tdc -o out.csv
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>
./run out.tdc -o out.csv (разделитель — точка с запятой)
id;name;category
1;Ручка;Офис
2;Кружка;Кухня

(Атрибут delimiter — это про другое: он говорит генератору file, как читать входной CSV. На выходе вы просто пишете нужный символ.)

JSON​

У JSON в TDC тоже нет «режима» — это текст, как и CSV. Но у него четыре правила синтаксиса, которые простой сборщик текста радостно нарушит. Вот все четыре и лекарство для каждого.

Массив без висящей запятой​

Задача. Три объекта — ещё не JSON, пока что-то не соединит их в массив. Напрашивающийся ход — запятая в конце каждого объекта — ставит запятую и после последнего, а её не прощает ни один парсер:

python -m json.tool users.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>
./run out.tdc -o out.json
[
{"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>
./run out.tdc
  "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>
./run out.tdc
  "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>
./run out.tdc
  "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"/>
python -m json.tool users.json
{"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>
./run out.tdc
{"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>
./run out.tdc
{"id": 1, "note": "fragile"}
{"id": 2, "note": null}
{"id": 3, "note": "urgent"}

Кавычка из чужих данных​

Последняя поломка приходит из значений, которые вы не контролируете, — из имени, вытянутого из файла. Товар Кофе "Арабика" 250 г попадает в строку как есть, и вторая кавычка закрывает её раньше времени:

python -m json.tool products.json
{"name": "Кофе "Арабика" 250 г"}
JSONDecodeError: Expecting ',' delimiter

Фильтр json пишет значение как строку JSON: сам ставит кавычки и экранирует то, что требует JSON, — внутреннюю кавычку, обратный слэш, табуляцию, перевод строки:

<data>{"name": ${{Name | json}}}</data>
./run out.tdc
{"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>
./run out.tdc -o out.ndjson
{"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>
./run orders.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>
./run clinic.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>
./run nested.tdc
[
{"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 закрывает строку раньше времени, и команда не выполнится:

sqlite3 shop.db < out.sql
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>
./run out.tdc -o out.sql
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>
./run out.tdc -o shop.sql
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>

Затем заливаете:

sqlite3 shop.db < shop.sql
# 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>
./run out.tdc (таблица Markdown)
| id | name | city   |
|----|------|--------|
| 1  | Иван | Москва |
| 2  | Пётр | Казань |

TDC лишь подставляет значения — формат таков, каким вы его написали.

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