Урок курса

Размножение строк при JOIN

Продвинутый SQL: сложные запросы и задачи для собеседований

Размножение строк при соединении «один ко многим»

Для изучения кардинальности соединений подготовим базу из трёх связанных таблиц: заказы (orders), позиции заказов (items) и платежи (payments).

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 REFERENCES orders(order_id),
  amount INTEGER NOT NULL
);

CREATE TABLE payments (
  payment_id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL REFERENCES orders(order_id),
  amount INTEGER NOT NULL
);

INSERT INTO orders (order_id, client_id) VALUES
  (1, 10),
  (2, 10),
  (3, 20);

INSERT INTO items (item_id, order_id, amount) VALUES
  (1, 1, 500),
  (2, 1, 300),
  (3, 2, 200),
  (4, 3, 100);

INSERT INTO payments (payment_id, order_id, amount) VALUES
  (10, 1, 400),
  (11, 1, 400),
  (12, 2, 200),
  (13, 3, 100);

Соединим таблицу заказов с таблицей позиций для заказа 1:

SELECT o.order_id, o.client_id, i.item_id, i.amount AS item_amount
FROM orders o
JOIN items i ON o.order_id = i.order_id
WHERE o.order_id = 1
ORDER BY i.item_id;

Результат запроса:

order_id client_id item_id item_amount
1 10 1 500
1 10 2 300

В таблице orders заказ 1 представлен ровно одной строкой. В таблице items этому заказу соответствуют две строки: позиция 1 на сумму 500 и позиция 2 на сумму 300. Условие ON o.order_id = i.order_id находит совпадение для каждой строки из дочерней таблицы. В результате одна строка заказа соединяется сначала с первой позицией, затем со второй.

Число строк в результирующей таблице изменилось: вместо одной строки для заказа 1 запрос вернул две. Изменилась гранулярность результата: если в orders одна строка описывала заказ целиком, то после соединения строка описывает отдельную товарную позицию в рамках заказа. Первичный ключ orders.order_id перестал быть уникальным идентификатором строки результата. Уникальным ключом строки теперь выступает items.item_id (или составная пара (order_id, item_id)).

Кардинальность промежуточного соединения для одного родительского ключа определяется числом совпадений в дочерней таблице. Если у родительской записи ровно дочерних строк, внутреннее соединение вернёт строк. Это штатное поведение реляционного соединения: база данных формирует все допустимые комбинации строк, удовлетворяющие предикату соединения.

Перемножение двух независимых дочерних таблиц

Теперь добавим к заказу 1 вторую дочернюю таблицу — платежи (payments). Позиции и платежи не связаны между собой напрямую: позиции фиксируют состав покупки, а платежи — транзакции оплаты. Обе таблицы ссылаются только на родительскую таблицу orders по ключу order_id.

Выполним последовательное соединение всех трёх таблиц без агрегации:

SELECT o.order_id, i.item_id, i.amount AS item_amount, p.payment_id, p.amount AS pay_amount
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN payments p ON o.order_id = p.order_id
WHERE o.order_id = 1
ORDER BY i.item_id, p.payment_id;

Результат:

order_id item_id item_amount payment_id pay_amount
1 1 500 10 400
1 1 500 11 400
1 2 300 10 400
1 2 300 11 400

В таблице items для заказа 1 есть 2 записи. В таблице payments для заказа 1 также есть 2 записи (платежи 10 и 11 по 400). На выходе получилось 4 строки.

Такой результат возникает из-за последовательной работы оператора соединения:

  1. Первый шаг orders JOIN items сформировал 2 строки: (заказ 1, позиция 1) и (заказ 1, позиция 2).
  2. Второй шаг присоединяет таблицу payments по условию o.order_id = p.order_id. Для каждой из двух строк промежуточного набора СУБД находит в таблице payments все строки с order_id = 1. Их две.
  3. Строка (заказ 1, позиция 1) соединяется с платежом 10 и платежом 11. Строка (заказ 1, позиция 2) также соединяется с платежом 10 и платежом 11.

Внутри одного ключа заказа образовалось локальное декартово произведение строк двух несвязанных списков. Если у заказа позиций и платежей, число строк промежуточного соединения равно ровно . В данном случае строки. Если бы у заказа было 3 позиции и 2 платежа, результат содержал бы 6 строк. База данных выполнила ровно то, что предписано предикатами: нашла все пары строк, у которых совпадает order_id.

Искажение агрегатной функции SUM

Попытка вычислить итоговую стоимость позиций и сумму платежей в одном запросе с группировкой по order_id приводит к математической ошибке:

SELECT o.order_id,
       SUM(i.amount) AS distorted_item_sum,
       SUM(p.amount) AS distorted_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
ORDER BY o.order_id;

Результат выполнения:

order_id distorted_item_sum distorted_pay_sum
1 1600 1600
2 200 200
3 100 100

Проверим фактические суммы заказа 1 напрямую по исходным таблицам:

  • Позиции заказа 1: 500 + 300 = 800.
  • Платежи заказа 1: 400 + 400 = 800.

В отчёте вместо 800 в обеих колонках выведено 1600. Причина искажения видна в четырёх детальных строках промежуточного соединения из предыдущего раздела:

Строка 1: позиция 500, платёж 400
Строка 2: позиция 500, платёж 400
Строка 3: позиция 300, платёж 400
Строка 4: позиция 300, платёж 400

Функция SUM работает построчно над потоком данных, сформированным блоком FROM и JOIN. Она не знает, из какой исходной таблицы пришёл столбец:

  • Сумма позиций вычисляется как: 500 + 500 + 300 + 300 = 1600.
  • Сумма платежей вычисляется как: 400 + 400 + 400 + 400 = 1600.

Каждая позиция сложилась ровно 2 раза — столько строк платежей зарегистрировано для заказа 1. Каждый платёж сложился ровно 2 раза — столько позиций открыто в заказе 1.

Общее правило: при объединении двух независимых таблиц с кардинальностями и каждое значение первой таблицы суммируется раз, а каждое значение второй таблицы — раз. Итоговая сумма по первой таблице завышается в раз, по второй — в раз.

Для заказов 2 и 3 суммы не исказились (200 и 100), потому что у каждого из них ровно 1 позиция и 1 платёж: . Однако корректность крайних случаев не делает запрос верным.

Условие корректности SUM: агрегат даёт верный результат только тогда, когда каждое учитываемое числовое значение попадает в поток агрегации ровно один раз. Размножение строк нарушает это условие.

Диагностика размножения и ловушка GROUP BY

Оператор GROUP BY o.order_id сворачивает строки и возвращает ровно одну строку на заказ. Это создаёт иллюзию корректности: структура отчёта выглядит правильно, дублирующихся строк заказов на экране нет, однако вычисленные агрегаты уже содержат искажения.

Для проверки наличия размножения строк применяются два диагностических шага.

Шаг 1: Проекция первичных ключей всех соединённых таблиц без агрегации

В запрос добавляются первичные ключи каждой участвующей таблицы без GROUP BY:

SELECT o.order_id, i.item_id, p.payment_id
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN payments p ON o.order_id = p.order_id
WHERE o.order_id = 1;

Если на один order_id приходится несколько различных пар (item_id, payment_id), строки размножаются.

Шаг 2: Проверка кардинальности промежуточного соединения через COUNT(*)

Оценим количество строк, формируемых соединением для каждого заказа:

SELECT o.order_id, COUNT(*) AS intermediate_row_cnt
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN payments p ON o.order_id = p.order_id
WHERE o.client_id = 10
GROUP BY o.order_id
ORDER BY o.order_id;

Результат:

order_id intermediate_row_cnt
1 4
2 1

Затем подсчитаем число записей в каждой дочерней таблице независимо:

SELECT 'items' AS source, order_id, COUNT(*) AS row_cnt, SUM(amount) AS total_amount
FROM items
WHERE order_id = 1
GROUP BY order_id
UNION ALL
SELECT 'payments' AS source, order_id, COUNT(*) AS row_cnt, SUM(amount) AS total_amount
FROM payments
WHERE order_id = 1
GROUP BY order_id;

Результат сверки:

source order_id row_cnt total_amount
items 1 2 800
payments 1 2 800

В таблице items для заказа 1 получено 2 строки. В payments — 2 строки. Произведение точно совпадает со значением intermediate_row_cnt из промежуточного соединения. Это подтверждает наличие размножения.

Случайная делимость полученной суммы на число строк (например, 1600 делится на 2) не является доказательством размножения: корректная сумма тоже может делиться нацело на любое число. Единственным строгим подтверждением ошибки служит сопоставление вычисленного агрегата с результатом независимого запроса к исходной таблице.

Задача 1

Таблица orders содержит 1 строку (order_id = 1). Таблица items содержит 3 строки с order_id = 1. Таблица payments содержит 2 строки с order_id = 1. Выполняется запрос:

SELECT o.order_id, i.item_id, p.payment_id
FROM orders o
JOIN items i ON o.order_id = i.order_id
JOIN payments p ON o.order_id = p.order_id;

Сколько строк вернёт этот запрос?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Таблица orders: (order_id=1). Таблица items: (item_id=1, order_id=1, amount=100), (item_id=2, order_id=1, amount=200). Таблица payments: (payment_id=10, order_id=1, amount=50), (payment_id=11, order_id=1, amount=150).

Аналитик выполнил запрос:

SELECT o.order_id,
SUM(i.amount) AS item_sum,
SUM(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;

Какой результат вернёт запрос для order_id = 1?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Выберите полное соответствие четырёх независимых наблюдений объяснениям.

A) У одного заказа3позиции и2платежа; INNER JOIN даёт6строк.
B) У другого заказа разные суммы позиций дают в исходной таблице300; после JOIN с3платежами SUM(i.amount)=900.
C) После GROUP BY по заказу видна1строка, но итог завышен.
D) Независимый COUNT(*) по items для одного заказа равен3.

1 — Каждая позиция повторилась по числу платежей.
2 — Группировка свернула вывод, но суммировала уже размноженные факты.
3 — Соединение создало все пары позиций и платежей одного ключа.
4 — Подсчитан один из множителей числа пар.

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

У заказа7есть4позиции с положительными суммами и3платежа. Аналитик соединяет orders o, items i, payments p внутренними JOIN по order_id и считает SUM(i.amount), группируя по заказу. Выберите полный ответ: число строк до группировки, множитель суммы позиций и запрос, показывающий отдельные пары.

Общий блокFROM для всех запросов:

FROM orders o
JOIN items i ON o.order_id=i.order_id
JOIN payments p ON o.order_id=p.order_id
WHERE o.order_id=7

ЗапросA: SELECT o.order_id,i.item_id,p.payment_id с общим блокомFROM, без GROUP BY.
ЗапросB: SELECT o.order_id,SUM(i.amount) с общим блокомFROM и GROUP BY o.order_id.
ЗапросC: SELECT o.order_id,COUNT(*) с общим блокомFROM и GROUP BY o.order_id.
ЗапросD: SELECT DISTINCT o.order_id с общим блокомFROM.

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

Все пары позиций и платежей заказа

Что уже дано

В SQLite уже созданы и заполнены orders(order_id INTEGER PRIMARY KEY,client_id INTEGER), items(item_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER) и payments(payment_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER). Первичные ключи положительные и уникальные. client_id — положительный INTEGER либо NULL. Ссылки order_id в дочерних таблицах могут быть NULL или указывать на отсутствующий заказ. amount — целые копейки от0до2000000000 либо NULL; NULL означает неизвестную сумму. Каждая таблица содержит не более100строк; итоги помещаются в64-битныйINTEGER. Равные суммы разных записей — отдельные факты.

Что нужно сделать

Для заказа order_id=1покажите все пары его позиции и его платежа, которые образует INNER JOIN обеих дочерних таблиц с orders. Каждая подходящая пара должна быть отдельной строкой; суммы не агрегируйте. Если заказ отсутствует или хотя бы одна дочерняя таблица не даёт совпадений, результат пуст.

Ввод и вывод

Отправьте один SELECT или WITH-запрос. stdin и печать не используются. Верните ровно5столбцов в порядке order_id, item_id, item_amount, payment_id, payment_amount с этими именами. Идентификаторы — INTEGER без NULL; суммы — INTEGER либо NULL из соответствующих строк. Сортировка: item_id ASC, затем payment_id ASC. В отображении тестов разделительстолбцов|; NULL представлен пустым полем. Допустим любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

Задача 6

Диагностика клиентского отчёта

Что уже дано

В SQLite уже созданы и заполнены orders(order_id INTEGER PRIMARY KEY,client_id INTEGER), items(item_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER) и payments(payment_id INTEGER PRIMARY KEY,order_id INTEGER,amount INTEGER). Первичные ключи положительные и уникальные. client_id — положительный INTEGER либо NULL. Ссылки order_id в дочерних таблицах могут быть NULL или указывать на отсутствующий заказ. amount — целые копейки от0до2000000000 либо NULL; NULL означает неизвестную сумму. Каждая таблица содержит не более100строк; итоги помещаются в64-битныйINTEGER. Равные суммы разных записей — отдельные факты.

Что нужно сделать

Проверьте отчёт для client_id=10. Ошибочная версия считает SUM(i.amount) и SUM(p.amount) по всем строкам orders INNER JOIN items INNER JOIN payments, группируя по client_id. Для каждого заказа этого клиента, который попал в такое внутреннее соединение, выведите число промежуточных пар. Рядом повторите четыре итога по всему клиенту10: обе суммы ошибочного соединения и обе фактические суммы, рассчитанные независимо по исходным позициям и платежам всех его заказов. Фактические суммы учитывают и заказы, у которых другая дочерняя таблица пуста. NULL в суммах игнорируется; если все участвующие значения неизвестны либо фактов нет, соответствующий итог NULL. Ничего не заменяйте нулём.

Ввод и вывод

Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно6столбцов: order_id, intermediate_row_cnt, client_join_item_sum, client_join_pay_sum, client_true_item_sum, client_true_pay_sum, в этом порядке и с этими именами. Первые2столбца INTEGER без NULL; остальные INTEGER либо NULL. Одна строка на заказ, сортировка order_id ASC. Если ни один заказ клиента10не даёт пару позиции/платежа, результат пуст. Клиентские суммы одинаковы во всех строках. В тестах разделитель|, NULL — пустое поле. Допустим любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

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

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

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