Hammerhall EN Сколько стоит Войти

Миграции помнят решения

Статья 18 · читать минут 30

Для кого. Ты прочитал статьи 0–17, и у тебя есть песочница game-day после статьи 17. Особенно пригодятся миграция, Flyway и правило «применённую миграцию не правят» (статья 3а), ddl-auto: validate (статья 3б) и ключ таблицы задач (статья 16).

Что будет. Как читать папку миграций как историю решений. История одного ключа: что считать «той же задачей» и почему ключ меняют новой миграцией, а не правкой старой. Что Hibernate сверяет при старте, чего он не видит — и как самому сменить ключ в таблице, где уже лежат повторы.

Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От системы к делу» — следующий шаг после «От Spring к системе»: как правила дела — сроки, деньги, обещания людям — живут в коде. Примеры — учебные: их можно повторить у себя.


Зачем читать миграции#

Открой задачу учебной CRM — сущность Task из статьи 17. Десять полей, геттеры дописывает Lombok, и ни слова о ключе из статьи 16. А именно из-за него ошибочное закрытие задачи необратимо: две строки с одинаковым видом, человеком, группой и сроком брони база не пустит.

Почему в ключе срок брони, а не срок задачи, который стоит рядом? Почему вообще срок, а не просто «человек в группе»? Класс показывает, как устроено сейчас. Почему оно устроено именно так, в класс не помещается.

Ответ лежит в другой папке — src/main/resources/db/migration. Там все изменения базы по порядку (статья 3а), и каждый файл — решение, принятое однажды. Код — снимок системы на сегодня. Миграции — её дневник: что меняли, в каком порядке и, если автор не поленился, зачем.

Это последняя остановка серии. Лид, заметки менеджера, скидки, задачи, бронь — всё, о чём шли статьи 12–17, лежит строками в таблицах учебной CRM. Сегодня — как эти таблицы появились и что в них передумали.

Папку миграций учебной CRM клуба соберём из того, что о ней рассказали статьи 12–17. Куски миграций — для чтения, выброшенное в них помечено -- …. Свой код статьи — не Java, а три учебные миграции в схеме example: таблица, новый столбец, новый ключ. Ты прогонишь их подряд в отдельном контейнере Postgres и увидишь, что встаёт, что падает и почему. Полные файлы — в конце статьи.


Часть 1. Папка как история решений

Семь файлов#

Папка миграций учебной CRM:

файл что решили где об этом было
V1__create_lead.sql лиды: карточка и статус статья 13
V2__create_lead_note.sql заметки менеджера — отдельной таблицей статья 17
V3__create_discount.sql скидки тарифов: процент или сумма — ровно одно статья 14
V4__create_task.sql задачи менеджеру с двумя сроками и ключ от повторов статьи 12 и 16
V5__rename_member_to_became_member.sql старым лидам — новое имя конечного статуса статья 13
V6__task_dedup_by_group_and_until.sql новый ключ задач часть 2
V7__add_task_hold_id.sql задаче — номер брони статья 16

Прочитай список сверху вниз — и увидишь, как росла CRM: сначала лиды и разговор с ними, потом деньги, потом задачи. А V5 и V6 — не новое, а передуманное. Имя статуса обещало то, чего CRM не ведёт (статья 13), а ключ задач держал не то (часть 2). Код показывает только итог. Что передумали, видно лишь здесь.

Все таблицы учебной CRM — в схеме sales, где в статье 14 ты уже заводил таблицу скидок. В статьях 13 и 16 куски миграций шли без схемы, для краткости; в файлах она названа явно, как велит статья 3а.

Когда и вместе с чем: git log по файлу

Файл говорит «что», а иногда и «почему». «Когда и вместе с чем» знает git. Команда git log печатает историю коммитов, а путь после -- оставляет только те, что трогали этот файл. Попробуй в любом репозитории с историей. Подойдёт рабочий проект, чужой проект на GitHub или песочница, если ты её коммитил:

git log --follow --format='%h %ad %s' --date=short \
    -- src/main/resources/db/migration/V2__game_day_incident_severity.sql

Вывод — строка на коммит. Примерно так; номер, дату и заголовок ты увидишь свои:

4e1b9c2 2026-10-08 Журнал: серьёзность, подробности атаки и индекс

