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

Форматы вывода (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 хочет ", "с кавычками, — и тут вы упираетесь в стену: грамматика не даёт положить " внутрь значения атрибута, а &quot; проходит буквально.

Обход: разделить нейтральным символом, которого в значениях никогда нет, а потом превратить его в JSON фильтром replace, где кавычки писать можно:

<sequence name="Tags">
<gen type="text" value="sale,new,gift,vip" repeat="1..3" separator="~"/>
</sequence>
<line><data> "tags": ["${{Tags | replace:~,", "}}"],</data></line>
./run out.tdc
  "tags": ["vip"],
"tags": ["sale", "gift"],
"tags": ["gift", "vip", "new"]

Для массива чисел ничего этого не нужно — кавычек там нет, хватает separator="," напрямую. И учтите: repeat берёт каждое значение независимо, так что один тег может выпасть в строке дважды; если так не нужно, задайте фиксированный набор через <mix>.

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}

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

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

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

По стандарту JSON внутреннюю кавычку экранируют обратным слэшем. Тот же фильтр replace это и делает — это единственное место, где слэш пишете вы сами:

<data>{"name": "${{Name | replace:",\"}}"}</data>
./run out.tdc
{"name": "Кофе \"Арабика\" 250 г"}

Это разбирается обратно в исходную строку, кавычки на месте.

примечание

Если в ваших данных может встретиться и сам обратный слэш, удвойте его первым (проход replace для \\\), и только потом экранируйте кавычки — иначе второй проход испортит экранирование, добавленное первым.

Когда записей много: 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 пишет такое на диск, не держа файл в памяти, — см. Большие выгрузки и потоковая запись.

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 лишь подставляет значения — формат таков, каким вы его написали.

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