diff --git a/conventions/arch/db-identifiers.md b/conventions/arch/db-identifiers.md index 0d3f2a5..e435514 100644 --- a/conventions/arch/db-identifiers.md +++ b/conventions/arch/db-identifiers.md @@ -8,42 +8,35 @@ Схема базы меняется тяжело: таблица не переезжает от того, что её потрогали. Поэтому правила распространяются на **новые таблицы**; существующие живут как есть и перечисляются в отступлениях, причём этот -список постоянный, а не список задач на дочистку. +список постоянный, а не список задач на дочистку. Целочисленные ключи +существующих приложений — именно такой случай: они не мигрируют, и правила +их работы описаны в конвенции схемы, а не здесь. ## Правила -### R1. Вид первичного ключа выбирается один раз на репозиторий +### R1. Первичный ключ новой сущности — ULID -**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех -своих таблиц: +**ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор, +который порождает приложение, — во **всех** таблицах, включая те, что +снаружи не адресуются. -> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** — -> по идентификатору из URL, запроса API или callback-данных? +**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту +сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности +имеют привычку становиться внешними — и тогда целочисленный идентификатор +утекает в URL задним числом, а миграция ключа на живых данных стоит +несопоставимо дороже, чем взять строковый сразу. Заранее отличить те, с +кем это случится, не получается: если бы получалось, они бы уже назывались +внешними. -| № | Ответ | Вид ключа | -|---|---|---| -| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние | -| R1.2 | ни одной | автоинкремент | +Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от +спора при заведении каждой таблицы и делает идентификатор **глобальным** — +уникальным across таблиц, а не только внутри своей. На этом держится +корреляция по логам (R7). -**Почему.** Квантор репозиторный, а не потабличный, по двум причинам. -Внутренние сущности имеют привычку становиться внешними — и тогда -целочисленный идентификатор утекает в URL задним числом, а миграция ключа -на живых данных стоит несопоставимо дороже, чем взять строковый сразу. -Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет -от спора при заведении каждой таблицы. +Правило про **сгенерированные суррогатные** ключи. Естественные и составные +ключи у таблиц-деталей (R6) — третья категория, они допустимы всегда. -Критерий — именно **адресация**: снаружи по этому идентификатору -возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает -любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима. - -Запрет смешивания касается двух видов **сгенерированных суррогатных** -ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья -категория, они допустимы при любом ответе. - - - - -### R2. При выборе R1.1 идентификатор генерирует приложение, а не база +### R2. Идентификатор генерирует приложение, а не база **ДОЛЖЕН.** Значение ключа известно до вставки строки. @@ -93,6 +86,14 @@ URL — это чужая или протухшая ссылка, и «не на экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики единственный момент, когда он заметен. +Таблица перечисляет **внешние** источники — те, откуда значение приходит +вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из +конфигурации, из собственной базы или из фикстуры сюда не относится: он +ничего не отдаёт наружу, а его невалидность означает, что сломано у нас. +Формат идентификатора в конфигурации проверяется на старте +(`arch/config.md` R18), невалидное значение в собственной базе — нарушенный +инвариант единой точки (R3). + ### R6. У таблиц-деталей допустим естественный или составной ключ **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный @@ -117,7 +118,7 @@ URL — это чужая или протухшая ссылка, и «не на ## Почему ULID, а не UUID -Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не +R1 требует **сортируемый** строковый идентификатор. UUIDv4 не сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него остаются два довода: 36 символов против 26 и дефисы, из-за которых идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово. diff --git a/conventions/lang/go/db-identifiers.md b/conventions/lang/go/db-identifiers.md index ff38aed..6c40c36 100644 --- a/conventions/lang/go/db-identifiers.md +++ b/conventions/lang/go/db-identifiers.md @@ -4,8 +4,8 @@ extends: arch/db-identifiers.md # Идентификаторы: реализация на Go -Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID -(ветка `arch/db-identifiers.md` R1.1). Форма записи — `LANGUAGE.md`. +Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи — +`LANGUAGE.md`. Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает diff --git a/conventions/lang/go/db-schema.md b/conventions/lang/go/db-schema.md index 3a98c0f..0eb4460 100644 --- a/conventions/lang/go/db-schema.md +++ b/conventions/lang/go/db-schema.md @@ -152,32 +152,33 @@ down останавливает сразу и заставляет пересо типа. Такой дефект не падает, не виден в логе и переживает тесты, которые проверяют, что список не пуст. -### R11. Вид первичного ключа задаёт `arch/db-identifiers.md` +### R11. Первичный ключ новой таблицы — TEXT ULID -**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид -ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется. +**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из +приложения (`arch/db-identifiers.md` R1, R2). -**Почему.** Вопрос о виде ключа решается один раз на репозиторий -(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы -второй источник правды, и соседние таблицы разъехались бы по разным -ответам на один и тот же вопрос. +**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, +принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие +значило бы завести второй источник правды, и соседние таблицы разъехались бы +по разным ответам на один вопрос. -`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не -касается вовсе, а целочисленному даёт единственную гарантию — что значение -rowid не будет переиспользовано после удаления строки, — ценой служебной -таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта -имеет смысл, только если старые идентификаторы живут где-то вне базы. +`AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на +`INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего. -### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент +### R12. Целочисленный ключ идёт вместе с `AUTOINCREMENT` -**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`, -берёт целочисленный автоинкрементный ключ. +**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая +схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. -**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование -подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня -репозитория, и конвенция про типы колонок его за репозиторий не принимает; -приложению, сущности которого не адресуют снаружи, целочисленный ключ -ничего не стоит. +**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после +удаления последней строки номер переиспользуется. Протухшая ссылка на +удалённую запись — из закладки, из чужой таблицы, из старого лога — молча +наводится на другую сущность и возвращает правдоподобный, но чужой ответ. +Обнаружить это по данным нельзя: обе строки валидны. + +Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой +вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает: +там ключ строковый (R11).