Дальше — git show --stat <номер>: какие файлы пришли тем же коммитом. Миграция редко приходит одна: рядом сущность, которой нужен новый столбец, служба, тест. По соседям видно, ради чего меняли базу.

Заголовок коммита и описание пул-реквеста иногда говорят, зачем. Иногда — нет: «правки», «fix по ревью». Поэтому главное «почему» пишут в самом файле — об этом часть 2.

💡 Коммитов у миграции больше одного — присмотрись к датам. Правки до слияния законны: пока пул-реквест не влит, файл живёт только в ветке (статья 3а). Правка после слияния — та самая, из-за которой приложение не стартует на базах, где миграция уже прошла.

Итог — \d, история — миграции

Итог всех файлов показывает \d — команда psql «опиши таблицу»: столбцы, умолчания, индексы, ограничения. \d sales.task после V7 в разделе индексов печатает:

Indexes:
    "task_pkey" PRIMARY KEY, btree (id)
    "ix_task_open_due" btree (due_at) WHERE done_at IS NULL
    "task_dedup" UNIQUE CONSTRAINT, btree (kind, person_id, group_id, until_at)

\d говорит, что есть. Что было до и почему стало так — только миграции. Почему в последней строке нет due_at, хотя ключ задумывали с ним, — часть 2.

Имя — тоже запись#

Имя файла — первое, что видно в папке, и Flyway его запоминает: в таблице истории flyway_schema_history описание — это часть имени после __, с пробелами вместо подчёркиваний (как game day incident в статье 3а). V6__task_dedup_by_group_and_until.sql останется в истории каждой базы строкой task dedup by group and until.

Хорошее имя говорит, что решили: create_task, rename_member_to_became_member, task_dedup_by_group_and_until — по списку видно, какой файл открыть. Плохое говорит только, что что-то было: V6__fix.sql, V6__task_changes.sql. Через полгода по нему не найдёшь ничего.

Отсюда правило: одна миграция — одно решение. Новый ключ задач и номер брони — два файла, V6 и V7: у каждого своё «почему», и каждое находится по имени. В рабочем проекте имена, номера и схему во всех миграциях проверяет отдельный тест правил. Об этом было в статье 3а.

Что видно в первых файлах#

V1__create_lead.sql, сокращено:

-- учебная CRM клуба: V1__create_lead.sql — сокращено
CREATE TABLE sales.lead (
    id          UUID          PRIMARY KEY DEFAULT gen_random_uuid(),
    -- … имя, контакты, источник, канал, интерес, менеджер
    status      VARCHAR(32)   NOT NULL DEFAULT 'NEW',
    created_at  TIMESTAMPTZ   NOT NULL
);

gen_random_uuid() ты видел в статье 3а: ключ придумает база, если строку вставили SQL-ом, мимо Java. Это функция самого Postgres, она встроена с 13-й версии.

⚠️ В интернете ты увидишь перед gen_random_uuid() строку CREATE EXTENSION pgcrypto или функцию uuid_generate_v4() из расширения uuid-ossp. Это Postgres до 13-й версии: там UUID делали только расширения. У нас — Postgres 17 из статьи 3а, расширение не нужно.

'NEW' в умолчании status — не просто слово, а имя константы LeadStatus.NEW: на поле стоит @Enumerated(EnumType.STRING) (статьи 9 и 13). База и Java договорились о написании. Переименуешь константу — старые строки и умолчание базы станут неправдой, и чинить их придётся миграцией данных. Так и вышло с конечным статусом — вот V5 целиком:

-- учебная CRM клуба: V5__rename_member_to_became_member.sql
UPDATE sales.lead SET status = 'BECAME_MEMBER'
 WHERE status = 'MEMBER';

Миграция меняет не только устройство таблиц, но и строки в них: для Flyway нет разницы, что в файле, он применит его по порядку и один раз. В чужих папках встретишь и строки справочников, которые миграция заливает сразу, — часто с хвостом ON CONFLICT … DO NOTHING: «такая строка уже есть — не вставлять». Как он устроен, в рабочем проекте разбирает мидл.

V4__create_task.sql заводит задачам, кроме ключа, частичный индекс из статьи 9:

-- учебная CRM клуба: из V4__create_task.sql
CREATE INDEX ix_task_open_due ON sales.task (due_at) WHERE done_at IS NULL;

В указателе только открытые задачи, по сроку задачи, — ровно то, что спрашивает список на сегодня из статьи 12: done_at is null и due_at раньше границы. Закрытых со временем большинство, и держать их там незачем.


