- тема — набор правил об одном фокусе разработки, имя латиницей (нижний kebab-case рекомендуется, годится любой идентификатор, пригодный для имени файла); определение в LANGUAGE.md и README.md - заведены META-28 и META-29: тема объявляется в шапке (`topic:`), стоит в манифесте набора и не переиспользуется; `topic:` добавлен во все 12 файлов - prefixes.toml и topics.toml слиты в manifest.toml — манифест набора против манифеста подключения `.conventions.toml`, разделы topics/prefixes с live и retired
14 KiB
topic, prefix
| topic | prefix |
|---|---|
| db-schema | MIGR |
Схема и миграции (SQLite, Go)
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Go-приложении.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Область действия
Схема меняется тяжело: таблица не переезжает от того, что её потрогали. Правила распространяются на новые миграции; существующая схема не переписывается, и проверяется граница изменения — то, что миграция добавляет, а не то, что уже лежит в базе.
Миграции
MIGR-1. Миграции ведёт goose
ДОЛЖЕН. Набор миграций репозитория применяется одним инструментом — goose.
ПОЧЕМУ. Журнал применённых версий goose держит в самой базе
(goose_db_version) и по нему решает, что ещё не накатывалось. Второй
инструмент заводит второй журнал: миграция, применённая одним, для другого
выглядит неприменённой, и попытка накатить её повторно упирается в уже
существующую таблицу. На сервере это означает ручной разбор состояния
схемы вместо автоматического деплоя.
MIGR-2. Файлы миграций лежат рядом со store-слоем
СЛЕДУЕТ. Миграции хранятся рядом с кодом, который работает с этой схемой.
ПОЧЕМУ. Миграция и код, читающий схему, — одно изменение: колонка появляется вместе с полем структуры и запросом. Лежащие в другом конце дерева миграции выпадают из поля зрения при правке store, и уезжает либо код без миграции, либо миграция без кода; расходятся они на сервере, где схема ещё старая.
MIGR-3. Форма миграции выбирается по тому, нужен ли код
ДОЛЖЕН. Миграция пишется в той форме, которой требует её содержимое:
| № | Что делает миграция | Форма |
|---|---|---|
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (goose.AddMigrationContext) |
ПОЧЕМУ. DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, который уедет в базу; обёртка на Go вокруг него добавляет место, где можно ошибиться, не добавляя ничего к результату.
Обратное направление дороже. Перенос данных и генерация идентификаторов
выражаются на SQL либо громоздко, либо неточно: идентификатор по
KEYS-2 порождает приложение, и SQL-миграция вынуждена
завести для него второй генератор — ровно то, что запрещает
KEYS-3. Единообразие формы здесь покупается
дублированием логики, которая уже есть в коде.
MIGR-4. В деплое схема движется только вперёд
НЕ ДОЛЖЕН. Откат схемы на сервере не выполняется down-миграцией; ошибка исправляется новой миграцией вперёд.
ПОЧЕМУ. Down на сервере не возвращает прежнее состояние, а имитирует его: колонка, которую убрал up, восстанавливается пустой, а строки, записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом происходит молча — миграция отчитывается об успехе. Исправление, приехавшее следующей миграцией, оставляет целыми и данные, и журнал применённых версий.
MIGR-5. Down пишется, когда он честно обращает up
ДОЛЖЕН. Наличие down-миграции определяется тем, обратим ли up:
| № | Что делает up | Down |
|---|---|---|
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| MIGR-5.2 | необратимо преобразует данные | не пишется |
ПОЧЕМУ. Down — инструмент разработки, где ветку переключают туда-сюда, и именно там он обязан действительно обращать up. Имитация опаснее отсутствия: разработчик применяет её, получает схему прежней формы и продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий down останавливает сразу и заставляет пересоздать базу — это дешевле, чем отладка по данным, которых уже нет.
MIGR-6. ER-схема обновляется в том же изменении
ДОЛЖЕН. Изменение структуры и правка ER-схемы в спеках едут одним изменением.
ПОЧЕМУ. Диаграмму читают вместо DDL — в этом весь её смысл. Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить это можно, только сверив её с миграциями, то есть проделав работу, которую диаграмма экономит. Отложенное обновление не делается: изменение уже влито, и повода вернуться к схеме больше нет.
Типы колонок
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, а не язык приложения.
MIGR-7. Enum-поля — TEXT, допустимые значения держит код
ДОЛЖЕН. Поле-перечисление (state, kind, …) объявляется как TEXT
без CHECK-ограничения на список значений.
ПОЧЕМУ. ALTER TABLE в SQLite не умеет менять ограничения ни в одной
версии. Поэтому каждое новое значение перечисления в CHECK (... IN (...))
превращается из строки в коде в пересоздание таблицы по 12-шаговой
процедуре, с копированием данных и восстановлением внешних ключей.
Платить эту цену не за что: невалидное значение отсекается типами Go
раньше, чем дойдёт до вставки, и CHECK лишь дублирует защиту, которая
всё равно нужна выше. TEXT при этом читается в дампе и в логе без
таблицы соответствия, которую пришлось бы держать в голове для числового
кода.
MIGR-8. Метки времени — TEXT в каноническом формате
ДОЛЖЕН. Колонка с меткой времени объявляется как TEXT, значения
пишутся как RFC 3339 в UTC с суффиксом Z и фиксированной шириной.
ПОЧЕМУ. Типа даты в SQLite нет, поэтому единственное, что делает
значения сравнимыми, — договорённость о формате; сам формат выбран не
здесь, а конвенцией time (TIME-1, TIME-2). Текст в нём сортируется
лексикографически в том же порядке, что и
хронологически: ORDER BY и диапазонные условия работают без функций
преобразования, а значит и без потери индекса. Соседство двух форматов в
одной колонке ломает и сравнение, и разбор на стороне Go.
MIGR-9. Умолчание DEFAULT (datetime('now')) не ставится
НЕ ДОЛЖЕН. Колонка с меткой времени не получает значение по умолчанию на уровне схемы.
ПОЧЕМУ. Время ставит приложение, и умолчание в схеме заводит второй источник этого значения: пропущенное приложением поле не падает, а тихо получает время сервера базы — расхождение обнаруживается по данным, а не по ошибке.
Вдобавок datetime('now') даёт YYYY-MM-DD HH:MM:SS — без T и без Z,
то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки
двух видов, и ломается ровно то, ради чего формат выбран.
MIGR-10. Булевы поля — INTEGER со значениями 0 и 1
ДОЛЖЕН. Булево значение хранится как INTEGER 0/1.
ПОЧЕМУ. Отдельного булева типа в SQLite нет, поэтому от разнобоя
колонку удерживает только договорённость о представлении. Цена ошибки
здесь несимметрична: строка 'true' в булевом контексте приводится к
0, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
проверяют, что список не пуст.
MIGR-11. Первичный ключ новой таблицы — TEXT ULID
ДОЛЖЕН. Колонка ключа объявляется как TEXT, значение приходит из
приложения.
ПОЧЕМУ. Здесь конвенция схемы ничего не решает — она реализует решение,
принятое конвенцией db-identifiers (KEYS-1, KEYS-2). Повторить там
ветвление или условие значило бы завести второй источник правды, и соседние
таблицы разъехались бы по разным ответам на один вопрос.
AUTOINCREMENT в такой таблице невозможен: SQLite разрешает его только на
INTEGER PRIMARY KEY. То есть для новых таблиц запрещать нечего.
MIGR-12. Целочисленный ключ идёт вместе с AUTOINCREMENT
ДОЛЖЕН. Там, где первичный ключ всё-таки целочисленный — существующая
схема, миграция легаси-таблицы, — он объявляется с AUTOINCREMENT.
ПОЧЕМУ. Без него SQLite выдаёт rowid как max(rowid)+1, поэтому после
удаления последней строки номер переиспользуется. Протухшая ссылка на
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
Обнаружить это по данным нельзя: обе строки валидны.
Цена — служебная таблица sqlite_sequence и запись в неё на каждой
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
там ключ строковый (MIGR-11).
Связано
- конвенция
time— формат меток времени. - конвенция
db-identifiers— выбор первичных ключей. - конвенция
errors— граничные ошибкиdatabase/sqlтранслируются в доменные у источника, в слое store.