SQLite-репозиторий через sqlight: реальный adapter под тот же порт
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
SQLite-репозиторий через sqlight: реальный adapter под тот же порт
Девятая ката. Кульминация первого блока. У нас есть порт
ReservationRepo(22), in-memory adapter под ним, HTTP поверх (24). Сегодня пишем настоящий adapter на SQLite и убеждаемся: домен, хендлеры и тесты домена не меняются ни на строчку.
Сцена · обещание порта, которое надо сдержать
В уроке 22 мы написали порт и сказали: “завтра подложим SQLite, домен не заметит”. Завтра наступило. Сегодня мы это докажем, и доказательство будет жёстким: тот же contract_test, который гонял in-memory adapter, прогоним на SQLite без единой правки.
Это и есть смысл Hexagonal-архитектуры в действии. Adapter меняется, порт держит контракт, всё, что зависит от порта, остаётся на месте. Если новый adapter ломает поведение, контракт-тест краснеет.
Новая сложность по сравнению с in-memory: SQLite хранит строки, а у нас Reservation это ADT с пятью вариантами. Значит, нужен data mapper: encode разбирает вариант ADT в JSON, decode читает строку и собирает Reservation назад, проверяя каждое поле через smart constructors.
Карта урока · что заберёшь через 3 часа
- Подключишь
sqlight, заведёшь схему и идемпотентную миграцию. - Напишешь encode/decode между
Reservationи строкой таблицы, маппя любой сбой вStorageFailure. - Соберёшь
make(conn) -> ReservationRepo: тот же тип записи, что у in-memory, другая начинка. - Прогонишь общий контракт-тест на SQLite в режиме
:memory:и увидишь зелёное.
Концепт · adapter как мост между двумя мирами
Adapter живёт на границе. С одной стороны от него чистый домен с типами Reservation, ReservationId, branded-полями. С другой стороны SQLite с текстом и числами. Adapter переводит туда и обратно, и берёт на себя всю грязь перевода.
Две опасности на границе, которые adapter обязан закрыть:
- Потеря типов при записи.
Reservationэто ADT, в таблице строка. Кодируем вариант явно: тег состояния в одну колонку, поля в JSON-payload. - Мусор при чтении. В базе мог оказаться битый JSON, невалидный id, неизвестный тег (миграция, ручная правка, баг прошлой версии). Decode не доверяет базе: каждое поле прогоняется через тот же smart constructor, что и на входе из HTTP. Любой сбой это
StorageFailure, а не паника.
Эта недоверчивость к собственной базе и отличает доменный adapter от наивного “прочитал строку, привёл типы”.
Языковые механики Gleam · sqlight
sqlight даёт три операции, которых нам хватит:
sqlight.open(path)открывает соединение.pathэто файл, либо:memory:для временной базы в памяти (живёт до закрытия соединения).sqlight.exec(sql, on: conn)выполняет команду без возврата строк (DDL, наш upsert).sqlight.query(sql, on:, with:, expecting:)выполняет запрос с параметрами-плейсхолдерами и декодером строки.
Декодер строки это та же decode-цепочка, что в HTTP, но по индексам колонок:
let row_decoder = {
use tag <- decode.field(0, decode.string)
use payload <- decode.field(1, decode.string)
decode.success(#(tag, payload))
}
Задача · сигнатуры
Собери adapters/sqlite_repo.gleam:
pub fn migrate(conn: sqlight.Connection) -> Result(Nil, sqlight.Error)
pub fn make(conn: sqlight.Connection) -> ReservationRepo
Схема, одна таблица:
create table if not exists reservations (
stream_id text primary key,
state_tag text not null,
payload text not null
);
Контракт:
migrateидемпотентна (IF NOT EXISTS), её зовут перед первымmake.makeвозвращает тот же типReservationRepo, что in-memory adapter: запись сloadиsave, замкнутыми наconn.saveидемпотентен (upsert): повторный save того же id обновляет строку.NotPlacedсохранять нельзя, этоStorageFailure.loadнесуществующего этоNotFound. Битый payload этоStorageFailure, не паника.
Подсказки
- Имя колонки
stream_id, а неid, намеренно. В R3 (event sourcing) тут будет id потока событий. Сейчас просто id брони. Это задел. - Для
saveхватитINSERT OR REPLACE(upsert одной командой). Тег состояния (reservation.tag_of) вstate_tag, остальные поля в JSON-payload. - Decode веди по тегу:
ReservedиCheckedInнесут полный набор полей,CheckedOutиCancelledтолько id,NotPlacedв базе быть не должно. - Не доверяй прочитанному id: прогони
raw.idчерезreservation.reservation_id(...). Если база отдала мусор, этоStorageFailure. reservation.id_of(state)вернётError(Nil)дляNotPlaced, это твой guard перед сохранением.
Разбор · фабрика make
// examples/ddd-hotel/gleam/src/adapters/sqlite_repo.gleam (фрагмент)
pub fn make(conn: sqlight.Connection) -> ReservationRepo {
ReservationRepo(
load: fn(id) {
let id_text = reservation.reservation_id_to_string(id)
let row_decoder = {
use tag <- decode.field(0, decode.string)
use payload <- decode.field(1, decode.string)
decode.success(#(tag, payload))
}
case
sqlight.query(
"select state_tag, payload from reservations where stream_id = ?",
on: conn,
with: [sqlight.text(id_text)],
expecting: row_decoder,
)
{
Ok([#(tag, payload), ..]) ->
case decode_payload(tag, payload) {
Ok(state) -> Ok(state)
Error(reason) -> Error(StorageFailure(reason))
}
Ok([]) -> Error(NotFound(reservation_id: id_text))
Error(error) -> Error(StorageFailure(reason: sqlight_error_to_string(error)))
}
},
save: fn(state) {
case reservation.id_of(state) {
Error(Nil) -> Error(StorageFailure(reason: "cannot save NotPlaced reservation"))
Ok(id) -> {
let id_text = reservation.reservation_id_to_string(id)
let tag = reservation.tag_of(state)
let payload = encode_payload(state)
case sqlight.exec(upsert_sql(id_text, tag, payload), on: conn) {
Ok(Nil) -> Ok(Nil)
Error(error) -> Error(StorageFailure(reason: sqlight_error_to_string(error)))
}
}
}
},
)
}
Сравни форму с in-memory adapter из урока 22. Тип возврата идентичен (ReservationRepo). load и save принимают и возвращают ровно то же. Внутри другое: вместо Dict запросы к SQLite. Это и есть сменный adapter под неизменным портом.
Разбор · недоверчивый decode
Чтение из базы не верит базе на слово. Берём активные состояния:
fn build_active(tag: String, raw: RawActive) -> Result(Reservation, String) {
case reservation.reservation_id(raw.id) {
Error(_) -> Error("invalid reservation id")
Ok(id) ->
case customer.customer_id(raw.guest) {
Error(_) -> Error("invalid customer id")
Ok(guest) ->
case reservation.room_number(raw.room) {
Error(_) -> Error("invalid room number")
Ok(room) ->
case reservation.date_range(raw.check_in, raw.check_out) {
Error(_) -> Error("invalid date range")
Ok(range) ->
case tag {
"Reserved" -> Ok(Reserved(id, guest, room, range))
"CheckedIn" -> Ok(CheckedIn(id, guest, room, range))
_ -> Error("unexpected active tag: " <> tag)
}
}
}
}
}
}
Каждое поле проходит тот же smart constructor, что и при создании брони через HTTP. База не может протащить невалидный room number мимо домена. Если протащила (баг старой версии, ручной UPDATE), это StorageFailure, и контроллер ответит 500, а не упадёт процесс.
Про эскейп в upsert
В upsert_sql мы собираем SQL строкой и эскейпим кавычки руками (string.replace(value, "'", "''")). Это намеренный компромисс урока, и в коде он прокомментирован. Все три значения, что туда едут, ограничены: stream_id валидируется паттерном branded-id, state_tag это один из четырёх литералов, payload это JSON (он эскейпит кавычки сам). Инъекции неоткуда взяться.
Но это всё равно второй сорт. Правильно параметризовать через плейсхолдеры, как в load. Почему мы тут не сделали так сразу: sqlight.exec плейсхолдеры не принимает, нужен sqlight.query с пустым декодером. Это ровно ДЗ урока, и его стоит сделать: ручной эскейп в проде это технический долг с первого дня.
Награда · тот же контракт-тест на SQLite
В уроке 22 мы написали contract_test(repo) и пообещали прогнать его на SQLite в уроке 25. Вот обещанная функция, добавленная в тот же файл:
// examples/ddd-hotel/gleam/test/repo_contract_test.gleam
pub fn sqlite_repo_contract_test() {
let assert Ok(conn) = sqlight.open(":memory:")
let assert Ok(Nil) = sqlite_repo.migrate(conn)
let repo = sqlite_repo.make(conn)
contract_test(repo)
let assert Ok(Nil) = sqlight.close(conn)
}
Тело contract_test не тронуто. Save-load round-trip, NotFound на несуществующего, upsert при повторном save, всё гоняется на реальном SQLite и проходит. Это самый сильный аргумент за порт/adapter, какой можно показать: один тест, два хранилища, ноль правок.
Критика · что не покрыто
- Ручной эскейп в upsert. Технический долг, лечится параметризацией (ДЗ).
- Миграции одним IF NOT EXISTS. Нет версий схемы, нет отката. Для второй колонки нужен нормальный migration runner (ДЗ).
- Нет запросов кроме load по id.
list_active,find_by_roomэтоSELECT WHERE, добавляются по мере UI (ДЗ). - Connection один на adapter. Для конкурентной нагрузки нужен пул. SQLite это терпит хуже Postgres, и в R3 при серьёзной нагрузке мы посмотрим на Postgres.
- Snapshot-стиль, не event sourcing. Мы храним текущее состояние брони, перезаписывая его. История переходов теряется. Это сознательно: ES это R3, тут snapshot-репозиторий.
Takeaway
Одна фраза:
Сменить хранилище значит написать новый adapter под тем же портом. Домен, хендлеры и контракт-тест не меняются, и зелёный контракт-тест на новом adapter это доказательство.
ДЗ
Дальше
Следующая ката · 26. Конфиг и build_repo: финальная сборка. Закрываем первый блок: envoy читает env, фабрика build_repo(config) выбирает adapter по флагу, composition root склеивает всё в работающее приложение. Десять кат пройдены.
Параллельно полезно перечитать:
- 22. Repository как порт, там лежит контракт-тест, который сегодня впервые прогнался на двух adapter.
- 08. Erlang FFI, про границу с миром эффектов. SQLite это ровно такая граница: за ней не чистый Gleam, а C-библиотека.