Часть 2. История одного ключа: V4 → V6

Зачем задачам ключ#

Опрос броней идёт каждые полчаса (статьи 15 и 16), а живая бронь держится сутки. Значит, одну и ту же бронь опрос видит много раз подряд. Бронь вроде той, что в статье 16, держится до 06.10 11:00. Если её не оплатят, опрос увидит её ровно 48 раз: с 11:15 понедельника до 10:45 вторника.

Служба броней на каждой строке ответа пробует завести задачу «довести до оплаты». Без защиты к утру у менеджера были бы десятки одинаковых задач об одном человеке.

Защита — ограничение уникальности UNIQUE, тот самый ключ из статьи 16: правило таблицы «двух строк с одинаковыми значениями в этих столбцах быть не может». Вторую такую строку база не вставит и вернёт ошибку. Как первичный ключ из статьи 3а, только это не адрес строки, а правило «одно и то же — один раз». В учебной CRM он зовётся task_dedup — от «дедупликации», отсечения повторов.

Почему правило в базе, а не проверка в коде перед вставкой? Проверку можно забыть, обойти скриптом или вставкой руками в psql. Правило базы держится при любом коде (статья 14) — последний рубеж из статьи 3а.

Весь вопрос — какие столбцы взять. Набор столбцов в ключе и есть ответ на вопрос «что считать той же задачей».

V4: тот же вид, тот же человек, тот же срок задачи

Таблица задач появилась в V4 с таким ключом:

-- учебная CRM клуба: из V4__create_task.sql
    CONSTRAINT task_dedup UNIQUE (kind, person_id, due_at)

due_at — срок задачи, первый из двух сроков статьи 12. Выглядит разумно: тот же вид задачи, тот же человек, тот же срок — значит, повтор.

Теперь вспомни, какой срок у задачи по брони: «сейчас» — момент, когда опрос её заметил (статья 12; в строителе из статьи 17 — .dueAt(now)). Опрос в 11:15 заводит задачу со сроком 11:15. Опрос в 11:45 видит ту же бронь — и заводит задачу со сроком 11:45. Ключ сравнивает: вид тот же, человек тот же, срок другой — не повтор. В таблице после двух опросов:

 kind |         due_at         |        until_at
------+------------------------+------------------------
 HOLD | 2026-10-05 08:15:00+00 | 2026-10-06 08:00:00+00
 HOLD | 2026-10-05 08:45:00+00 | 2026-10-06 08:00:00+00

Время psql показывает здесь по UTC (статья 3а): 08:15 — это 11:15 по Москве. Две строки об одной брони, а к утру их было бы под полсотни. Ключ стоит — и не держит ничего: в нём момент действия, а он у каждого опроса свой.

Другая крайность: ключ без срока

Тогда, может, срок из ключа выкинуть совсем? UNIQUE (kind, person_id, group_id) значит «тот же вид задачи на то же место», а место — человек в группе (статья 16). Повторы опроса этот ключ отсечёт.

Но вспомни правило 1 из статьи 16: бронь сгорела, человек вернулся и забронировал место снова. Задача по старой брони закрыта, а закрытые строки ключ тоже считает (статья 16). Новая бронь, новый срок, новый разговор — а база отвечает:

ERROR:  duplicate key value violates unique constraint "task_dedup"

Строкой ниже, в DETAIL, база называет и сам ключ: HOLD, номер человека, номер группы. Для службы такая ошибка — обычный повтор: так она и узнаёт, что задача уже заведена (статья 16). Новая бронь осталась бы без задачи, и никто бы этого не заметил.

Ключ ошибается в обе стороны. Слишком узкий, с моментом действия, — пропускает повторы. Слишком широкий — принимает другую задачу за повтор. Вторая ошибка опаснее: лишнюю задачу менеджер видит, пропавшую — нет.

V6: то же место и тот же срок брони

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

-- учебная CRM клуба: V6__task_dedup_by_group_and_until.sql
-- Ключ от повторов задач по брони: тот же вид, тот же человек, та же группа, тот же срок брони.
--
-- V4 держала UNIQUE (kind, person_id, due_at). Этого мало: due_at — срок ЗАДАЧИ, а у задачи
-- по брони он «сейчас» — момент опроса, который её заметил. Следующий опрос даёт новый due_at,
-- и ключ пропускал повтор на каждом опросе, раз в полчаса.
-- Срок из ключа не убираем, а берём срок самой брони — until_at: человек забронировал место
-- снова — срок новый — это ДРУГАЯ задача.
--
-- Задач по брони в таблице ещё нет — опрос броней не включали, — так что повторов, которые
-- помешали бы новому ключу, нет и чистить нечего.
--
-- V4 уже применена и не правится, поэтому — новая миграция.
ALTER TABLE sales.task DROP CONSTRAINT task_dedup;

ALTER TABLE sales.task
    ADD CONSTRAINT task_dedup UNIQUE (kind, person_id, group_id, until_at);

По строкам:

Те же случаи на таблице после V6:

случай что делает ключ V6
опрос в 11:45 снова видит бронь, задача по ней уже есть не пускает: повтор
в тот же опрос — бронь того же человека в другой группе заводит: группа другая
бронь сгорела — задача «место ушло» по той же брони заводит: вид другой
задачу закрыли, а опрос снова видит ту же бронь не пускает: закрытая строка держит ключ
человек забронировал место снова заводит: срок брони другой

На повторе опроса база отвечает той же строкой, что выше:

ERROR:  duplicate key value violates unique constraint "task_dedup"

Только в DETAIL теперь четыре столбца — вид, человек, группа и срок брони 2026-10-06 08:00:00+00, по UTC.

Номер брони, hold_id, пришёл позже, своей миграцией V7 — той, что в статье 16. В ключ его не добавляли: место и срок и так различают бронь. У человека одно место в группе, и система клуба отдаёт по нему одну строку (статья 16).

Почему новая миграция, а не правка V4

Последняя строка комментария V6 — правило из статьи 3а. Поправь ключ прямо в V4, и Flyway сверит контрольную сумму файла с историей. Они не сойдутся, и CRM не стартует на каждой базе, где V4 уже прошла. flyway repair не спасёт: он перепишет сумму в истории, а ключ на тех базах останется старым.

Коварно другое: на чистой базе, в тестах, правка прошла бы незаметно — поэтому её так легко сделать. Новый файл — единственный путь.

🔑 Что считать «тем же самым» — вопрос дела, а не базы. База держит любой ключ одинаково строго, и неверный тоже. Прежде чем писать UNIQUE, скажи словами: «две строки — одна и та же задача, если…». Если в продолжении есть «когда сделали», «когда отправили», «когда завели» — насторожись: момент действия меняется каждый раз, а то, о чём задача, — нет. И проверь обратное: не назовёт ли ключ «тем же самым» две разные вещи.

💡 В рабочем проекте похожую ошибку однажды нашли в описании задачи на доработку: оно предлагало ключ со сроком задачи. Описание прочитали с вопросом «а что будет при следующем опросе?». Ключ построили на сроке предмета, закрыли это тестом, а описание потом поправили. Его тоже кто-то написал, и читать его так — та же работа, что ревью кода.

Комментарий — для того, кто придёт через полгода

Положи рядом V4 и V6. В V4 ни слова: почему ключ такой — догадывайся. В V6 — что было, чем это плохо, что теперь значит «та же задача», почему чистить нечего и почему файл новый. Кто через полгода увидит until_at в ключе, не станет «упрощать» его обратно до due_at: причина написана над строкой.

V5 тоже без слов, а причина у неё длинная — целая часть статьи 13: имя «клиент» обещало состояние, которого CRM не ведёт. По одной строке UPDATE её уже не восстановить.

С причиной написана и V7 из статьи 16: зачем хранить номер брони, если CRM не хранит копий? Затем, что потом его будет негде взять. А в рабочем проекте копию цены однажды убрали так же — новой миграцией и с комментарием прямо в ней (статья 14).

Что делает миграция, видно из SQL. Комментарий нужен для «почему так» и «почему не иначе» — и для того, чего намеренно не сделали: строка «чистить нечего» объясняет, почему в V6 нет DELETE.


Часть 3. Что знает база и чего не видит Java

Hibernate сверяет, а пишет только Flyway

В статье 3б ты поставил ddl-auto: validate: таблицы создаёт только Flyway, а Hibernate при старте сверяет с ними классы. В учебной CRM — так же. Почему не update из туториалов? Таблицы меняли бы двое, и базы разъехались бы.

ddl-auto — что Hibernate делает со схемой при старте. При validate он ничего не меняет, только сверяет; документация Hibernate так и пишет: «validate the database schema without changing it». К этому моменту Flyway уже применил миграции (статья 3а), и Hibernate проходит по сущностям. По исходнику Hibernate 7 он проверяет:

Не сошлось — приложение не стартует. Появись в Task поле holdId раньше миграции V7 — и старт кончился бы той же ошибкой, что опечатка в статье 3б: Schema validation: missing column [hold_id] in table […].

⚠️ Стартовало — не значит «совпало»

«Стартовало — значит, схема совпала с кодом» — неправда. Сверка идёт в одну сторону: от класса к базе. Лишний столбец, CHECK, UNIQUE, индекс — всё, чего в классе нет, validate не ищет. А у описанных полей не сравнивает NOT NULL, длину строки и умолчания: nullable = false, length = 32 над статусом лида (статья 13) — пометка, которую validate с таблицей не сверяет.

В сущностях учебной CRM нет ни task_dedup, ни discount_percent_or_amount. В Task из статьи 17 десять полей — и ни слова о ключе. Ты узнаешь о нём из миграций — или из ошибки базы, когда правило сработает.

Отсюда же беда ручных правок. Кто-то на боевом руками добавил индекс или CHECK «на минутку». Приложение стартует, validate промолчит, а миграции о нём не знают. На стенде и в тестах этого правила нет, и следующая миграция может о него споткнуться только там, где его поставили. Поэтому устройство базы меняют только миграцией (статья 3а).

🔑 Правило, которого нет в классе, держит одна база, — а почему оно такое, расскажут только миграции. Читаешь чужую систему — читай сущность вместе с её миграциями.


Часть 4. Сменить ключ самому#

Приглашения на день открытых дверей

Учебный пример, в учебной CRM такой таблицы нет. Клуб зовёт лидов на день открытых дверей: ночной обход пишет каждому в один канал — на почту или в мессенджер (контакты карточки лида из статьи 13). Первая миграция заводит example.open_day_invite: кого позвали (lead_id), на какой день (open_day_at), куда написали (email или messenger), когда отправили (sent_at). Целиком она — в «Пример целиком», а здесь два правила:

    CONSTRAINT open_day_invite_one_channel CHECK (
        (email IS NOT NULL AND messenger IS NULL) OR
        (email IS NULL AND messenger IS NOT NULL)),

    CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, sent_at)

CHECK «ровно один из двух» записан так же, как «процент или сумма» в таблице скидок статьи 14: два случая через OR, в каждом одно есть, а другого нет. Оба пустые или оба заполнены — база строку не пустит.

А ключ повторяет ошибку V4: в нём момент действия, sent_at. Обход полагается на ключ: «что уже было, база не пустит». А ключ пускает: у каждой ночи свой момент отправки. Того же человека зовут на тот же день заново, и повторы копятся в таблице. Файл data.sql — две такие ночи.

Новый столбец: умолчание для старых строк

Потом у каждой группы клуба появился свой день открытых дверей. Вторая миграция:

ALTER TABLE example.open_day_invite
    ADD COLUMN group_code VARCHAR(64) NOT NULL DEFAULT 'all';

ALTER TABLE example.open_day_invite
    ALTER COLUMN group_code DROP DEFAULT;

NOT NULL без умолчания на таблице со строками не встанет (ошибка contains null values из статьи 9). Умолчание 'all' нужно старым строкам: тогда день был общий для всех. Новым оно вредно: забыл назвать группу — и приглашение тихо ушло бы на «общий» день. DROP DEFAULT снимает умолчание для будущих вставок, а старые строки своё 'all' сохраняют. Вставка без группы теперь громко падает на NOT NULL.

Оставлять ли умолчание новым строкам, решают для каждого столбца отдельно. В статье 9 'low' оставили: без него покраснел бы тест со вставкой SQL-ом из статьи 3а. Здесь такого теста нет, а «общего дня» у нового приглашения не бывает — умолчание снимаем.

💡 Умолчание-константу вроде 'all' база просто запоминает (статья 9). Умолчание-функцию она вычисляет для каждой старой строки заново: ADD COLUMN new_id UUID NOT NULL DEFAULT gen_random_uuid() раздаст строкам разные UUID. Так таблицу с данными переводят на новый ключ шагами ALTER, не теряя ни строки.

Новый ключ — новой миграцией#

Третья миграция меняет ключ, как V6: «тот же человек, та же группа, тот же день». Первое, что хочется написать:

ALTER TABLE example.open_day_invite DROP CONSTRAINT open_day_invite_dedup;

ALTER TABLE example.open_day_invite
    ADD CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, group_code, open_day_at);

⚠️ В тестах это пройдёт: база там пустая. А на таблице, где повторы уже лежат, — нет:

ERROR:  could not create unique index "open_day_invite_dedup"

Строкой ниже, в DETAIL, база называет сам повтор: лид, которого звали дважды, группа all, день 2026-10-17 15:00:00+00 — is duplicated, «встречается дважды». Новый ключ база ставит, только если таблица уже ему подчиняется. Flyway выполняет каждую миграцию в своей транзакции. На Postgres упавшая откатывается целиком, вместе с DROP CONSTRAINT: старый ключ останется — но приложение не стартует.

Значит, до нового ключа надо решить, что делать с повторами: какое приглашение настоящее? Оставляем первое — его человек получил раньше:

DELETE FROM example.open_day_invite AS later
USING example.open_day_invite AS earlier
WHERE later.lead_id     = earlier.lead_id
  AND later.group_code  = earlier.group_code
  AND later.open_day_at = earlier.open_day_at
  AND later.sent_at     > earlier.sent_at;

DELETE … USING удаляет строки, для которых нашлась пара в другой таблице. Здесь «другая» — та же самая под вторым именем: AS later и AS earlier, как если бы список сравнивали с его копией. Строка удаляется, если есть приглашение о том же, отправленное раньше; у самого первого такого нет — оно остаётся. Одинаковых sent_at у одного человека старый ключ не пускал, так что среди повторов одного приглашения самое раннее всегда одно. После DELETE новый ключ встаёт.

🔴 Миграция, которая удаляет строки, необратима. Миграции идут только вперёд: вернуть прежнюю версию приложения — не значит вернуть удалённые строки. Какие строки — повторы и какую оставить, решает клуб. Запиши это решение в комментарии миграции, как в V6. В рабочем проекте такое в одиночку не решают: удаление строк миграцией джун обсуждает с мидлом.


Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. В сущности есть поле, и непонятно, зачем оно. Где искать ответ и как узнать, когда и вместе с чем оно появилось? Зачем --follow?
  2. Ключ V4 (kind, person_id, due_at) не держал повторов задач по брони. Почему? Что не так с ключом без срока (kind, person_id, group_id) — и почему эта ошибка опаснее?
  3. Что значит «та же задача» по ключу V6? Зачем в нём kind и until_at?
  4. Почему V6 — новая миграция, а не правка V4? Что случилось бы после правки и почему в тестах этого не видно?
  5. Почему новый ключ называется так же, как старый, — task_dedup?
  6. Что проверяет Hibernate при ddl-auto: validate и чего не проверяет? Откуда ты узнаешь о task_dedup?
  7. Зачем учебная вторая миграция сначала ставит умолчание, а потом снимает его? Что было бы без DROP DEFAULT?
  8. Новый ключ прошёл тесты и упал на базе с данными. Почему? Что должна сделать миграция до ADD CONSTRAINT и кто решает, какие строки оставить?

Что дальше#

Это последняя статья серии «От системы к делу», серия закончена. Теперь ты умеешь открыть чужую работающую систему и прочитать в ней дело:

Дальше — чужие проекты. Открой любой открытый проект на Spring и прочитай его папку миграций от первого файла до последнего. У каждого файла спроси, что в нём решили и что передумали.

Первоисточники#


Пример целиком#

Учебный пример, в учебной CRM его нет. Песочница game-day после статьи 17 — журнал в example.gameday.journal со всем, что накопили статьи 1–10, сторожа статьи 11, модель статей 12–17 в example.crm и её тесты, Postgres в контейнере, охрана, Actuator и стенд — остаётся как есть. Java в этой статье нет, в pom.xml ничего не добавляй.

Что добавить: папку open-day в корне песочницы, рядом с src, и в ней пять файлов ниже. Это не миграции журнала, и в src/main/resources/db/migration их не клади: номера V1 и V2 там уже заняты, а двух миграций с одним номером Flyway не примет — песочница перестанет стартовать (статья 3а). Сам Flyway папку open-day не видит: он ищет миграции в db/migration. Сторож статьи 11 ищет их там же.

