Go: SQL

Теория: Работа со сложными структурами

Работа со сложными структурами

Когда таблиц становится больше одной, простые выборки перестают хватать. В запросах появляются JOIN-ы, агрегаты, вычисляемые поля. Результат уже не напоминает одну таблицу, а выглядит как проекция кусочков из разных сущностей. Код на Go должен как-то это принять: либо в плоскую структуру с понятными полями, либо во вложенную доменную модель. sqlc в этом месте помогает, но не делает магию. Он смотрит только на то, что написано в SELECT, и по именам колонок строит структуру результата. Поэтому форма результата должна быть продумана: явные колонки, аккуратные алиасы и привязка имён к тому, что ожидает приложение.

SQL JOIN'ы и соответствующие структуры в Go

JOIN объединяет строки из разных таблиц в один набор. Для sqlc это просто ещё один SELECT: он не «понимает» связи, он видит список колонок с именами и по ним генерирует тип. Поэтому задача сводится к двум шагам. Сначала запрос должен вернуть ровно те поля, которые нужны коду. Затем каждому полю задаются алиасы так, чтобы имена были однозначными и читаемыми.

Возьмём связь «заказ принадлежит пользователю». В базе есть orders с полем user_id и таблица users с id. В .sql-файле удобно сразу оформить проекцию под будущую структуру.

-- query/orders/orders_read.sql

-- name: GetOrderWithUser :one
SELECT
  o.id           AS order_id,
  o.amount_cents AS order_amount_cents,
  o.created_at   AS order_created_at,
  u.id           AS user_id,
  u.email        AS user_email,
  u.name         AS user_name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.id = $1;

sqlc увидит этот SELECT и сгенерирует плоскую структуру вроде такой:

type GetOrderWithUserRow struct {
	OrderID          int64
	OrderAmountCents int64
	OrderCreatedAt   time.Time
	UserID           int64
	UserEmail        string
	UserName         sql.NullString
}

Имена полей берутся из алиасов: order_id превращается в OrderID, user_email — в UserEmail. Такой тип удобен как транспортный. Сервис может оставить его как есть или разложить во вложенные сущности, собрав, например, Order и вложенный в него User.

Левое соединение требует аккуратной работы с нулями. Когда связанная запись может отсутствовать, правую часть лучше сразу проектировать в nullable-типы. В запросе это остаётся обычным LEFT JOIN, но при чтении sqlc уже понимает, что поля потенциально NULL, и использует sql.Null*.

-- name: GetPostWithCategory :one
SELECT
  p.id         AS post_id,
  p.title      AS title,
  c.id         AS category_id,
  c.name       AS category_name
FROM posts p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.id = $1;

Структура будет выглядеть так:

type GetPostWithCategoryRow struct {
	PostID       int64
	Title        string
	CategoryID   sql.NullInt64
	CategoryName sql.NullString
}

Эта форма однозначно кодирует три состояния: категория есть, категория отсутствует, а также «пустое» имя в рамках существующей категории. В бизнес-модели можно уже преобразовать это в указатели или опциональные поля, но слой данных всегда знает, что пришло именно из базы.

Связь «один-ко-многим» через прямой JOIN даёт дубли главной сущности. Для выдач списков это часто нормально: каждая строка несёт один заказ и одну позицию, а фронтенд сам группирует по идентификатору заказа. Если же нужно получить одну сущность с слайсом дочерних, удобнее агрегировать на стороне SQL. PostgreSQL позволяет собрать дочерние записи в JSON или массив, а Go-код прочитает это в кастомный тип.

-- name: GetOrderWithItems :one
WITH items AS (
  SELECT
    oi.order_id,
    jsonb_build_object(
      'id',    oi.id,
      'sku',   oi.sku,
      'qty',   oi.qty,
      'price', oi.price_cents
    ) AS j
  FROM order_items oi
  WHERE oi.order_id = $1
)
SELECT
  o.id           AS id,
  o.amount_cents AS amount_cents,
  o.created_at   AS created_at,
  COALESCE(jsonb_agg(i.j ORDER BY (i.j->>'id')::int), '[]'::jsonb) AS items
FROM orders o
LEFT JOIN items i ON i.order_id = o.id
WHERE o.id = $1
GROUP BY o.id;

Под поле items можно завести тип Items с реализацией интерфейса sql.Scanner.

type Item struct {
	ID    int64  `json:"id"`
	SKU   string `json:"sku"`
	Qty   int    `json:"qty"`
	Price int64  `json:"price"`
}

type Items []Item

func (it *Items) Scan(src any) error {
	if src == nil {
		*it = nil
		return nil
	}
	b, ok := src.([]byte)
	if !ok {
		return fmt.Errorf("items: unexpected type %T", src)
	}
	return json.Unmarshal(b, it)
}

Если этот тип прописать в схеме или явно использовать в ручном коде, sqlc создаст структуру результата с полем Items и будет вызывать Scan автоматически. В итоге запрос возвращает заказ и уже собранный слайс позиций, а Go-код получает готовую доменную сущность без ручного обхода дубликатов.

Когда агрегировать на стороне базы нельзя или не хочется, коллекцию собирают в коде. rows.Next() идёт по результату JOIN, а словарь по ключу справляется с дубликатами. Главная сущность кладётся в map[id]*T один раз, дочерние добавляются в слайс по ключу, порядок при необходимости сохраняется отдельным массивом идентификаторов. Такой подход работает и с чистым database/sql, и со сгенерированными типами sqlc, если запрос оформлен как :many.

Использование алиасов и явного маппинга полей

Алиасы и явный маппинг превращают результат запроса в стабильный контракт между SQL и Go. Без них колонки легко начинают конфликтовать, а порядок полей становится хрупким: любая миграция ломает Scan, если в коде опираются на position-based подход и select *. При грамотных алиасах каждое поле в SELECT имеет своё имя, и это имя совпадает с полем структуры. Тогда переход от SQL к коду становится прозрачным.

Простейший пример — users и orders, которые оба содержат поле id. Если написать SELECT id, email, id, amount_cents, непонятно, какой id откуда. Вместо этого сразу задаются псевдонимы.

SELECT
  u.id           AS user_id,
  u.email        AS user_email,
  o.id           AS order_id,
  o.amount_cents AS order_amount_cents
from users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = $1;

В ручном коде на Go под такой SELECT заводится структура с полями UserID, UserEmail, OrderID, OrderAmountCents и ровно в таком же порядке передаётся в Scan. Порядок остаётся важен, но имена уже снимают двусмысленность.

В sqlc этот приём работает ещё лучше, потому что алиасы превращаются в имена полей автоматически.

-- name: GetOrderWithUser :one
SELECT
  o.id           AS order_id,
  o.amount_cents AS order_amount_cents,
  u.id           AS user_id,
  u.email        AS user_email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.id = $1;

После генерации появится тип вроде:

type GetOrderWithUserRow struct {
	OrderID          int64
	OrderAmountCents int64
	UserID           int64
	UserEmail        string
}

Если в конфигурации включить emit_json_tags, поля сразу получат JSON-теги, которые совпадают с алиасами. Это удобно, когда результат напрямую уходит в HTTP-ответ.

gen:
  go:
    package: "db"
    out: "internal/db"
    emit_json_tags: true

Тогда структура станет такой:

type GetOrderWithUserRow struct {
	OrderID          int64  `json:"order_id"`
	OrderAmountCents int64  `json:"order_amount_cents"`
	UserID           int64  `json:"user_id"`
	UserEmail        string `json:"user_email"`
}

Nullable-поля тоже зависят от явного маппинга. В запросе с LEFT JOIN каждое поле из правой таблицы потенциально может быть NULL. sqlc учитывает это и использует sql.NullString, sql.NullInt64 и другие нулевые типы.

-- name: GetUserWithAddress :one
SELECT
    u.id AS user_id,
    u.email AS user_email,
    u.name AS user_name,
    a.city AS address_city,
    a.country AS address_country
FROM users AS u
LEFT JOIN addresses AS a ON u.id = a.user_id
WHERE u.id = $1;

Результат в Go:

type GetUserWithAddressRow struct {
	UserID         int64
	UserEmail      string
	UserName       sql.NullString
	AddressCity    sql.NullString
	AddressCountry sql.NullString
}

Такой тип однозначно отражает модель: пользователь обязателен, адрес опционален. В доменном коде из этого уже строится та форма, которая удобна сервисам и API.

Выражения и агрегаты требуют такой же аккуратности. Любая вычисляемая колонка должна получить имя.

-- name: GetOrderStats :one
SELECT
    avg(amount_cents)::bigint AS avg_amount_cents,
    count(*) AS total_orders
FROM orders;

sqlc по этим алиасам создаст читаемую структуру:

type GetOrderStatsRow struct {
	TotalOrders    int64
	AvgAmountCents int64
}

Без понятных имен структура превратится в набор полей вида Column1, Column2, и код потеряет связь с бизнес-смыслом.

Главный принцип работы с алиасами и явным маппингом прост. SELECT всегда возвращает только нужные поля, каждое поле имеет уникальный и говорящий алиас, а этот алиас стабильно используется как имя поля структуры. sqlc опирается именно на эту форму: по ней он строит типы, добавляет JSON-теги и проверяет совместимость при компиляции. Пока контракт в SELECT не меняется, Go-код остаётся устойчивым даже тогда, когда таблицы активно мигрируются и дополняются новыми колонками.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845