Перейти к основному содержимому

Работа с базой данных

В Go-сервисах Ensi доступ к PostgreSQL идёт через ORM Goravel (facades.Orm()). Модели и их тестовые фабрики живут в инфраструктурном пакете app/adapters/db, а схема — в database/migrations. Правила именования таблиц и столбцов общие для платформы — см. Database Design Guide.

Где что лежит​

app/adapters/db/
brand.go # модель
brand_factory.go # фабрика для тестов и сидеров
offer.go
offer_factory.go
factory_helpers.go # общие хелперы ID / nullable
database/
migrations/ # схема PostgreSQL
seeders/ # сидер для локальной разработки
bootstrap/migrations.go # список миграций, которые подхватывает приложение

Одна таблица — один файл модели в adapters/db. Модели не раскладывают по app/modules/{module}, потому что модель может использоваться в нескольких модулях.

API DTO из OpenAPI (app/modules/.../dto) и ORM-модели — разные типы. В ответ API модель не отдают напрямую: преобразование делают mappers (или From у DTO, если поля совпадают по смыслу).

Модель​

Типичная модель встраивает orm.Model (поля ID, CreatedAt, UpdatedAt), объявляет столбцы через теги gorm, задаёт имя таблицы и фабрику:

type Brand struct {
orm.Model

BrandID uint64 `gorm:"column:brand_id" json:"brand_id"`
Name string `gorm:"column:name" json:"name"`
Code string `gorm:"column:code" json:"code"`
Description *string `gorm:"column:description" json:"description,omitempty"`
IsMigrated bool `gorm:"column:is_migrated" json:"is_migrated"`
}

func (*Brand) TableName() string { return "brands" }

func (*Brand) Factory() factory.Factory { return &BrandFactory{} }

На что обратить внимание:

  • Для nullable-полей в текущих моделях обычно используют указатели (*string, *int64), чтобы отличить «нет значения» от нуля/пустой строки. Другой nullable-тип требует совместимого ORM mapping.
  • Связи описывают тегами GORM (foreignKey, references). Поле, которое не колонка, помечают gorm:"-".
  • В моделях сервисов явно задают TableName; метод Factory связывает модель с её фабрикой для facades.Orm().Factory(). Это соглашение приложения, а не требование наличия фабрики для любого SQL-запроса.

Допустимы хелперы на модели или рядом с ней: составной preload для индексации, BeforeSave, флаги вроде «не трогать timestamps при сохранении» — если это устойчивое поведение сущности, а не разовая логика одного action.

Типы PostgreSQL и семантика полей​

Перенос Laravel casts начинается со схемы и смысла значения. Совпадение JSON-поля с именем колонки ещё не определяет подходящий Go-тип.

ДанныеЧто проверить
Локальный PK и внешний IDРазные колонки и уникальность; преобразование signed/unsigned без переполнения
NullableSQL NULL отличается от false, 0, пустой строки и JSON null
Деньги и decimalЕдиницы, диапазон, scale и правила округления; не преобразовывать точные значения через float64 без проверки
Date/timeТип колонки, timezone, точность и принадлежность timestamp источнику или локальной модели
EnumЗначения в SQL, OpenAPI и событии; обработка неизвестного значения
JSONBСериализатор, форма объекта/массива, nil, пустые значения и точность чисел

В OMS поле адреса заказа хранится через GORM JSON serializer:

DeliveryAddress map[string]any `gorm:"column:delivery_address;serializer:json;type:jsonb" json:"delivery_address"`

Это поле внутри db.Order, а не отдельная модель. Для собственного поля согласуйте Go-тип и реальный тип колонки в миграции. Проверьте round-trip запись → новое чтение: SQL NULL, JSON null, {}, [], вложенные числа и строки. Произвольная map после JSON-decoding может содержать float64; для больших целых и точных денежных значений предпочтительна подходящая типизированная структура или проверенный decoder.

Нулевое значение Go и default БД не взаимозаменяемы. Установите, отправляет ли ORM значение явно или оставляет колонку default-у. Для PATCH presence определяется до ORM-присваивания — см. изменяющие операции.

Время и timestamps​

orm.Model содержит локальные CreatedAt/UpdatedAt, представленные типами Carbon. Для DTO обычно нужен time.Time либо *time.Time; используйте преобразования сервиса и согласованный UTC. Схема Timestamps(6) ограничивает точность микросекундами: в тесте сравнивайте перечитанное значение с учётом точности и timezone, а не с наносекундами исходного объекта.

Определите, какие даты сохраняются из события, а какие меняет локальная запись. В Catalog Cache Offer.NotMark() вместе с BeforeSave исключает timestamps из сохранения. Это особенность модели: проверяйте реализацию флага и его жизненный цикл, прежде чем повторно сохранять тот же экземпляр. При no-op не вызывайте Save только ради повторной доставки, если контракт требует сохранить прежний timestamp.

Связи и идентификаторы​

В реплике Catalog Cache offers.id — локальный PK, offers.offer_id — ID Offers, а offers.product_id ссылается на внешний products.product_id. Соответствующая связь объявлена явно:

Product *Product `gorm:"foreignKey:ProductID;references:ProductID" json:"product,omitempty"`

У CMS Nameplate.Products использует NameplateID → Nameplate.ID, то есть локальный PK. Выбирать references:ID по умолчанию для любой интеграционной модели нельзя.

Для отношения согласуйте:

  1. реальные колонки, типы, уникальность и FK в схеме;
  2. foreignKey/references в модели;
  3. имя ORM preload, например With("Product.Categories");
  4. публичный include alias и mapper API;
  5. фабричные данные с нужным ключом родителя.

GORM-тег связи не создаёт FK сам по себе в схеме, управляемой миграциями. Наличие preload не означает наличие отношения: nullable-связь нужно корректно отобразить в DTO. Нужный граф загрузки определяют до mapper-а, чтобы обход коллекции не создавал N+1-запросы. Контроль includes и relationship-фильтров описан в SQL-поиске.

Чтение и запись через ORM​

Точка входа — facades.Orm(). Частые операции:

// найти одну запись
var brand db.Brand
err := facades.Orm().WithContext(ctx).Query().
Where("brand_id", brandID).
First(&brand)

// создать / обновить
err := tx.Create(&brand)
err := tx.Save(&brand)

// подгрузить связи (имена — как поля struct)
query = query.With("Product.Categories")

Для search-эндпоинтов поверх query навешивают общий слой app/common/search и контракт из app/modules/{module}/queries — фильтры/sort/include не размазывают по хэндлеру. Пошаговая реализация описана в руководстве по SQL-поиску, а запись и транзакции сценария — в изменяющих операциях.

Транзакции​

Связанные SQL-записи объединяют в одну транзакцию. Внешние эффекты имеют отдельную границу отказа:

return facades.Orm().WithContext(ctx).Transaction(func(tx orm.Query) error {
// использовать tx, а не facades.Orm().Query()
return nil
})

Внутри колбэка все запросы должны идти через переданный tx, иначе часть работы окажется вне транзакции.

Вложенное действие принимает tx явно либо получает его через предусмотренный helper. В Catalog Cache MarkOffersToIndexingAction.WithQuery(tx) возвращает копию action, которая выполняет маркировку через ту же транзакцию. Не заменяйте её новым default query при вызове из Kafka sync.

Если callback вернул ошибку, SQL откатывается; уже отправленные Kafka-сообщения, HTTP-запросы и изменения storage сохраняются. Публикация до commit может завершиться успешно перед отказом SQL commit. Публикация после commit может завершиться ошибкой при уже сохранённых данных. Существующий outbox используют только вместе с его relay и правилами повтора; он не появляется автоматически при вызове Transaction. Подробнее — Kafka.

Транзакция сама по себе не исключает гонку read → check → write. Уникальность защищают constraint-ом, переход состояния — подходящей блокировкой (LockForUpdate() внутри транзакции) либо условным update с проверкой результата. Выбирайте защиту по инварианту и проверяйте конкуренцию, если она относится к задаче. Блокировка отсутствующей строки не заменяет unique constraint для новой записи.

Поиск «есть / нет»​

У Goravel важно различать «запись не найдена» и прочие ошибки. Для lookup по внешнему id в sync-сценариях используют FirstOrFail и проверку OrmRecordNotFound, либо общие хелперы сервиса вроде FindModel / SaveModel (create при ID == 0, иначе save). Обычный First на отсутствующей строке может вернуть «пустую» структуру без ошибки — это легко принять за существующую запись.

Метод установленного ORMРезультат при отсутствии строки
First(&model)Может вернуть nil; передавайте новый destination и отдельно учитывайте отсутствие
FirstOrFail(&model)OrmRecordNotFound
FindOrFail(&model, id)OrmRecordNotFound, lookup по PK при таком аргументе
Get(&items)Пустая коллекция без ошибки сама по себе допустима

Для интеграционного lookup используйте внешний ключ явно, а не передавайте его как локальный PK:

var brand db.Brand
err := query.Where("brand_id", externalID).FirstOrFail(&brand)
if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return &brand, true, nil

Это тело helper-а, возвращающего (*db.Brand, bool, error); errors — стандартный пакет, frameworkerrors — github.com/goravel/framework/errors. В Catalog Cache/OMS эту задачу уже решает common/actions.FindModel. Только not found превращается в found=false: сбой подключения или SQL не означает отсутствие сущности.

Zero values и массовые изменения​

Способ записи влияет на false, 0 и NULL. Для ограниченного набора колонок можно использовать Update(map[string]any{...}), сверив поведение установленного ORM. Пример фрагмента action для CMS Page:

result, err := query.Model(&db.Page{}).Where("id", pageID).Update(map[string]any{
"is_active": false,
"active_from": nil,
})
if err != nil {
return err
}

query здесь — query нужного контекста/транзакции. Ключи map — серверные имена колонок; карту HTTP-полей нельзя передавать в ORM как mass assignment. result.RowsAffected проверяют по смыслу операции: ноль строк может означать missing record или проигранный конкурентный update. Подтверждайте запись новым чтением модели из БД, включая очистку ранее заполненного поля.

Hooks, scopes и удаление​

Laravel observer, mutator и global scope не переносятся вместе с названием модели. Найдите соответствующее поведение в action, Goravel observer, GORM hook или query. BeforeSave(*gorm.DB) в Catalog Cache и события framework ORM — разные механизмы; проверьте, какой из них реально вызывается выбранной операцией.

Массовые Update/Delete, SaveQuietly и SQL cascade могут отличаться от поштучной записи по hooks и событиям. Не считайте, что FK cascade автоматически опубликует Kafka delete для каждой дочерней строки или удалит её файл. Выбирайте явный сценарий эффектов и тестируйте его.

orm.Model сам по себе не включает soft delete. Для сущности, у которой оно требуется, отдельно перенесите колонку, модельное поведение и правила чтения/удаления. Raw SQL и relationship helpers могут обходить ORM scopes; проверьте ограничение владельца, active и deleted для каждого пути чтения.

Фабрики моделей​

Фабрика живёт рядом с моделью: offer_factory.go. Она нужна для component-тестов и сидеров, чтобы быстро получить валидную строку в БД без ручного заполнения всех полей.

type OfferFactory struct {
state map[string]any
}

func (f *OfferFactory) Definition() map[string]any {
definition := map[string]any{
"OfferID": nextFactoryID(),
"ProductID": randomFactoryID(),
"BasePrice": nullable(randomPrice()),
"Price": nullable(uint64(randomPrice())),
"IsActive": true,
"IsMigrated": true,
}
maps.Copy(definition, f.state)
return definition
}

Ключи в Definition — имена полей Go-структуры (OfferID), не имена колонок SQL.

Для фикстур в SQL-варианте ComponentSuite используйте CreateQuietly:

var defaultOffer db.Offer
s.CreateQuietly(&defaultOffer)

var offer db.Offer
s.CreateQuietly(&offer, map[string]any{
"ProductID": product.ProductID,
"IsActive": false,
})

CreateQuietly вызывает facades.Orm().Factory().CreateQuietly(...): подставляется Definition, применяются переданные атрибуты, строка сохраняется без model events и observers. Ранее записанные Kafka-сообщения сохраняются. Переопределяйте только то, что важно для сценария.

Если создание с observers — часть проверяемого поведения, используйте обычный s.Create(...): он вызывает Factory().Create(...) с событиями. Проверьте их через AssertKafkaMessages и assertions содержимого; не очищайте fake после каждого создания. Порядок фаз и teardown описан в автотестах.

Общие хелперы (nextFactoryID, nullable, уникальные строки) держат в factory_helpers.go, чтобы ID не конфликтовали между фабриками в одном прогоне тестов.

Это не те же фабрики, что в app/modules/.../factories и в *-client-go: те собирают тела HTTP-запросов и stub-ответы апстрима. Фабрики в adapters/db работают только с ORM-моделями. Подробнее про использование в тестах — автотесты.

Миграции​

Схема описывается миграциями Goravel в database/migrations/. Новые миграции регистрируют в bootstrap/migrations.go, иначе приложение их не увидит.

В этих сервисах список миграций задаётся явно при сборке: добавьте экземпляр миграции в bootstrap/migrations.go. Наличие .go-файла в каталоге само по себе не регистрирует миграцию.

Пример создания таблицы в миграции:

return facades.Schema().Create("brands", func(table schema.Blueprint) {
table.ID()
table.UnsignedBigInteger("brand_id")
table.Unique("brand_id")
table.String("name")
table.String("code")
table.Text("description").Nullable()
table.Boolean("is_migrated").Default(true)
table.Timestamps(6)
})

Практические ожидания:

  • имена таблиц и столбцов — snake_case, таблицы во множественном числе (как в Database Design Guide);
  • Timestamps(6) — timestamps с микросекундами (удобно для точных сравнений в тестах).

Миграции применяют командой artisan migrate, а если точнее

  • elc go run . artisan migrate для локальной разработки
  • /var/www/main artisan migrate в k8s (при сборке образа бинарник обычно кладётся в /var/www/main)

При изменении схемы:

  1. добавить миграцию и зарегистрировать её;
  2. обновить struct в adapters/db и фабрику;
  3. поправить mappers / Kafka fill, если поля участвуют в API или событиях;
  4. обновить component-тесты.

Новая миграция и baseline при портировании​

У миграции есть стабильный Signature(), методы Up()/Down() и регистрация в bootstrap. Проверьте порядок зависимостей: родительская таблица и уникальный ключ существуют до FK, а обратная операция удаляет зависимости в допустимом порядке.

Новая таблица в работающем Go-сервисе и baseline портируемого сервиса решают разные задачи. Baseline Catalog Cache создаёт итоговую схему для пустой БД и пропускает исторические data transformations. Он не является инструкцией обновления заполненной Laravel-БД. Проверка HasTable в такой миграции не подтверждает совпадение колонок, индексов и constraints существующей таблицы.

Для изменения действующей схемы добавляйте новую миграцию. Отдельно определите перенос данных, backfill и совместимость работающих версий приложения. Если колонка становится NOT NULL, сначала нужно установить, что произойдёт с существующими строками. Для удаления/переименования требуется согласованный порядок выкладки и стратегия восстановления данных; Down() не гарантирует восстановление удалённых значений.

Что проверить после изменения схемы​

ПроверкаЧто она подтверждает
Миграции с нуляРегистрацию, порядок и полноту схемы новой БД
Применение новой миграции к предыдущей схемеПуть обновления, если сервис уже развёрнут
Defaults/NULL/constraintsРеальное поведение SQL, включая повторный внешний ID и неверный FK
ИндексыСоответствие фильтрам, join и сортировкам; для значимых запросов — план выполнения
Новое чтение после create/updateСовпадение модели, serializer и типов БД
Ошибка внутри транзакцииОтсутствие частичных SQL-изменений
Тестовая очисткаНовые таблицы/схемы включены в изоляцию тестов

Компонентная БД применяет миграции с нуля: этого недостаточно для доказательства обновления заполненной базы. Не подменяйте PostgreSQL другим движком ради прохождения теста — типы, JSON, timezone и SQL-поведение могут отличаться.

Сидеры​

Сидеры в database/seeders наполняют локальную/стендовую БД осмысленными данными. Обычно они тоже опираются на фабрики моделей (Orm().Factory() / те же Definition), чтобы не дублировать дефолты. Регистрация — в bootstrap/seeders.go.

Сидеры не используются для:

  • создания в БД "хардкодных" записей, вроде роли суперадмина - это делается либо в миграции, либо отдельной командой
  • подготовки данных для тестов - каждому тесту нужно своё состояние БД, учесть их все в одном сидере было бы слишком сложно

Связь с модулями​

СлойРоль относительно БД
adapters/dbмодели, связи, фабрики, иногда query-хелперы сущности
modules/.../actionsбизнес-операции: читают/пишут через ORM, не отдают HTTP
modules/.../mappersdb.Model → API DTO
modules/.../queriesконтракт filter/sort/include для search
modules/.../kafkaсинхронизация модели из событий (часто в транзакции)
database/migrationsфизическая схема

Хэндлер не должен вызывать facades.Orm() напрямую для бизнес-чтения: это работа action (или общего search-слоя внутри action).

Исходные примеры​

Практические сценарии — в SQL-поиске, изменяющих операциях и автотестах.