Запускай файлы руками, в отдельном контейнере Postgres, а не в базе журнала: учебные таблицы и повторы в game-day-db ни к чему. Нужен запущенный Docker. Порядок — как у Flyway: по номерам, каждый файл один раз. Между первой и второй миграцией идёт data.sql — жизнь таблицы: повторы двух ночей, которые чинит третья миграция. Путь к файлу — комментарием в первой строке.

-- open-day/V1__open_day_invite.sql
-- Приглашения на день открытых дверей: кого позвали, на какой день, куда написали.
-- Учебный пример статьи 18 — в учебной CRM клуба такой таблицы нет.
CREATE TABLE example.open_day_invite (
    id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    lead_id      UUID         NOT NULL,
    open_day_at  TIMESTAMPTZ  NOT NULL,
    email        TEXT,
    messenger    TEXT,
    sent_at      TIMESTAMPTZ  NOT NULL,

    -- Пишем в один канал: почта или мессенджер, ровно один из двух.
    CONSTRAINT open_day_invite_one_channel CHECK (
        (email IS NOT NULL AND messenger IS NULL) OR
        (email IS NULL AND messenger IS NOT NULL)),

    -- Не звать человека дважды за один обход.
    CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, sent_at)
);
-- open-day/data.sql
-- Жизнь между V1 и V2: ночной обход звал на один и тот же день две ночи подряд.
INSERT INTO example.open_day_invite (lead_id, open_day_at, email, messenger, sent_at) VALUES
    ('00000000-0000-4000-8000-000000000001', '2026-10-17T15:00:00Z',
     'lead-1@example.org', NULL, '2026-10-09T23:00:00Z'),
    ('00000000-0000-4000-8000-000000000001', '2026-10-17T15:00:00Z',
     'lead-1@example.org', NULL, '2026-10-10T23:00:00Z'),
    ('00000000-0000-4000-8000-000000000002', '2026-10-17T15:00:00Z',
     NULL, '@lead_2', '2026-10-10T23:00:00Z');
-- open-day/V2__open_day_invite_group.sql
-- Дни открытых дверей теперь у каждой группы свои: приглашение — на день группы.
-- Прежние приглашения звали на общий день, поэтому старым строкам — 'all'.
-- Новым умолчание не нужно: группу называет тот, кто приглашает, — поэтому его снимаем.
ALTER TABLE example.open_day_invite
    ADD COLUMN group_code VARCHAR(64) NOT NULL DEFAULT 'all';

ALTER TABLE example.open_day_invite
    ALTER COLUMN group_code DROP DEFAULT;
-- open-day/V3__open_day_invite_dedup_by_day.sql
-- Ключ повтора: «этот человек, эта группа, этот день открытых дверей».
--
-- V1 держала UNIQUE (lead_id, sent_at). sent_at — когда отправили, а обход
-- отправляет каждую ночь заново: ключ пропускал повтор каждую ночь.
-- То же приглашение — тот же человек на тот же день той же группы,
-- когда бы мы ему ни писали.
--
-- Повторы уже лежат в таблице, и с ними новый ключ не встанет. Оставляем
-- первое приглашение — его человек получил раньше, — остальные удаляем.
--
-- V1 уже применена и не правится, поэтому — новая миграция.

DELETE FROM example.open_day_invite AS later
USING example.open_day_invite AS earlier
WHERE later.lead_id     = earlier.lead_id
  AND later.group_code  = earlier.group_code
  AND later.open_day_at = earlier.open_day_at
  AND later.sent_at     > earlier.sent_at;

ALTER TABLE example.open_day_invite DROP CONSTRAINT open_day_invite_dedup;

ALTER TABLE example.open_day_invite
    ADD CONSTRAINT open_day_invite_dedup UNIQUE (lead_id, group_code, open_day_at);
-- open-day/check.sql
-- Что держит схема после V3. Каждая вставка — свой случай; ошибки ожидаемы.
SELECT lead_id, group_code, open_day_at, sent_at FROM example.open_day_invite ORDER BY sent_at;

-- 1. Третья ночь, тот же день, та же группа — повтор.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000001', 'all', '2026-10-17T15:00:00Z',
        'lead-1@example.org', '2026-10-11T23:00:00Z');

-- 2. Тот же человек, тот же час, но день другой группы — другое приглашение.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000001', 'morning-yoga', '2026-10-17T15:00:00Z',
        'lead-1@example.org', '2026-10-11T23:00:00Z');

-- 3. Группа не названа — умолчания больше нет.
INSERT INTO example.open_day_invite (lead_id, open_day_at, email, sent_at)
VALUES ('00000000-0000-4000-8000-000000000002', '2026-10-24T15:00:00Z',
        'lead-2@example.org', '2026-10-11T23:00:00Z');

-- 4. Оба канала сразу.
INSERT INTO example.open_day_invite (lead_id, group_code, open_day_at, email, messenger, sent_at)
VALUES ('00000000-0000-4000-8000-000000000002', 'morning-yoga', '2026-10-24T15:00:00Z',
        'lead-2@example.org', '@lead_2', '2026-10-11T23:00:00Z');

Запуск. Контейнер — как Postgres на минуту из статьи 3а, только с другим именем и без -p: порт наружу не открываем, psql работает внутри контейнера, а порт 5432 у тебя может занимать game-day-db. Пароль явно учебный:

docker run -d --rm --name open-day-db -e POSTGRES_PASSWORD=open-day-local postgres:17-alpine

Готова ли база — спроси pg_isready, как в статье 6; -h 127.0.0.1 — по той же причине: при первом запуске образ ненадолго поднимает базу без сети. Через несколько секунд ответ кончится на accepting connections:

docker exec open-day-db pg_isready -h 127.0.0.1 -U postgres

Завести схему — psql от имени пользователя postgres (-U), одной командой (-c):

docker exec open-day-db psql -U postgres -c "CREATE SCHEMA example"

Дальше — из папки open-day, файлы по порядку: V1__open_day_invite.sql, data.sql, V2__open_day_invite_group.sql, V3__open_day_invite_dedup_by_day.sql. Каждый — такой командой, подставляя имя:

docker exec -i open-day-db psql -U postgres -v ON_ERROR_STOP=1 -1 -f - < V1__open_day_invite.sql

< файл подаёт файл на вход команды, -i передаёт этот вход в контейнер, -f - велит psql читать SQL с него. -v ON_ERROR_STOP=1 — остановиться на первой ошибке, -1 — весь файл одной транзакцией, как у Flyway.

Проверки — без ON_ERROR_STOP, чтобы увидеть все случаи:

docker exec -i open-day-db psql -U postgres -f - < check.sql

Что должно получиться:

  1. V1 — CREATE TABLE; data.sql — INSERT 0 3; V2 — два раза ALTER TABLE; V3 — DELETE 1 и два ALTER TABLE.
  2. check.sql: две строки, обе с группой all; случай 1 — duplicate key value violates unique constraint "open_day_invite_dedup"; случай 2 — INSERT 0 1; случай 3 — null value in column "group_code"; случай 4 — violates check constraint "open_day_invite_one_channel".
  3. Опыт с ошибкой: docker stop open-day-db, подними контейнер заново и пройди тот же путь, удалив из V3 весь DELETE … ;. V3 упадёт с could not create unique index, а docker exec open-day-db psql -U postgres -c "\d example.open_day_invite" покажет старый ключ (lead_id, sent_at): транзакция откатилась целиком, вместе с DROP CONSTRAINT.
  4. Опыт с умолчанием: снова пересоздай контейнер и пройди путь, убрав из V2 второй ALTER TABLE — тот, что с DROP DEFAULT. В check.sql случай 3 пройдёт — INSERT 0 1, и приглашение тихо уйдёт на «общий» день all.
  5. docker stop open-day-db — контейнер остановится и благодаря --rm удалится вместе с базой.
  6. ./mvnw verify в песочнице — зелёный, как после статьи 17: тесты статей 12–17 — TodayListTest, FunnelNumbersTest, CheckoutTest, RublesTest, ReminderPointTest, PaidSweepTest и LeadCardTest — на месте, у сторожа статьи 11 по-прежнему 43 запуска, миграций журнала по-прежнему две: папку open-day не видят ни Flyway, ни сторож.

← Все статьи

Попробовать руками

В кузницах Hammerhall — задачи по Java, которые проверяет сервер: решаешь в своей IDE, отправляешь одной командой, проверка запускает твои тесты и закрытые. Сложность растёт вместе с решённым, первые задачи бесплатны.