Урок курса

Предварительная агрегация перед 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. Их реальная сумма равна

  1. В таблице payments находятся два платежа по 150, их сумма равна
  2. Если соединить эти таблицы напрямую и посчитать суммы в одной группировке, результат исказится:
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.

Шаблон состоит из трёх шагов:

  1. Свернуть позиции в CTE item_totals с группировкой по order_id.
  2. Свернуть платежи в CTE payment_totals с группировкой по order_id.
  3. В основном запросе взять строки из 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

Эффекты:

  1. Даёт правильный результат 400 — каждая позиция посчитана один раз
  2. Даёт завышенный результат 800 — каждая позиция посчитана дважды из-за 2 платежей
  3. Даёт заниженный результат 300 — одна из одинаковых позиций (100) отброшена
  4. Даёт заниженный результат 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 — пустое поле. Допустим любой эквивалентный запрос.

РешениеSQL · SQLite

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 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-столбцах.

РешениеSQL · SQLite

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку