Урок курса

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 — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

Задача 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 — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

Задача 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 — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

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

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

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