Нарастающий итог в SQL с разрывами в данных

от автора

Ранее мы рассмотрели способы расчёта нарастающего (накопительного) итога в SQL. Самый распространённый вопрос — как посчитать тоже самое, но на данных с разрывами? После написания исходной статьи мне его задавали неоднократно – так что есть смысл написать об этом отдельно.

Для начала освежим в памяти саму постановку вопроса. Предположим, у нас есть таблица продаж разных товаров (product) по дням (dt):

Создание таблиц
-- основная таблица с данными: продажи (sales) по дням (dt) и товарам (product) create table product_sales (dt date null,                             product varchar(10) null,  -- varchar2(10) in Oracle                             sales int null                           ); insert into product_sales (dt, product, sales) values ('2021-12-01', 'A', 10); insert into product_sales (dt, product, sales) values ('2021-12-01', 'B', 20); insert into product_sales (dt, product, sales) values ('2021-12-05', 'C', 50); insert into product_sales (dt, product, sales) values ('2021-12-10', 'A', 30); insert into product_sales (dt, product, sales) values ('2021-12-12', 'B', 40); insert into product_sales (dt, product, sales) values ('2021-12-15', 'C', 10); insert into product_sales (dt, product, sales) values ('2021-12-20', 'A', 20); insert into product_sales (dt, product, sales) values ('2021-12-25', 'B', 50); insert into product_sales (dt, product, sales) values ('2021-12-31', 'B', 30);  -- календарь create table dim_dt (dt date not null); insert into dim_dt (dt) values ('2021-12-01'); insert into dim_dt (dt) values ('2021-12-02'); insert into dim_dt (dt) values ('2021-12-03'); insert into dim_dt (dt) values ('2021-12-04'); insert into dim_dt (dt) values ('2021-12-05'); insert into dim_dt (dt) values ('2021-12-06'); insert into dim_dt (dt) values ('2021-12-07'); insert into dim_dt (dt) values ('2021-12-08'); insert into dim_dt (dt) values ('2021-12-09'); insert into dim_dt (dt) values ('2021-12-10'); insert into dim_dt (dt) values ('2021-12-11'); insert into dim_dt (dt) values ('2021-12-12'); insert into dim_dt (dt) values ('2021-12-13'); insert into dim_dt (dt) values ('2021-12-14'); insert into dim_dt (dt) values ('2021-12-15'); insert into dim_dt (dt) values ('2021-12-16'); insert into dim_dt (dt) values ('2021-12-17'); insert into dim_dt (dt) values ('2021-12-18'); insert into dim_dt (dt) values ('2021-12-19'); insert into dim_dt (dt) values ('2021-12-20'); insert into dim_dt (dt) values ('2021-12-21'); insert into dim_dt (dt) values ('2021-12-22'); insert into dim_dt (dt) values ('2021-12-23'); insert into dim_dt (dt) values ('2021-12-24'); insert into dim_dt (dt) values ('2021-12-25'); insert into dim_dt (dt) values ('2021-12-26'); insert into dim_dt (dt) values ('2021-12-27'); insert into dim_dt (dt) values ('2021-12-28'); insert into dim_dt (dt) values ('2021-12-29'); insert into dim_dt (dt) values ('2021-12-30'); insert into dim_dt (dt) values ('2021-12-31');  -- словарь товаров create table dim_product (product varchar(10) not null); insert into dim_product (product) values ('A'); insert into dim_product (product) values ('B'); insert into dim_product (product) values ('C');

Как видим, продажи есть только в некоторые дни. Все остальные дни – это «разрывы» без данных. Т.е. это вариант очень распространенной в SQL задачи о разрывах (gaps and islands problem).

Посчитаем нарастающий итог, используя оконную функцию:

select          ps.dt       , ps.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by ps.product order by ps.dt), 0) as sales_total from product_sales ps order by ps.product, ps.dt;

Результат корректен, но сохраняет те же самые разрывы, что были в исходных данных. Как посчитать итоги за все дни декабря, даже если в этот конкретный день не было записей в исходной таблице? Т.е. так, чтобы у нас вышло 93 строки за декабрь (комбинация 31 день Х 3 вида товаров). Фрагмент ожидаемого результата:

Проще всего, если у нас в базе есть таблицы-справочники со всеми датами (календарь) и со всеми товарами (словарь товаров). Тогда мы может создать идеальный (полный) набор сочетаний дат и товаров:

select           d.dt        , p.product from dim_dt d cross join dim_product p where d.dt between '2021-12-01' and '2021-12-31';

Далее к идеальному набору можно присоединить через left join таблицу с фактическими продажами:

with ideal_combination as     (select               d.dt            , p.product     from dim_dt d     cross join dim_product p     where d.dt between '2021-12-01' and '2021-12-31') select         i.dt       , i.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by i.product order by i.dt), 0) as sales_total from ideal_combination i left join product_sales ps    on i.dt = ps.dt       and i.product = ps.product ;

