C, PHP, VB, .NET

Дневникът на Филип Петров


* Рекурсивно извикване на подзаявки

Публикувано на 04 юни 2020 в раздел Бази от Данни.

Рекурсията е традиционно слаб елемент в езика SQL. Едни от най-трудните задачи обикновено са свързани именно с извеждане на данни, които са наредени в някаква йерархия (например дървовидна структура) и зависят едни от други. В тази статия ще дам много прост пример за начални стъпки в рекурсивните заявки с оператор WITH.

Нека първо решим една наглед проста, но всъщност не съвсем тривиална задача. Нека имаме таблица, в която се отбелязват датите на възникнали покупки на продукти. Искаме да извадим списък с дата и брой покупки за деня от текущата седмица. За улеснение ще направя таблицата максимално проста:

CREATE TABLE test(
	id SERIAL PRIMARY KEY,
	dt DATETIME NOT NULL DEFAULT NOW()
);
INSERT INTO test(dt) 
VALUES ("2020-06-01"),("2020-05-30"),("2020-06-03"),("2020-06-03");

Ако се опитам да изкарам списъка за текущата седмица (денят днес е 2020-06-04), ще се види следния резултат:

SELECT DATE(dt), COUNT(dt)
FROM test
WHERE WEEK(dt) = WEEK(NOW())
GROUP BY dt
ORDER BY dt;
+------------+-----------+
| DATE(dt)   | COUNT(dt) |
+------------+-----------+
| 2020-06-01 |         1 |
| 2020-06-03 |         2 |
+------------+-----------+

Това очевидно не ни удовлетворява, защото за дните, в които не е имало нито една покупка, не се показва нищо. Предполагам на всички е ясно защо се е получило така – когато няма запис, няма какво да бъде преброено. Какво да направим, за да може да се изведат и останалите дни от текущата седмица, а до тях да се изведе 0?

Първо ни трябва заявка, която да изкара датите на дните от текущата седмица. Един много груб начин за съставяне на такава е:

SELECT DATE(SUBDATE(NOW(), weekday(NOW()))) AS day_of_week
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 1 DAY)
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 2 DAY)
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 3 DAY)
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 4 DAY)
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 5 DAY)
UNION
SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 6 DAY);
+-------------+
| day_of_week |
+-------------+
| 2020-06-01  |
| 2020-06-02  |
| 2020-06-03  |
| 2020-06-04  |
| 2020-06-05  |
| 2020-06-06  |
| 2020-06-07  |
+-------------+

Сега към нея можем да направим LEFT JOIN с предишната заявка и с помощта на оператор IF да сменим NULL с 0:

SELECT all_days_in_this_week.day_of_week, 
       IF(days_of_this_week_with_sales.sales_cnt IS NULL,
	  0,
          days_of_this_week_with_sales.sales_cnt) AS sales_count
FROM
(SELECT DATE(SUBDATE(NOW(), weekday(NOW()))) AS day_of_week
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 1 DAY)
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 2 DAY)
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 3 DAY)
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 4 DAY)
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 5 DAY)
 UNION 
 SELECT DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 6 DAY)
) AS all_days_in_this_week
LEFT JOIN
(SELECT DATE(dt) AS day_of_week, COUNT(dt) AS sales_cnt
 FROM test
 WHERE WEEK(dt) = WEEK(NOW())
 GROUP BY dt
) AS days_of_this_week_with_sales
ON all_days_in_this_week.day_of_week 
     = 
   days_of_this_week_with_sales.day_of_week
ORDER BY all_days_in_this_week.day_of_week;
+-------------+-------------+
| day_of_week | sales_count |
+-------------+-------------+
| 2020-06-01  |           1 |
| 2020-06-02  |           0 |
| 2020-06-03  |           2 |
| 2020-06-04  |           0 |
| 2020-06-05  |           0 |
| 2020-06-06  |           0 |
| 2020-06-07  |           0 |
+-------------+-------------+

Разбира се ще се досетите веднага, че този метод не се скалира добре, защото ако ви трябват не дните от текущата седмица, а текущия месец, година… или изобщо произволен интервал от време, таблицата с генерираните дати ще набъбне и ще стане огромна, а да описваме датите една по една е нереалистичен подход. Можем ли да опростим и автоматизираме нещата?

Един традиционен „хак“ е да се създаде отделна таблица с единствена колона, която с цикъл да се запълни с някакъв огромен интервал от дати. Така вече по нея ще може лесно да се правят интервали с просто WHERE условие. Това решение би било валидно, но определено също не ни удовлетворява – изисква допълнително запазване на информация, която като цяло е ненужна. Друг вариант е да се използват сесийни променливи като вид брояч за дни, които да се използват по хитър начин вътре в заявката, но и това би се приело като нестандартно решение.

Много удобен в случая се явява оператор WITH, който е наличен от MariaDB 10.2.2 нататък. Чрез него е възможно една заявка да извиква сама себе си многократно чрез рекурсия. Ще го обясня директно с пример. Ето как би изглеждал списъка с дати:

WITH RECURSIVE date_range AS (
	SELECT '2020-06-01' AS d
	UNION
	SELECT d + INTERVAL 1 DAY
	FROM date_range
	WHERE d < '2020-06-07'
)
SELECT d FROM date_range;
+------------+
| d          |
+------------+
| 2020-06-01 |
| 2020-06-02 |
| 2020-06-03 |
| 2020-06-04 |
| 2020-06-05 |
| 2020-06-06 |
| 2020-06-07 |
+------------+

Виждате, че заявката date_range има рекурсивно извикване към самата себе си. Може да приемете първия SELECT (в червен цвят) в оператора като „начално условие“, а втория (след UNION, който е в син цвят) като рекурсивно добавяне на допълнителни записи до достигане на крайното условие (неговия WHERE). При всяко ново извикване на синята подзаявка се взима предишното ѝ текущо състояние и резултата от него се натрупва във временна таблица в паметта. Рекурсията приключва тогава, когато синята заявка върне празно множество (то ще се случи само когато въпросната дата е подмината). Резултатът от WITH е резултатът от началното условие с добавени всички междинно натрупани състояния от рекурсивната подзаявка.

За щастие оператор WITH би могъл да се използва и в контекст като вложен SELECT от по-сложна заявка. Така решението на дадената в началото задача би могло да се направи по следния значително по-елегантен (и вече лесно скалируем за произволни интервали от време) начин:

SELECT all_days_in_this_week.day_of_week, 
       IF(days_of_this_week_with_sales.sales_cnt IS NULL,
	  0,
          days_of_this_week_with_sales.sales_cnt) AS sales_count
FROM
(
 WITH RECURSIVE date_range AS (
	SELECT DATE(SUBDATE(NOW(), weekday(NOW()))) AS day_of_week
	UNION
	SELECT day_of_week + INTERVAL 1 DAY
	FROM date_range
	WHERE day_of_week < DATE(SUBDATE(NOW(), weekday(NOW())) + INTERVAL 6 DAY)
 )
 SELECT day_of_week FROM date_range
) AS all_days_in_this_week
LEFT JOIN
(SELECT DATE(dt) AS day_of_week, COUNT(dt) AS sales_cnt
 FROM test
 WHERE WEEK(dt) = WEEK(NOW())
 GROUP BY dt
) AS days_of_this_week_with_sales
ON all_days_in_this_week.day_of_week 
     = 
   days_of_this_week_with_sales.day_of_week
ORDER BY all_days_in_this_week.day_of_week;

 



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

Адресът на електронната поща няма да се публикува


*