Files
dev-conventions/lang/go/db-schema.md
T
av 31d0620f55 остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
2026-07-25 19:17:32 +03:00

14 KiB

Схема и миграции (SQLite, Go)

Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Go-приложении. Форма записи — common/language.md.

Область действия

Схема меняется тяжело: таблица не переезжает от того, что её потрогали. Правила распространяются на новые миграции; существующая схема не переписывается, и проверяется граница изменения — то, что миграция добавляет, а не то, что уже лежит в базе.

Миграции

R1. Миграции ведёт goose

ДОЛЖЕН. Набор миграций репозитория применяется одним инструментом — goose.

Почему. Журнал применённых версий goose держит в самой базе (goose_db_version) и по нему решает, что ещё не накатывалось. Второй инструмент заводит второй журнал: миграция, применённая одним, для другого выглядит неприменённой, и попытка накатить её повторно упирается в уже существующую таблицу. На сервере это означает ручной разбор состояния схемы вместо автоматического деплоя.

R2. Файлы миграций лежат рядом со store-слоем

СЛЕДУЕТ. Миграции хранятся рядом с кодом, который работает с этой схемой.

Почему. Миграция и код, читающий схему, — одно изменение: колонка появляется вместе с полем структуры и запросом. Лежащие в другом конце дерева миграции выпадают из поля зрения при правке store, и уезжает либо код без миграции, либо миграция без кода; расходятся они на сервере, где схема ещё старая.

R3. Форма миграции выбирается по тому, нужен ли код

ДОЛЖЕН. Миграция пишется в той форме, которой требует её содержимое:

Что делает миграция Форма
R3.1 DDL: создание таблиц, индексы, изменение структуры SQL-файл
R3.2 требует кода: генерация идентификаторов, backfill, перенос данных между формами Go-миграция (goose.AddMigrationContext)

Почему. DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, который уедет в базу; обёртка на Go вокруг него добавляет место, где можно ошибиться, не добавляя ничего к результату.

Обратное направление дороже. Перенос данных и генерация идентификаторов выражаются на SQL либо громоздко, либо неточно: идентификатор по arch/db-identifiers.md R2 порождает приложение, и SQL-миграция вынуждена завести для него второй генератор — ровно то, что запрещает arch/db-identifiers.md R3. Единообразие формы здесь покупается дублированием логики, которая уже есть в коде.

R4. В деплое схема движется только вперёд

НЕ ДОЛЖЕН. Откат схемы на сервере не выполняется down-миграцией; ошибка исправляется новой миграцией вперёд.

Почему. Down на сервере не возвращает прежнее состояние, а имитирует его: колонка, которую убрал up, восстанавливается пустой, а строки, записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом происходит молча — миграция отчитывается об успехе. Исправление, приехавшее следующей миграцией, оставляет целыми и данные, и журнал применённых версий.

R5. Down пишется, когда он честно обращает up

ДОЛЖЕН. Наличие down-миграции определяется тем, обратим ли up:

Что делает up Down
R5.1 добавляет структуру: таблицу, колонку, индекс пишется, убирает добавленное
R5.2 необратимо преобразует данные не пишется

Почему. Down — инструмент разработки, где ветку переключают туда-сюда, и именно там он обязан действительно обращать up. Имитация опаснее отсутствия: разработчик применяет её, получает схему прежней формы и продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий down останавливает сразу и заставляет пересоздать базу — это дешевле, чем отладка по данным, которых уже нет.

R6. ER-схема обновляется в том же изменении

ДОЛЖЕН. Изменение структуры и правка ER-схемы в спеках едут одним изменением.

Почему. Диаграмму читают вместо DDL — в этом весь её смысл. Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить это можно, только сверив её с миграциями, то есть проделав работу, которую диаграмма экономит. Отложенное обновление не делается: изменение уже влито, и повода вернуться к схеме больше нет.

Типы колонок

Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, а не язык приложения.

R7. Enum-поля — TEXT, допустимые значения держит код

ДОЛЖЕН. Поле-перечисление (state, kind, …) объявляется как TEXT без CHECK-ограничения на список значений.

Почему. ALTER TABLE в SQLite не умеет менять ограничения ни в одной версии. Поэтому каждое новое значение перечисления в CHECK (... IN (...)) превращается из строки в коде в пересоздание таблицы по 12-шаговой процедуре, с копированием данных и восстановлением внешних ключей.

Платить эту цену не за что: невалидное значение отсекается типами Go раньше, чем дойдёт до вставки, и CHECK лишь дублирует защиту, которая всё равно нужна выше. TEXT при этом читается в дампе и в логе без таблицы соответствия, которую пришлось бы держать в голове для числового кода.

R8. Метки времени — TEXT в формате из arch/time.md

ДОЛЖЕН. Колонка с меткой времени объявляется как TEXT, значения пишутся в формате из arch/time.md.

Почему. Типа даты в SQLite нет, поэтому единственное, что делает значения сравнимыми, — договорённость о формате. Текст в формате из arch/time.md сортируется лексикографически в том же порядке, что и хронологически: ORDER BY и диапазонные условия работают без функций преобразования, а значит и без потери индекса. Соседство двух форматов в одной колонке ломает и сравнение, и разбор на стороне Go.

R9. Умолчание DEFAULT (datetime('now')) не ставится

НЕ ДОЛЖЕН. Колонка с меткой времени не получает значение по умолчанию на уровне схемы.

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

Вдобавок datetime('now') даёт YYYY-MM-DD HH:MM:SS — без T и без Z, то есть не тот формат, которого требует R8. В колонке оказываются строки двух видов, и ломается ровно то, ради чего формат выбран.

R10. Булевы поля — INTEGER со значениями 0 и 1

ДОЛЖЕН. Булево значение хранится как INTEGER 0/1.

Почему. Отдельного булева типа в SQLite нет, поэтому от разнобоя колонку удерживает только договорённость о представлении. Цена ошибки здесь несимметрична: строка 'true' в булевом контексте приводится к 0, то есть даёт противоположный ответ, а не пустую выборку и не ошибку типа. Такой дефект не падает, не виден в логе и переживает тесты, которые проверяют, что список не пуст.

R11. Вид первичного ключа задаёт arch/db-identifiers.md

ДОЛЖЕН. В репозитории, подписанном на arch/db-identifiers.md, вид ключа выбирается по её R1, и AUTOINCREMENT в миграции не пишется.

Почему. Вопрос о виде ключа решается один раз на репозиторий (arch/db-identifiers.md R1). Повторив здесь его ветвление, мы завели бы второй источник правды, и соседние таблицы разъехались бы по разным ответам на один и тот же вопрос.

AUTOINCREMENT не нужен ни в одной из веток R1. Строкового ключа он не касается вовсе, а целочисленному даёт единственную гарантию — что значение rowid не будет переиспользовано после удаления строки, — ценой служебной таблицы sqlite_sequence и записи в неё на каждой вставке. Гарантия эта имеет смысл, только если старые идентификаторы живут где-то вне базы.

R12. Вне arch/db-identifiers.md первичный ключ — автоинкремент

ДОПУСКАЕТСЯ. Репозиторий, не подписанный на arch/db-identifiers.md, берёт целочисленный автоинкрементный ключ.

Почему. Явное разрешение нужно, чтобы R11 не читался как требование подписаться на arch/db-identifiers.md. Выбор вида ключа — решение уровня репозитория, и конвенция про типы колонок его за репозиторий не принимает; приложению, сущности которого не адресуют снаружи, целочисленный ключ ничего не стоит.

Связано

  • arch/time.md — формат меток времени.
  • arch/db-identifiers.md — выбор первичных ключей.
  • lang/go/errors.md — граничные ошибки database/sql транслируются в доменные у источника, в слое store.