Цикл «Данные без противоречий» — про TDCV2, открытый конструктор тестовых данных, который я пишу сам. Работает он так: вы описываете конфигом, какой файл вам нужен, а конструктор порождает его целиком — от двух строк до сотен тысяч. Нужно это, когда надо наполнить базу, проверить отчёт или показать демо, а настоящие данные брать нельзя. От генераторов случайных значений он отличается одним: следит, чтобы данные не противоречили сами себе — про это весь цикл.
○ Часть 1 — Связи между полями
○ Часть 2 — Свободный формат вывода
● Часть 3 — Справочники
В первой части я связывал поля внутри одной строки, чтобы не выходило женщины с мужским диагнозом. Во второй — собирал из этих значений файл любого вида. Сегодня случай, где связывать надо не поля, а запись целиком.
Нужен журнал приёмов в клинике. Пациент, врач, специальность, кабинет. Я сделал его в лоб, как сделал бы любой в первый раз, и вот первые восемь строк:
Светлана -> Волков, Неврология, каб. 101 Мария -> Морозов, Кардиология, каб. 103 Злата -> Соколов, Хирургия, каб. 101 Марина -> Волков, Хирургия, каб. 103 Юлия -> Морозов, Хирургия, каб. 103 Татьяна -> Петров, Дерматология, каб. 104 Татьяна -> Соколов, Кардиология, каб. 103 Ксения -> Морозов, Кардиология, каб. 101
По полям тут не придраться: фамилии настоящие, специальности настоящие, кабинеты из нужного диапазона. Такой файл спокойно проходит любую проверку вида «поле заполнено и лежит в допустимом диапазоне». А теперь посмотрите на врачей.
Волков в первой строке — невролог из 101-го кабинета, а в четвёртой он уже хирург из 103-го. Морозов за эти восемь строк успел побывать кардиологом в 103-м, хирургом там же и снова кардиологом, но уже в 101-м. В 103-м кабинете за эти восемь строк прошло четыре приёма, и вели их три разных врача.
Такого врача, как в четвёртой строке, в клинике не существует. Он собран из фамилии одного человека, специальности второго и кабинета третьего.
Возможно где‑то есть врач на все руки мастер, но у нас должно все быть по правилам!
Четыре врача превращаются в шестьдесят четыре
Давайте посмотрим на конфиг, который это выдал. Написан он самым очевидным способом — три поля рядом, каждое само по себе:
<sequence name="DocName"> <gen type="text" value="Петров,Соколов,Морозов,Волков"/> </sequence> <sequence name="DocSpec"> <gen type="text" value="Кардиология,Дерматология,Неврология,Хирургия"/> </sequence> <sequence name="DocRoom"> <gen type="number" value="101..104"/> </sequence>
Четыре фамилии, четыре специальности, четыре кабинета. Сколько разных врачей выйдет из этого на двух тысячах строк? Я убрал пациента, оставил три поля врача и посчитал, сколько получилось непохожих друг на друга троек:
$ tdcv2 naive.tdc | sort -u | wc -l 64
Шестьдесят четыре. Ровно четыре на четыре на четыре: движок честно перебрал все сочетания, потому что ничто ему не мешало. У Волкова в таком наборе есть все четыре специальности и все четыре кабинета.
И вот что тут неприятнее всего: ломается оно тихо. Если вы проверяете такие данные по полям, проверка пройдёт, придраться не к чему: каждое значение взято из правильного списка.
Вылезет это позже и в стороне. Кто‑нибудь сведёт журнал с таблицей врачей по фамилии и получит не то число строк — потому что Волковых в журнале четыре штуки, по одному на специальность. Отчёт «сколько у нас врачей» ответит: шестьдесят четыре. Расписание по кабинетам покажет, что в 101-м принимают невролог, хирург и кардиолог. И каждый раз искать будут не там, где сломано, а там, где вылезло.
Справочник должен существовать раньше первой строки
А не хватало нам ровно одной вещи. Врач должен быть записью, и все врачи должны появиться раньше первого пациента. Для этого в TDCV2 есть <pool> — маленькая таблица, которая строится один раз, до начала прогона. Дальше я буду называть её справочником.
<pool name="Doctors" count="4"> <sequence name="name" uniq="true"> <gen type="text" value="Петров,Соколов,Морозов,Волков"/> </sequence> <sequence name="spec"> <gen type="text" value="Кардиология,Дерматология,Неврология,Хирургия"/> </sequence> <sequence name="room" uniq="true"> <gen type="number" value="101..104"/> </sequence> </pool> <sequence name="Patient"> <gen type="template" value="person.female.firstName"/> </sequence> <sequence name="Seen"> <gen type="pool" value="Doctors"/> </sequence>
Внутри справочника ничего нового нет. count="4" — сколько в нём записей; не путайте с count всего прогона, который задаёт число строк на выходе. Тут четыре врача, а строк может быть хоть две тысячи. <sequence> — поле каждой записи, ровно те же теги, что и снаружи. uniq="true" — требование, чтобы значение этого поля не повторялось между записями. Двух Петровых в справочнике не будет, как не будет и двух врачей с одним номером кабинета.
А снаружи Seen у нас обычное поле, по одной ячейке на строку. Только в ячейке лежит не значение, а целая запись из справочника, и до её полей добираются через точку:
<data>${{Patient}} -> ${{Seen.name}}, ${{Seen.spec}}, каб. ${{Seen.room}}</data>
Светлана -> Волков, Хирургия, каб. 103 Мария -> Морозов, Неврология, каб. 104 Злата -> Соколов, Кардиология, каб. 101 Марина -> Петров, Дерматология, каб. 102 Юлия -> Морозов, Неврология, каб. 104 Татьяна -> Морозов, Неврология, каб. 104
И вот теперь Морозов — невролог из 104-го в любой строке, где встретится. Не потому, что мы это где‑то прописали, а потому, что строка взяла не три значения по отдельности, а одного человека целиком.
А вот саму ссылку напечатать нечем — печатать‑то нечего. Стоит написать имя без точки:
<data>${{Patient}} -> ${{Seen}}</data>
Прогон не начнётся, тут ${{Seen}} ссылка на объект. Молчать об этом движок не должен, поэтому я сделал отдельную ошибку: она не просто отказывает, а показывает, что писать вместо этого:
error[TDC229]: "Seen" draws a whole member from a pool — it has no value of its own to print note: Read a field: ${{Seen.name}}, ${{Seen.spec}}, ${{Seen.room}}.
Правильно вызывать нужно так:
<data>${{Seen.name}}</data>
Раз запись справочника собрана из тех же <sequence>, что и обычная строка, внутри него работают и связи из первой части. Это не отдельный механизм: уже существующие связи просто действуют и внутри записей справочника.
Скажем, фамилия врача должна сходиться с его полом:
<pool name="Doctors" count="6"> <sequence name="sex"><gen type="text" value="M,F"/></sequence> <sequence name="name" parent="sex"> <gen if="sex.M" type="template" value="person.male.lastName"/> <gen if="sex.F" type="template" value="person.female.lastName"/> </sequence> </pool>
пациент Светлана -> врач Смирнов (M) пациент Елена -> врач Петрова (F) пациент Ирина -> врач Иванова (F) пациент Татьяна -> врач Васильев (M) пациент Ольга -> врач Петрова (F) пациент Наталья -> врач Петрова (F)
Те же parent и if, что и в первой части, только теперь они связывают поля внутри записи справочника, а не внутри строки журнала.
Одно сочетание при этом запрещено. Если к такому ветвлению добавить uniq="true", движок откажется: TDC218: uniq="true" is not allowed ... its value is picked per row from <gen if="…"> branches rather than drawn as one pool, so it cannot promise uniqueness. Причина в том, как uniq вообще работает: он раскладывает значения из одного источника так, чтобы они не повторились. А когда источников два и выбор между ними делается для каждой записи отдельно, раскладывать нечего — и пообещать уникальность движок не может. Честно сказать об этом заранее лучше, чем выдать справочник с двумя Петровыми.
Две записи не должны совпасть
Тут стоит остановиться отдельно, потому что без uniq="true" справочник тихо теряет половину смысла. Давайте уберём его с кабинета, а всё остальное оставим как было. Вот что окажется в справочнике из восьми врачей:
Васильев (Хирургия), каб. 120 Козлов (Дерматология), каб. 122 Ларичев (Дерматология), каб. 110 Михайлов (Хирургия), каб. 122 Новиков (Кардиология), каб. 120 Петров (Неврология), каб. 128 Попов (Кардиология), каб. 121 Соколов (Неврология), каб. 126
Смотрите: восемь врачей, а кабинетов шесть. Васильев с Новиковым сидят в 120-м, Козлов с Михайловым — в 122-м. Формально ничего не сломано: кабинеты из нужного диапазона, врачи разные. Просто два разных врача получили один и тот же кабинет, и заметите вы это не раньше, чем кто‑нибудь начнёт строить расписание.
С uniq="true" кабинетов ровно восемь. Движок раскладывает значения так, чтобы совпадений не было, и делает это до того, как напечатается первая строка.
Цена у такого требования есть, и она честная: значений должно хватить. Если я попрошу шесть врачей из четырёх фамилий, прогон не начнётся — cannot produce 6 unique values — its source holds only 4 distinct values. Число в тексте ошибки стоит не для красоты. Отказать можно было и молча, но тогда вы гадаете, сколько же значений просить, — а так потолок виден сразу. Попросите пять тысяч уникальных фамилий, и движок ответит, что в русском пакете их 1608.
Фильтр решает, из кого выбирать, а не кого выбрать
А теперь то, ради чего справочник и затевался. У пациента есть нужная ему специальность, и попасть он должен к тому, у кого она есть. Справочник тот же, на восемь врачей.
Вот кто в нём оказался — это тот же прогон, просто я выкинул пациентов и оставил уникальные записи:
Васильев (Хирургия), каб. 113 Козлов (Дерматология), каб. 112 Ларичев (Дерматология), каб. 128 Михайлов (Хирургия), каб. 123 Новиков (Кардиология), каб. 130 Петров (Неврология), каб. 117 Попов (Кардиология), каб. 124 Соколов (Неврология), каб. 121
Обратите внимание: специальности тут распределились ровно, по двое на каждую — но это вышло само, я долей не задавал. Ниже вернусь к тому, что меняется, если доли задать явно.
А в строке у нас появится новая колонка — что пациенту нужно. И фильтр, который отсекает тех, кто не подходит:
<sequence name="Needs"> <gen type="text" value="Кардиология,Дерматология,Неврология,Хирургия" percent="40,20,20,20"/> </sequence> <sequence name="Seen"> <gen type="pool" value="Doctors" filter="spec == Needs"/> </sequence>
Светлана, нужна Кардиология -> Попов (Кардиология), каб. 124 Мария, нужна Кардиология -> Попов (Кардиология), каб. 124 Злата, нужна Кардиология -> Новиков (Кардиология), каб. 130 Марина, нужна Кардиология -> Новиков (Кардиология), каб. 130 Юлия, нужна Неврология -> Соколов (Неврология), каб. 121 Татьяна, нужна Неврология -> Соколов (Неврология), каб. 121
Условие spec == Needs читается так: слева поле записи справочника, справа — поле текущей строки. То есть для каждой строки движок сначала смотрит, что ей нужно, и только потом решает, из кого выбирать.
На всех двух тысячах строк специальность врача совпала с нужной. Но обратите внимание, чего filter при этом не делает: он не выбирает за нас. Он сужает круг кандидатов, а дальше выбирает среди них случайно — у Кардиологии в первых строках то Попов, то Новиков.
И тут стоит сказать точно, потому что в первой части я напирал на то, что доли в TDCV2 не примерные. Доли — да, точные. А вот выбор записи из справочника — обычный равномерный случайный, без квот. Я проверил: если подходящих двое, на двух тысячах строк выходит 1010 и 990, а не ровно по тысяче. Это не мелочь: если вам нужна именно ровная нагрузка на врачей, её надо задавать долями, а не надеяться на фильтр.
Очевидное решение «бери первого подходящего» я не стал делать специально. Оно отдало бы всех кардиологических пациентов одному Попову и убило бы тот самый разброс, ради которого справочник и заводится.
Условий в фильтре может быть несколько. Если в клинике есть отдельное крыло и принимать надо там, к специальности добавляется кабинет: filter="spec == Needs && room > 120". С обоими условиями сразу все две тысячи строк по‑прежнему сходятся — и специальность совпадает, и кабинет за 120-м.
Поле записи можно не только печатать, но и спрашивать о нём в условии. Хирургам, скажем, нужна пометка про операционную — <data if="Seen.spec==Хирургия"> [операционная]</data>:
Татьяна, нужна Хирургия -> Михайлов (Хирургия), каб. 123 [операционная] Светлана, нужна Кардиология -> Попов (Кардиология), каб. 124
Пометку получили ровно четыреста строк из двух тысяч, и все до одной хирургические. Условие смотрит на поле той записи, которая попала в эту строку, — то есть на врача, а не на то, что пациенту было нужно.
А если подходящих не осталось — это ошибка, а не пустая ячейка. Добавляю пациентам офтальмологию, которой нет ни у одного врача:
tdcv2: pool "Doctors": no member satisfies filter="spec == Needs" for row 5 (Needs="Офтальмология"). Add a member that matches, or widen the filter.
Ноль строк на выходе, код возврата 1. Молча подставить пустоту было бы хуже: такая дырка всплывёт у вас потом в тесте, который проверяет что‑то совсем другое, и искать её вы будете не там.
Два места, где здравый смысл подводит
Теперь про две вещи, устроенные не так, как подсказывает интуиция. Обе — не про то, чего движок не умеет, а про то, что легко прочитать неверно, и обе я развожу здесь нарочно, потому что путаются в них чаще всего.
Первая — про доли. В первой части я напирал на то, что доли в TDCV2 точные, а не примерные. У справочника появляется вторая сторона: долю можно задать в двух разных местах, и это совсем не одно и то же.
<pool name="Doctors" count="8"> <mix name="spec" percent="25,75"> <case><gen type="text" value="Хирургия"/></case> <case><gen type="text" value="Терапия"/></case> </mix> </pool> <sequence name="Needs"> <gen type="text" value="Хирургия,Терапия" percent="30,70"/> </sequence>
<mix> — это выбор с долями: каждый <case> внутри получает свой процент. Двадцать пять процентов здесь относятся к восьми врачам. Тридцать — к двум тысячам строк. Считаю и то и другое:
строк всего: 2000 нужна Хирургия: 600 (30%) нужна Терапия: 1400 (70%) врачей в справочнике: 8 из них хирургов: 2 (25%) из них терапевтов: 6
Два хирурга из восьми и шестьсот хирургических приёмов из двух тысяч — это одна и та же клиника, просто посчитанная от разных величин. Два хирурга вместе обслуживают тридцать процентов потока — ровно столько, сколько составляет доля хирургических приёмов. И это ровно то, что бывает в жизни.
А вторая — про то, что из одного справочника можно тянуть не одно поле, а сколько угодно. И вот тут важно понимать, что именно при этом происходит. В журнале, кроме лечащего врача, часто нужен дежурный:
<sequence name="Duty"> <gen type="pool" value="Doctors"/> </sequence> <sequence name="Seen"> <gen type="pool" value="Doctors" filter="spec == Needs"/> </sequence>
Светлана, нужна Кардиология -> Попов (Кардиология), каб. 124, дежурный Новиков Мария, нужна Кардиология -> Попов (Кардиология), каб. 124, дежурный Ларичев Злата, нужна Кардиология -> Новиков (Кардиология), каб. 130, дежурный Ларичев
Работает, и каждый из двоих — настоящий врач со своими полями. Но за этим стоит тонкость, о которой надо сказать сразу.
Справочник — это набор записей, а каждый <gen type="pool"> — отдельный выбор из него. Написав имя справочника дважды, вы не используете одну таблицу дважды — вы делаете два независимых выбора. Ничто не мешает им попасть в одну и ту же запись: на двух тысячах строк дежурный совпал с лечащим в 228 случаях. Сам справочник за этим не проследит — uniq работает между его записями, а не между двумя полями одной строки.
А если по задаче совпадать они не должны, задачу эту можно решить двумя способами, и оба честные.
Первый — фильтром. Мы уже знаем, что фильтр видит поля текущей строки, а Duty к этому моменту в ней уже стоит. Значит можно прямо сказать: бери из тех, кто подходит по специальности и не дежурит сегодня.
<sequence name="Duty"><gen type="pool" value="Doctors"/></sequence> <sequence name="Seen"> <gen type="pool" value="Doctors" filter="spec == Needs && name != Duty.name"/> </sequence>
Второй — тегом <distinct>. Если uniq смотрит по вертикали, вдоль колонки, то этот — поперёк одной строки: его прямые дети обязаны получиться разными. Кладём обе ссылки внутрь, и фильтр остаётся при своём деле — отбирать по специальности.
<distinct> <sequence name="Duty"><gen type="pool" value="Doctors"/></sequence> <sequence name="Seen"><gen type="pool" value="Doctors" filter="spec == Needs"/></sequence> </distinct>
Светлана, нужна Кардиология -> Козлов (Кардиология), каб. 124, дежурный Ларичев Мария, нужна Кардиология -> Козлов (Кардиология), каб. 124, дежурный Соколов Злата, нужна Кардиология -> Ларичев (Кардиология), каб. 130, дежурный Соколов
Вывод я привёл один, потому что он один и есть: на двух тысячах строк оба способа дают побайтово одинаковый файл. Совпадений ноль, и специальность врача по‑прежнему сходится с нужной во всех двух тысячах.
Чем же они тогда различаются. Фильтр сравнивает по полю — я написал name, и сравниваются фамилии. Попадись в справочнике два однофамильца, фильтр счёл бы их одним человеком. <distinct> сравнивает по записи: два врача со случайно совпавшей фамилией — всё равно две разные записи, и группа разведёт ссылки именно по записям, а не по буквам.
И ведут они себя по‑разному, когда задача невыполнима. Оставлю в справочнике одного врача, а ссылок по‑прежнему две:
error[TDC302]: <distinct> puts 2 references on pool "Doctors", which has 1 members tdcv2: pool "Doctors": no member satisfies filter="name != Duty.name" for row 1
Сверху — <distinct>, снизу — фильтр. Группа считает расстановку по всему конфигу и отказывает до прогона: две ссылки на один справочник из одной записи не разложить никогда. Фильтр же честно доходит до первой строки и только там обнаруживает, что выбирать не из кого. Обе ошибки внятные, но первая приходит раньше и говорит о конфиге, а не о строке.
Справочник можно положить на справочник
Дальше врачу понадобилась не просто строчка с названием города, а конкретная клиника — со своим телефоном, адресом и всем прочим. То есть тоже запись:
<pool name="Clinics" count="3"> <sequence name="city" uniq="true"><gen type="text" value="Северная,Южная,Восточная"/></sequence> <sequence name="phone" uniq="true"><gen type="number" value="200..299"/></sequence> </pool> <pool name="Doctors" count="8"> <sequence name="at"><gen type="pool" value="Clinics"/></sequence> </pool>
Светлана, нужна Хирургия -> Козлов, Южная клиника, тел. 284 Мария, нужна Хирургия -> Ларичев, Восточная клиника, тел. 239 Злата, нужна Хирургия -> Новиков, Южная клиника, тел. 284 Марина, нужна Хирургия -> Попов, Восточная клиника, тел. 239
Точка у нас ушла на уровень глубже: ${{Seen.at.city}} — это город клиники того врача, который стоит в этой строке. Телефон всегда при своём городе, потому что он поле записи о клинике, а не поле строки: Южная — это всегда 284.
А раз запись врача знает свою клинику, две ссылки можно и сцепить между собой — не запретом, а условием. Фильтр одной ссылки умеет смотреть на другую: к тому моменту, когда движок берётся за вторую, первая уже стоит в строке, а её поля — такие же колонки, как все остальные. Скажем, медсестру надо брать из той клиники, где работает врач этой строки — filter="city == Seen.at.city":
Иванов (Южная) - сестра Наталья (Южная) Петров (Восточная) - сестра Татьяна (Восточная) Кузнецов (Южная) - сестра Марина (Южная) Попов (Северная) - сестра Ольга (Северная)
Выражение тянется сразу через две связи: Seen.at.city — это город клиники того врача, который попал в эту строку. Ссылки разворачиваются в порядке объявления, поэтому ту, на которую смотрят, объявляют выше. На двух тысячах строк город сестры совпал с городом врача во всех до единой.
Справочник читает только тот справочник, который объявлен выше него. Отсюда бесплатно следует ещё одно: закольцевать их не выйдет. Если сослаться из клиник обратно на врачей, движок остановится до первой строки — TDC236: pool "Clinics" draws from "Doctors", which is not declared above it.
Ссылаться — можно, вкладывать — нет. Написать <pool> внутри <pool> движок не даст:
error[TDC230]: <pool> cannot live inside a <pool> note: a pool stays a flat table — point one pool at another instead of nesting them.
Причина тут не та же, что с кольцом, хотя ограничение общее. Кольцо запрещено, потому что зависимость не должна закольцовываться. А вложенность — потому что справочник остаётся плоской таблицей, которую можно распечатать целиком. Вложи один в другой — и на каждый следующий вопрос, от уникальности до размера, пришлось бы отвечать «на каком уровне?».
Что справочник не ломает
Итак, справочник у нас есть. Дальше два вопроса, которые задаёт каждый, кто собирается тащить такое в уже работающий конфиг. Первый: не разъедется ли от него то, что уже настроено? Нет, и вот почему. Новый справочник не сдвигает то, что объявлено в конфиге ниже него. Происходит это потому, что справочник не берёт числа из общего потока случайности, а выводит свой сид из имени. Сид — это стартовое число, от которого движок пляшет; с одним и тем же сидом конфиг всегда даёт одни и те же данные. И это не случайность реализации, а объявленное правило: справочник строится из сида прогона и собственного имени, склеенных вместе.
Отсюда следствие, о котором надо знать заранее: переименуете справочник — и состав его записей поменяется. Имя входит в сид, так что переименование тут не косметика.
Проверяю: беру рабочий конфиг, добавляю перед врачами справочник медсестёр, дописываю медсестру в строку и сравниваю остальные колонки построчно на двух тысячах строк.
Светлана, нужна Кардиология -> Попов (Кардиология), каб. 124, сестра Кириллова Мария, нужна Кардиология -> Попов (Кардиология), каб. 124, сестра Фёдорова
Пациенты, специальности, врачи и кабинеты остались теми же до последнего знака — разошлась только новая колонка. Иначе вчерашний файл, с которым вы сравниваете результат, разъехался бы весь, и найти причину в этой каше было бы нечем.
Второй вопрос: справочник строится до прогона — а не станет ли от него медленно, если записей в нём много? Проверил на размере, который уже похож на настоящую базу: справочник на тысячу врачей и двести тысяч строк журнала.
$ time tdcv2 clinic.tdc 1.25s $ tdcv2 clinic.tdc | sed 's/.*-> //' | sort -u | wc -l 1000
Секунда с четвертью на двести тысяч строк, и в выводе ровно тысяча разных врачей — ни одного лишнего, собранного по кусочкам. Ровно столько, сколько мы заказали. Важно тут не само число секунд, а то, откуда оно берётся. Стоимость постройки самого справочника от числа строк журнала не зависит: он собирается один раз, до прогона. Сам прогон на двухстах тысячах строк, разумеется, идёт дольше, чем на двух, — эти двести тысяч надо напечатать. А вот справочник в обоих случаях обходится одинаково.
Обратная сторона у этого тоже есть. Обычную последовательность движок может считать по ходу дела и не держать целиком. Справочник так нельзя: из него будут выбирать до самой последней строки, поэтому он лежит в памяти весь прогон — на то он и справочник. Для тысячи записей это ничто, но если вам вдруг понадобится справочник на миллион, стоит помнить, что он там именно лежит, а не вычисляется по ходу дела.
Что из этого следует
Что в итоге получилось. Справочник закрывает целый класс правок, который иначе приходится держать в голове. Когда в строке появится шестнадцатая колонка, все поля одного врача поедут вместе не потому, что кто‑то про них не забыл, а потому, что это его поля.
И ещё одно, менее очевидное. Пока врач — это три отдельные колонки, у него нет способа существовать вне строки: нельзя спросить «сколько у нас врачей», нельзя проверить, что у каждого свой кабинет, нельзя выдать всех кардиологов. Как только он стал записью, все эти вопросы получают ответ сами собой, потому что отвечать на них есть чему.
Признак, по которому стоит про справочник вспомнить в своём конфиге, простой: если несколько колонок описывают один и тот же объект и должны меняться только вместе — это не колонки, это запись.
Инструмент называется TDCV2, лежит под MIT.
○ Часть 1 — Связи между полями
○ Часть 2 — Свободный формат вывода
● Часть 3 — Справочники
Если где‑то ошибся, не стесняйтесь поправлять меня в комментариях. Увидимся в следующей статье!

