Go: SQL
Теория: Работа со сложными структурами
Работа со сложными структурами
Когда таблиц становится больше одной, простые выборки перестают хватать. В запросах появляются JOIN-ы, агрегаты, вычисляемые поля. Результат уже не напоминает одну таблицу, а выглядит как проекция кусочков из разных сущностей. Код на Go должен как-то это принять: либо в плоскую структуру с понятными полями, либо во вложенную доменную модель. sqlc в этом месте помогает, но не делает магию. Он смотрит только на то, что написано в SELECT, и по именам колонок строит структуру результата. Поэтому форма результата должна быть продумана: явные колонки, аккуратные алиасы и привязка имён к тому, что ожидает приложение.
SQL JOIN'ы и соответствующие структуры в Go
JOIN объединяет строки из разных таблиц в один набор. Для sqlc это просто ещё один SELECT: он не «понимает» связи, он видит список колонок с именами и по ним генерирует тип. Поэтому задача сводится к двум шагам. Сначала запрос должен вернуть ровно те поля, которые нужны коду. Затем каждому полю задаются алиасы так, чтобы имена были однозначными и читаемыми.
Возьмём связь «заказ принадлежит пользователю». В базе есть orders с полем user_id и таблица users с id. В .sql-файле удобно сразу оформить проекцию под будущую структуру.
sqlc увидит этот SELECT и сгенерирует плоскую структуру вроде такой:
Имена полей берутся из алиасов: order_id превращается в OrderID, user_email — в UserEmail. Такой тип удобен как транспортный. Сервис может оставить его как есть или разложить во вложенные сущности, собрав, например, Order и вложенный в него User.
Левое соединение требует аккуратной работы с нулями. Когда связанная запись может отсутствовать, правую часть лучше сразу проектировать в nullable-типы. В запросе это остаётся обычным LEFT JOIN, но при чтении sqlc уже понимает, что поля потенциально NULL, и использует sql.Null*.
Структура будет выглядеть так:
Эта форма однозначно кодирует три состояния: категория есть, категория отсутствует, а также «пустое» имя в рамках существующей категории. В бизнес-модели можно уже преобразовать это в указатели или опциональные поля, но слой данных всегда знает, что пришло именно из базы.
Связь «один-ко-многим» через прямой JOIN даёт дубли главной сущности. Для выдач списков это часто нормально: каждая строка несёт один заказ и одну позицию, а фронтенд сам группирует по идентификатору заказа. Если же нужно получить одну сущность с слайсом дочерних, удобнее агрегировать на стороне SQL. PostgreSQL позволяет собрать дочерние записи в JSON или массив, а Go-код прочитает это в кастомный тип.
Под поле items можно завести тип Items с реализацией интерфейса sql.Scanner.
Если этот тип прописать в схеме или явно использовать в ручном коде, 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 откуда. Вместо этого сразу задаются псевдонимы.
В ручном коде на Go под такой SELECT заводится структура с полями UserID, UserEmail, OrderID, OrderAmountCents и ровно в таком же порядке передаётся в Scan. Порядок остаётся важен, но имена уже снимают двусмысленность.
В sqlc этот приём работает ещё лучше, потому что алиасы превращаются в имена полей автоматически.
После генерации появится тип вроде:
Если в конфигурации включить emit_json_tags, поля сразу получат JSON-теги, которые совпадают с алиасами. Это удобно, когда результат напрямую уходит в HTTP-ответ.
Тогда структура станет такой:
Nullable-поля тоже зависят от явного маппинга. В запросе с LEFT JOIN каждое поле из правой таблицы потенциально может быть NULL. sqlc учитывает это и использует sql.NullString, sql.NullInt64 и другие нулевые типы.
Результат в Go:
Такой тип однозначно отражает модель: пользователь обязателен, адрес опционален. В доменном коде из этого уже строится та форма, которая удобна сервисам и API.
Выражения и агрегаты требуют такой же аккуратности. Любая вычисляемая колонка должна получить имя.
sqlc по этим алиасам создаст читаемую структуру:
Без понятных имен структура превратится в набор полей вида Column1, Column2, и код потеряет связь с бизнес-смыслом.
Главный принцип работы с алиасами и явным маппингом прост. SELECT всегда возвращает только нужные поля, каждое поле имеет уникальный и говорящий алиас, а этот алиас стабильно используется как имя поля структуры. sqlc опирается именно на эту форму: по ней он строит типы, добавляет JSON-теги и проверяет совместимость при компиляции. Пока контракт в SELECT не меняется, Go-код остаётся устойчивым даже тогда, когда таблицы активно мигрируются и дополняются новыми колонками.


