Содержание урока · 13 разделов
- sqlc join: две таблицы, одна Row-структура
- Row-структура: правило имён и переиспользование модели
- Суффиксы колонок: id, id_2 и Column_1
- LEFT JOIN: NOT NULL перестаёт быть гарантией
- Миф о вложенных структурах и sqlc.embed
- Плоский скан и сборка: три дороги к дереву
- GROUP BY и джойн: первый агрегат
- Шпаргалка: SELECT-выражение и поле Row-структуры
- Пять правил джойнов
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Одиннадцатый урок закрыл транзакции и закончился обещанием: часть сценариев, ради которых вчера открывали рамку с 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 не запускаются — runner-ов здесь нет вовсе. Понимание проверяют quiz, predict, fill, order и флешкарты: они гоняют то же, что проверил бы sqlc generate на вашей машине. Сгенерированные фрагменты сверены с кодом генератора версии 1.27; повторить всё руками можно по шагам второго урока.
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.id | ID_2 int64 | суффикс _2 достаётся второму повтору имени |
| u.name AS assignee | Assignee string | AS переименовывает поле и защищает от суффиксов |
| u.name без AS (LEFT JOIN) | Assignee pgtype.Text | LEFT делает правую сторону опциональной; database/sql — sql.NullString, с emit_pointers_for_null_types — *string |
| COUNT(t.id) AS todo_count | TodoCount int64 | агрегат не бывает NULL; без AS поле назвалось бы Count |
| sqlc.embed(users) | User User | модель целиком — максимум вложенности генератора |
| CASE WHEN ... без AS | Column_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.
Первая: ORDER BY id после соединения — генерация падает с «is ambiguous»; пишите ORDER BY t.id, а strict_order_by: false включайте только осознанно — она глушит полезную проверку. Вторая: суффиксы не для людей — код, завязанный на ID_2, рассыпается от перестановки колонок в SELECT; любое имя, на которое смотрит соседний код или клиент, фиксируйте AS. Третья: джойн один-ко-многим умножает строки родителя — задача с тремя списаниями появится в выборке трижды, и любой счётчик в приложении услужливо утроится; спасают DISTINCT, EXISTS и GROUP BY, но это уже соседняя тема группировок.
Восстановите выборку с исполнителем
Четыре пропуска: читаемое имя nullable-поля, вид соединения, ключ связи, дисциплина мягкого удаления.
Соберите запрос по строкам
Выборка «задача с исполнителем»: аннотация, список колонок с алиасами, соединение, дисциплина удаления, сортировка.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: джойны
Что дальше#
Джойны разобраны по буквам: 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 — прививка от первой ловушки.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему в 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 делают опциональными левую сторону и обе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила