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

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

В 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-поля в Go — указатели (*string, *int64), чтобы отличить «нет значения» от нуля/пустой строки.
  • Связи описывают тегами GORM (foreignKey, references). Поле, которое не колонка, помечают gorm:"-".
  • Методы вроде TableName и Factory обязательны для корректной работы ORM и facades.Orm().Factory().

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

Чтение и запись через 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 не размазывают по хэндлеру.

Транзакции

Несколько связанных записей или «сохранить модель + побочный эффект» оборачивают в транзакцию:

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

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

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

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

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

Фабрика живёт рядом с моделью: 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.

В тестах с ComponentSuite запись создают так:

var offer db.Offer
s.Create(&offer)

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

Create внутри вызывает facades.Orm().Factory().Create(...): подставляется Definition, применяются переданные атрибуты, строка сохраняется в БД. Переопределяйте только то, что важно для сценария.

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

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

Миграции

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

В отличие от php, где в проде доступен весь код приложения, в go код компилируется, исходники в прод не попадают. Там где php спокойно читает список файлов из папки с миграциям, в go надо вручную вести список миграций - добавлять экземпляр структуры в специальный список в bootstrap/migrations.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-тесты.

Сидеры

Сидеры в 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).