Работа с базой данных
В 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 без переполнения |
| Nullable | SQL 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 по умолчанию для любой интеграционной модели нельзя.
Для отношения согласуйте:
- реальные колонки, типы, уникальность и FK в схеме;
foreignKey/referencesв модели;- имя ORM preload, например
With("Product.Categories"); - публичный include alias и mapper API;
- фабричные данные с нужным ключом родителя.
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)
При изменении схемы:
- добавить миграцию и зарегистрировать её;
- обновить struct в
adapters/dbи фабрику; - поправить mappers / Kafka fill, если поля участвуют в API или событиях;
- обновить 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/.../mappers | db.Model → API DTO |
modules/.../queries | контракт filter/sort/include для search |
modules/.../kafka | синхронизация модели из событий (часто в транзакции) |
database/migrations | физическая схема |
Хэндлер не должен вызывать facades.Orm() напрямую для бизнес-чтения: это работа action (или общего search-слоя внутри action).
Исходные примеры
- Catalog Cache: Offer — внешние ключи, preload и управление timestamps.
- OMS: Order — nullable, enum и JSONB serializer.
- Catalog Cache: sync helpers — not found и create/save через переданный query.
- Catalog Cache: baseline — итоговая схема для пустой БД.
- CMS: очистка тестовой БД — список таблиц и truncation.
Практические сценарии — в SQL-поиске, изменяющих операциях и автотестах.