Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~20 мин +50 XP

Джойны и вложенные структуры

sqlc join — тема, на которую трекер шёл всю первую половину курса: у задач появились исполнители, и самый частый вопрос слоя данных — «покажи задачу вместе с её исполнителем» — требует колонок из двух таблиц в одной выборке. Разбираем по буквам: что генератор делает с запросом-джойном и почему ответ всегда плоский — Row-структура из всех колонок SELECT-списка; как формируются имена полей, когда одинаковые колонки получают суффиксы _2 и _3, а безымянные выражения — Column_1; почему INNER JOIN сохраняет NOT NULL, а LEFT JOIN превращает каждую колонку правой таблицы в nullable-тип из девятого урока; миф о вложенных структурах, который sqlc не поддерживает, и максимум вложенности, который всё-таки есть, — sqlc.embed; плоский скан и три дороги к настоящему дереву — сборка в Go, два запроса со склейкой, ручной Scan после Query; и первый агрегат поверх соединения — COUNT с GROUP BY, тизер темы группировок. Механика транзакций осталась в [одиннадцатом уроке](/courses/sqlc/lessons/11-tranzakcii/); правила вывода имён и формы :one и :many — в [седьмом](/courses/sqlc/lessons/07-select/); nullable-мир — в [девятом](/courses/sqlc/lessons/09-null/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

Содержание урока · 13 разделов

Одиннадцатый урок закрыл транзакции и закончился обещанием: часть сценариев, ради которых вчера открывали рамку с Begin и Commit, сегодня читается одним запросом с соединением таблиц. Обещание выполняем: у трекера появились пользователи, и чтение вчерашней пары «задача плюс списание» начинается с SELECT с JOIN — две таблицы в одной выборке, без рамок и без откатов.

Двенадцатый урок отвечает на пять вопросов. Первый: что генератор делает с джойном и почему у всякой много-колоночной выборки своя Row-структура вместо переиспользованной модели. Второй: откуда берутся имена полей — суффиксы колонок _2 и _3 у повторов, Column_1 у безымянных выражений и как держать имена под контролем алиасами AS. Третий: что внешнее соединение делает с nullability — LEFT JOIN превращает колонки правой таблицы в nullable-типы, даже если в схеме они NOT NULL. Четвёртый: миф о вложенных структурах — sqlc их не строит, всё плоско; что вместо этого умеет sqlc.embed и как жить, когда нужно настоящее дерево: плоский скан плюс сборка в Go или ручной Scan после Query. Пятый: первый агрегат поверх джойна — COUNT с GROUP BY, вход в тему тринадцатого урока. Всё — на знакомой схеме todos с одним новым участником: таблицей users.

sqlc join: две таблицы, одна Row-структура#

Запрос «джойны sqlc» — это ровно карта сегодняшнего материала, и начнём с главного: для генератора джойн — обычный SQL. Никаких особых аннотаций, флагов в конфиге и новых форм: :many и :one работают как в седьмом уроке, параметры называются по правилам седьмого и восьмого, типы колонок выводятся по правилам четвёртого. Генератор разбирает FROM-дерево, узнаёт обе таблицы по схеме, проверяет каждое имя колонки, а условие ON валидирует как любое выражение — опечатка в user_id уронит sqlc generate раньше, чем вы добежите до базы.

Единственное, что меняется по-настоящему, — результат. В однотабличных запросах SELECT-список почти совпадает с моделью, и sqlc возвращал готовый Todo. В джойне список колонок собран из двух таблиц — переиспользовать модель нельзя, и генератор строит частную структуру под конкретный запрос, перечисляя ВСЕ колонки SELECT-списка подряд, без группировок и вложенности. Это и есть Row-структура: плоский снимок результата, поле за полем. Ключевой навык сегодняшнего урока — читать её и держать под контролем: имена полей, их типы и порядок следуют из вашего SQL, и каждая перестановка в SELECT меняет структуру. Канон трекера не отменяется: у каждой живой выборки стоит фильтр мягкого удаления, и сейчас вы увидите его на месте.

Миграция 008 и первый файл джойнов

GO
-- db/migrations/008_users.sql: у задач появляется исполнитель
CREATE TABLE users (
        id    BIGSERIAL PRIMARY KEY,
        name  TEXT NOT NULL,
        email TEXT NOT NULL
);

-- исполнение необязательно: у задачи может не быть исполнителя
ALTER TABLE todos ADD COLUMN user_id BIGINT REFERENCES users(id);

-- db/queries/joins.sql: новая тема выборок — новый файл
-- name: ListTodosWithUser :many
SELECT t.id, t.title, t.done, u.id AS user_id, u.name AS assignee
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL
ORDER BY t.id;

-- name: TodoAndUserIDs :many
SELECT t.id, u.id
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL;

Миграция 008 продолжает нумерацию из второго урока; после sqlc generate модель Todo получит nullable-поле UserID — правила девятого урока, здесь его не трогаем. Запросы-джойны собраны в отдельный joins.sql: конвенция «одна сущность — один файл» из третьего урока сознательно расширена до «одна тема выборок — один файл», правил по именам файлов у sqlc нет. Алиасы t и u — обычный SQL; два AS стоят заранее, и ниже вы увидите, зачем. WHERE t.deleted_at IS NULL — дисциплина мягкого удаления из шестого урока: deleted_at живёт в todos, поэтому фильтр стоит на этой таблице.

Что сгенерировано: Row-структуры и плоский скан

GO
// db/sqlc/joins.sql.go — сгенерировано (pgx/v5, v1.27)
const todoAndUserIDs = `-- name: TodoAndUserIDs :many
SELECT t.id, u.id
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL
`

type TodoAndUserIDsRow struct {
        ID   int64
        ID_2 int64 // второй id в списке получил суффикс
}

const listTodosWithUser = `-- name: ListTodosWithUser :many
SELECT t.id, t.title, t.done, u.id AS user_id, u.name AS assignee
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL
ORDER BY t.id
`

type ListTodosWithUserRow struct {
        ID       int64
        Title    string
        Done     bool
        UserID   int64  // из u.id AS user_id
        Assignee string // NOT NULL сохранён: INNER JOIN
}

func (q *Queries) ListTodosWithUser(ctx context.Context) ([]ListTodosWithUserRow, error) {
        rows, err := q.db.Query(ctx, listTodosWithUser)
        if err != nil {
                return nil, err
        }
        defer rows.Close()
        var items []ListTodosWithUserRow
        for rows.Next() {
                var i ListTodosWithUserRow
                // плоский скан: поле за полем, в порядке SELECT-списка
                if err := rows.Scan(&i.ID, &i.Title, &i.Done, &i.UserID, &i.Assignee); err != nil {
                        return nil, err
                }
                items = append(items, i)
        }
        if err := rows.Err(); err != nil {
                return nil, err
        }
        return items, nil
}

Сгенерированный метод — обычный :many из седьмого урока: Query, defer rows.Close, цикл rows.Next, Scan всех полей сразу в структуру, rows.Err, срез. Сканирование в структуры здесь и происходит — но в плоскую структуру, по порядку SELECT-списка. Имя Row-структуры собрано из имени запроса плюс Row: ListTodosWithUser дало ListTodosWithUserRow, TodoAndUserIDs — TodoAndUserIDsRow. Обратите внимание на ID_2: ниже — целый раздел про суффиксы.

Row-структура: правило имён и переиспользование модели#

Правило именования одно: если выборка возвращает больше одной колонки, генератор строит структуру из имени запроса плюс Row — ListTodosWithUserRow, TodoAndUserIDsRow, — а тип возврата :many становится срезом этих структур. Исключений два, и оба приятные. Первое: одна колонка — структуры нет вовсе; GetTodoNote из девятого урока возвращал просто pgtype.Text, а выборка одного поля из десятого — срез скаляров. Второе: если SELECT-список совпал с моделью таблицы колонка-в-колонку — имена, типы и таблица каждого поля, — генератор вернёт готовую модель: SELECT * FROM todos WHERE ... даёт []Todo, и никакая TodoRow не рождается. Джойн почти никогда не попадает в это исключение: в списке чужие таблицы — значит, своя Row-структура на каждый запрос.

Отдельная честность: в официальной документации нет отдельного руководства по джойнам — поведение целиком видно в генерации, и мы сверили его с кодом генератора версии 1.27: имя Row собирается из имени метода, а переиспользование ищется полным сравнением выборки со структурой — количество, имена, типы и таблица каждого поля. Практический вывод: Row-структуры у джойнов частные, их не шарят между запросами; похожая выборка для другого сценария — отдельный запрос с отдельной структурой, и это дёшево, генерация бесплатна.

Проверка: что вернёт метод

Метод ListTodosWithUser :many соединяет todos и users. Какой тип возврата увидит компилятор?

Суффиксы колонок: id, id_2 и Column_1#

Суффиксы колонок — первое, что видит глаз в любой Row-структуре джойна. Логика генератора проста и видна прямо в его исходниках: имя поля берётся из имени колонки, а если такое имя в структуре уже встречалось — повтор получает номер: второй _2, третий _3. TodoAndUserIDsRow с полями ID и ID_2 — ровно это: оба столбца зовутся id, и второй честно приписал суффикс. Заодно суффикс снимает конфликт типов: будь users.id обычным integer, рядом спокойно жили бы ID int64 и ID_2 int32 — имена различены, компилятор доволен. Безымянные колонки — выражения без AS — генератор нумерует по позиции: column_1, column_2, что в Go станет Column_1 и Column_2; функции везут своё имя — COUNT(*) без алиаса назовёт поле Count.

Седьмой урок уже показывал Column1 и Column2_2 — но у параметров, в непростых выражениях вроде CASE WHEN. Здесь та же болезнь на другом конце запроса: генератор честно обрабатывает повторы, но имена с суффиксами — не для людей. Код, завязанный на ID_2, рассыпается при любой перестановке колонок в SELECT. Лечение то же, что у параметров: алиасы AS, поставленные в момент написания запроса. Строка u.id AS user_id читается сама и фиксирует имя UserID навсегда — структура перестаёт зависеть от порядка колонок.

Предскажите поля структуры

Прочитайте код и выберите, что он выведет:

-- name: TodoAndUserEmails :many
SELECT t.id, u.email, t.title, u.name
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL;

LEFT JOIN: NOT NULL перестаёт быть гарантией#

INNER JOIN сохраняет контракты: обе стороны соединения обязательны, и NOT NULL-колонка любой из них остаётся NOT NULL в Row-структуре — Assignee из ListTodosWithUser именно поэтому string. У внешних соединений своя арифметика, и генератор воспроизводит её точно: LEFT JOIN объявляет правую таблицу опциональной, значит каждая её колонка в результате может быть NULL — включая те, что в схеме NOT NULL. name у users — TEXT NOT NULL, но в выборке с LEFT JOIN поле Assignee станет nullable-обёрткой: pgtype.Text при pgx/v5, sql.NullString при database/sql, а с включённой в девятом уроке опцией emit_pointers_for_null_types — *string в строке джойна. Схема не нарушила обещание — его нарушило соединение: LEFT разрешает строку todos, у которой правой половины нет, и NULL заполняет эту половину.

Правило генератора стоит запомнить целиком: внутренние соединения оставляют nullability как в схеме, LEFT делает опциональной правую сторону, RIGHT — левую, FULL — обе. Проверка идёт не по слову NOT NULL в схеме, а по структуре FROM: не важно, что колонка была обязательной, — важно, может ли она не приехать в строке. Отсюда и рабочая привычка: назначили колонке nullable-тип в джойне — обрабатывайте отсутствие значения, обёртки и указатели из девятого урока здесь не украшение, а контракт.

Один список колонок — два типа поля

GO
-- name: ListTodosWithAssignee :many
SELECT t.id, t.title, u.name AS assignee
FROM todos t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.deleted_at IS NULL
ORDER BY t.id;

-- name: TodoWithExecutor :one
SELECT t.id, u.name AS assignee
FROM todos t
JOIN users u ON u.id = t.user_id
WHERE t.id = $1 AND t.deleted_at IS NULL;

// сгенерировано (pgx/v5): разница только в JOIN
type ListTodosWithAssigneeRow struct {
        ID       int64
        Title    string
        Assignee pgtype.Text // LEFT JOIN: NOT NULL не спасает
}

type TodoWithExecutorRow struct {
        ID       int64
        Assignee string // INNER JOIN: контракт схемы жив
}

Две структуры с одним списком колонок и разным JOIN — самая наглядная разница урока. При emit_pointers_for_null_types: true Assignee слева станет *string — тот же переключатель, что девятый урок показывал на models.go; при database/sql на его месте sql.NullString. TodoWithExecutor :one показывает вторую форму: Row-структура та же, возврат — пара со структурой, контракт :one из седьмого урока работает как везде. Задача без исполнителя во вторую выборку не попадёт вовсе — INNER отрезал таких, и nullable-тип ему не нужен.

Проверка: тип после LEFT JOIN

users.name объявлен TEXT NOT NULL. Запрос с LEFT JOIN users вернул поле Assignee. Его тип при pgx/v5 без overrides?

Предскажите вердикт генератора

Прочитайте код и выберите, что он выведет:

-- name: TodoAndUserIDs :many
SELECT t.id, u.id
FROM todos t
JOIN users u ON u.id = t.user_id
ORDER BY id;

Миф о вложенных структурах и sqlc.embed#

Миф звучит заманчиво: напиши JOIN — получишь User с полем Todos []Todo внутри. В других ORM так и есть, и ожидание переносится на sqlc. Правда жёстче: sqlc не строит вложенные структуры из джойнов — всё плоско. Причина в самом контракте: SQL возвращает кортеж колонок, и генератор честно отображает кортеж один-в-один, поле за полем; дерева в результате SELECT не существует, и выдумывать его генератор не берётся. Поэтому и оговорка «сканирование в структуры» из плана курса звучит именно так: скан всегда плоский — в Row-структуру со всеми колонками списка, а что делать дальше, решаете вы.

Максимум вложенности, который умеет генератор, — sqlc.embed: вместо списка колонок можно попросить модель таблицы целиком, и Row-структура получит поле-структуру. Один уровень, только целые модели, только то, что уже есть в models.go: TodoWithUserRow с полями Todo и User — это не дерево, собранное из джойна, а удобная упаковка двух моделей рядом. Вложенность произвольной формы генератор не соберёт — и это осознанная граница инструмента, а не недоработка: у плоского скана нет скрытых поездок в базу и сюрпризов производительности.

sqlc.embed: модели целиком

GO
-- name: TodoWithUser :many
SELECT sqlc.embed(todos), sqlc.embed(users)
FROM todos
JOIN users ON users.id = todos.user_id
WHERE todos.deleted_at IS NULL;

// сгенерировано: модели целиком, без разворота в колонки
type TodoWithUserRow struct {
        Todo Todo
        User User
}

sqlc.embed принимает таблицу из FROM и вставляет её модель в Row-структуру одним полем: Todo — вся модель todos, User — вся users. Сравните с разворотом звёздочки: SELECT todos., users. без embed дал бы десяток полей подряд, и повторы имён получили бы суффиксы. Внутри каждого поля — обычная модель с обычными правилами; вложенности вглубь — User с его Todos — embed не создаёт. Это свойство Go-генерации, а не драйвера: в обоих sql_package работает одинаково.

Плоский скан и сборка: три дороги к дереву#

Когда плоского скана мало — заказчику нужен JSON вида «исполнитель и его задачи», — есть три рабочих дороги, и все три проходят мимо магии. Первая: собрать дерево в Go из плоской выборки. Джойн с задачами и исполнителями, :many, а дальше цикл с map: встретили нового пользователя — создали узел, добавили задачу в его срез. Плюс — один запрос, одна поездка в базу; минус — код сборки пишете вы. Вторая дорога — два запроса вместо одного: список пользователей и список задач, а склейка в Go. Это стандартный ответ на вопрос «как делать один-ко-многим» вокруг sqlc: генератор не умеет дерево — значит, дерево собирает приложение. Два запроса почти никогда не медленнее одного джойна: обе выборки идут по индексам и склеиваются за линейное время. Третья дорога — обойти sqlc для конкретного запроса: голые Query и Scan из драйвера, где rows.Scan кладёт колонки прямо в вашу вложенную структуру. Ручной скан — полный контроль, но и вся ответственность за типы, колонки и ошибки на вас: именно от этого слоя sqlc берёг с третьего урока.

Выбор простой по деньгам: вложенность нужна на границе API — собирайте её в Go из плоских выборок; запрос уникальный и горячий — ручной Scan; дерево нужно часто и везде — возможно, вам нужен не sqlc, а ORM, и это честный ответ. И не тяните в чтение транзакцию в надежде на согласованность: обычному отчёту хватает двух последовательных выборок, рамка из одиннадцатого урока нужна записям, а не спискам на экран.

Сборка дерева и ручной скан

GO
// сборка дерева из плоской Row-структуры
type TodoBrief struct {
        ID    int64
        Title string
        Done  bool
}

type UserView struct {
        ID    int64
        Name  string
        Todos []TodoBrief
}

func buildUserViews(rows []db.ListTodosWithUserRow) []*UserView {
        byUser := map[int64]*UserView{}
        var order []*UserView // map не хранит порядок — держим свой
        for _, r := range rows {
                u, ok := byUser[r.UserID]
                if !ok {
                        u = &UserView{ID: r.UserID, Name: r.Assignee}
                        byUser[r.UserID] = u
                        order = append(order, u)
                }
                u.Todos = append(u.Todos, TodoBrief{ID: r.ID, Title: r.Title, Done: r.Done})
        }
        return order
}

// путь 3: ручной скан после Query — sqlc не участвует
rows, err := pool.Query(ctx, `
        SELECT u.id, u.name, t.id, t.title
        FROM users u LEFT JOIN todos t ON t.user_id = u.id
        WHERE t.deleted_at IS NULL ORDER BY u.id`)
// rows.Next(): rows.Scan(&u.ID, &u.Name, &t.ID, &t.Title) — в свои структуры

Сборка линейная: map держит найденных пользователей, срез order сохраняет порядок первого появления — map в Go порядок не гарантирует, и без order JSON перемешивался бы от запуска к запуску. Ручной скан внизу — для контраста: строки, Scan в свои структуры, и вся ответственность за совпадение колонок и полей на вас. Такая выборка не появится в генерации и не будет сверена со схемой — это цена третьего пути.

GROUP BY и джойн: первый агрегат#

Джойн и группировка — естественная пара, и первый агрегат трекера именно такой: сколько живых задач у каждого пользователя. На SQL это выглядит так: SELECT u.name, COUNT(t.id) AS todo_count FROM users u LEFT JOIN todos t ON t.user_id = u.id AND t.deleted_at IS NULL GROUP BY u.id, u.name ORDER BY todo_count DESC. Три детали покажутся и в структуре CountTodosPerUserRow. Первая: COUNT не бывает NULL — даже LEFT JOIN с пустой правой половиной вернёт ноль, и поле будет int64 без обёрток. Вторая: имя поля задаёт AS — todo_count станет TodoCount; без алиаса поле назвалось бы по функции, Count. Третья: LEFT JOIN стоит ровно затем, чтобы пользователь без задач попал в отчёт со счётчиком ноль; INNER выбросил бы таких молча. Заметьте и фильтр: deleted_at уехал в ON, а не в WHERE — иначе условие в WHERE отрезало бы строки с NULL-половиной и LEFT выродился бы в INNER.

Это только первая ступень агрегаций: SUM, AVG, HAVING и подсчёты по условию ждут в тринадцатом уроке «Агрегации и GROUP BY» — там же разберём, почему группировка поверх джойна так любит умноженные строки.

Шпаргалка: SELECT-выражение и поле Row-структуры#

Выражение в SELECTПоле Row-структурыПочему так
t.id (INNER JOIN)ID int64имя и тип от колонки; INNER хранит NOT NULL
u.id рядом с t.idID_2 int64суффикс _2 достаётся второму повтору имени
u.name AS assigneeAssignee stringAS переименовывает поле и защищает от суффиксов
u.name без AS (LEFT JOIN)Assignee pgtype.TextLEFT делает правую сторону опциональной; database/sql — sql.NullString, с emit_pointers_for_null_types — *string
COUNT(t.id) AS todo_countTodoCount int64агрегат не бывает NULL; без AS поле назвалось бы Count
sqlc.embed(users)User Userмодель целиком — максимум вложенности генератора
CASE WHEN ... без ASColumn_1безымянные выражения нумеруются по позиции

Проверка: вложенный ответ API

Продакшену нужен ответ «исполнитель и все его задачи» — вложенный JSON. Какой ход правильный?

Пять правил джойнов#

  • Джойн — обычный SQL: аннотации те же, проверка по схеме та же, но результат — плоская Row-структура из имени запроса плюс Row; модель переиспользуется только при полном совпадении выборки с таблицей.
  • Повторы имён получают суффиксы _2 и _3, безымянные выражения — Column_1 по позиции; лечится алиасами AS, поставленными в момент написания запроса.
  • Квалифицируйте каждую колонку алиасом таблицы: голый id в двух таблицах роняет генерацию с «column reference "id" is ambiguous» — и в SELECT, и в ORDER BY.
  • INNER JOIN сохраняет NOT NULL, LEFT делает опциональной правую сторону, RIGHT — левую, FULL — обе: nullable-типы из девятого урока приезжают в Row-структуру сами.
  • Вложенных структур из джойнов не бывает: максимум генератора — sqlc.embed; дальше плоский скан и сборка в Go, два запроса со склейкой или ручной Scan после Query.

Восстановите выборку с исполнителем

Четыре пропуска: читаемое имя nullable-поля, вид соединения, ключ связи, дисциплина мягкого удаления.

-- name: ListTodosWithAssignee :many SELECT t.id, t.title, u.name AS FROM todos t JOIN users u ON u.id = t. WHERE t. IS NULL ORDER BY t.id;

Соберите запрос по строкам

Выборка «задача с исполнителем»: аннотация, список колонок с алиасами, соединение, дисциплина удаления, сортировка.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: джойны

Термин

Row-структура джойна

нажмите, чтобы перевернуть
Определение

Имя собирается из имени запроса плюс Row: ListTodosWithUser → ListTodosWithUserRow. Строится, когда выборка больше одной колонки и не совпадает с моделью таблицы колонка-в-колонку; одна колонка — скаляр или срез скаляров, точное совпадение — переиспользуется модель.

1 / 6

Что дальше#

Джойны разобраны по буквам: Row-структура на каждую много-колоночную выборку, суффиксы колонок у повторов имён, алиасы AS как контракт имён, INNER против LEFT в вопросе nullability, sqlc.embed как максимум вложенности генератора и три дороги к настоящему дереву — сборка в Go, два запроса со склейкой, ручной Scan после Query. Трекер научился показывать задачу вместе с исполнителем одним запросом — и знает, почему вокруг дерева нет магии.

Следующая остановка — тринадцатый урок «Агрегации и GROUP BY»: COUNT, SUM, HAVING и сканирование агрегатов; сегодняшний CountTodosPerUser — только вход в тему, там разберём группировки по-настоящему, включая подсчёты по условию и ловушку умноженных строк. Если понадобится освежить правила выборок — они в седьмом уроке; nullable-типы и указатели — в девятом; механика транзакций — в одиннадцатом; план остальных тем — на странице курса sqlc.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Джойн для sqlc — обычный SQL с той же проверкой по схеме; результат много-колоночной выборки — плоская Row-структура из имени запроса плюс Row: ListTodosWithUser даёт ListTodosWithUserRow.
  • Модель переиспользуется только при полном совпадении SELECT-списка с таблицей — имена, типы и таблица каждого поля; одна колонка возвращает скаляр или срез скаляров без структур.
  • Повторы имён колонок получают суффиксы _2 и _3, безымянные выражения — Column_1 по позиции; алиасы AS фиксируют имена и снимают конфликты типов.
  • INNER JOIN сохраняет NOT NULL; LEFT JOIN делает опциональной правую сторону — её колонки становятся pgtype.Text, sql.NullString или *string даже при NOT NULL в схеме; RIGHT и FULL — зеркально.
  • sqlc не строит вложенные структуры из джойнов: максимум — sqlc.embed, модель таблицы целиком одним полем, один уровень; дальше сборка в Go, два запроса со склейкой или ручной Scan после Query.
  • SELECT и ORDER BY с голым id в соединении роняют генерацию с «column reference "id" is ambiguous» и кодом 42703: квалифицируйте алиасом или осознанно ставьте strict_order_by: false.
  • COUNT поверх джойна — int64 без обёрток и не бывает NULL; фильтр deleted_at при LEFT JOIN ставится в ON, иначе соединение вырождается во внутреннее.

Домашнее задание

Проведите трекер через джойны. Первое: миграцией 008 добавьте users и колонку user_id, запустите sqlc generate и найдите в models.go nullable-поле UserID — объясните себе его тип по правилам девятого урока. Второе: напишите ListTodosWithUser с INNER JOIN и сверьте ListTodosWithUserRow поле за полем; затем поменяйте местами две колонки в SELECT и найдите, у кого появился суффикс. Третье: соберите TodoAndUserIDs с SELECT t.id, u.id и убедитесь в ID и ID_2; почините читаемость алиасами AS и перегенерируйте. Четвёртое: замените INNER на LEFT и проследите, как Assignee стал pgtype.Text; включите emit_pointers_for_null_types и проверьте, что в строке джойна появился *string, а INNER-версия того же запроса осталась string. Пятое: соберите CountTodosPerUser и объясните себе, почему у пользователя без задач счётчик ноль, а не NULL. Шестое: напишите buildUserViews из плоской выборки и отдайте вложенный JSON; рядом соберите версию на двух запросах со склейкой и сравните читаемость. Седьмое: уроните генерацию голым ORDER BY id и прочитайте ошибку с кодом 42703 — прививка от первой ловушки.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Джойны и вложенные структуры)

