Урок курса
AS-OF JOIN: выбор исторического значения
Продвинутый SQL: сложные запросы и задачи для собеседованийПоиск исторического состояния на момент события
В аналитических витринах часто требуется узнать состояние объекта именно в ту секунду, когда с ним произошло событие. Примером служит расчёт стоимости: заказ должен получить не текущую цену товара из каталога и не случайную запись из истории, а ту цену, которая действовала непосредственно в момент оформления покупки.
Создадим таблицы заказов orders и журнала цен price_history:
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
order_at TEXT NOT NULL
);
CREATE TABLE price_history (
version_id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL,
effective_at TEXT NOT NULL,
unit_price INTEGER NOT NULL
);
INSERT INTO orders (order_id, product_id, order_at) VALUES
(1, 100, '2024-01-10 10:00:00'),
(2, 100, '2024-01-01 09:00:00'),
(3, 200, '2024-01-15 12:00:00');
INSERT INTO price_history (version_id, product_id, effective_at, unit_price) VALUES
(1, 100, '2024-01-05 00:00:00', 50),
(2, 100, '2024-01-10 10:00:00', 70),
(3, 100, '2024-01-10 10:00:00', 80),
(4, 100, '2024-01-12 00:00:00', 95),
(5, 200, '2024-01-14 08:00:00', 150);
Таблица orders содержит три строки:
| order_id | product_id | order_at |
|---|---|---|
| 1 | 100 | 2024-01-10 10:00:00 |
| 2 | 100 | 2024-01-01 09:00:00 |
| 3 | 200 | 2024-01-15 12:00:00 |
Таблица price_history хранит смену цен во времени:
| version_id | product_id | effective_at | unit_price |
|---|---|---|---|
| 1 | 100 | 2024-01-05 00:00:00 | 50 |
| 2 | 100 | 2024-01-10 10:00:00 | 70 |
| 3 | 100 | 2024-01-10 10:00:00 | 80 |
| 4 | 100 | 2024-01-12 00:00:00 | 95 |
| 5 | 200 | 2024-01-14 08:00:00 | 150 |
Временные метки хранятся в строковом формате ISO-8601 UTC (YYYY-MM-DD HH:MM:SS), поэтому операторы <, <=, > и >= сравнивают даты в хронологическом порядке.
Посмотрим на заказ 1: он оформлен 2024-01-10 10:00:00 на товар 100. В истории цен для товара 100 есть четыре записи. Запись 4 с датой 2024-01-12 00:00:00 относится к будущему по отношению к заказу. Использовать её нельзя: в момент оформления заказа этой цены ещё не существовало. Подходят только записи, у которых effective_at <= order_at: версии 1, 2 и 3. Среди этих подходящих кандидатов актуальной является самая свежая запись на момент заказа.
Такая операция сопоставления события с последней по времени версией справочника, не превышающей время события, называется AS-OF JOIN (буквально: соединение по состоянию «на момент времени»). В SQLite нет специального оператора ASOF JOIN, поэтому этот механизм реализуется стандартными конструкциями языка. При этом гранулярность результата должна строго соответствовать левой таблице: на каждый order_id в выходе должна остаться ровно одна строка.
Реализация через LEFT JOIN и ранжирование ROW_NUMBER()
Реализация AS-OF JOIN состоит из трёх последовательных шагов.
Сначала соединяем заказы с историей через LEFT JOIN. В условии соединения указываем равенство бизнес-ключа товара и неэквивалентное условие времени: ON o.product_id = p.product_id AND p.effective_at <= o.order_at. Строки из будущего отсекаются сразу в предикате соединения.
Выполним это соединение и посмотрим на сформированные строки-кандидаты:
SELECT
o.order_id,
o.product_id,
o.order_at,
p.version_id,
p.effective_at,
p.unit_price
FROM orders o
LEFT JOIN price_history p
ON o.product_id = p.product_id
AND p.effective_at <= o.order_at
ORDER BY o.order_id, p.effective_at DESC, p.version_id DESC;
Результат:
| order_id | product_id | order_at | version_id | effective_at | unit_price |
|---|---|---|---|---|---|
| 1 | 100 | 2024-01-10 10:00:00 | 3 | 2024-01-10 10:00:00 | 80 |
| 1 | 100 | 2024-01-10 10:00:00 | 2 | 2024-01-10 10:00:00 | 70 |
| 1 | 100 | 2024-01-10 10:00:00 | 1 | 2024-01-05 00:00:00 | 50 |
| 2 | 100 | 2024-01-01 09:00:00 | NULL | NULL | NULL |
| 3 | 200 | 2024-01-15 12:00:00 | 5 | 2024-01-14 08:00:00 | 150 |
Для заказа 1 вернулись три подходящие строки. Запись 4 с ценой 95 не попала в вывод, так как её effective_at (2024-01-12) больше даты заказа. Заказ 2 получил NULL, поскольку до 2024-01-01 записей цен для товара 100 не было вовсе. Заказ 3 нашёл ровно одну предшествующую цену 150.
На втором шаге нужно выделить ровно одну самую свежую запись для каждого заказа. Для этого вычисляем оконную функцию ROW_NUMBER() OVER (PARTITION BY o.order_id ORDER BY p.effective_at DESC, p.version_id DESC) AS rn внутри CTE.
На третьем шаге во внешнем запросе отбираем строки с WHERE rn = 1. Фильтр rn = 1 нельзя поставить в WHERE того же запроса, где вычисляется ROW_NUMBER(), так как по правилам логического порядка выполнения SQL фильтрация WHERE выполняется до вычисления оконных функций.
Итоговый запрос собирает схему целиком:
WITH ranked_candidates AS (
SELECT
o.order_id,
o.product_id,
o.order_at,
p.version_id,
p.unit_price,
ROW_NUMBER() OVER (
PARTITION BY o.order_id
ORDER BY p.effective_at DESC, p.version_id DESC
) AS rn
FROM orders o
LEFT JOIN price_history p
ON o.product_id = p.product_id
AND p.effective_at <= o.order_at
)
SELECT
order_id,
product_id,
order_at,
version_id,
unit_price
FROM ranked_candidates
WHERE rn = 1
ORDER BY order_id ASC;
Результат:
| order_id | product_id | order_at | version_id | unit_price |
|---|---|---|---|---|
| 1 | 100 | 2024-01-10 10:00:00 | 3 | 80 |
| 2 | 100 | 2024-01-01 09:00:00 | NULL | NULL |
| 3 | 200 | 2024-01-15 12:00:00 | 5 | 150 |
Фильтр rn = 1 устранил размножение строк, вернув результат к исходной гранулярности: ровно одна строка на один order_id.
Детерминированный выбор при совпадающих метках времени
В истории изменений нередко встречаются ситуации, когда несколько записей имеют абсолютно одинаковую отметку времени. В таблице price_history записи 2 и 3 имеют одно и то же значение effective_at: '2024-01-10 10:00:00'. Это происходит при технических корректировках или массовой загрузке данных в одну и ту же секунду.
Если отсортировать окно только по времени: ORDER BY p.effective_at DESC, то строки 2 и 3 будут равны с точки зрения компаратора. Порядок строк с одинаковым значением сортировки в SQL не гарантирован. База данных может вернуть первой как версию 2 (цена 70), так и версию 3 (цена 80). Результат аналитического отчёта станет недетерминированным: один и тот же запрос на неизменных данных сможет возвращать разные суммы заказа в зависимости от плана выполнения или внутренней перестройки страниц таблицы.
Порядок физической вставки строк в SQLite также не гарантирует порядок выборки. Единственный способ получить стабильный результат — добавить в секцию ORDER BY уникальный ключ, который разрешит равенство меток времени (tie-breaker).
В нашей схеме первичным ключом истории выступает монотонно возрастающий version_id. Условие ORDER BY p.effective_at DESC, p.version_id DESC сначала находит самые поздние даты, а при совпадении секунд отдаёт приоритет строке с максимальным version_id. Для заказа 1 версия 3 гарантированно получит rn = 1, а версия 2 — rn = 2. Добавление уникального ключа делает ранжирование полностью детерминированным.
Сохранение заказов без истории и ловушка INNER JOIN
При сопоставлении с историей критически важен тип соединения. Если вместо LEFT JOIN написать INNER JOIN, логика поиска сломается на событиях, у которых нет истории в прошлом.
Проверим поведение запроса с INNER JOIN:
WITH ranked_candidates AS (
SELECT
o.order_id,
o.product_id,
o.order_at,
p.version_id,
p.unit_price,
ROW_NUMBER() OVER (
PARTITION BY o.order_id
ORDER BY p.effective_at DESC, p.version_id DESC
) AS rn
FROM orders o
INNER JOIN price_history p
ON o.product_id = p.product_id
AND p.effective_at <= o.order_at
)
SELECT
order_id,
product_id,
order_at,
version_id,
unit_price
FROM ranked_candidates
WHERE rn = 1
ORDER BY order_id ASC;
Результат:
| order_id | product_id | order_at | version_id | unit_price |
|---|---|---|---|---|
| 1 | 100 | 2024-01-10 10:00:00 | 3 | 80 |
| 3 | 200 | 2024-01-15 12:00:00 | 5 | 150 |
Заказ 2 полностью выпал из результата. Он был совершен 2024-01-01 09:00:00, а первая запись о цене товара 100 появилась только 2024-01-05 00:00:00. Условие p.effective_at <= o.order_at не нашло ни одной подходящей строки в таблице цен, и INNER JOIN удалил сам заказ. В итоге нарушилась полнота отчёта: витрина потеряла факт совершения заказа.
Такая же потеря строки произойдёт, если оставить LEFT JOIN, но вынести ограничение по времени из блока ON в секцию WHERE:
-- Ошибочный запрос: WHERE отсекает строки с NULL
WITH ranked_candidates AS (
SELECT
o.order_id,
o.product_id,
o.order_at,
p.version_id,
p.unit_price,
ROW_NUMBER() OVER (
PARTITION BY o.order_id
ORDER BY p.effective_at DESC, p.version_id DESC
) AS rn
FROM orders o
LEFT JOIN price_history p
ON o.product_id = p.product_id
WHERE p.effective_at <= o.order_at
)
SELECT
order_id,
product_id,
order_at,
version_id,
unit_price
FROM ranked_candidates
WHERE rn = 1
ORDER BY order_id ASC;
В этом ошибочном запросе соединение ON o.product_id = p.product_id находит для заказа 2 все четыре записи товара 100 из price_history. Однако даты всех этих записей начинаются с 2024-01-05 и относятся к будущему относительно даты заказа 2024-01-01 09:00:00. Условие WHERE p.effective_at <= o.order_at для каждой из четырёх строк даёт FALSE и исключает их из выборки, удаляя заказ целиком. Если бы товар вообще не имел записей в price_history, LEFT JOIN сгенерировал бы строку с NULL, но предикат WHERE с вычислением NULL <= o.order_at вернул бы NULL и точно так же отбросил строку.
В обоих случаях перенос условия в WHERE превращает внешнее соединение во внутреннее.
Предикат p.effective_at <= o.order_at должен находиться именно в предложении ON таблицы price_history. В этом случае заказ 2 остаётся в выборке, получает строку с NULL во всех столбцах правой таблицы, успешно проходит ранжирование с rn = 1 и сохраняет исходную гранулярность таблицы заказов без потери строк.
Задача 1
Товар 501 имеет в журнале цен две записи: (version_id=1, effective_at='2024-06-01 12:00:00', price=100) и (version_id=2, effective_at='2024-06-01 12:00:00', price=120). Клиент оформил заказ в '2024-06-01 15:00:00'. В запросе для выбора актуальной цены используется оконная функция ROW_NUMBER() OVER (PARTITION BY o.order_id ORDER BY p.effective_at DESC, p.version_id DESC). Какая цена будет привязана к заказу и почему?
Задача 2
Заказ оформлен в '2024-02-01 10:00:00'. Первая запись в таблице истории цен для этого товара появилась только в '2024-02-05 00:00:00'. Что произойдёт с этим заказом в результирующем отчёте, если соединение таблиц orders и prices выполнено через LEFT JOIN ... ON p.product_id = o.product_id AND p.effective_at <= o.order_at с последующей фильтрацией WHERE rn = 1?
Задача 3
Аналитик объединил таблицу заказов с таблицей цен: orders o LEFT JOIN prices p ON o.product_id = p.product_id AND p.effective_at <= o.order_at. Затем он применил ROW_NUMBER() OVER (PARTITION BY o.order_id ORDER BY p.effective_at DESC). Почему сортировки только по p.effective_at DESC недостаточно для надёжного отчёта?
Задача 4
Историческая цена и её изменение между заказами
Что уже дано
Таблицы orders(order_id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, order_at TEXT NOT NULL) и price_history(version_id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, effective_at TEXT NOT NULL, unit_price INTEGER NOT NULL). До 500 строк в каждой; ID положительные, unit_price от 0 до 100000. Все метки UTC YYYY-MM-DD HH:MM:SS в 2024 году. Для товара возможны несколько версий с равным effective_at; ID не обязан возрастать со временем. Времена заказов тоже могут совпадать.
Что нужно сделать
Сохраните каждый заказ и назначьте ему цену товара на этот момент: допустима версия с effective_at не позже order_at; побеждает максимальная пара (effective_at,version_id). Если версии нет, version_id и unit_price остаются NULL. Дополнительно покажите изменение этой цены относительно цены предыдущего заказа того же товара в порядке (order_at,order_id): текущая цена минус предыдущая. Для первого заказа товара и при неизвестности любой из двух цен разница NULL. Одна строка на заказ.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 6 столбцов в указанном порядке и с этими именами: order_id, product_id, order_at, version_id, unit_price, price_delta_from_previous_order. order_id, product_id, version_id, unit_price и price_delta_from_previous_order — INTEGER; order_at — TEXT. NULL возможен в version_id, unit_price и разнице. Сортировка: order_id ASC. Пустая orders — пустой результат. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 5
Ставка на момент действия и возраст версии
Что уже дано
Таблицы user_actions(action_id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, plan_id INTEGER NOT NULL, performed_at TEXT NOT NULL) и plan_rates(rate_id INTEGER PRIMARY KEY, plan_id INTEGER NOT NULL, effective_at TEXT NOT NULL, rate_per_unit REAL NOT NULL). До 500 строк в каждой, ID положительные. Ставка — целое от 0 до 1000, сохранённое как REAL. UTC-метки YYYY-MM-DD HH:MM:SS в 2024 году. Равные effective_at допустимы.
Что нужно сделать
Сохраните каждое действие и назначьте последнюю по effective_at ставку его плана, действовавшую не позже performed_at. При равном времени выбирайте больший rate_id. Дополнительно покажите rate_age_seconds — полное число секунд между началом выбранной версии и действием. Если версии нет, все три поля ставки и её возраста NULL.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 7 столбцов в указанном порядке и с этими именами: action_id, user_id, plan_id, performed_at, rate_id, rate_per_unit, rate_age_seconds. action_id, user_id, plan_id, rate_id, rate_age_seconds — INTEGER; performed_at — TEXT; rate_per_unit — REAL. NULL только в трёх последних полях. Сортировка: action_id ASC. Пустая user_actions — пустой результат. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Задача 6
Списания по тарифам на момент операции
Оператор ведёт два журнала: в одном фиксируются списания со счёта абонентов, в другом — история смены тарифных планов с датами вступления в силу. Возникает естественный вопрос: под каким именно тарифом работал абонент в момент каждого конкретного списания, и сколько таких списаний и на какую общую сумму приходится на каждый из тарифов?
Для каждого списания нужно найти запись истории того же абонента, которая вступила в силу не позже момента этого списания и является при этом самой поздней из подходящих. Если несколько записей истории имеют одинаковую дату вступления в силу, среди них выбирается та, у которой наибольший идентификатор версии. Каждое списание учитывается ровно один раз — в той группе, которой соответствует найденный тарифный план. Если для списания не нашлось ни одной подходящей записи истории, оно всё равно учитывается — в отдельной сводной группе, обозначающей неизвестный план.
В итоге запрос должен вернуть по одной строке на каждый тарифный план, встретившийся среди операций, плюс одну строку для группы с неизвестным планом — если такие списания есть. Для каждой строки указывается идентификатор тарифного плана (или признак его отсутствия), количество попавших в группу списаний и их суммарная величина. Списания разных абонентов с одним и тем же тарифом объединяются в одну строку.
Что уже дано
Таблицы charges(charge_id INTEGER PRIMARY KEY, subscriber_id INTEGER NOT NULL, charged_at TEXT NOT NULL, amount INTEGER NOT NULL) и plan_history(version_id INTEGER PRIMARY KEY, subscriber_id INTEGER NOT NULL, effective_at TEXT NOT NULL, plan_id INTEGER NOT NULL). До 500 строк в каждой. ID положительные; amount — целые копейки от 1 до 100000. Время UTC YYYY-MM-DD HH:MM:SS в 2024 году; несколько версий одного времени допустимы. План обозначается только идентификатором plan_id, отдельного справочника названий нет.
Что нужно сделать
Сформируйте описанную сводку по плану, действовавшему для абонента в момент каждого списания. Допустима версия effective_at <= charged_at, из них выбирается максимальное время и затем максимальный version_id. Все списания без допустимой истории объедините в одну NULL-группу. Каждое списание учитывается ровно один раз, даже при равной сумме; разные абоненты одного плана объединяются. Неиспользованный план строки не создаёт.
Ввод и вывод
Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 3 столбцов в указанном порядке и с этими именами: plan_id, charges_count, total_amount. plan_id, charges_count и total_amount — INTEGER. NULL допустим только в plan_id неизвестной группы. Сортировка: сначала NULL plan_id, затем plan_id ASC. Пустая charges — пустой результат; NULL-группа появляется только при неизвестных списаниях. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
