Урок курса
Предварительная агрегация перед JOIN
Продвинутый SQL: сложные запросы и задачи для собеседованийСведение дочерних таблиц к одной строке на заказ до JOIN
Когда у одного заказа есть несколько позиций и несколько платежей, прямое соединение таблиц через LEFT JOIN перемножает строки между собой. В итоге агрегатная функция SUM суммирует каждую позицию столько раз, сколько платежей привязано к заказу, а каждый платёж — столько раз, сколько в нём позиций.
Чтобы проследить этот механизм, подготовим тестовую базу данных в SQLite:
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL
);
CREATE TABLE items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
amount INTEGER NOT NULL
);
CREATE TABLE payments (
payment_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
amount INTEGER NOT NULL
);
INSERT INTO orders (order_id, client_id) VALUES
(1, 10),
(2, 10),
(3, 20),
(4, 30);
INSERT INTO items (item_id, order_id, amount) VALUES
(1, 1, 100),
(2, 1, 200),
(3, 1, 100),
(4, 2, 200),
(5, 4, 400);
INSERT INTO payments (payment_id, order_id, amount) VALUES
(10, 1, 150),
(11, 1, 150),
(12, 2, 200),
(13, 3, 350);
Посмотрим на заказ order_id = 1. В таблице items для него лежат три позиции: 100, 200 и 100. Их реальная сумма равна
- В таблице
paymentsнаходятся два платежа по 150, их сумма равна - Если соединить эти таблицы напрямую и посчитать суммы в одной группировке, результат исказится:
SELECT o.order_id,
SUM(i.amount) AS items_sum,
SUM(p.amount) AS pay_sum
FROM orders o
LEFT JOIN items i ON o.order_id = i.order_id
LEFT JOIN payments p ON o.order_id = p.order_id
WHERE o.order_id = 1
GROUP BY o.order_id;
| order_id | items_sum | pay_sum |
|---|---|---|
| 1 | 800 | 900 |
Соединение сформировало декартово произведение строк: 3 позиции умножились на 2 платежа, получилось 6 промежуточных строк. Каждая позиция сложилась дважды (400 × 2 = 800), а каждый платёж — трижды (300 × 3 = 900).
Приём, который устраняет это искажение, называется предварительной агрегацией. Его суть — свести каждую дочернюю таблицу к гранулярности «не более одной строки на ключ» ещё до того, как таблица будет участвовать в общем соединении. Если дочерний набор сгруппирован по order_id, отношение между родительской таблицей orders и агрегированным набором становится «один к одному» (или «один к нулю», если дочерних записей нет). При таком соединении строки родителя не размножаются.
Шаблон CTE с контролем уникальности ключа
Для раздельной агрегации используют табличные выражения (WITH). Каждая независимая дочерняя сущность сворачивается в собственном блоке CTE, после чего родительская таблица соединяется с полученными витринами через LEFT JOIN.
Шаблон состоит из трёх шагов:
- Свернуть позиции в CTE
item_totalsс группировкой поorder_id. - Свернуть платежи в CTE
payment_totalsс группировкой поorder_id. - В основном запросе взять строки из
ordersи присоединить оба CTE по ключуorder_id.
Запрос выглядит так:
WITH item_totals AS (
SELECT order_id, SUM(amount) AS items_sum
FROM items
GROUP BY order_id
),
payment_totals AS (
SELECT order_id, SUM(amount) AS pay_sum
FROM payments
GROUP BY order_id
)
SELECT o.order_id,
COALESCE(i.items_sum, 0) AS items_sum,
COALESCE(p.pay_sum, 0) AS pay_sum
FROM orders o
LEFT JOIN item_totals i ON o.order_id = i.order_id
LEFT JOIN payment_totals p ON o.order_id = p.order_id
ORDER BY o.order_id;
| order_id | items_sum | pay_sum |
|---|---|---|
| 1 | 400 | 300 |
| 2 | 200 | 200 |
| 3 | 0 | 350 |
| 4 | 400 | 0 |
Корректность итоговых сумм опирается на строгое правило: соединяемый CTE обязан содержать не более одной строки на каждый ключ order_id. Убедиться в уникальности ключа внутри CTE можно отдельным проверочным запросом:
WITH item_totals AS (
SELECT order_id, SUM(amount) AS items_sum
FROM items
GROUP BY order_id
)
SELECT order_id, COUNT(*)
FROM item_totals
GROUP BY order_id
HAVING COUNT(*) > 1;
| order_id | COUNT(*) |
|---|
Запрос возвращает пустой набор. Это подтверждает, что в item_totals нет дубликатов по столбцу order_id для текущих данных. Пустой результат доказывает уникальность ключа соединения, но сам по себе не гарантирует полноту выборки или правильность расчёта формул внутри агрегатов.
Обработка заказов без дочерних строк и применение COALESCE
По входному контракту нашей схемы суммы amount в таблицах items и payments задаются целыми числами, а ограничение NOT NULL запрещает пропуск суммы у созданной строки. Однако в итоговом отчёте присутствуют заказы, у которых дочерние записи полностью отсутствуют:
- У заказа 3 есть платежи на сумму 350, но нет ни одной позиции в
items. - У заказа 4 есть позиция на сумму 400, но нет ни одной записи в
payments.
Когда для заказа 3 выполняется группировка таблицы items, строка с order_id = 3 просто не попадает в CTE item_totals. При последующем соединении orders LEFT JOIN item_totals для заказа 3 не находится пары справа. По правилам LEFT JOIN все столбцы неприсоединённой таблицы заполняются значениями NULL. Поэтому столбец i.items_sum становится равен NULL.
Значение NULL здесь выражает именно отсутствие дочерних строк, а не неизвестное число в исходной таблице. Для аналитического отчёта сумма позиций отсутствующего списка равна нулю. Чтобы заменить маркер отсутствия на число, используется функция COALESCE(i.items_sum, 0).
COALESCE возвращает первый аргумент, отличный от NULL. Если агрегат вернул число (как 400 для заказа 1), оно остаётся без изменений. Если строки не было и в выборку пришёл NULL (как для заказа 3), функция подставляет 0. Без этой замены заказ 3 вернул бы NULL в столбце items_sum, что привело бы к получению NULL при любых дальнейших арифметических операциях над суммами заказа.
Границы применимости: почему SUM(DISTINCT) искажает итоги
Популярная попытка обойти размножение строк без использования CTE — вызов SUM(DISTINCT amount) внутри общего соединения. Предполагается, что ключевое слово DISTINCT отбросит размножившиеся копии строк.
Проверим этот приём на заказе 1, выполнив запрос с DISTINCT:
SELECT o.order_id,
SUM(DISTINCT i.amount) AS distinct_items_sum,
SUM(DISTINCT p.amount) AS distinct_pay_sum
FROM orders o
LEFT JOIN items i ON o.order_id = i.order_id
LEFT JOIN payments p ON o.order_id = p.order_id
WHERE o.order_id = 1
GROUP BY o.order_id;
| order_id | distinct_items_sum | distinct_pay_sum |
|---|---|---|
| 1 | 300 | 150 |
Результат оказался неверным по обоим столбцам: вместо 400 и 300 запрос вернул 300 и 150.
Причина сбоя заключается в том, что DISTINCT удаляет повторяющиеся значения чисел, а не дубликаты строк соединения:
- В таблице
itemsдля заказа 1 лежат три реальные строки: 100, 200 и 100. Число 100 встречается дважды в независимых позициях.SUM(DISTINCT i.amount)оставил только уникальный набор чисел{100, 200}и сложил их один раз, получив 300 вместо 400. - В таблице
paymentsдля заказа 1 внесены два платежа: 150 и 150. Значение 150 одно, поэтомуSUM(DISTINCT p.amount)вернул 150 вместо 300.
SUM(DISTINCT amount) может случайно совпасть с правильным результатом — например, если все исходные значения заказа различны или дублируются только нули. Однако это совпадение не гарантирует корректности на других данных: любые повторяющиеся ненулевые суммы теряются. В общем случае SUM(DISTINCT ...) не устраняет размножение строк от соединения, поэтому надёжным решением остаётся предварительная агрегация.
Задача 1
Таблица orders: одна строка (order_id = 5). Таблица items: три строки с order_id = 5, суммы 100, 200, 300. Таблица payments: две строки с order_id = 5, суммы 400, 600. Аналитик написал:
WITH item_totals AS (
SELECT order_id, SUM(amount) AS items_sum
FROM items GROUP BY order_id
),
payment_totals AS (
SELECT order_id, SUM(amount) AS pay_sum
FROM payments GROUP BY order_id
)
SELECT o.order_id, i.items_sum, p.pay_sum
FROM orders o
LEFT JOIN item_totals i ON o.order_id = i.order_id
LEFT JOIN payment_totals p ON o.order_id = p.order_id;
Сколько строк вернёт этот запрос и какими будут items_sum и pay_sum?
Задача 2
Таблица items содержит строки с order_id = 3: (item_id=1, amount=150), (item_id=2, amount=150), (item_id=3, amount=150). Правильная сумма позиций = 450. Аналитик попытался исправить размножение строк через SUM(DISTINCT i.amount) вместо предварительной агрегации.
Какой результат вернёт SUM(DISTINCT i.amount) для order_id = 3?
Задача 3
Сопоставьте каждый подход (левый столбец) с его точным эффектом для заказа с 3 позициями (суммы: 100, 200, 100) и 2 платежами (суммы: 150, 150) при двойном JOIN без предварительной агрегации.
Подходы:
A) SUM(i.amount) после двойного JOIN
B) SUM(DISTINCT i.amount) после двойного JOIN
C) Предварительный CTE: SUM(amount) в item_totals, затем JOIN
D) SUM(DISTINCT p.amount) после двойного JOIN
Эффекты:
- Даёт правильный результат 400 — каждая позиция посчитана один раз
- Даёт завышенный результат 800 — каждая позиция посчитана дважды из-за 2 платежей
- Даёт заниженный результат 300 — одна из одинаковых позиций (100) отброшена
- Даёт заниженный результат 150 — один из одинаковых платежей (150) отброшен
Выберите полное соответствие A, B, C, D.
Задача 4
Аналитик написал запрос:
SELECT o.order_id,
SUM(DISTINCT i.amount) AS items_sum,
SUM(DISTINCT p.amount) AS pay_sum
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN payments p ON o.order_id = p.order_id
GROUP BY o.order_id;
Он утверждает, что SUM(DISTINCT ...) «исправил» размножение строк, потому что для тестового заказа (order_id = 1) результат совпал с ожидаемым.
Опишите: (1) при каком минимальном изменении данных (добавьте или измените одну строку) этот запрос вернёт неверный результат для items_sum, и (2) почему предварительная агрегация в CTE решает задачу корректно в общем случае, а SUM(DISTINCT ...) — нет.
Исходный тестовый заказ уточнён: items = (1,1,100),(2,1,200), payments = (10,1,50),(11,1,150). Выберите ответ, содержащий и изменение одной строки, и объяснение.
Задача 5
Независимые суммы заказов каждого клиента
Что уже дано
Таблицы уже заполнены: orders(order_id INTEGER PRIMARY KEY,client_id INTEGER), items(item_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER NOT NULL), payments(payment_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER NOT NULL). Первичные ключи положительны и уникальны. client_id — произвольный INTEGER либо NULL. Все заказы одного client_id относятся к одной группе; все NULL client_id образуют одну общую группу неизвестного клиента. Дочерний order_id может быть NULL или ссылаться на отсутствующий заказ; такие факты не относятся к заказам отчёта. amount — целые копейки от 0 до 2 000 000 000, без NULL. В каждой таблице не более 1000 строк; суммы помещаются в 64-битный INTEGER. Разные позиции или платежи с равной суммой считаются отдельно.
Что нужно сделать
Подготовьте сводку по клиентам, встречающимся в orders. Для каждой группы покажите число её заказов, общую сумму всех позиций этих заказов и общую сумму всех их платежей. Каждая реальная позиция или платёж учитывается ровно один раз. Заказы без дочерних фактов всё равно увеличивают число заказов; если у всех заказов группы нет фактов соответствующего вида, эта сумма равна 0. Осиротевшие дочерние факты в отчёт не входят.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Одна строка на каждый различный client_id из orders, включая одну общую NULL-группу при её наличии. Ровно четыре столбца в порядке client_id, order_count, items_sum, pay_sum, с этими именами. Все значения INTEGER; NULL допустим только в client_id. Сортировка: сначала NULL client_id, затем client_id ASC. Пустая orders даёт пустой результат. В проверочных результатах разделитель |, NULL — пустое поле. Допустим любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 6
Сравнение сумм до и после исправления JOIN
Что уже дано
Таблицы уже заполнены: orders(order_id INTEGER PRIMARY KEY,client_id INTEGER), items(item_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER NOT NULL), payments(payment_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER NOT NULL). Первичные ключи положительны и уникальны. client_id — произвольный INTEGER либо NULL, для расчётов не нужен. Дочерний order_id может быть NULL или ссылаться на отсутствующий заказ; такие факты не относятся к заказам отчёта. amount — целые копейки от 0 до 2 000 000 000, без NULL. В каждой таблице не более 1000 строк; суммы помещаются в 64-битный INTEGER. Разные позиции или платежи с равной суммой считаются отдельно.
Исходный отчёт аналитика:
SELECT o.order_id,SUM(i.amount) AS items_sum,SUM(p.amount) AS pay_sum FROM orders o LEFT JOIN items i ON i.order_id=o.order_id LEFT JOIN payments p ON p.order_id=o.order_id GROUP BY o.order_id;
Что нужно сделать
Исправьте суммы предварительной агрегацией и подготовьте сравнение для каждого заказа. Первые три столбца — order_id, корректные items_sum и pay_sum; при отсутствии дочерних фактов корректная сумма 0. Добавьте raw_items_sum и raw_pay_sum: точные результаты SUM из приведённого исходного запроса до исправления. Если соответствующих фактов нет, сырая сумма NULL. Не заменяйте её нулём: сравнение должно различать отсутствие фактов и нулевую сумму существующих фактов.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Одна строка на каждый orders.order_id, сортировка order_id ASC. Пустая orders даёт пустой результат. В проверочных результатах разделитель |, NULL — пустое поле. Допустим любой эквивалентный запрос. Ровно пять столбцов в порядке order_id, items_sum, pay_sum, raw_items_sum, raw_pay_sum. Все значения INTEGER; NULL допустим только в двух raw-столбцах.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