Вопросы и ответы по уроку

Почему в Row-структуре поля ID и ID_2 — что за суффикс?

В SELECT-списке встретились две колонки с одинаковым именем: t.id и u.id. Первая забрала имя ID, вторая получила суффикс _2, третья была бы _3. Генератор сохраняет все колонки и не теряет ни одной, заодно снимая конфликт типов. Читаемые имена ставьте алиасами AS прямо в SQL — структура перестанет зависеть от порядка колонок.

Умеет ли sqlc строить вложенные структуры из джойнов?

Нет: SELECT возвращает плоский кортеж колонок, и генератор отображает его один-в-один в Row-структуру. Максимум вложенности — sqlc.embed: модель таблицы целиком одним полем, без разворота в колонки, один уровень. Настоящее дерево — пользователь с его задачами — собирает приложение: map из плоской выборки, два запроса со склейкой или ручной Scan после Query.

Почему колонка с NOT NULL после LEFT JOIN стала nullable в Go?

Потому что проверка идёт по структуре FROM: LEFT JOIN объявляет правую таблицу опциональной, и каждая её колонка может не приехать в строке — даже объявленная NOT NULL. Тип будет pgtype.Text при pgx/v5, sql.NullString при database/sql, *string с emit_pointers_for_null_types. INNER JOIN сохраняет контракты схемы; RIGHT и FULL делают опциональными левую сторону и обе.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила