Ранее мы рассмотрели способы расчёта нарастающего (накопительного) итога в 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 свои фактические данные.

Комментарии (9)


  1. Myclass
    26.12.2021 23:38

    Не согласен с такой постановкой задачи. Не может быть никакого dashboard(а) если нет таких таблиц с как _day, _week, _month итд. Ведь они нужны не только для select(a), а также для отображения на gui в например списках для фильтрации. Плюс. Например в фирмах, где продажи на выходных всегда ноль - зачем эти дни отображать?

    Те. автоматическое генерирование данных может и нужно, но именно для заполнения этих dimension-таблиц, а не для real time.


    1. demche Автор
      27.12.2021 15:08
      +1

      А при чем тут вообще дашборды? Во-первых, данные могут использоваться для разных целей. Во-вторых, современные BI системы позволяют набросать дашборд с фильтрами и без dimension таблиц. Если у вас база не проектировалась как хранилище данных – в ней таких таблиц и не будет.


  1. varanio
    27.12.2021 11:28

    Интересный стиль записи, когда запятая ставится перед полем. Странновато смотрится. Это чтобы диффы на код ревью лучше смотрелись?


    1. atri24
      27.12.2021 11:58
      +3

      Так удобнее коментировать строку при отладке селектов.Например, нужно убрать последний столбец в селекте - коментируешь только его:

      --, p.name


    1. Myclass
      27.12.2021 12:49
      +1

      Интересный стиль записи, когда запятая ставится перед полем.

      тоже так всегда делаю, хотя почти все редакторы как-то это не внедряют.

      Комментирование одной строки, где стоит запатая и логика поля — выключает полностью это поле. Не надо перходить в другую строку и там что-либо делать. Единственое поле, где это не так работает — первое поле в Sql. Ведь там нет запятой. Но первое поле — это по-большей части чуть-ли не самое основное, которое редко когда «выключается» для показа.


      1. Korobei
        27.12.2021 19:33

        Ещё в git будет одна изменённая строка при добавлении нового поля, а не две, как с запятой после имени поля.


  1. LaRN
    27.12.2021 18:04
    +1

    С рекурсивными запросами есть нюанс - ограничение на глубину рекурсии.

    Для mssql глубина по-умолчанию 100, те можно так построить интервал размером не больше квартала.


    1. demche Автор
      27.12.2021 19:05

      Спасибо, это довольно важное замечание, хотя и не во всех СУБД такие ограничения есть (добавил в статью). Кстати, дефолтная глубина в MySQL 8.0 – 1000 (линк), причем это была новая фича релиза.


  1. Kilor
    28.12.2021 09:47

    В предлагаемом подходе есть пара недостатков, которые могут стать проблемой, если размер выборки велик:

    • проход по выборке делается дважды - сначала для DISTINCT, потом для OVER

    • вычисление OVER ведется по уже "проджойненному" количеству записей (N товаров * M дней в интервале)

    Это можно обойти с помощью LATERAL, если эффективность запроса важнее его простоты:

    WITH T(dt, product, sales) AS (
    	VALUES
    		('2021-12-01'::date, 'A', 10)
    	,	('2021-12-10', 'A', 30)
    	,	('2021-12-20', 'A', 20)
    	,	('2021-12-01', 'B', 20)
    	,	('2021-12-12', 'B', 40)
    	,	('2021-12-25', 'B', 50)
    	,	('2021-12-31', 'B', 30)
    	,	('2021-12-05', 'C', 50)
    	,	('2021-12-15', 'C', 10)
    )
    SELECT
    	Y.*
    FROM
    	(
    		SELECT
    			dt
    		,	product
    		,	sales
    		,	sum(sales) OVER w
    		,	lag(dt) OVER w dtp
    		,	lead(dt) OVER w dtn
    		FROM
    			T
    		WINDOW
    			w AS (PARTITION BY product ORDER BY dt)
    	) X
    ,	LATERAL (
    		SELECT -- "нулевое" начало интервала
    			dt::date
    		,	X.product
    		,	0 sales
    		,	0 sum
    		FROM
    			generate_series('2021-12-01', X.dt - 1, '1 day') dt
    		WHERE
    			X.dtp IS NULL
    	UNION ALL
    		SELECT -- текущая запись
    			X.dt
    		,	X.product
    		,	X.sales
    		,	X.sum
    	UNION ALL
    		SELECT -- клонирование суммы до следующей точки/конца интервала
    			dt::date
    		,	X.product
    		,	0 sales
    		,	X.sum
    		FROM
    			generate_series(X.dt + 1, coalesce(X.dtn - 1, '2021-12-31'), '1 day') dt
    	) Y;