Под таким хитрым заголовком скрывается достаточно несложная задача, но сначала небольшое вступление:
Приходят пользователи и просят: «Вот мы внесли данные в базу, а скажите нам, чего не хватает? Какие данные мы ещё не внесли в базу и их не хватает для полного счастья?»
Первая (и скажем честно, весьма глупая) реакция: «Как же я вам найду то, чего нет в базе данных?».
Но отбросим эмоции и применим логику. Ведь, как правило, требуются данные, формирование которых подчиняется некоему правилу — номера квитанций, справок и так далее… И я исхожу из того, что все эти номера и идентификаторы могут быть преобразованы в натуральную последовательность.
То есть задача будет сформулирована следующим образом: в базе данных хранится последовательность натуральных чисел, в которой есть пропуски, и необходимо вывести пропущенные числа для пользователя.
В такой формулировке задача уже выглядит достаточно простой. Более того — возникает желание реализовать эту задачу одним единственным sql-запросом.
Давайте создадим таблицу и заполним какими-нибудь данными.
CREATE TABLE IF NOT EXISTS `Test` (`id` int(6) NOT NULL);
INSERT INTO `Test` (`id`) VALUES (3), (5), (7), (8) , (9) , (11) , (12), (16) , (17) ;
Основная идея следующая: сравнить таблицу с самой собой же и для каждого значения ИКС найти минимальное ИГРЕК (которое всё же больше ИКСа), где (ИКС + 1) и (ИГРЕК — 1) будут нашими границами пропущенных диапазонов чисел. Добавив логичное условие, что, (ИКС + 1) должен быть не меньше (ИГРЕК — 1) получим следующие диапазоны: от 4 до 4, от 6 до 6, от 10 до 10 и от 13 до 15.
Какие есть нюансы:
1) Может быть пропущен первый элемент последовательности (в нашем случае это 1)
2) Неизвестен последний элемент последовательности (а вдруг это 22). Можно, конечно, запрашивать эту информацию у пользователя, но опыт подсказывает, что лучше этого избегать.
3) Диапазон «от 4 до 4» выглядит глючно, надо заменить просто на одно число
4) Результат всё-таки желательно получить значением одной строки, а не набором строк
Учитываем замечания и получаем вариант скрипта под MySQL:
SELECT GROUP_CONCAT( ranges )
FROM (
SELECT
CASE
WHEN id2 IS NULL
THEN CONCAT( id1, '...' )
WHEN id1 = id2
THEN id1
ELSE CONCAT( id1, '-', id2 )
END ranges
FROM (
SELECT id +1 id1, (
SELECT MIN( id ) -1
FROM `Test` t2
WHERE t2.id > t1.id
)id2
FROM `Test` t1
UNION
SELECT 1 , MIN( id ) -1
FROM `Test` t3
)t
WHERE id1 <= id2
OR id2 IS NULL
ORDER BY id1
)tt
и вариант под Oracle:
SELECT LISTAGG (ranges, ', ')
FROM (
SELECT CASE
WHEN id2 IS NULL THEN TO_CHAR (id1) || '...'
WHEN id1 = id2 THEN TO_CHAR (id1)
ELSE TO_CHAR (id1) || '-' || TO_CHAR (id2)
END ranges
FROM (
SELECT id + 1 id1,
(SELECT MIN (id) - 1
FROM TEST t2
WHERE t2.id < t1.id) id2
FROM TEST t1
UNION
SELECT 1, MIN (id) - 1
FROM TEST t3) t
WHERE id1 <= id2 OR id2 IS NULL
ORDER BY id1
) tt
Результатом их выполнения является строка '1-2, 4, 6, 10, 13-15, 18...'
Во-первых, эта строка содержит то, что хотели пользователи.
Во-вторых, результат выглядит понятно для любого пользователя.
И в-главных, запрос выводит данные, которые действительно в базе данных не хранятся!
UPD1:
Большое спасибо всем, кто предложил варианты по улучшению скриптов, да и просто высказал интерес к этому вопросу.
Как это часто бывает, комментарии оказались интереснее и полезнее первоначальной статьи.
Действительно, стоило отметить, что нюанс номер 4 не реализуется стандартным синтаксисом SQL и для этой задачи необходимо привлекать дополнительный функционал, реализуемый на каждой отдельной СУБД по своему.
Ниже привожу скрипты, вытянутые мной из комментариев к статье.
Вариант для MySQL от asmm
SELECT CONCAT(IFNULL(CONCAT(GROUP_CONCAT(miss_num), ','), '')
, IFNULL(MAX(id) + 1, @start_num)
, '...'
) miss_num
FROM (
SELECT @prev_id prev_id
, CASE
WHEN @prev_id + 1 = id THEN NULL
WHEN @prev_id + 2 = id THEN @prev_id + 1
ELSE CONCAT(@prev_id + 1, '-', id - 1)
END miss_num
, @prev_id := id id
FROM (SELECT @start_num := 1 start_num, @prev_id := @start_num - 1 prev_id) p
, `Test` t
WHERE t.id >= p.start_num
ORDER BY t.id
) t
Вариант для Oracle от xtender
select listagg(id1||decode(id2
,id1 ,null
,null,'...'
,'-'||id2)
,',')
within group(order by id1)s
from (select max(id)+1 id1
,lead(min(id)) over(order by min(id)) id2
from (select 0 id, 0 rn from dual
union all
select id,row_number()over(order by id) rn from test)
group by id - rn)
Вариант для MSSQL от yizraor
select rlist =
(
select "text()" =
iif(id1 < id2, convert(varchar(15), id1) +
iif(id1 < (id2 - 1), '-' + convert(varchar(15), id2 - 1), ''), '') +
iif(id3 is null, iif(id1 < id2, ', ', '') + convert(varchar(15), id2 + 1) + '...', ', ')
from
(
select
id1 = isnull(lag(id) over (order by id), 0) + 1,
id2 = id,
id3 = lead(id) over (order by id)
from test
) t
where ( id1 < id2 ) or ( id3 is null )
order by id2
for xml path('')
)