Раздел 27 · Gleam на практике

SQLite-репозиторий через sqlight: реальный adapter под тот же порт

senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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 обязан закрыть:

  1. Потеря типов при записи. Reservation это ADT, в таблице строка. Кодируем вариант явно: тег состояния в одну колонку, поля в JSON-payload.
  2. Мусор при чтении. В базе мог оказаться битый 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-библиотека.