Соединение таблиц: INNER JOIN и LEFT JOIN
Курс MS SQL с нуля · Урок 4 из 12 · Часть 2. Соединения и агрегация
Нормализованная база держит сущности по разным таблицам, и почти каждый полезный запрос собирает их обратно. Урок даёт соединения INNER и LEFT, правила алиасов и главную ловушку — размножение строк при связи один-ко-многим.
Перед уроком убедитесь, что база восстановлена скриптами урока 2: предыдущий урок должен был завершиться чистой TechStore.
Содержание
1. Что вы освоите
К концу урока вы:
- соединяете таблицы явным JOIN с условием в ON;
- выбираете между INNER и LEFT по смыслу задачи;
- строите цепочки из трёх-четырёх таблиц и самосоединения;
- узнаёте размножение строк по завышенной сумме и чините его агрегацией.
2. Соединение по ключам: INNER JOIN
INNER JOIN оставляет пары строк, в которых условие соединения выполнилось в обеих таблицах. Соединение строится по паре ключей: первичный ключ «одной» стороны и внешний ключ «многих»:
Чистый код
SELECT o.OrderID, o.OrderDate, c.FullName, c.City
FROM dbo.Orders AS o
JOIN dbo.Customers AS c
ON c.CustomerID = o.CustomerID;
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с двух дефисов и стрелки вправо, содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
SELECT o.OrderID, o.OrderDate, c.FullName, c.City -- → какие столбцы: из обеих таблиц, имя таблицы — через алиас FROM dbo.Orders AS o -- → левая таблица получает алиас o JOIN dbo.Customers AS c -- → правая — алиас c; INNER JOIN: только пары, где есть совпадение ON c.CustomerID = o.CustomerID; -- → условие пары: внешний ключ заказа равен ключу клиентаSQL
- каждая таблица получает короткий алиас (o, c); все столбцы пишутся с алиасом: иначе совпадающие имена дают ошибку двусмысленности Msg 209;
- условие соединения идёт в ON и только туда; WHERE — для фильтрации уже готового результата;
- порядок таблиц в INNER JOIN на результат не влияет.
3. LEFT JOIN и поиск сирот
LEFT JOIN сохраняет все строки левой таблицы; где пары не нашлось, справа подставляется NULL. Классические применения:
- «сущность и необязательное свойство»: клиенты и их заказы;
- поиск сирот: клиенты вовсе без заказов находятся отбором NULL после левого соединения.
Чистый код
SELECT c.CustomerID, c.FullName, c.City
FROM dbo.Customers AS c
LEFT JOIN dbo.Orders AS o ON o.CustomerID = c.CustomerID
WHERE o.OrderID IS NULL;
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с двух дефисов и стрелки вправо, содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
SELECT c.CustomerID, c.FullName, c.City -- → ищем клиентов-«сирот» FROM dbo.Customers AS c LEFT JOIN dbo.Orders AS o ON o.CustomerID = c.CustomerID -- → LEFT: все клиенты сохраняются, заказам без хозяина не бывает WHERE o.OrderID IS NULL; -- → после левой стройки NULL в правой стороне означает отсутствие заказовSQL
4. Цепочки соединений
Отчёт по строкам заказов собирается цепочкой из четырёх таблиц: Orders → OrderItems → Products → Categories. Читается сверху вниз: «заказ и его клиент, и каждая строка заказа, и товар строки, и категория товара».
Самосоединение дерева категорий
Родитель категории лежит в той же таблице. Соединить таблицу саму с собой позволяют разные алиасы:
Чистый код
SELECT c.CategoryID, c.Name AS category_name, p.Name AS parent_name
FROM dbo.Categories AS c
LEFT JOIN dbo.Categories AS p ON p.CategoryID = c.ParentCategoryID
ORDER BY parent_name, category_name;
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с двух дефисов и стрелки вправо, содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
SELECT c.CategoryID, c.Name AS category_name, p.Name AS parent_name -- → самосоединение: ребёнок и его родитель FROM dbo.Categories AS c -- → роль «ребёнок» LEFT JOIN dbo.Categories AS p ON p.CategoryID = c.ParentCategoryID -- → та же таблица в роли «родитель»; LEFT — чтобы корни с NULL не выпали ORDER BY parent_name, category_name; -- → сначала родитель, внутри — имена детейSQL
5. Размножение строк и завышение сумм
При связи один-ко-многим каждая строка «одной» стороны стыкуется с каждой строкой «многих»: заказ с тремя позициями даст три строки результата. Следствия для отчётности прямые:
- агрегат по такой выборке считает сумму заказа с повторами — итог завышается на коэффициент размножения;
- перед соединением прикидывают ожидаемое число строк результата и сверяют счётчиком COUNT.
Завышение и починка
проверочный пример задачи 4.6
Чистый код
DECLARE @last datetime2 = (SELECT MAX(o.OrderDate) FROM dbo.Orders AS o);
DECLARE @d0 datetime2 = CAST(CAST(@last AS date) AS datetime2);
SELECT SUM(o.TotalAmount) AS sum_orders
FROM dbo.Orders AS o
WHERE o.OrderDate BETWEEN @d0 AND DATEADD(second, -1, DATEADD(day, 1, @d0));
SELECT SUM(lines.order_amount) AS sum_repaired
FROM dbo.Orders AS o
JOIN (SELECT oi.OrderID, SUM(oi.Quantity * oi.UnitPrice) AS order_amount
FROM dbo.OrderItems AS oi GROUP BY oi.OrderID) AS lines
ON lines.OrderID = o.OrderID
WHERE o.OrderDate BETWEEN @d0 AND DATEADD(second, -1, DATEADD(day, 1, @d0));
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с двух дефисов и стрелки вправо, содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
DECLARE @last datetime2 = (SELECT MAX(o.OrderDate) FROM dbo.Orders AS o); -- → эталон дня: переменная с самой свежей датой заказа DECLARE @d0 datetime2 = CAST(CAST(@last AS date) AS datetime2); -- → полночь этого дня: время сброшено приведением через date SELECT SUM(o.TotalAmount) AS sum_orders -- → контрольная сумма дня по самой таблице Orders FROM dbo.Orders AS o WHERE o.OrderDate BETWEEN @d0 AND DATEADD(second, -1, DATEADD(day, 1, @d0)); -- → весь день: от полуночи до секунды перед следующей полуночью SELECT SUM(lines.order_amount) AS sum_repaired -- → починка: позиции агрегированы заранее, соединение уже один-к-одному FROM dbo.Orders AS o JOIN (SELECT oi.OrderID, SUM(oi.Quantity * oi.UnitPrice) AS order_amount FROM dbo.OrderItems AS oi GROUP BY oi.OrderID) AS lines -- → производная таблица: по одной строке на заказ, размножение исключено ON lines.OrderID = o.OrderID WHERE o.OrderDate BETWEEN @d0 AND DATEADD(second, -1, DATEADD(day, 1, @d0)); -- → обе суммы обязаны совпастьобе суммы обязаны совпасть
6. Задачи и эталонные решения
Шесть задач на контексте TechStore.
- последние двадцать заказов с именем и городом клиента, внутреннее соединение;
- активные товары с именем категории, по алфавиту названия;
- полный состав заказа 1000: товар, количество, цена, сумма строки;
- дерево категорий с именем родителя: у корней — явный NULL;
- клиенты без единственного заказа: левое соединение и отбор NULL, сверка числа разностью счётчиков;
- ложное завышение: сумма дня по соединению против суммы по таблице, починка предварительной агрегацией позиций.
Эталоны урока
открывать после самостоятельной попытки
Чистый код
SELECT TOP (20) o.OrderID, o.OrderDate, o.Status, c.FullName, c.City
FROM dbo.Orders AS o
JOIN dbo.Customers AS c ON c.CustomerID = o.CustomerID
ORDER BY o.OrderDate DESC;
GO
SELECT p.Name AS product_name, cat.Name AS category_name, p.Price
FROM dbo.Products AS p
JOIN dbo.Categories AS cat ON cat.CategoryID = p.CategoryID
WHERE p.IsActive = 1
ORDER BY p.Name;
GO
SELECT oi.ProductID, p.Name, oi.Quantity, oi.UnitPrice,
oi.Quantity * oi.UnitPrice AS line_amount
FROM dbo.OrderItems AS oi
JOIN dbo.Products AS p ON p.ProductID = oi.ProductID
WHERE oi.OrderID = 1000
ORDER BY oi.ProductID;
GO
- Построчный разбор
Ниже приведён тот же фрагмент с пояснением после каждой смысловой строки. Строки, которые начинаются с двух дефисов и стрелки вправо, содержат комментарий преподавателя. Эти строки не являются обязательной частью программы.
SELECT TOP (20) o.OrderID, o.OrderDate, o.Status, c.FullName, c.City -- → задача 4.1: двадцать последних заказов вместе с клиентом FROM dbo.Orders AS o JOIN dbo.Customers AS c ON c.CustomerID = o.CustomerID ORDER BY o.OrderDate DESC; -- → свежие сверху GO SELECT p.Name AS product_name, cat.Name AS category_name, p.Price -- → задача 4.2: два столбца Name из разных таблиц — обоим алиасы FROM dbo.Products AS p JOIN dbo.Categories AS cat ON cat.CategoryID = p.CategoryID WHERE p.IsActive = 1 -- → только продающиеся ORDER BY p.Name; GO SELECT oi.ProductID, p.Name, oi.Quantity, oi.UnitPrice, oi.Quantity * oi.UnitPrice AS line_amount -- → задача 4.3: состав заказа и сумма строки вычислением FROM dbo.OrderItems AS oi JOIN dbo.Products AS p ON p.ProductID = oi.ProductID WHERE oi.OrderID = 1000 ORDER BY oi.ProductID; GOрешения задач 4.1–4.3
7. Типичные ошибки урока
- Двусмысленное имя столбца (Msg 209) — столбец без алиаса при двух таблицах с таким именем; алиасы пишутся на все столбцы.
- Фильтр убил LEFT — признак: пропали пустые строки; лечение: фильтр правой таблицы переносится в ON.
- Завышение суммы — агрегат по левой таблице после соединения с правой один-ко-многим; диагностика: сравнение с суммой чистой таблицы.
- Декартово произведение — забыт ON при сокращённой записи: строки результата миллионами, панель грузится долго; стоп-кнопка и возврат ON.
- Самосоединение без алиасов — оба участника требуют разных имён: c и p в задаче дерева.
- Порядок ON для LEFT — при левом соединении таблица «оставшееся» идёт первой; поменяли местами — получили правое.
8. Чек-лист практики и источники
Чек-лист урока 4
отметки делаются по факту
Источники для углубления:
Проверено: 23.10.2026
↑ К оглавлению