Или если избавиться от CTE в запросе:

select         d.dt       , p.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by p.product order by d.dt), 0) as sales_total from dim_dt d cross join dim_product p left join product_sales ps    on d.dt = ps.dt       and p.product = ps.product where d.dt between '2021-12-01' and '2021-12-31';

Впрочем, далеко не всегда нам доступны заранее созданные и заполненные таблицы, как dim_dt и dim_product в этом примере. Если их у нас нет – можно генерировать соответствующие наборы самим.

С набором товаров все просто — это либо ручной ввод как

          select 'A' as product  union all select 'B'  union all select 'C' ;

или же извлечение уникальных названий товаров из таблицы продаж по дням:

select distinct product from product_sales;

А вот генерация набора дат будет специфична для конкретной СУБД. Проще всего с PostgreSQL, где есть функции для генерации данных (set returning functions):

select t.dt::date as dt from generate_series('2021-12-01', '2021-12-31', interval  '1 day') as t(dt);  -- или тоже самое чуть короче: select generate_series('2021-12-01', '2021-12-31', interval '1 day')::date as dt;

Соответственно, полный запрос в PostgreSQL будет выглядеть так:

select         d.dt       , p.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by p.product order by d.dt), 0) as sales_total from (select generate_series('2021-12-01', '2021-12-31', interval '1 day')::date as dt) d cross join (select distinct product from product_sales) p left join product_sales ps    on d.dt = ps.dt       and p.product = ps.product ;

В большинстве других СУБД функции generate_series или её эквивалента нет, так что придется воспользоваться рекурсивным CTE. Например, в SQL Server мы возьмем исходную дату периода как select '2021-12-01', и будем прибавлять к ней по одному дню при помощи функции dateadd, пока не достигнем конца периода (where dt <= '2021-12-31'):

with dates_range (dt) as     (     select convert(date, '2021-12-01', 102) as dt     union all     select dateadd(day, 1, dt)      from dates_range     where dt <=  convert(date, '2021-12-31', 102)     ) select * from dates_range

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

with dates_range (dt) as     (     select convert(date, '2021-12-01', 102) as dt     union all     select dateadd(day, 1, dt)      from dates_range     where dt <=  convert(date, '2021-12-31', 102)     ) select         d.dt       , p.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by p.product order by d.dt), 0) as sales_total from dates_range d cross join (select distinct product from product_sales) p left join product_sales ps    on d.dt = ps.dt       and p.product = ps.product ;

В других СУБД, поддерживающих рекурсивные CTE, запрос будет практически таким же. Возможная разница заключается только в работе с датами (необходимо применить эквиваленты convert и dateadd из SQL Server). Например, в PostgreSQL :

with recursive dates_range (dt) as     (     select '2021-12-01'::date as dt     union all     select (dt + interval '1 day')::date     from dates_range     where dt <= '2021-12-31'::date     ) select         d.dt       , p.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by p.product order by d.dt), 0) as sales_total from dates_range d cross join (select distinct product from product_sales) p left join product_sales ps    on d.dt = ps.dt       and p.product = ps.product ;

При использовании рекурсивных CTE следует помнить, что в некоторых СУБД их максимальная глубина ограничена. Например, значение по умолчанию для MySQL 8.0 – 1000 итераций (параметр cte_max_recursion_depth), а для SQL Server 2014+ – 100 итераций (параметр MAXRECURSION).

В Oracle DB рекурсивные CTE могут быть заменены проприетарной реализацией иерархических запросов через connect by. Так что диапазон дат генерируется как

select (to_date('2021-12-31', 'YYYY-MM-DD') - level + 1) as dt from dual connect by level <= (to_date('2021-12-31', 'YYYY-MM-DD') - to_date('2021-12-01', 'YYYY-MM-DD') + 1);

А запрос приобретает вид:

select         d.dt       , p.product       , coalesce(ps.sales, 0) as sales       , coalesce(sum(ps.sales) over (partition by p.product order by d.dt), 0) as sales_total from      (     select (to_date('2021-12-31', 'YYYY-MM-DD') - level + 1) as dt     from dual     connect by level <= (to_date('2021-12-31', 'YYYY-MM-DD') - to_date('2021-12-01', 'YYYY-MM-DD') + 1)     ) d cross join (select distinct product from product_sales) p left join product_sales ps    on d.dt = ps.dt       and p.product = ps.product ;

В других СУБД могут использоваться альтернативные варианты синтаксиса, но общая идея решения будет похожей: генерируем идеальный набор и присоединяем к нему через left join свои фактические данные.


ссылка на оригинал статьи https://habr.com/ru/post/597963/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *