Данные без противоречий. Связи между полями
Привет! Сейчас покажу штуку, которую я довольно долго доводил до ума, и мне кажется, она может пригодиться не только мне.
Задача звучит скучно: нагенерировать тестовые карточки людей. Пол, имя, диагноз. Скучно ровно до того момента, пока не посмотришь, что получилось:
женский, Ольга, Аденома простатыФормально придраться не к чему. Пол настоящий, имя настоящее, диагноз из справочника. Просто вместе они дают человека, которого не бывает. Каждое поле правильное по отдельности — неправильно то, что вышло из них вместе.
Причина простая: обычный генератор заполняет поля по отдельности. Он не в курсе, что диагноз как‑то связан с полом, — ему про это никто не говорил. И пока полей два‑три, всё нормально. А потом их становится пятнадцать, половина связана друг с другом, и фикстура начинает тихо врать.
Такие инструменты, конечно, есть, и не я один это придумал. Готовя эту статью, я даже проверил даты и выяснил, что в своё время просто плохо искал: Snowfakery и Synth уже существовали. Ну и ладно. Своё уже написано, работает, и жалко мне его не было — поэтому вот, рассказываю, как оно устроено внутри. Заодно, может, кому пригодится.
Поехали.
Карточка пользователя
Я решил сделать свой декларативный язык — такой, чтобы связанные данные описывались просто. Отталкивался от привычной штуки: карточки. В тестировании часто работают с профилем пользователя в виде такой карточки — пол, имя, фамилия, дата рождения, ну и что там ещё нужно по задаче.
Вот такую карточку и хочется получать. Обратите внимание: тут всё сходится между собой
Дальше вопрос был один: как сделать, чтобы поля в карточке знали друг о друге?
Начал с самого простого. Пусть будет набор значений, всего два — мужчина и женщина. Назовём его по‑английски: gender.
Последовательность и генератор
Как такой набор выглядит на десяти карточках? Просто десять значений подряд, одно на карточку — по сути массив. Такой ряд значений я и назвал последовательностью, по‑английски sequence.
А внутри последовательности надо разместить генератор — то, что её наполняет. В нашем случае он должен выдавать всего два значения: мужчина или женщина.
И вот мой первый код выглядел примерно так:
<sequence name="Gender">
<gen type="text" value="Male,Female"/>
</sequence>sequence с именем Gender — это и есть тот самый ряд значений, у него просто появилось имя. Внутри текстовый генератор, у которого всего два значения: Male или Female.
Сколько именно значений он выдаст и в каком порядке — задаётся снаружи, в обёртке вокруг всех последовательностей:
<env count="10" seed="demo">
... здесь лежат все sequence ...
</env>count — сколько карточек сгенерировать. seed — стартовое число для случайности: с одним и тем же сидом получаются одни и те же карточки, сколько раз ни запускай. Дальше я его не показываю, чтобы не загромождать примеры, но он есть всегда.
Сама секвенция ничего не решает — это просто именованный ряд ячеек. Наполняет его генератор: раскладывает свои значения по карточкам, в случайном порядке. Получается что‑то вроде такого:
["Female", "Female", "Male", "Male", "Male", "Male", "Female", "Female", "Female", "Male"]Последовательность — это именованный ряд ячеек, по одной на карточку. Наполняет его генератор
Проценты я здесь нигде не задавал — а если их не задать, значения делятся поровну. И вот тут стоит сказать одну вещь, которая для меня принципиальна: делятся они не «примерно», а точно. На десяти карточках всегда выйдет ровно пять мужчин и ровно пять женщин — хоть сто раз перезапустите с разными сидами. Случаен здесь только порядок, в котором они лягут по карточкам, а не количество.
Это не то же самое, что бросать монетку на каждую карточку. Монетка на десяти бросках запросто даст семь и три — и потом вы полдня ищете, почему тест иногда падает. Здесь доли раскладываются заранее, на всю выборку, и только потом перемешиваются. Если карточек нечётное число, остаток уходит той доле, у которой больше недобор: на семи карточках получится четыре и три, на ста одной — пятьдесят один и пятьдесят.
Теперь самое главное — связь
Итак, мы получили первую последовательность. Она у нас красивая, всё нам вроде нравится. А теперь давайте сделаем из этого самое главное — связь. И на помощь нам приходит вот такой атрибут, который позволяет указать родителя:
<sequence name="Gender">
<gen type="text" value="Male,Female"/>
</sequence>
<sequence name="FirstName" parent="Gender">
<gen if="Gender.Male" type="file" src="male.firstName.txt"/>
<gen if="Gender.Female" type="file" src="female.firstName.txt"/>
</sequence>Оба генератора берут значения из обычных текстовых файлов, лежащих рядом с конфигом. Полный список файлов будет чуть ниже.
Сразу оговорюсь про файлы, чтобы не создалось неверного впечатления. Брать данные можно не только из них: есть генераторы, работающие как Faker — со списками, уже заготовленными в языковых пакетах, и тогда никаких файлов заводить не надо. Я выбрал файлы специально: так видно, что источником может быть что угодно, хоть ваша собственная выгрузка из базы.
Первое, что здесь непривычно: внутри одной последовательности стоят сразу два генератора. Так можно — их бывает и больше. Последовательность от этого не удваивается: это по‑прежнему один ряд значений, по одному на карточку. Просто заполнять его могут разные генераторы, каждый в своём случае.
Дальше два новых слова, и они делают разное. Их легко перепутать, поэтому разберу отдельно.
parent="Gender" — это связь. Она говорит: эта последовательность зависит от той. Отсюда следует порядок — сначала на всю выборку раскладывается Gender, и только потом заполняется FirstName. Без этого имя могло бы посчитаться раньше пола, и связывать было бы не с чем.
parent задаёт порядок: сначала целиком раскладывается ряд значений пола — и только когда он готов, заполняется зависимый от него ряд имён
if="Gender.Male" — это условие запуска конкретного генератора. В нём проверяется, что в данный момент генерации для вот именно этой карточки гендер равен мужскому — и только тогда генератор начинает брать мужское имя и вставлять его. Если же это не так, генератор даже не запустится и вообще ничего не вернёт. Такая же история с генератором для женских имён: он включается только тогда, когда обрабатываемая ячейка выдаёт, что Gender.Female истинно.
Коротко: parent отвечает на вопрос «от чего зависим и что считаем раньше», а if — на вопрос «какой из генераторов сработает в этой строке».
Второй генератор не «отфильтрован» — он просто не запускается и ничего не возвращает
Запускается это одной командой в терминале — конфиг на вход, данные на выход:
$ tdcv2 people.tdcВместе с библиотекой ставится и консольная утилита, так что генератором можно пользоваться как обычной программой — из скрипта, из CI, откуда угодно. А можно дёргать из кода как библиотеку, если данные нужны прямо внутри теста.
И вот что мы увидим приблизительно, когда прогоним такой файл на десять карточек. Здесь сформируется своего рода двумерный массив: последовательностей у нас две, значит и строк в массиве две.
Сразу хочу оговориться. Я привожу примеры массивов именно потому, что так легче всего понять, как это работает. Внутри устроено иначе: на быстром движке значения в памяти вообще не лежат — каждая карточка вычисляется в тот момент, когда до неё дошла очередь. Поэтому миллион строк занимает столько же памяти, сколько десять. Но для понимания лучше показать на примере массива: человеческому мозгу, особенно если человек первый раз такое видит, будет легче воспринимать. И сама концепция, мне кажется, будет легче усваиваться. А может, и нет.
Как это примерно могло бы выглядеть, если бы это был двумерный массив:
[["Female", "Female", "Male", "Male", "Male", "Male", "Female", "Female", "Female", "Male"]
["Анна", "Елена", "Никита", "Денис", "Денис", "Николай", "Елена", "Анна", "Дарья", "Николай"]]И вот тут мы уже видим совпадение: если посмотреть в столбик, сверху вниз, то каждая карточка — это по сути столбик, который мы можем собирать из многомерных данных, то есть постоянно подключать что‑то новое. Добавим третью последовательность — появится третий ряд, а карточка станет из трёх этажей. Именно это мы и сделаем чуть позже.
Две последовательности рядом. Карточка — это столбик сверху вниз: каждая новая последовательность добавляет ряд, а связи держатся
«А зачем такие сложности?»
Человек, который работал с какой‑нибудь программой типа Faker.js, скорее всего скажет: ой, зачем такие сложности, ведь можно было бы просто запросить полное имя с фамилией — и там сразу же вылезет то, что ты запросил у Faker.
Тут могу сказать следующее. Дело в том, что вот это поле Gender влияет не только на фамилию — оно может влиять на медицинский диагноз. Вы же понимаете, что болезни есть общего плана, которыми болеют и мужчины, и женщины, а есть такие, которые в карточке определённого пола появиться просто не должны. Справочник диагнозов это знает — а генератор, который заполняет поля по отдельности, нет.
И вот тут открывается главная сила связанных данных и вообще такого подхода в целом. Он позволяет миксовать данные так, как вам хочется. Вы можете любые данные, которые представляете себе в голове и которые должны быть связаны, связать как угодно.
Добавляем болезни, да ещё и с распределением
Следующий пример как раз включает предыдущий образец кода — плюс мы добавим, что Gender влияет на дальнейшее развитие данных. Добавим сюда болезни. Причём сделаем это ещё и с распределением: это будет немного новая конструкция, но она даст хороший повод задуматься, насколько просто можно описывать данные, если нам нужна связь.
Для начала представим, что у нас есть несколько файлов. Два с именами, которые уже были, и три новых — с диагнозами:
male.firstName.txt - мужские имена: Олег, Никита, Сергей...
female.firstName.txt - женские имена: Марина, Светлана, Дарья...
male.diagnosis.txt - только мужские диагнозы: аденома простаты, варикоцеле...
female.diagnosis.txt - только женские: миома матки, эндометрит...
common.diagnosis.txt - общие, бывают у всех: анемия, мигрень, гастрит...Файлы обычные текстовые, по одному значению в строке, лежат рядом с конфигом. Здесь они условные — вы подставите свои.
Хочу я вот чего: у мужчин каждая пятая карточка — со специфично мужской болезнью, у женщин каждая пятая — со специфично женской, а всем остальным достаются общие. Каждая пятая — это 20%, и именно это число вы дальше и увидите в конфиге.
Если нарисовать, чего я хочу добиться, получается вот такое дерево:
Сначала развилка по полу, потом в каждой ветке — доли. Общий файл достаётся обеим веткам
Ну а дальше уже пошёл код конструктора. Пол и имя остаются ровно такими, какими были, поэтому покажу сначала только новый кусок — тот, что отвечает за диагноз:
<switch name="Diagnosis" on="Gender">
<case is="Male">
<mix percent="20,80">
<case><gen type="file" src="male.diagnosis.txt"/></case>
<case><gen type="file" src="common.diagnosis.txt"/></case>
</mix>
</case>
<case is="Female">
<mix percent="20,80">
<case><gen type="file" src="female.diagnosis.txt"/></case>
<case><gen type="file" src="common.diagnosis.txt"/></case>
</mix>
</case>
</switch>Здесь сразу две новые конструкции, и они вложены одна в другую. Разберём по очереди.
switch — это развилка. Он смотрит на значение другой последовательности (какой именно — сказано в on="Gender") и в зависимости от него выбирает, какую ветку выполнять. Мужская карточка идёт в case is="Male", женская — в case is="Female". Третьего не дано, потому что у Gender других значений нет.
mix — это раскладка долей. Он не выбирает по условию, а делит строки в заданной пропорции: внутри лежат варианты, каждый в своём case, а percent="20,80" говорит, что первому достанется 20% строк, второму — 80%.
И здесь работает ровно то же правило, что и с полом выше: это не взвешенный рандом, а квота. Не «у каждой строки 20% шанс», а «ровно пятая часть строк, и ни строкой меньше». Движок сначала считает, сколько карточек кому причитается, и только потом перемешивает порядок. Поэтому распределение можно проверять ассертом, а не глазом.
А вместе получается вот что: сначала карточка попадает в свою половину по полу, и уже внутри этой половины разыгрываются доли. То есть каждая пятая мужская карточка получает мужской диагноз, а остальные четыре — общий. У женщин ровно так же, своим файлом.
И вот здесь стоит притормозить, потому что в процентах легко запутаться. В конфиге везде написано 20 на 80 — но эти 20% считаются не от всей выборки, а внутри своей половины. Про то, что пол мы до этого поделили пополам, легко забыть, а он‑то как раз всё и решает.
Возьмём тысячу карточек. Пол делится поровну — значит мужчин 500. Каждый пятый из них и есть те самые 20%, то есть 100 человек. А сто человек от всей тысячи — это уже 10%. У женщин ровно та же цепочка. Остаток, 800 карточек, забирают общие диагнозы — 80%.
Так что 20% и 10% тут друг другу не противоречат: это одно и то же количество, просто посчитанное от разных величин. 20% от половины и есть 10% от целого. Ровно то, что я и хотел.
А вот весь конфиг целиком, чтобы было видно, как три последовательности стоят рядом:
<env count="10" seed="demo">
<sequence name="Gender">
<gen type="text" value="Male,Female"/>
</sequence>
<sequence name="FirstName" parent="Gender">
<gen if="Gender.Male" type="file" src="male.firstName.txt"/>
<gen if="Gender.Female" type="file" src="female.firstName.txt"/>
</sequence>
<switch name="Diagnosis" on="Gender">
<case is="Male">
<mix percent="20,80">
<case><gen type="file" src="male.diagnosis.txt"/></case>
<case><gen type="file" src="common.diagnosis.txt"/></case>
</mix>
</case>
<case is="Female">
<mix percent="20,80">
<case><gen type="file" src="female.diagnosis.txt"/></case>
<case><gen type="file" src="common.diagnosis.txt"/></case>
</mix>
</case>
</switch>
</env>Отдельно отмечу, что вкладывать так можно как угодно и на любую глубину: switch в switch, mix в mix, mix в switch и наоборот. Комбинируется всё со всем — и это открывает дорогу к куда более сложным зависимостям, чем в моём примере.
Все четыре сочетания работают, и глубина не ограничена
Откуда берутся 10%, если в конфиге написано 20%. Двадцать процентов от пятисот — это сто, а сто от тысячи — уже десять процентов
Проверим на тысяче карточек. Не «примерно десять» — ровно сто, сто и восемьсот:
мужские специфичные 100 10.0 %
женские специфичные 100 10.0 %
общие 800 80.0 %Те самые 20% внутри каждой половины — вот они же, пересчитанные на всю тысячу.
А вот как выглядят первые десять карточек:
Female, Анна, Хронический гастрит
Female, Елена, Мигрень
Male, Никита, Анемия
Male, Денис, Варикоцеле
Male, Денис, Хронический гастрит
Male, Николай, Анемия
Female, Елена, Анемия
Female, Анна, Эндометрит
Female, Дарья, Анемия
Male, Николай, МигреньИ вот уже из безобидного примера мы получили довольно‑таки интересный набор. У Анны эндометрит — болезнь, которой у мужчины быть не может. У Дениса варикоцеле, которое никогда не попадёт в женскую карточку. А анемия, мигрень и гастрит достаются и тем, и другим — потому что лежат в общем файле.
Что из этого выросло
То, что я показал, — самая основа, с неё всё начиналось. За ней потянулось остальное, и каждый пункт вырос из той же одной мысли: поле может зависеть от поля.
Уникальные значения — чтобы значение не повторялось от карточки к карточке, а внутри одной карточки два поля не совпадали между собой.
Целая запись вместо отдельных полей — когда связано не одно поле, а вся карточка разом: тридцать врачей на две тысячи пациентов, и в карточке должны быть имя, фамилия и кабинет одного и того же врача.
Родительская и дочерняя таблицы одним описанием — клиент и его заказы выходят двумя таблицами, связанными ключами, из одного файла.
Распределение, которое рисуешь картинкой — рисуешь кривую в редакторе, и данные ложатся по её форме.
И две вещи, которые я считаю в этой истории главными.
Один и тот же конфиг работает в пяти языках. TypeScript, Python, Java, C# и Rust — и выдают они не «похожие» данные, а совпадающие. Один файл, один сид, одни и те же карточки, куда бы вы их ни подключили: в джава‑сервис, в питоновый пайплайн, в тайпскриптовые тесты.
Пять независимых реализаций одного языка. Один конфиг, один сид — и вывод совпадает построчно, а не «примерно похож»
И вывод можно сложить в любой формат. Данные отдаются либо прямо в программу, либо в файл — и структуру этого файла вы описываете сами, там же в конфиге. CSV, JSON, XML, YAML, SQL‑дамп, лог под ваш парсер, да хоть формат, который придумают через два года. Готового списка форматов просто нет, потому что он вам не нужен. Как это устроено — тема отдельной статьи, здесь бы не поместилось.
Пет‑проект я вёл для себя несколько лет, в 2023-м завёл под него первый репозиторий — приватный, потому что выкладывать было незачем: инструмент работал ровно под мои задачи, и половина задуманного в нём просто не была сделана. В этом году я решил, что из него получится нормальный открытый проект, и переписал движок целиком. Называется TDCV2, лежит под MIT, платных версий не будет.
Если тема окажется интересной, буду писать дальше и разбирать, как это устроено и что ещё умеет библиотека. А пока мне полезнее всего — взгляд со стороны: если вы работаете с тестовыми данными и у вас есть случай, который на такую схему не ложится — расскажите про него в комментариях. Механика выросла из моих задач, и я наверняка не вижу половины чужих.