A traditional way to find the first and last values of column value sequences in a table like this ...
drop table if exists t;
create table t(id int);
insert into t values(1),(2),(3),(4),(6),(7),(8);
... uses an exclusion join on the previous sequential value to find the first value of each sequence, and the minimum next value from a left join and an exclusion join on the previous sequential value to find the end of each sequence:
SELECT
a.id AS Start,
MIN( c.id ) AS End
FROM tbl AS a
LEFT JOIN tbl AS b ON a.id = b.id + 1
LEFT JOIN tbl AS c ON a.id <= c.id
LEFT JOIN tbl AS d ON c.id = d.id - 1
WHERE b.id IS NULL
AND c.id IS NOT NULL
AND d.id IS NULL
GROUP BY a.id;
+-------+------+
| Start | End |
+-------+------+
| 1 | 4 |
| 6 | 8 |
+-------+------+
Thanks to Scott Noyes for noticing that a.id<c.id fails to pick up sequences of 1 followed by skips of 1, but a.id<=c.id does.
To see how that query works, look at the output of this version of the query with exclusion and aggregation clauses removed:
But elegant as it looks, the exclusion join method scales badly. In the MySQL Performance forum, Rick James posted a solution that runs much faster and scales linearly:
select starts, ends
from (
select
@start as starts,
if(mx=2, id-1, null) as ends,
@start := if(mn=1, id, null)
from (
select @start = -9
) v
join (
select
max(x) as mx,
min(x) as mn,
count(*) as ct,
id
from (
( select 1 as x, id from tbl )
union all
( select 2, id+1 from tbl )
) z
group by id
having ct = 1
) w
) u
where starts is not null;
A variant of the problem: when some IDs are used and some are not, find blocks of unused IDs:
DROP TABLE IF EXISTS tbl;
CREATE TABLE tbl(id INT,used BOOL);
INSERT INTO tbl VALUES(1,1),(2,0),(3,0),(4,1),(5,0),(6,0);
SELECT a.id AS Start, MIN( c.id ) AS End
FROM tbl AS a
LEFT JOIN tbl AS b ON a.id=b.id + 1 AND a.used=0 AND b.used=0
LEFT JOIN tbl AS c ON a.id<=c.id AND a.used=0 AND c.used=0
LEFT JOIN tbl AS d ON c.id=d.id-1 AND c.used=0 AND d.used=0
WHERE b.id IS NULL
AND c.id IS NOT NULL
AND d.id IS NULL
GROUP BY a.id;
+-------+------+
| Start | End |
+-------+------+
| 2 | 3 |
| 5 | 6 |
+-------+------+
Here's another variation on the pattern from a MySQL forum. You have a history of prescription dose changes ...
Forum contributor "laptop alias" posted this solution:
SELECT a.dose_date AS start
, MIN(DATE(c.dose_date)) + INTERVAL 1 DAY AS end
, a.dose
FROM
( SELECT x.dose_date, x.dose, COUNT(*) id
FROM dose_change x
JOIN dose_change y
ON y.dose_date <= x.dose_date
GROUP BY x.oid
) AS a
LEFT JOIN
( SELECT x.dose_date, x.dose, COUNT(*) id
FROM dose_change x
JOIN dose_change y
ON y.dose_date <= x.dose_date
GROUP BY x.oid
) AS b ON a.id = b.id + 1 AND b.dose = a.dose
LEFT JOIN
( SELECT x.dose_date, x.dose, COUNT(*) id
FROM dose_change x
JOIN dose_change y
ON y.dose_date <= x.dose_date
GROUP BY x.oid
) AS c ON a.id <= c.id AND c.dose = a.dose
LEFT JOIN
( SELECT x.dose_date, x.dose, COUNT(*) id
FROM dose_change x
JOIN dose_change y
ON y.dose_date <= x.dose_date
GROUP BY x.oid
) AS d ON c.id = d.id - 1 AND d.dose = c.dose
WHERE b.id IS NULL AND c.id IS NOT NULL AND d.id IS NULL
GROUP BY start;
+---------------------+------------+------+
| start | end | dose |
+---------------------+------------+------+
| 2025-01-01 00:00:00 | 2025-01-03 | 10 |
| 2025-01-03 00:00:00 | 2025-01-05 | 20 |
| 2025-01-05 00:00:00 | 2025-01-08 | 10 |
| 2025-01-10 00:00:00 | 2025-01-13 | 30 |
| 2025-01-13 00:00:00 | 2025-01-14 | 10 |
| 2025-01-14 00:00:00 | 2025-01-15 | 20 |
| 2025-01-15 00:00:00 | 2025-01-16 | 10 |
| 2025-01-17 00:00:00 | 2025-01-18 | 10 |
+---------------------+------------+------+
Tom Melly found this simpler but slower solution:
SELECT
a.dose_date AS StartDate,
a.dose AS Dose,
( SELECT b.dose_date
FROM dose_change AS b
WHERE b.dose_date > a.dose_date AND (b.dose <> a.dose OR b.dose IS NULL)
ORDER BY b.dose_date ASC LIMIT 1
) AS StopDate
FROM dose_change AS a
WHERE Coalesce(
(SELECT c.dose
FROM dose_change AS c
WHERE c.dose_date <= a.dose_date
ORDER BY c.dose_date DESC LIMIT 1,1
), -99999
) <> a.dose
AND a.dose IS NOT NULL
ORDER BY a.dose_date ASC;