Преобразование маркированных интервалов времени в хронологию событий
Любопытная задача по SQL с технического собеседования в Amazon. Статистика успешных решений на тренажёре - менее 5% на 9 октября 2025 г. Суть: нужно преобразовать последовательность интервалов, каждый из которых имеет определённый маркер, в последовательность событий, отражающих смену маркеров в момент времени.
Условие
Дана таблица subscriptions с историей всех подписочных периодов для каждого клиента.
Напишите SQL-запрос, который преобразует историю подписок в хронологический список событий.
Пример исходных данных:
| customer_id | membership_start_date | membership_end_date | membership_status |
|---|---|---|---|
| 115 | 2020-01-01 | 2020-02-15 | Free |
| 115 | 2020-02-15 | 2020-03-15 | Paid |
| 115 | 2020-03-15 | 2020-04-01 | Non-member |
| 115 | 2020-04-01 | 2020-10-01 | Paid |
Основные правила переходов:
Free → Paid: 'Convert'
Paid → Free: 'ReverseConvert'
Paid → Non-member: 'Cancel'
Free → Non-member: 'Cancel'
Non-member → Paid: 'ColdStart'
Non-member → Free: 'WarmStart'
Paid → Paid: 'Renewal'
Free → Free: 'Renewal'
Пример результата:
| customer_id | change_date | event |
|---|---|---|
| 115 | 2020-01-01 | WarmStart |
| 115 | 2020-02-15 | Convert |
| 115 | 2020-03-15 | Cancel |
| 115 | 2020-04-01 | ColdStart |
| 115 | 2020-10-01 | Cancel |
Поля в результирующей таблице: customer_id, change_date, event.
Отсортируйте результат в порядке возрастания по customer_id, а затем по change_date.
Исходные данные
CREATE TABLE subscriptions (
customer_id INT NOT NULL,
membership_start_date DATE NOT NULL,
membership_end_date DATE NOT NULL,
membership_status VARCHAR NOT NULL CHECK (membership_status IN ('Paid', 'Free', 'Non-member'))
);
INSERT INTO subscriptions (customer_id, membership_start_date, membership_end_date, membership_status)
VALUES
(6234, '2020-01-20', '2020-04-12', 'Paid'),
(8112, '2020-02-10', '2020-03-10', 'Free'),
(9543, '2020-02-15', '2020-04-01', 'Non-member'),
(7890, '2020-03-01', '2020-08-16', 'Paid'),
(8112, '2020-03-10', '2020-05-10', 'Paid'),
(9543, '2020-04-01', '2020-05-01', 'Free'),
(5501, '2020-04-01', '2020-09-15', 'Non-member'),
(6234, '2020-04-12', '2020-07-12', 'Non-member'),
(9543, '2020-05-01', '2020-07-16', 'Paid'),
(8112, '2020-05-10', '2020-08-25', 'Non-member'),
(6234, '2020-07-12', '2020-09-28', 'Paid'),
(9543, '2020-07-16', '2020-10-16', 'Non-member'),
(7890, '2020-08-16', '2020-11-16', 'Non-member'),
(8112, '2020-08-25', '2021-02-01', 'Non-member'),
(5501, '2020-09-15', '2020-10-15', 'Free'),
(6234, '2020-09-28', '2021-09-28', 'Paid'),
(5501, '2020-10-15', '2020-11-15', 'Free'),
(9543, '2020-10-16', '2021-02-08', 'Paid'),
(5501, '2020-11-15', '2021-04-10', 'Non-member'),
(7890, '2020-11-16', '2021-02-13', 'Paid'),
(8112, '2021-02-01', '2021-04-20', 'Paid'),
(9543, '2021-02-08', '2021-03-24', 'Non-member'),
(7890, '2021-02-13', '2021-07-16', 'Non-member'),
(9543, '2021-03-24', '2021-04-24', 'Free'),
(5501, '2021-04-10', '2021-05-10', 'Free'),
(8112, '2021-04-20', '2021-07-20', 'Non-member'),
(9543, '2021-04-24', '2021-05-24', 'Free'),
(5501, '2021-05-10', '2022-05-10', 'Paid'),
(9543, '2021-05-24', '2021-08-24', 'Paid'),
(7890, '2021-07-16', '2022-07-16', 'Paid'),
(8112, '2021-07-20', '2021-08-20', 'Free'),
(8112, '2021-08-20', '2021-11-20', 'Paid'),
(9543, '2021-08-24', '2021-11-11', 'Non-member'),
(6234, '2021-09-28', '2021-12-28', 'Non-member'),
(9543, '2021-11-11', '2021-12-11', 'Free'),
(8112, '2021-11-20', '2022-01-20', 'Paid'),
(9543, '2021-12-11', '2022-01-11', 'Free'),
(6234, '2021-12-28', '2022-02-28', 'Free'),
(9543, '2022-01-11', '2022-04-11', 'Non-member'),
(8112, '2022-01-20', '2022-04-18', 'Non-member'),
(6234, '2022-02-28', '2022-05-14', 'Paid'),
(9543, '2022-04-11', '2022-06-25', 'Non-member'),
(8112, '2022-04-18', '2022-09-02', 'Non-member'),
(5501, '2022-05-10', '2022-08-10', 'Non-member'),
(6234, '2022-05-14', '2022-09-12', 'Non-member'),
(9543, '2022-06-25', '2022-07-25', 'Free'),
(7890, '2022-07-16', '2022-08-16', 'Free'),
(9543, '2022-07-25', '2022-08-25', 'Free'),
(5501, '2022-08-10', '2023-01-05', 'Non-member'),
(7890, '2022-08-16', '2022-11-16', 'Paid'),
(9543, '2022-08-25', '2023-02-15', 'Non-member'),
(8112, '2022-09-02', '2022-10-02', 'Free'),
(6234, '2022-09-12', '2022-12-12', 'Non-member'),
(8112, '2022-10-02', '2022-11-02', 'Free'),
(8112, '2022-11-02', '2023-02-05', 'Non-member'),
(7890, '2022-11-16', '2023-03-05', 'Non-member'),
(6234, '2022-12-12', '2023-01-12', 'Free'),
(5501, '2023-01-05', '2023-02-05', 'Free'),
(6234, '2023-01-12', '2023-02-12', 'Free'),
(5501, '2023-02-05', '2023-03-05', 'Free'),
(8112, '2023-02-05', '2023-03-05', 'Free'),
(6234, '2023-02-12', '2023-06-15', 'Paid'),
(9543, '2023-02-15', '2023-03-15', 'Free'),
(7890, '2023-03-05', '2023-04-05', 'Free'),
(5501, '2023-03-05', '2023-06-05', 'Non-member'),
(8112, '2023-03-05', '2023-06-19', 'Non-member'),
(9543, '2023-03-15', '2023-04-15', 'Free'),
(7890, '2023-04-05', '2023-08-08', 'Paid'),
(9543, '2023-04-15', '2023-05-15', 'Free'),
(9543, '2023-05-15', '2023-06-15', 'Free'),
(5501, '2023-06-05', '2023-07-05', 'Free'),
(9543, '2023-06-15', '2023-07-15', 'Free'),
(6234, '2023-06-15', '2023-09-15', 'Non-member'),
(8112, '2023-06-19', '2024-06-19', 'Paid'),
(5501, '2023-07-05', '2023-10-05', 'Paid'),
(9543, '2023-07-15', '2023-09-28', 'Paid'),
(7890, '2023-08-08', '2023-11-20', 'Non-member'),
(6234, '2023-09-15', '2023-10-15', 'Free'),
(9543, '2023-09-28', '2024-03-01', 'Non-member'),
(5501, '2023-10-05', '2024-01-05', 'Paid'),
(6234, '2023-10-15', '2024-01-15', 'Paid'),
(7890, '2023-11-20', '2023-12-20', 'Free'),
(7890, '2023-12-20', '2024-02-23', 'Non-member'),
(5501, '2024-01-05', '2024-04-22', 'Non-member'),
(6234, '2024-01-15', '2024-04-03', 'Non-member'),
(7890, '2024-02-23', '2024-03-23', 'Free'),
(9543, '2024-03-01', '2024-04-01', 'Free'),
(7890, '2024-03-23', '2025-03-23', 'Paid'),
(9543, '2024-04-01', '2025-04-01', 'Paid'),
(6234, '2024-04-03', '2024-05-03', 'Free'),
(5501, '2024-04-22', '2024-05-22', 'Free'),
(6234, '2024-05-03', '2024-06-03', 'Free'),
(5501, '2024-05-22', '2025-05-22', 'Paid'),
(6234, '2024-06-03', '2024-09-03', 'Paid'),
(8112, '2024-06-19', '2024-07-19', 'Free'),
(8112, '2024-07-19', '2024-10-19', 'Paid'),
(6234, '2024-09-03', '2025-01-03', 'Paid'),
(8112, '2024-10-19', '2025-01-19', 'Paid'),
(6234, '2025-01-03', '2025-02-03', 'Free'),
(8112, '2025-01-19', '2025-02-19', 'Free'),
(6234, '2025-02-03', '2025-05-03', 'Non-member'),
(8112, '2025-02-19', '2025-03-19', 'Free'),
(7890, '2025-03-23', '2025-06-23', 'Non-member'),
(9543, '2025-04-01', '2025-07-01', 'Non-member'),
(5501, '2025-05-22', '2025-06-22', 'Free');
В чём вопрос?
Я могу решить эту задачу парой способов как любитель. Но мне хотелось бы увидеть размышления людей на опыте. Какие вещи вы поставите под сомнение? На что хочется ругаться? Какие идеи считаете правильным привлечь? И в конечном счете - в каком виде вы как интервьюер приняли бы ответ?
Ответы (2 шт):
WITH cte1 AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY membership_start_date) rn
FROM subscriptions
)
SELECT COALESCE(t1.customer_id, t2.customer_id) customer_id,
COALESCE(t1.membership_start_date, t2.membership_end_date) change_date,
actions.event
FROM cte1 t1
FULL JOIN cte1 t2 ON t1.customer_id = t2.customer_id
AND t1.rn = t2.rn + 1
JOIN actions ON COALESCE(t1.membership_status, 'Non-member') = actions.status_to
AND COALESCE(t2.membership_status, 'Non-member') = actions.status_from
ORDER BY 1, 2;
Примечание: в таблицу изменений статусов добавлена запись
('Non-member','Non-member','Non-member')
Эта запись будет использоваться в случаях:
- В исходных данных имеется две записи со статусом
'Non-member'подряд (а такие случаи присутствуют); - Самая первая по датам запись имеет статус
'Non-member'(и такие тоже есть).
UPDATE
CREATE TEMPORARY TABLE actions (status_from VARCHAR, status_to VARCHAR, event VARCHAR);
INSERT INTO actions (status_from, status_to, event) VALUES
('Free','Paid','Convert'),
('Paid','Free','ReverseConvert'),
('Paid','Non-member','Cancel'),
('Free','Non-member','Cancel'),
('Non-member','Paid','ColdStart'),
('Non-member','Free','WarmStart'),
('Non-member','Non-member','Non-member'),
('Paid','Paid','Renewal'),
('Free','Free','Renewal');
SELECT * FROM actions;
Не знаю от чего отталкивались при составлении условия задачи, но на 3 сутки пришел к методу исключения. Возможно, исходя из правильного ответа и условия задачи, теперь и можно где-то уловить суть исключения. За основу взял решение выше, просто исключил Non-member:
with actions as (
SELECT 'Free' status_from ,'Paid' status_to, 'Convert' event
union all
SELECT 'Paid','Free','ReverseConvert'
union all
SELECT 'Paid','Non-member','Cancel'
union all
SELECT 'Free','Non-member','Cancel'
union all
SELECT 'Non-member','Paid','ColdStart'
union all
SELECT 'Non-member','Free','WarmStart'
union all
SELECT 'Non-member','Non-member','Non-member'
union all
SELECT 'Paid','Paid','Renewal'
union all
SELECT 'Free','Free','Renewal' )
, cte1 AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id ORDER BY membership_start_date) rn
FROM subscriptions)
select *
from (
SELECT
COALESCE(t1.customer_id, t2.customer_id) customer_id,
COALESCE(t1.membership_start_date, t2.membership_end_date) change_date,
actions.event
FROM cte1 t1
FULL JOIN cte1 t2 ON t1.customer_id = t2.customer_id
AND t1.rn = t2.rn + 1
JOIN actions ON COALESCE(t1.membership_status, 'Non-member') = actions.status_to
AND COALESCE(t2.membership_status, 'Non-member') = actions.status_from)
where event != 'Non-member'
ORDER BY 1, 2;