clement13.net
Photo d’origine : Une île vue du ciel
Description
Une île vue du ciel
Source
Chief Master Sergeant Don Sutherland, U.S. Air Force · page d’origine
Licence
PD Domaine public
Photo d’origine, non retouchée. L’arrière-plan en est une version stylisée par le site.

Plages continues (îlots)

Le problème qui semble exiger un état mémorisé de ligne en ligne, et qui se résout par un regroupement.

≈ 3 min de lectureLecture

Énoncé#

Pour chaque utilisateur, lister les plages de jours consécutifs pendant lesquels il s'est connecté (début, fin, nombre de jours).

Jeu de données#

CREATE TABLE dbo.Connexions
(
    ConnexionId   int IDENTITY(1,1) PRIMARY KEY,
    UtilisateurId int          NOT NULL,
    DateConnexion datetime2(0) NOT NULL
);

INSERT INTO dbo.Connexions (UtilisateurId, DateConnexion)
VALUES (1, '2026-03-01 08:00'), (1, '2026-03-02 09:30'), (1, '2026-03-03 07:45'),
       (1, '2026-03-03 18:10'),                       -- 2e connexion le même jour
       (1, '2026-03-06 10:00'), (1, '2026-03-07 11:20'),
       (2, '2026-03-01 12:00'), (2, '2026-03-05 12:00');

Procédural contre ensembliste#

Procédural#

DECLARE @R TABLE (UtilisateurId int, Debut date, Fin date);
DECLARE @U int, @J date, @UPrec int = NULL, @Debut date, @Fin date;

DECLARE c CURSOR LOCAL FAST_FORWARD FOR
    SELECT DISTINCT UtilisateurId, CAST(DateConnexion AS date) AS Jour
    FROM   dbo.Connexions
    ORDER BY UtilisateurId, Jour;
OPEN c;
FETCH NEXT FROM c INTO @U, @J;
WHILE @@FETCH_STATUS = 0
BEGIN
    IF @UPrec IS NULL OR @U <> @UPrec OR @J <> DATEADD(DAY, 1, @Fin)
    BEGIN
        IF @UPrec IS NOT NULL INSERT INTO @R VALUES (@UPrec, @Debut, @Fin);
        SET @Debut = @J;
    END;
    SET @Fin = @J;
    SET @UPrec = @U;
    FETCH NEXT FROM c INTO @U, @J;
END;
IF @UPrec IS NOT NULL INSERT INTO @R VALUES (@UPrec, @Debut, @Fin);
CLOSE c;
DEALLOCATE c;

SELECT * FROM @R ORDER BY UtilisateurId, Debut;

Ensembliste#

WITH J AS
(
    SELECT DISTINCT UtilisateurId, CAST(DateConnexion AS date) AS Jour
    FROM   dbo.Connexions
),
G AS
(
    SELECT UtilisateurId, Jour,
           DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY UtilisateurId ORDER BY Jour), Jour) AS Groupe
    FROM   J
)
SELECT UtilisateurId,
       MIN(Jour) AS Debut,
       MAX(Jour) AS Fin,
       COUNT(*)  AS NbJours
FROM   G
GROUP BY UtilisateurId, Groupe
ORDER BY UtilisateurId, Debut;

Résultat attendu#

UtilisateurIdDébutFinNbJours
12026-03-012026-03-033
12026-03-062026-03-072
22026-03-012026-03-011
22026-03-052026-03-051

Pourquoi l'ensembliste est préférable#

  • L'astuce : pour des jours consécutifs, la différence entre la date et son rang (ROW_NUMBER) est constante à l'intérieur d'une plage et change dès qu'il y a un trou. Cette valeur identifie donc la plage.
  • Le problème « séquentiel » (« je me souviens de la ligne précédente ») se ramène à un regroupement : l'état n'est plus porté par des variables, il est dans les données.
  • La version procédurale doit gérer elle-même les transitions (début, fin, changement d'utilisateur) ; la moindre inattention y produit un bug de bord.

Pièges#

  • Doublons de jours : la CTE J utilise DISTINCT pour ne retenir qu'une ligne par jour et par utilisateur ; sans cela, le rang avance sans que la date n'avance et la plage est cassée.
  • Type de la date : travailler au grain voulu (ici le jour) avant de calculer le rang.
  • Tolérance aux trous : la technique stricte détecte des jours consécutifs. Pour tolérer un écart, utiliser la variante avec LAG.

Variantes#

-- Plages tolérant un jour manquant (écart ≤ 2 jours entre deux connexions)
WITH J AS (SELECT DISTINCT UtilisateurId, CAST(DateConnexion AS date) AS Jour FROM dbo.Connexions),
P AS (SELECT UtilisateurId, Jour, LAG(Jour) OVER (PARTITION BY UtilisateurId ORDER BY Jour) AS Prec FROM J),
F AS (SELECT UtilisateurId, Jour,
             CASE WHEN Prec IS NULL OR DATEDIFF(DAY, Prec, Jour) > 2 THEN 1 ELSE 0 END AS Rupture
      FROM P),
G AS (SELECT UtilisateurId, Jour,
             SUM(Rupture) OVER (PARTITION BY UtilisateurId ORDER BY Jour ROWS UNBOUNDED PRECEDING) AS Groupe
      FROM F)
SELECT UtilisateurId, MIN(Jour) AS Debut, MAX(Jour) AS Fin
FROM   G
GROUP BY UtilisateurId, Groupe;

Vérifier l'équivalence#

Un changement de méthode ne vaut que si le résultat est identique. Comparer dans les deux sens ; aucune ligne ne doit sortir.

-- À exécuter dans le même lot que la version procédurale (la variable @R doit exister)
WITH J AS (SELECT DISTINCT UtilisateurId, CAST(DateConnexion AS date) AS Jour FROM dbo.Connexions),
G AS (SELECT UtilisateurId, Jour, DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY UtilisateurId ORDER BY Jour), Jour) AS Groupe FROM J),
E AS (SELECT UtilisateurId, MIN(Jour) AS Debut, MAX(Jour) AS Fin FROM G GROUP BY UtilisateurId, Groupe)
SELECT UtilisateurId, Debut, Fin FROM @R
EXCEPT
SELECT UtilisateurId, Debut, Fin FROM E;
-- Puis l'inverse. Aucune ligne attendue.

Versions de SQL Server#

ROW_NUMBER : SQL Server 2005 ; LAG et cadres ROWS (variante) : SQL Server 2012.