Соединения в модели каталога
Карточка курса должна показывать название темы, а редакторское оглавление — связанные уроки. Эти значения находятся в разных таблицах. Соединение создаёт результат из подходящих пар строк, поэтому важно понимать не только синтаксис JOIN, но и ожидаемое количество строк. Иначе запрос карточек незаметно превратится в список повторов одного курса.
Используем PostgreSQL 17.11 и самостоятельный снимок catalog-db/lesson-10 из архива серии. Исходный набор содержит четыре темы, шесть курсов и двенадцать уроков. SQL не запускался; все количества дальше выведены из фиксированных учебных данных. Снимок читает таблицы и не изменяет их.
Один курс и одна тема
Поле topic_id хранит ссылку курса на тему. Ключ topics.id уникален, а ссылка обязательна. Поэтому соединение по этой связи даёт одну тему для каждого курса:
SELECT c.id, c.slug, c.title, t.title AS topic_title
FROM catalog.courses AS c
JOIN catalog.topics AS t ON t.id = c.topic_id
ORDER BY c.id;
Ожидаются шесть строк. Название темы повторится у нескольких курсов фронтенда, но каждая карточка останется отдельной. Повтор значения колонки ещё не означает ошибочную копию строки. Нужно сравнивать смысл результата: здесь одна строка соответствует одному курсу, а не одной уникальной теме.
Псевдонимы c и t помогают отличить поля id и title двух таблиц. Имя topic_title задаёт понятную колонку ответа. Если выбрать два поля title без различимых имён, клиенту будет сложнее обращаться к результату. Это не изменение исходных колонок, а представление конкретного запроса для его потребителя.
Сам JOIN без уточнения означает внутреннее соединение: возвращаются пары, для которых условие совпало. Связь и обязательность в нашей схеме гарантируют наличие темы у каждого курса. Если бы ссылка допускала отсутствие, внутреннее соединение убрало бы такие курсы из ответа. Поэтому количество строк зависит и от SQL, и от правил модели.
Тема без курса
Для страницы разделов нужно показать также «Дизайн», куда ещё не добавлено ни одного курса. Начнём с таблицы тем и используем левое соединение:
SELECT t.id, t.slug AS topic, c.slug AS course
FROM catalog.topics AS t
LEFT JOIN catalog.courses AS c ON c.topic_id = t.id
ORDER BY t.id, c.id;
Ожидаются три строки фронтенда, две публикации, одна бэкенда и одна строка дизайна с отсутствующим курсом. Всего семь строк, хотя тем только четыре. Для дизайна правая часть результата заполнена NULL, чтобы сохранить левую тему без подходящей пары. Это поведение описано в руководстве табличных выражений.
Теперь потребуем показывать только опубликованные курсы, сохранив пустые темы. Условие статуса следует включить в правило подходящей пары:
SELECT t.slug AS topic, c.slug AS course
FROM catalog.topics AS t
LEFT JOIN catalog.courses AS c
ON c.topic_id = t.id AND c.status = 'published'
ORDER BY t.id, c.id;
Черновик производительности исчезнет из правых соответствий. Дизайн сохранится как тема без курса. Ожидаются шесть строк: пять опубликованных карточек и одна пустая тема. Если перенести c.status = 'published' в WHERE, строка дизайна не пройдёт условие: её правый статус отсутствует. Так похожая перестановка текста изменит смысл страницы разделов.
Условие в ON определяет, какие пары существуют. WHERE отбирает уже полученные строки. Такое различие особенно важно у внешних соединений. Не стоит запоминать правило «фильтр всегда в ON»: для другой задачи требуется именно убрать пустые темы. Сначала назовите желаемый результат, затем выберите место условия.
Один курс и несколько уроков
Соединение курса с уроками имеет другую кратность:
SELECT c.slug, l.position, l.title
FROM catalog.courses AS c
JOIN catalog.lessons AS l ON l.course_id = c.id
WHERE c.slug = 'js-browser'
ORDER BY l.position;
Ожидаются две строки для одного JavaScript-курса. Теперь строка результата соответствует уроку внутри выбранного курса. Повтор c.slug закономерен, поскольку родитель участвует в двух парах. Если использовать такой результат для списка карточек, интерфейс покажет повторённую карточку. SQL при этом может быть совершенно корректным для другой задачи — оглавления.
Добавление DISTINCT способно спрятать повтор полей курса, но не объясняет, зачем присоединялись уроки. Если цель состоит только в проверке наличия подходящего урока, лучше выразить существование. Это сохраняет уровень «одна строка на курс» без лишних столбцов и зависимости от числа глав.
SELECT c.id, c.slug
FROM catalog.courses AS c
WHERE EXISTS (
SELECT 1
FROM catalog.lessons AS l
WHERE l.course_id = c.id AND l.body IS NULL
)
ORDER BY c.id;
В исходном наборе только у производительности есть учебный урок без текста. Поэтому ожидается одна строка web-performance. Если в этом курсе появится второй отсутствующий текст, ответ по-прежнему будет содержать один курс. Проверка отвечает «существует ли хотя бы один», а не перечисляет все найденные главы.
Такая выборка редакторская: она не фильтрует опубликованный статус. Если добавить c.status = 'published', исходный черновик уже не пройдёт, и ответ станет пустым. Не переносите условия публичной страницы в административный отчёт автоматически. Они могут рассматривать разные состояния одного каталога.
Правильная связь важнее похожих значений
Можно ошибочно соединить таблицы по c.id = l.id. Поля имеют одинаковый тип и первые значения похожи, но идентификатор урока не является идентификатором его родителя. Получатся случайные пары, которые нарушают предметный смысл. Правильная ссылка задана колонкой l.course_id, а не совпадением названий id.
Ещё один опасный вариант — забыть условие соединения и получить комбинации каждой темы с каждым курсом. На маленьком наборе такой ответ выглядит как множество строк с повторяющимися названиями. Он не означает «база дублирует данные»: запрос сформировал пары другого вида. Поэтому при чтении сложного SQL полезно объяснять каждую связь отдельным предложением.
В старом уроке соединений LINQ to SQL отношения представлены через другую систему запросов. Здесь мы читаем явные пары PostgreSQL и не полагаемся на автоматически раскрытые навигационные свойства. Ключи и ожидаемый уровень результата остаются главным ориентиром.
Соединение и размер страницы
Постраничный список также зависит от уровня строки. Если применить LIMIT к соединению курса с уроками, ограничится число пар. Один курс с несколькими главами может занять почти весь ответ, а другой не попасть в него. Это не равно ограничению числа карточек курса, которое ожидал интерфейс.
Для такой страницы сначала выбирают нужный набор родительских курсов в определённом порядке, затем получают дочерние сведения выбранных объектов. Конкретная форма запроса зависит от ответа приложения: нужны отдельные строки глав, одна агрегированная карточка или несколько независимых чтений. Главное — не считать любой LIMIT на широком JOIN готовой пагинацией родителей.
Рассмотрим мысленный вариант с тремя уроками JavaScript и одним уроком HTML. Запрос оглавления закономерно вернёт разное количество строк каждого курса. Если после него приложение считает строки как число курсов, показатель станет ошибочным. Поэтому проверять нужно не только отсутствие повторённых названий, но и смысл единицы результата.
В снимке каждый курс содержит ровно две главы для простоты. Такая симметрия может скрывать проблему взвешенных подсчётов и страниц, потому что одинаковые множители выглядят закономерно. При проектировании настоящего каталога представьте несимметричный набор до оптимизации: он быстрее обнаружит ошибочную кратность, чем добавление DISTINCT без объяснения.
При будущем ручном повторении сравните количество строк трёх задач: карточки курсов с темами, темы с возможными курсами и оглавление одного курса. Одинаковое слово JOIN не обещает одинаковое количество объектов. Если перед выполнением вы можете предсказать шесть, семь и две строки и объяснить причину, следующий отчёт с группировкой получится осмысленным, а не исправлением случайно умноженного результата.