Урок курса

FIRST_VALUE и LAST_VALUE

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

Первое значение раздела: поведение FIRST_VALUE

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

DROP TABLE IF EXISTS ticket_events;
CREATE TABLE ticket_events (
  event_id INTEGER PRIMARY KEY,
  ticket_id INTEGER NOT NULL,
  status_time TEXT NOT NULL,
  status TEXT NOT NULL,
  assigned_agent TEXT NOT NULL
);

INSERT INTO ticket_events (event_id, ticket_id, status_time, status, assigned_agent) VALUES
  (1, 101, '2023-10-01 09:00:00', 'NEW', 'alice'),
  (2, 101, '2023-10-01 10:30:00', 'IN_PROGRESS', 'bob'),
  (3, 101, '2023-10-01 12:00:00', 'RESOLVED', 'bob'),
  (4, 102, '2023-10-02 08:00:00', 'NEW', 'charlie'),
  (5, 102, '2023-10-02 08:00:00', 'ASSIGNED', 'alice'),
  (6, 102, '2023-10-02 11:00:00', 'CLOSED', 'alice');

Таблица содержит следующие строки:

event_id ticket_id status_time status assigned_agent
1 101 2023-10-01 09:00:00 NEW alice
2 101 2023-10-01 10:30:00 IN_PROGRESS bob
3 101 2023-10-01 12:00:00 RESOLVED bob
4 102 2023-10-02 08:00:00 NEW charlie
5 102 2023-10-02 08:00:00 ASSIGNED alice
6 102 2023-10-02 11:00:00 CLOSED alice

Оконная функция FIRST_VALUE(expr) возвращает значение выражения expr для первой физической строки текущей оконной рамки. Выполним запрос с рамкой по умолчанию:

SELECT 
  ticket_id,
  status_time,
  event_id,
  status,
  FIRST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
  ) AS initial_status
FROM ticket_events
ORDER BY ticket_id, status_time, event_id;

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

ticket_id status_time event_id status initial_status
101 2023-10-01 09:00:00 1 NEW NEW
101 2023-10-01 10:30:00 2 IN_PROGRESS NEW
101 2023-10-01 12:00:00 3 RESOLVED NEW
102 2023-10-02 08:00:00 4 NEW NEW
102 2023-10-02 08:00:00 5 ASSIGNED NEW
102 2023-10-02 11:00:00 6 CLOSED NEW

Для всех строк каждого тикета столбец initial_status содержит значение NEW. Это происходит потому, что при наличии ORDER BY и отсутствии явного указания границ окна применяется рамка по умолчанию: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Нижняя граница этой рамки — UNBOUNDED PRECEDING. Она всегда указывает на самую первую строку текущего раздела. Первая строка рамки совпадает с началом раздела при обработке любой строки тикета, поэтому FIRST_VALUE успешно возвращает первое значение на протяжении всего раздела.

Ловушка рамки по умолчанию при использовании LAST_VALUE

По аналогии с FIRST_VALUE функция LAST_VALUE(expr) призвана вычислять граничное значение. Ожидание часто состоит в том, что LAST_VALUE вернёт последнее значение всего раздела. Попробуем получить итоговый статус тикета тем же способом, не задавая рамку явно:

SELECT 
  ticket_id,
  status_time,
  event_id,
  status,
  LAST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
  ) AS naive_last_status
FROM ticket_events
ORDER BY ticket_id, status_time, event_id;

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

ticket_id status_time event_id status naive_last_status
101 2023-10-01 09:00:00 1 NEW NEW
101 2023-10-01 10:30:00 2 IN_PROGRESS IN_PROGRESS
101 2023-10-01 12:00:00 3 RESOLVED RESOLVED
102 2023-10-02 08:00:00 4 NEW NEW
102 2023-10-02 08:00:00 5 ASSIGNED ASSIGNED
102 2023-10-02 11:00:00 6 CLOSED CLOSED

Вместо финального статуса (RESOLVED для тикета 101 и CLOSED для тикета 102) столбец naive_last_status в каждой строке просто дублирует значение status текущей строки.

Причина кроется в правилах стандарта SQL. Функция LAST_VALUE возвращает значение для последней строки текущей рамки, а не всего раздела. Неявная рамка по умолчанию RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW заканчивается на текущей строке (точнее, на последней строке группы с равными значениями ключа сортировки). При расчёте для первой строки тикета 101 рамка видит только строки от начала раздела до текущей позиции — то есть только строку 1. Последней строкой в этой рамке оказывается сама строка 1 со значением NEW.

На строке 2 рамка расширяется до строк 1 и 2, и её последней строкой становится строка 2 со значением IN_PROGRESS. Окно ещё не дошло до конца раздела, поэтому LAST_VALUE физически не может увидеть будущие записи.

Симметричная рамка для обоих концов раздела

Чтобы LAST_VALUE возвращала итоговое значение раздела, верхнюю границу рамки необходимо явно расширить до самого конца раздела. Для этого используется конструкция ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

Шаблон одновременного расчёта начального и конечного значений раздела выглядит следующим образом:

SELECT 
  ticket_id,
  status_time,
  event_id,
  status,
  FIRST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS initial_status,
  LAST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS final_status
FROM ticket_events
ORDER BY ticket_id, status_time, event_id;

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

ticket_id status_time event_id status initial_status final_status
101 2023-10-01 09:00:00 1 NEW NEW RESOLVED
101 2023-10-01 10:30:00 2 IN_PROGRESS NEW RESOLVED
101 2023-10-01 12:00:00 3 RESOLVED NEW RESOLVED
102 2023-10-02 08:00:00 4 NEW NEW CLOSED
102 2023-10-02 08:00:00 5 ASSIGNED NEW CLOSED
102 2023-10-02 11:00:00 6 CLOSED NEW CLOSED

Явное определение ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING фиксирует рамку окна равной всему разделу для каждой строки. В этом случае первая строка рамки — это всегда первая строка тикета, а последняя строка рамки — последняя строка тикета по порядку сортировки. Теперь initial_status и final_status стабильно содержат начальное и финальное состояния на протяжении всей истории тикета.

Разрешение одинаковых временных меток и tie-breaker

Обратим внимание на события тикета 102: строки с event_id 4 и 5 имеют абсолютно одинаковое время 2023-10-02 08:00:00. Если в предложении ORDER BY внутри окна указан только столбец status_time, порядок строк с одинаковым временем стандартом не определён.

Посмотрим на запрос без вторичного ключа сортировки:

SELECT 
  ticket_id,
  status_time,
  status,
  FIRST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS ambiguous_first_status
FROM ticket_events
WHERE ticket_id = 102
ORDER BY ticket_id, status_time;

Результат в текущем запуске:

ticket_id status_time status ambiguous_first_status
102 2023-10-02 08:00:00 NEW NEW
102 2023-10-02 08:00:00 ASSIGNED NEW
102 2023-10-02 11:00:00 CLOSED NEW

Хотя здесь первой строкой оказалась запись со статусом NEW, реляционная СУБД имеет право вернуть строки с одинаковым временем в любом физическом порядке. Если оптимизатор или план выполнения изменятся, первой строкой раздела может оказаться ASSIGNED, и столбец ambiguous_first_status вернёт ASSIGNED для всех строк.

Чтобы устранить неоднозначность, в ORDER BY внутри OVER добавляют уникальный столбец — tie-breaker. В нашей таблице это первичный ключ event_id:

SELECT 
  ticket_id,
  status_time,
  event_id,
  status,
  FIRST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS deterministic_first_status,
  LAST_VALUE(status) OVER (
    PARTITION BY ticket_id 
    ORDER BY status_time, event_id
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS deterministic_final_status
FROM ticket_events
WHERE ticket_id = 102
ORDER BY ticket_id, status_time, event_id;

Результат:

ticket_id status_time event_id status deterministic_first_status deterministic_final_status
102 2023-10-02 08:00:00 4 NEW NEW CLOSED
102 2023-10-02 08:00:00 5 ASSIGNED NEW CLOSED
102 2023-10-02 11:00:00 6 CLOSED NEW CLOSED

При равенстве status_time сортировка по event_id гарантирует, что строка 4 всегда предшествует строке 5. Детерминированный порядок внутри окна в сочетании с явной полной рамкой ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING обеспечивает строго воспроизводимый результат вычислений FIRST_VALUE и LAST_VALUE.

Задача 1

Дана таблица фактов: (id, category, score), отсортированная по score: (1, 'A', 10), (2, 'A', 20), (3, 'A', 30). Выполняется запрос без явного указания рамки:
SELECT id, score, LAST_VALUE(score) OVER (PARTITION BY category ORDER BY score) AS last_s
FROM facts;
Какое значение last_s будет в строке с id = 2?

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

Задача 2

В разделе клиента три заказа по датам: (1 мая, 1000), (5 мая, 2500), (10 мая, 700). Для первой строки выберите верное сопоставление: A — FIRST_VALUE(amount) OVER (ORDER BY order_date); B — LAST_VALUE(amount) OVER (ORDER BY order_date); C — LAST_VALUE(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).

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

Задача 3

В таблице истории цен price_log(product_id, change_date, price, log_id) для одного товара есть две записи на одну и ту же дату с разными ценами: (101, '2023-05-01', 500, 1) и (101, '2023-05-01', 600, 2). Почему вызов FIRST_VALUE(price) OVER (PARTITION BY product_id ORDER BY change_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) считается недетерминированным и как обеспечить стабильность результата?

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

Задача 4

Первый и итоговый статус тикета

Что уже дано

Таблица support_tickets(ticket_id INTEGER NOT NULL, status_time TEXT NOT NULL, status TEXT NOT NULL, log_id INTEGER PRIMARY KEY). До 500 событий; ID положительные. Время UTC YYYY-MM-DD HH:MM:SS в 2024 году, status — непустая ASCII-строка. Повтор времени возможен; меньший log_id при равном времени идёт раньше.

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

Сохраните каждое событие и добавьте начальный и итоговый статус его тикета по всей истории. История упорядочена по status_time ASC, log_id ASC; итоговая таблица — по ticket_id, log_id.

Ввод и вывод

Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 6 столбцов в указанном порядке и с этими именами: ticket_id, log_id, status_time, status, initial_status, final_status. ticket_id и log_id — INTEGER; остальные — TEXT; NULL нет. Сортировка: ticket_id ASC, log_id ASC. Пустая история — пустой результат. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

Задача 5

Изменение нагрузки от первого замера

Что уже дано

Таблица server_metrics(server_id INTEGER NOT NULL, check_time TEXT NOT NULL, metric_id INTEGER PRIMARY KEY, cpu_load REAL NOT NULL). До 500 строк; ID положительные; время UTC YYYY-MM-DD HH:MM:SS в 2024 году; cpu_load — целое от 0 до 100, сохранённое как REAL. Равные времена допустимы.

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

Для каждого замера выведите первое значение нагрузки этого сервера first_cpu_load и разницу текущей нагрузки с первым значением cpu_load_diff. Первое событие определяется порядком check_time ASC, metric_id ASC.

Ввод и вывод

Отправьте один SELECT или WITH-запрос; stdin и печать не используются. Ровно 5 столбцов в указанном порядке и с этими именами: server_id, check_time, cpu_load, first_cpu_load, cpu_load_diff. server_id — INTEGER; check_time — TEXT; cpu_load, first_cpu_load, cpu_load_diff — REAL; NULL нет. Сортировка: server_id ASC, metric_id ASC. Пустая таблица — пустой результат. В тестовых результатах разделитель |, NULL — пустое поле. Допускается любой эквивалентный запрос.

РешениеSQL · SQLite

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

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

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

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

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