Regroupez l'horaire quotidien en [Date de début; Date de fin] intervalles avec la liste des jours de la semaine

18

J'ai besoin de convertir des données entre deux systèmes.

Le premier système stocke les plannings sous forme de liste simple de dates. Chaque date incluse dans le programme est une ligne. Il peut y avoir diverses lacunes dans la séquence des dates (week-ends, jours fériés et pauses plus longues, certains jours de la semaine peuvent être exclus de l'horaire). Il ne peut y avoir aucune interruption, même les week-ends peuvent être inclus. Le calendrier peut durer jusqu'à 2 ans. Habituellement, cela dure quelques semaines.

Voici un exemple simple d'un horaire qui s'étale sur deux semaines hors week-end (il y a des exemples plus compliqués dans le script ci-dessous):

+----+------------+------------+---------+--------+
| ID | ContractID |     dt     | dowChar | dowInt |
+----+------------+------------+---------+--------+
| 10 |          1 | 2016-05-02 | Mon     |      2 |
| 11 |          1 | 2016-05-03 | Tue     |      3 |
| 12 |          1 | 2016-05-04 | Wed     |      4 |
| 13 |          1 | 2016-05-05 | Thu     |      5 |
| 14 |          1 | 2016-05-06 | Fri     |      6 |
| 15 |          1 | 2016-05-09 | Mon     |      2 |
| 16 |          1 | 2016-05-10 | Tue     |      3 |
| 17 |          1 | 2016-05-11 | Wed     |      4 |
| 18 |          1 | 2016-05-12 | Thu     |      5 |
| 19 |          1 | 2016-05-13 | Fri     |      6 |
+----+------------+------------+---------+--------+

IDest unique, mais il n'est pas nécessairement séquentiel (c'est la clé primaire). Les dates sont uniques dans chaque contrat (il existe un index unique (ContractID, dt)).

Le deuxième système stocke les planifications sous forme d'intervalles avec la liste des jours de la semaine qui font partie de la planification. Chaque intervalle est défini par ses dates de début et de fin (inclus) et une liste de jours de semaine inclus dans le planning. Dans ce format, vous pouvez définir efficacement des modèles hebdomadaires répétitifs, tels que du lundi au mercredi, mais cela devient pénible lorsqu'un modèle est perturbé, par exemple par un jour férié.

Voici à quoi ressemblera l'exemple simple ci-dessus:

+------------+------------+------------+----------+----------------------+
| ContractID |  StartDT   |   EndDT    | DayCount |       WeekDays       |
+------------+------------+------------+----------+----------------------+
|          1 | 2016-05-02 | 2016-05-13 |       10 | Mon,Tue,Wed,Thu,Fri, |
+------------+------------+------------+----------+----------------------+

[StartDT;EndDT] les intervalles qui appartiennent au même contrat ne doivent pas se chevaucher.

J'ai besoin de convertir les données du premier système dans le format utilisé par le deuxième système. Pour le moment, je résous ce problème côté client en C # pour le contrat donné, mais j'aimerais le faire en T-SQL côté serveur pour le traitement en bloc et l'exportation / importation entre les serveurs. Très probablement, cela pourrait être fait en utilisant CLR UDF, mais à ce stade, je ne peux pas utiliser SQLCLR.

Le défi ici est de rendre la liste des intervalles aussi courte et conviviale que possible.

Par exemple, ce calendrier:

+-----+------------+------------+---------+--------+
| ID  | ContractID |     dt     | dowChar | dowInt |
+-----+------------+------------+---------+--------+
| 223 |          2 | 2016-05-05 | Thu     |      5 |
| 224 |          2 | 2016-05-06 | Fri     |      6 |
| 225 |          2 | 2016-05-09 | Mon     |      2 |
| 226 |          2 | 2016-05-10 | Tue     |      3 |
| 227 |          2 | 2016-05-11 | Wed     |      4 |
| 228 |          2 | 2016-05-12 | Thu     |      5 |
| 229 |          2 | 2016-05-13 | Fri     |      6 |
| 230 |          2 | 2016-05-16 | Mon     |      2 |
| 231 |          2 | 2016-05-17 | Tue     |      3 |
+-----+------------+------------+---------+--------+

devrait devenir ceci:

+------------+------------+------------+----------+----------------------+
| ContractID |  StartDT   |   EndDT    | DayCount |       WeekDays       |
+------------+------------+------------+----------+----------------------+
|          2 | 2016-05-05 | 2016-05-17 |        9 | Mon,Tue,Wed,Thu,Fri, |
+------------+------------+------------+----------+----------------------+

,pas ça:

+------------+------------+------------+----------+----------------------+
| ContractID |  StartDT   |   EndDT    | DayCount |       WeekDays       |
+------------+------------+------------+----------+----------------------+
|          2 | 2016-05-05 | 2016-05-06 |        2 | Thu,Fri,             |
|          2 | 2016-05-09 | 2016-05-13 |        5 | Mon,Tue,Wed,Thu,Fri, |
|          2 | 2016-05-16 | 2016-05-17 |        2 | Mon,Tue,             |
+------------+------------+------------+----------+----------------------+

J'ai essayé d'appliquer une gaps-and-islandsapproche à ce problème. J'ai essayé de le faire en deux passes. Dans la première passe, je trouve des îles de simples jours consécutifs, c'est-à-dire que la fin de l'île est un écart dans la séquence de jours, que ce soit le week-end, les jours fériés ou autre chose. Pour chaque île trouvée, je construis une liste séparée par des virgules WeekDays. Dans la deuxième passe, j'ai trouvé des îles plus loin en examinant l'écart dans la séquence des numéros de semaine ou un changement dans le WeekDays.

Avec cette approche, chaque semaine partielle se termine comme un intervalle supplémentaire comme indiqué ci-dessus, car même si les numéros de semaine sont consécutifs, le WeekDayschangement. En outre, il peut y avoir des écarts réguliers en une semaine (voir ContractID=3dans les exemples de données, qui ne contiennent des données que pour Mon,Wed,Fri,) et cette approche générerait des intervalles séparés pour chaque jour dans un tel calendrier. Du bon côté, il génère un intervalle si le calendrier ne comporte aucun écart (voir ContractID=7dans les exemples de données qui incluent les week-ends) et dans ce cas, peu importe si la semaine de début ou de fin est partielle.

Veuillez voir d'autres exemples dans le script ci-dessous pour avoir une meilleure idée de ce que je recherche. Vous pouvez voir que très souvent les week-ends sont exclus, mais tous les autres jours de la semaine peuvent également être exclus. Dans l'exemple 3 uniquement Mon, Wedet Frifont partie de la planification. En outre, les week-ends peuvent être inclus, comme dans l'exemple 7. La solution doit traiter tous les jours de la semaine de manière égale. N'importe quel jour de la semaine peut être inclus ou exclu de l'horaire.

Pour vérifier que la liste d'intervalles générée décrit correctement la planification donnée, vous pouvez utiliser le pseudo-code suivant:

  • parcourir tous les intervalles
  • pour chaque intervalle, parcourez toutes les dates du calendrier entre les dates de début et de fin (inclus).
  • pour chaque date, vérifiez si son jour de la semaine est répertorié dans le WeekDays. Si oui, cette date est incluse dans le calendrier.

Espérons que cela clarifie dans quels cas un nouvel intervalle doit être créé. Dans les exemples 4 et 5, un lundi ( 2016-05-09) est supprimé du milieu de la planification et cette planification ne peut pas être représentée par un seul intervalle. Dans l'exemple 6, il y a un long intervalle dans le programme, donc deux intervalles sont nécessaires.

Les intervalles représentent des modèles hebdomadaires dans le programme et lorsqu'un modèle est interrompu / modifié, le nouvel intervalle doit être ajouté. Dans l'exemple 11, les trois premières semaines ont un modèle Tue, puis ce modèle devient Thu. Par conséquent, nous avons besoin de deux intervalles pour décrire un tel calendrier.


J'utilise SQL Server 2008 pour le moment, donc la solution devrait fonctionner dans cette version. Si une solution pour SQL Server 2008 peut être simplifiée / améliorée à l'aide des fonctionnalités des versions ultérieures, c'est un bonus, veuillez également la montrer.

J'ai un Calendartableau (liste des dates) et un Numberstableau (liste des nombres entiers à partir de 1), il est donc correct de les utiliser, si nécessaire. Il est également correct de créer des tables temporaires et d'avoir plusieurs requêtes qui traitent les données en plusieurs étapes. Le nombre d'étapes dans un algorithme doit être fixé cependant, les curseurs et les WHILEboucles explicites ne sont pas OK.


Script pour les exemples de données et les résultats attendus

-- @Src is sample data
-- @Dst is expected result

DECLARE @Src TABLE (ID int PRIMARY KEY, ContractID int, dt date, dowChar char(3), dowInt int);
INSERT INTO @Src (ID, ContractID, dt, dowChar, dowInt) VALUES

-- simple two weeks (without weekend)
(110, 1, '2016-05-02', 'Mon', 2),
(111, 1, '2016-05-03', 'Tue', 3),
(112, 1, '2016-05-04', 'Wed', 4),
(113, 1, '2016-05-05', 'Thu', 5),
(114, 1, '2016-05-06', 'Fri', 6),
(115, 1, '2016-05-09', 'Mon', 2),
(116, 1, '2016-05-10', 'Tue', 3),
(117, 1, '2016-05-11', 'Wed', 4),
(118, 1, '2016-05-12', 'Thu', 5),
(119, 1, '2016-05-13', 'Fri', 6),

-- a partial end of the week, the whole week, partial start of the week (without weekends)
(223, 2, '2016-05-05', 'Thu', 5),
(224, 2, '2016-05-06', 'Fri', 6),
(225, 2, '2016-05-09', 'Mon', 2),
(226, 2, '2016-05-10', 'Tue', 3),
(227, 2, '2016-05-11', 'Wed', 4),
(228, 2, '2016-05-12', 'Thu', 5),
(229, 2, '2016-05-13', 'Fri', 6),
(230, 2, '2016-05-16', 'Mon', 2),
(231, 2, '2016-05-17', 'Tue', 3),

-- only Mon, Wed, Fri are included across two weeks plus partial third week
(310, 3, '2016-05-02', 'Mon', 2),
(311, 3, '2016-05-04', 'Wed', 4),
(314, 3, '2016-05-06', 'Fri', 6),
(315, 3, '2016-05-09', 'Mon', 2),
(317, 3, '2016-05-11', 'Wed', 4),
(319, 3, '2016-05-13', 'Fri', 6),
(330, 3, '2016-05-16', 'Mon', 2),

-- a whole week (without weekend), in the second week Mon is not included
(410, 4, '2016-05-02', 'Mon', 2),
(411, 4, '2016-05-03', 'Tue', 3),
(412, 4, '2016-05-04', 'Wed', 4),
(413, 4, '2016-05-05', 'Thu', 5),
(414, 4, '2016-05-06', 'Fri', 6),
(416, 4, '2016-05-10', 'Tue', 3),
(417, 4, '2016-05-11', 'Wed', 4),
(418, 4, '2016-05-12', 'Thu', 5),
(419, 4, '2016-05-13', 'Fri', 6),

-- three weeks, but without Mon in the second week (no weekends)
(510, 5, '2016-05-02', 'Mon', 2),
(511, 5, '2016-05-03', 'Tue', 3),
(512, 5, '2016-05-04', 'Wed', 4),
(513, 5, '2016-05-05', 'Thu', 5),
(514, 5, '2016-05-06', 'Fri', 6),
(516, 5, '2016-05-10', 'Tue', 3),
(517, 5, '2016-05-11', 'Wed', 4),
(518, 5, '2016-05-12', 'Thu', 5),
(519, 5, '2016-05-13', 'Fri', 6),
(520, 5, '2016-05-16', 'Mon', 2),
(521, 5, '2016-05-17', 'Tue', 3),
(522, 5, '2016-05-18', 'Wed', 4),
(523, 5, '2016-05-19', 'Thu', 5),
(524, 5, '2016-05-20', 'Fri', 6),

-- long gap between two intervals
(623, 6, '2016-05-05', 'Thu', 5),
(624, 6, '2016-05-06', 'Fri', 6),
(625, 6, '2016-05-09', 'Mon', 2),
(626, 6, '2016-05-10', 'Tue', 3),
(627, 6, '2016-05-11', 'Wed', 4),
(628, 6, '2016-05-12', 'Thu', 5),
(629, 6, '2016-05-13', 'Fri', 6),
(630, 6, '2016-05-16', 'Mon', 2),
(631, 6, '2016-05-17', 'Tue', 3),
(645, 6, '2016-06-06', 'Mon', 2),
(646, 6, '2016-06-07', 'Tue', 3),
(647, 6, '2016-06-08', 'Wed', 4),
(648, 6, '2016-06-09', 'Thu', 5),
(649, 6, '2016-06-10', 'Fri', 6),
(655, 6, '2016-06-13', 'Mon', 2),
(656, 6, '2016-06-14', 'Tue', 3),
(657, 6, '2016-06-15', 'Wed', 4),
(658, 6, '2016-06-16', 'Thu', 5),
(659, 6, '2016-06-17', 'Fri', 6),

-- two weeks, no gaps between days at all, even weekends are included
(710, 7, '2016-05-02', 'Mon', 2),
(711, 7, '2016-05-03', 'Tue', 3),
(712, 7, '2016-05-04', 'Wed', 4),
(713, 7, '2016-05-05', 'Thu', 5),
(714, 7, '2016-05-06', 'Fri', 6),
(715, 7, '2016-05-07', 'Sat', 7),
(716, 7, '2016-05-08', 'Sun', 1),
(725, 7, '2016-05-09', 'Mon', 2),
(726, 7, '2016-05-10', 'Tue', 3),
(727, 7, '2016-05-11', 'Wed', 4),
(728, 7, '2016-05-12', 'Thu', 5),
(729, 7, '2016-05-13', 'Fri', 6),

-- no gaps between days at all, even weekends are included, with partial weeks
(805, 8, '2016-04-30', 'Sat', 7),
(806, 8, '2016-05-01', 'Sun', 1),
(810, 8, '2016-05-02', 'Mon', 2),
(811, 8, '2016-05-03', 'Tue', 3),
(812, 8, '2016-05-04', 'Wed', 4),
(813, 8, '2016-05-05', 'Thu', 5),
(814, 8, '2016-05-06', 'Fri', 6),
(815, 8, '2016-05-07', 'Sat', 7),
(816, 8, '2016-05-08', 'Sun', 1),
(825, 8, '2016-05-09', 'Mon', 2),
(826, 8, '2016-05-10', 'Tue', 3),
(827, 8, '2016-05-11', 'Wed', 4),
(828, 8, '2016-05-12', 'Thu', 5),
(829, 8, '2016-05-13', 'Fri', 6),
(830, 8, '2016-05-14', 'Sat', 7),

-- only Mon-Wed included, two weeks plus partial third week
(910, 9, '2016-05-02', 'Mon', 2),
(911, 9, '2016-05-03', 'Tue', 3),
(912, 9, '2016-05-04', 'Wed', 4),
(915, 9, '2016-05-09', 'Mon', 2),
(916, 9, '2016-05-10', 'Tue', 3),
(917, 9, '2016-05-11', 'Wed', 4),
(930, 9, '2016-05-16', 'Mon', 2),
(931, 9, '2016-05-17', 'Tue', 3),

-- only Thu-Sun included, three weeks
(1013,10,'2016-05-05', 'Thu', 5),
(1014,10,'2016-05-06', 'Fri', 6),
(1015,10,'2016-05-07', 'Sat', 7),
(1016,10,'2016-05-08', 'Sun', 1),
(1018,10,'2016-05-12', 'Thu', 5),
(1019,10,'2016-05-13', 'Fri', 6),
(1020,10,'2016-05-14', 'Sat', 7),
(1021,10,'2016-05-15', 'Sun', 1),
(1023,10,'2016-05-19', 'Thu', 5),
(1024,10,'2016-05-20', 'Fri', 6),
(1025,10,'2016-05-21', 'Sat', 7),
(1026,10,'2016-05-22', 'Sun', 1),

-- only Tue for first three weeks, then only Thu for the next three weeks
(1111,11,'2016-05-03', 'Tue', 3),
(1116,11,'2016-05-10', 'Tue', 3),
(1131,11,'2016-05-17', 'Tue', 3),
(1123,11,'2016-05-19', 'Thu', 5),
(1124,11,'2016-05-26', 'Thu', 5),
(1125,11,'2016-06-02', 'Thu', 5),

-- one week, then one week gap, then one week
(1210,12,'2016-05-02', 'Mon', 2),
(1211,12,'2016-05-03', 'Tue', 3),
(1212,12,'2016-05-04', 'Wed', 4),
(1213,12,'2016-05-05', 'Thu', 5),
(1214,12,'2016-05-06', 'Fri', 6),
(1215,12,'2016-05-16', 'Mon', 2),
(1216,12,'2016-05-17', 'Tue', 3),
(1217,12,'2016-05-18', 'Wed', 4),
(1218,12,'2016-05-19', 'Thu', 5),
(1219,12,'2016-05-20', 'Fri', 6);

SELECT ID, ContractID, dt, dowChar, dowInt
FROM @Src
ORDER BY ContractID, dt;


DECLARE @Dst TABLE (ContractID int, StartDT date, EndDT date, DayCount int, WeekDays varchar(255));
INSERT INTO @Dst (ContractID, StartDT, EndDT, DayCount, WeekDays) VALUES
(1, '2016-05-02', '2016-05-13', 10, 'Mon,Tue,Wed,Thu,Fri,'),
(2, '2016-05-05', '2016-05-17',  9, 'Mon,Tue,Wed,Thu,Fri,'),
(3, '2016-05-02', '2016-05-16',  7, 'Mon,Wed,Fri,'),
(4, '2016-05-02', '2016-05-06',  5, 'Mon,Tue,Wed,Thu,Fri,'),
(4, '2016-05-10', '2016-05-13',  4, 'Tue,Wed,Thu,Fri,'),
(5, '2016-05-02', '2016-05-06',  5, 'Mon,Tue,Wed,Thu,Fri,'),
(5, '2016-05-10', '2016-05-20',  9, 'Mon,Tue,Wed,Thu,Fri,'),
(6, '2016-05-05', '2016-05-17',  9, 'Mon,Tue,Wed,Thu,Fri,'),
(6, '2016-06-06', '2016-06-17', 10, 'Mon,Tue,Wed,Thu,Fri,'),
(7, '2016-05-02', '2016-05-13', 12, 'Sun,Mon,Tue,Wed,Thu,Fri,Sat,'),
(8, '2016-04-30', '2016-05-14', 15, 'Sun,Mon,Tue,Wed,Thu,Fri,Sat,'),
(9, '2016-05-02', '2016-05-17',  8, 'Mon,Tue,Wed,'),
(10,'2016-05-05', '2016-05-22', 12, 'Sun,Thu,Fri,Sat,'),
(11,'2016-05-03', '2016-05-17',  3, 'Tue,'),
(11,'2016-05-19', '2016-06-02',  3, 'Thu,'),
(12,'2016-05-02', '2016-05-06',  5, 'Mon,Tue,Wed,Thu,Fri,'),
(12,'2016-05-16', '2016-05-20',  5, 'Mon,Tue,Wed,Thu,Fri,');

SELECT ContractID, StartDT, EndDT, DayCount, WeekDays
FROM @Dst
ORDER BY ContractID, StartDT;

Comparaison des réponses

Le vrai tableau @Srca des 403,555lignes 15,857distinctes ContractIDs. Toutes les réponses produisent des résultats corrects (au moins pour mes données) et toutes sont raisonnablement rapides, mais elles diffèrent par leur optimalité. Moins il y a d'intervalles, mieux c'est. J'ai inclus les temps d'exécution juste pour la curiosité. L'objectif principal est le résultat correct et optimal, pas la vitesse (sauf si cela prend trop de temps - j'ai arrêté la requête non récursive de Ziggy Crueltyfree Zeitgeister après 10 minutes).

+--------------------------------------------------------+-----------+---------+
|                         Answer                         | Intervals | Seconds |
+--------------------------------------------------------+-----------+---------+
| Ziggy Crueltyfree Zeitgeister                          |     25751 |    7.88 |
| While loop                                             |           |         |
|                                                        |           |         |
| Ziggy Crueltyfree Zeitgeister                          |     25751 |    8.27 |
| Recursive                                              |           |         |
|                                                        |           |         |
| Michael Green                                          |     25751 |   22.63 |
| Recursive                                              |           |         |
|                                                        |           |         |
| Geoff Patterson                                        |     26670 |    4.79 |
| Weekly gaps-and-islands with merging of partial weeks  |           |         |
|                                                        |           |         |
| Vladimir Baranov                                       |     34560 |    4.03 |
| Daily, then weekly gaps-and-islands                    |           |         |
|                                                        |           |         |
| Mikael Eriksson                                        |     35840 |    0.65 |
| Weekly gaps-and-islands                                |           |         |
+--------------------------------------------------------+-----------+---------+
| Vladimir Baranov                                       |     25751 |  121.51 |
| Cursor                                                 |           |         |
+--------------------------------------------------------+-----------+---------+
Vladimir Baranov
la source
Ne devrait-il pas (11,'2016-05-03', '2016-05-17', 3, 'Tue,'), (11,'2016-05-19', '2016-06-02', 3, 'Thu,');y avoir une ligne dans le @Dst Tue, Thu,?
Kin Shah
@Kin, l'exemple 11 doit avoir (au moins) deux intervalles (deux lignes dedans @Dst). Il n'y a que les deux premières semaines de l'horaire Tue, vous ne pouvez donc pas en avoir WeekDays=Tue,Thu,pendant ces semaines. Les deux dernières semaines de l'horaire ont seulement Thu, donc vous ne pouvez pas encore avoir WeekDays=Tue,Thu,pour ces semaines. La solution sous-optimale serait de trois rangées: juste Tuepour les deux premières semaines, puis Tue,Thu,pour la troisième semaine qui a les deux Tueet Thu, puis juste Thupour les deux dernières semaines.
Vladimir Baranov
1
Pouvez-vous expliquer l'algorithme selon lequel le contrat 11 est divisé "de manière optimale" en deux intervalles. Avez-vous réussi cela dans l'application C #? Comment?
Michael Green
@MichaelGreen, désolé, je n'ai pas pu répondre plus tôt. Oui, le code C # divise le contrat 11 en deux intervalles. L'algorithme approximatif: je passe en revue les dates planifiées, une par une, prends note des jours de la semaine que j'ai rencontrés jusqu'à présent depuis le début de l'intervalle et détermine si je dois commencer un nouvel intervalle: si un ContractIDchangement, si l'intervalle dépasse 7 jours et le nouveau jour de la semaine n'a pas été vu auparavant, s'il y a un écart dans la liste des jours programmés.
Vladimir Baranov
@MichaelGreen, j'ai converti mon code C # en un algorithme basé sur le curseur, juste pour voir comment il se compare à d'autres solutions sur des données réelles. J'ai ajouté le code source à ma réponse et les résultats au tableau récapitulatif de la question.
Vladimir Baranov,

Réponses:

6

Celui-ci utilise un CTE récursif. Son résultat est identique à l'exemple de la question . C'était un cauchemar à inventer ... Le code inclut des commentaires pour faciliter sa logique alambiquée.

SET DATEFIRST 1 -- Make Monday weekday=1

DECLARE @Ranked TABLE (RowID int NOT NULL IDENTITY PRIMARY KEY,                   -- Incremental uninterrupted sequence in the right order
                       ID int NOT NULL UNIQUE, ContractID int NOT NULL, dt date,  -- Original relevant values (ID is not really necessary)
                       WeekNo int NOT NULL, dowBit int NOT NULL);                 -- Useful to find gaps in days or weeks
INSERT INTO @Ranked
SELECT ID, ContractID, dt,
       DATEDIFF(WEEK, '1900-01-01', DATEADD(DAY, 1-DATEPART(dw, dt), dt)) AS WeekNo,
       POWER(2, DATEPART(dw, dt)-1) AS dowBit
FROM @Src
ORDER BY ContractID, WeekNo, dowBit

/*
Each evaluated date makes part of the carried sequence if:
  - this is not a new contract, and
    - sequence started this week, or
    - same day last week was part of the sequence, or
    - sequence started last week and today is a lower day than the accumulated weekdays list
  - and there are no sequence gaps since previous day
(otherwise it does not make part of the old sequence, so it starts a new one) */

DECLARE @RankedRanges TABLE (RowID int NOT NULL PRIMARY KEY, WeekDays int NOT NULL, StartRowID int NULL);

WITH WeeksCTE AS -- Needed for building the sequence gradually, and comparing the carried sequence (and previous day) with a current evaluated day
( 
    SELECT RowID, ContractID, dowBit, WeekNo, RowID AS StartRowID, WeekNo AS StartWN, dowBit AS WeekDays, dowBit AS StartWeekDays
    FROM @Ranked
    WHERE RowID = 1 
    UNION ALL
    SELECT RowID, ContractID, dowBit, WeekNo, StartRowID,
           CASE WHEN StartRowID IS NULL THEN StartWN ELSE WeekNo END AS WeekNo,
           CASE WHEN StartRowID IS NULL THEN WeekDays | dowBit ELSE dowBit END AS WeekDays,
           CASE WHEN StartRowID IS NOT NULL THEN dowBit WHEN WeekNo = StartWN THEN StartWeekDays | dowBit ELSE StartWeekDays END AS StartWeekDays
    FROM (
        SELECT w.*, pre.StartWN, pre.WeekDays, pre.StartWeekDays,
               CASE WHEN w.ContractID <> pre.ContractID OR     -- New contract always break the sequence
                         NOT (w.WeekNo = pre.StartWN OR        -- Same week as a new sequence always keeps the sequence
                              w.dowBit & pre.WeekDays > 0 OR   -- Days in the sequence keep the sequence (provided there are no gaps, checked later)
                              (w.WeekNo = pre.StartWN+1 AND (w.dowBit-1) & pre.StartWeekDays = 0)) OR -- Days in the second week when less than a week passed since the sequence started remain in sequence
                         (w.WeekNo > pre.StartWN AND -- look for gap after initial week
                          w.WeekNo > pre.WeekNo+1 OR -- look for full-week gaps
                          (w.WeekNo = pre.WeekNo AND                            -- when same week as previous day,
                           ((w.dowBit-1) ^ (pre.dowBit*2-1)) & pre.WeekDays > 0 -- days between this and previous weekdays, compared to current series
                          ) OR
                          (w.WeekNo > pre.WeekNo AND                                   -- when following week of previous day,
                           ((-1 ^ (pre.dowBit*2-1)) | (w.dowBit-1)) & pre.WeekDays > 0 -- days between this and previous weekdays, compared to current series
                          )) THEN w.RowID END AS StartRowID
        FROM WeeksCTE pre
        JOIN @Ranked w ON (w.RowID = pre.RowID + 1)
        ) w
) 
INSERT INTO @RankedRanges -- days sequence and starting point of each sequence
SELECT RowID, WeekDays, StartRowID
--SELECT *
FROM WeeksCTE
OPTION (MAXRECURSION 0)

--SELECT * FROM @RankedRanges

DECLARE @Ranges TABLE (RowNo int NOT NULL IDENTITY PRIMARY KEY, RowID int NOT NULL);

INSERT INTO @Ranges       -- @RankedRanges filtered only by start of each range, with numbered rows to easily find the end of each range
SELECT StartRowID
FROM @RankedRanges
WHERE StartRowID IS NOT NULL
ORDER BY 1

-- Final result putting everything together
SELECT rs.ContractID, rs.dt AS StartDT, re.dt AS EndDT, re.RowID-rs.RowID+1 AS DayCount,
       CASE WHEN rr.WeekDays & 64 > 0 THEN 'Sun,' ELSE '' END +
       CASE WHEN rr.WeekDays & 1 > 0 THEN 'Mon,' ELSE '' END +
       CASE WHEN rr.WeekDays & 2 > 0 THEN 'Tue,' ELSE '' END +
       CASE WHEN rr.WeekDays & 4 > 0 THEN 'Wed,' ELSE '' END +
       CASE WHEN rr.WeekDays & 8 > 0 THEN 'Thu,' ELSE '' END +
       CASE WHEN rr.WeekDays & 16 > 0 THEN 'Fri,' ELSE '' END +
       CASE WHEN rr.WeekDays & 32 > 0 THEN 'Sat,' ELSE '' END AS WeekDays
FROM (
    SELECT r.RowID AS StartRowID, COALESCE(pos.RowID-1, (SELECT MAX(RowID) FROM @Ranked)) AS EndRowID
    FROM @Ranges r
    LEFT JOIN @Ranges pos ON (pos.RowNo = r.RowNo + 1)
    ) g
JOIN @Ranked rs ON (rs.RowID = g.StartRowID)
JOIN @Ranked re ON (re.RowID = g.EndRowID)
JOIN @RankedRanges rr ON (rr.RowID = re.RowID)


Une autre stratégie

Celui-ci devrait être considérablement plus rapide que le précédent car il ne repose pas sur le CTE récursif lent et limité dans SQL Server 2008, bien qu'il implémente plus ou moins la même stratégie.

Il y a une WHILEboucle (je n'ai pas pu imaginer un moyen de l'éviter), mais va pour un nombre réduit d'itérations (le plus grand nombre de séquences (moins une) sur un contrat donné).

C'est une stratégie simple, et pourrait être utilisée pour des séquences plus courtes ou plus longues qu'une semaine (remplaçant toute occurrence de la constante 7 pour tout autre nombre, et le dowBitcalculé à partir de MODULUS x DayNoplutôt que DATEPART(wk)) et jusqu'à 32.

SET DATEFIRST 1 -- Make Monday weekday=1

-- Get the minimum information needed to calculate sequences
DECLARE @Days TABLE (ContractID int NOT NULL, dt date, DayNo int NOT NULL, dowBit int NOT NULL, PRIMARY KEY (ContractID, DayNo));
INSERT INTO @Days
SELECT ContractID, dt, CAST(CAST(dt AS datetime) AS int) AS DayNo, POWER(2, DATEPART(dw, dt)-1) AS dowBit
FROM @Src

DECLARE @RangeStartFirstPass TABLE (ContractID int NOT NULL, DayNo int NOT NULL, PRIMARY KEY (ContractID, DayNo))

-- Calculate, from the above list, which days are not present in the previous 7
INSERT INTO @RangeStartFirstPass
SELECT r.ContractID, r.DayNo
FROM @Days r
LEFT JOIN @Days pr ON (pr.ContractID = r.ContractID AND pr.DayNo BETWEEN r.DayNo-7 AND r.DayNo-1) -- Last 7 days
GROUP BY r.ContractID, r.DayNo, r.dowBit
HAVING r.dowBit & COALESCE(SUM(pr.dowBit), 0) = 0

-- Update the previous list with all days that occur right after a missing day
INSERT INTO @RangeStartFirstPass
SELECT *
FROM (
    SELECT DISTINCT ContractID, (SELECT MIN(DayNo) FROM @Days WHERE ContractID = d.ContractID AND DayNo > d.DayNo + 7) AS DayNo
    FROM @Days d
    WHERE NOT EXISTS (SELECT 1 FROM @Days WHERE ContractID = d.ContractID AND DayNo = d.DayNo + 7)
    ) d
WHERE DayNo IS NOT NULL AND
      NOT EXISTS (SELECT 1 FROM @RangeStartFirstPass WHERE ContractID = d.ContractID AND DayNo = d.DayNo)

DECLARE @RangeStart TABLE (ContractID int NOT NULL, DayNo int NOT NULL, PRIMARY KEY (ContractID, DayNo));

-- Fetch the first sequence for each contract
INSERT INTO @RangeStart
SELECT ContractID, MIN(DayNo)
FROM @RangeStartFirstPass
GROUP BY ContractID

-- Add to the list above the next sequence for each contract, until all are added
-- (ensure no sequence is added with less than 7 days)
WHILE @@ROWCOUNT > 0
  INSERT INTO @RangeStart
  SELECT f.ContractID, MIN(f.DayNo)
  FROM (SELECT ContractID, MAX(DayNo) AS DayNo FROM @RangeStart GROUP BY ContractID) s
  JOIN @RangeStartFirstPass f ON (f.ContractID = s.ContractID AND f.DayNo > s.DayNo + 7)
  GROUP BY f.ContractID

-- Summarise results
SELECT ContractID, StartDT, EndDT, DayCount,
       CASE WHEN WeekDays & 64 > 0 THEN 'Sun,' ELSE '' END +
       CASE WHEN WeekDays & 1 > 0 THEN 'Mon,' ELSE '' END +
       CASE WHEN WeekDays & 2 > 0 THEN 'Tue,' ELSE '' END +
       CASE WHEN WeekDays & 4 > 0 THEN 'Wed,' ELSE '' END +
       CASE WHEN WeekDays & 8 > 0 THEN 'Thu,' ELSE '' END +
       CASE WHEN WeekDays & 16 > 0 THEN 'Fri,' ELSE '' END +
       CASE WHEN WeekDays & 32 > 0 THEN 'Sat,' ELSE '' END AS WeekDays
FROM (
    SELECT r.ContractID,
           MIN(d.dt) AS StartDT,
           MAX(d.dt) AS EndDT,
           COUNT(*) AS DayCount,
           SUM(DISTINCT d.dowBit) AS WeekDays
    FROM (SELECT *, COALESCE((SELECT MIN(DayNo) FROM @RangeStart WHERE ContractID = rs.ContractID AND DayNo > rs.DayNo), 999999) AS DayEnd FROM @RangeStart rs) r
    JOIN @Days d ON (d.ContractID = r.ContractID AND d.DayNo BETWEEN r.DayNo AND r.DayEnd-1)
    GROUP BY r.ContractID, r.DayNo
    ) d
ORDER BY ContractID, StartDT
Ziggy Crueltyfree Zeitgeister
la source
@VladimirBaranov J'ai ajouté une nouvelle stratégie, qui devrait être beaucoup plus rapide. Faites-moi savoir comment il évalue vos données réelles!
Ziggy Crueltyfree Zeitgeister
2
@ZiggyCrueltyfreeZeitgeister, j'ai vérifié votre dernière solution et l'ai ajoutée à la liste de toutes les réponses à la question. Il produit des résultats corrects et le même nombre d'intervalles que le CTE récursif et sa vitesse est également très proche. Comme je l'ai dit, la vitesse n'est pas critique tant qu'elle est raisonnable. 1 seconde ou 10 secondes n'a pas vraiment d'importance pour moi.
Vladimir Baranov
D'autres réponses sont également excellentes et utiles, et j'aimerais pouvoir attribuer la prime à plusieurs réponses. J'ai choisi cette réponse, car au moment où j'ai commencé la prime, je ne pensais pas au CTE récursif et cette réponse a été la première à le suggérer et a une solution de travail. À strictement parler, le CTE récursif n'est pas une solution basée sur un ensemble, mais il donne des résultats optimaux et est relativement rapide. Une réponse de @GeoffPatterson est excellente, mais donne des résultats moins optimaux et, franchement, beaucoup trop compliqués.
Vladimir Baranov
5

Pas exactement ce que vous recherchez, mais pourrait peut-être vous intéresser.

La requête crée des semaines avec une chaîne séparée par des virgules pour les jours utilisés dans chaque semaine. Il trouve ensuite les îles de semaines consécutives qui utilisent le même schéma dans Weekdays.

with Weeks as
(
  select T.*,
         row_number() over(partition by T.ContractID, T.WeekDays order by T.WeekNumber) as rn
  from (
       select S1.ContractID,
              min(S1.dt) as StartDT,
              max(S1.dt) as EndDT,
              datediff(day, 0, S1.dt) / 7 as WeekNumber, -- Number of weeks since '1900-01-01 (a monday)'
              count(*) as DayCount,
              stuff((
                    select ','+S2.dowChar
                    from @Src as S2
                    where S2.ContractID = S1.ContractID and
                          S2.dt between min(S1.dt) and max(S1.dt)
                    order by S2.dt
                    for xml path('')
                    ), 1, 1, '') as WeekDays
       from @Src as S1
       group by S1.ContractID, 
                datediff(day, 0, S1.dt) / 7
       ) as T
)
select W.ContractID,
       min(W.StartDT) as StartDT,
       max(W.EndDT) as EndDT,
       count(*) * W.DayCount as DayCount,
       W.WeekDays
from Weeks as W
group by W.ContractID,
         W.WeekDays,
         W.DayCount,
         W.rn - W.WeekNumber
order by W.ContractID,
         min(W.WeekNumber);

Résultat:

ContractID  StartDT    EndDT      DayCount    WeekDays
----------- ---------- ---------- ----------- -----------------------------
1           2016-05-02 2016-05-13 10          Mon,Tue,Wed,Thu,Fri
2           2016-05-05 2016-05-06 2           Thu,Fri
2           2016-05-09 2016-05-13 5           Mon,Tue,Wed,Thu,Fri
2           2016-05-16 2016-05-17 2           Mon,Tue
3           2016-05-02 2016-05-13 6           Mon,Wed,Fri
3           2016-05-16 2016-05-16 1           Mon
4           2016-05-02 2016-05-06 5           Mon,Tue,Wed,Thu,Fri
4           2016-05-10 2016-05-13 4           Tue,Wed,Thu,Fri
5           2016-05-02 2016-05-06 5           Mon,Tue,Wed,Thu,Fri
5           2016-05-10 2016-05-13 4           Tue,Wed,Thu,Fri
5           2016-05-16 2016-05-20 5           Mon,Tue,Wed,Thu,Fri
6           2016-05-05 2016-05-06 2           Thu,Fri
6           2016-05-09 2016-05-13 5           Mon,Tue,Wed,Thu,Fri
6           2016-05-16 2016-05-17 2           Mon,Tue
6           2016-06-06 2016-06-17 10          Mon,Tue,Wed,Thu,Fri
7           2016-05-02 2016-05-08 7           Mon,Tue,Wed,Thu,Fri,Sat,Sun
7           2016-05-09 2016-05-13 5           Mon,Tue,Wed,Thu,Fri
8           2016-04-30 2016-05-01 2           Sat,Sun
8           2016-05-02 2016-05-08 7           Mon,Tue,Wed,Thu,Fri,Sat,Sun
8           2016-05-09 2016-05-14 6           Mon,Tue,Wed,Thu,Fri,Sat
9           2016-05-02 2016-05-11 6           Mon,Tue,Wed
9           2016-05-16 2016-05-17 2           Mon,Tue
10          2016-05-05 2016-05-22 12          Thu,Fri,Sat,Sun
11          2016-05-03 2016-05-10 2           Tue
11          2016-05-17 2016-05-19 2           Tue,Thu
11          2016-05-26 2016-06-02 2           Thu

ContractID = 2montre quelle est la différence dans le résultat par rapport à ce que vous voulez. La première et la dernière semaine seront traitées comme des périodes distinctes car elles WeekDayssont différentes.

Mikael Eriksson
la source
J'ai eu cette idée, mais je n'ai pas eu l'occasion de l'essayer. Merci d'avoir fourni une requête de travail. J'aime la façon dont cela donne un résultat plus structuré. En regroupant les données en semaines, l'inconvénient est une flexibilité réduite (dans une simple approche quotidienne des espaces et des îles, les exemples 7 et 8 seraient regroupés en un seul intervalle), mais c'est le côté positif en même temps - nous réduisons la complexité des le problème. Ainsi, le plus gros problème avec cette approche est des semaines partielles au début et à la fin du calendrier. Ces semaines partielles génèrent un intervalle supplémentaire ...
Vladimir Baranov
Pouvez-vous penser à un moyen d'ajouter / regrouper / fusionner ces semaines partielles dans le calendrier principal? Je n'ai qu'une idée très vague à ce stade. Si nous trouvons un moyen de fusionner correctement des semaines partielles, le résultat final serait très proche de l'optimal.
Vladimir Baranov le
@VladimirBaranov Je ne sais pas comment cela serait fait. Je mettrai à jour la réponse si quelque chose me vient à l'esprit.
Mikael Eriksson
Ma vague idée est la suivante: il n'y a que 7 jours dans une semaine, tout WeekDayscomme un nombre à 7 bits. Seulement 128 combinaisons. Il n'y a que 128 * 128 = 16384 paires possibles. Construisez une table temporaire avec toutes les paires possibles, puis déterminez un algorithme basé sur un ensemble qui marquerait quelles paires peuvent être fusionnées: un modèle d'une semaine est "couvert" par un modèle de la semaine suivante. Rejoignez vous-même le résultat hebdomadaire actuel (car il n'y LAGen a pas en 2008) et utilisez cette table temporaire pour décider quelles paires fusionner ... Je ne sais pas si cette idée a du mérite.
Vladimir Baranov
5

Je me suis retrouvé avec une approche qui donne la solution optimale dans ce cas et je pense qu'elle fera bien en général. La solution est cependant assez longue, il serait donc intéressant de voir si quelqu'un d'autre a une approche différente qui est plus concise.

Voici un script qui contient la solution complète .

Et voici un aperçu de l'algorithme:

  • Faites pivoter l'ensemble de données de sorte qu'il y ait une seule ligne représentant chaque semaine
  • Calculez les îles de semaines dans chaque ContractId
  • Fusionner toutes les semaines adjacentes qui tombent dans la même ContractIdet ont la mêmeWeekDays
  • Pour toutes les semaines (non encore fusionnées) où le groupe précédent se trouve sur la même île et où la WeekDayssemaine unique correspond à un sous-ensemble principal WeekDaysdu groupe précédent, fusionnez dans ce groupe précédent
  • Pour toutes les semaines (non encore fusionnées) où le groupe suivant est sur la même île et où la WeekDayssemaine unique correspond à un sous-ensemble de fin WeekDaysdu groupe suivant, fusionnez dans ce groupe suivant
  • Pour deux semaines adjacentes sur la même île où aucune des deux n'a été fusionnée, fusionnez-les si elles sont toutes les deux des semaines partielles pouvant être combinées (par exemple, "Lun, Mar, Mer, Jeu" et "Mer, Jeu, Sam") )
  • Pour les semaines individuelles restantes (pas encore fusionnées), si possible, divisez la semaine en deux parties et fusionnez les deux parties, la première partie dans le groupe précédent sur la même île et la deuxième partie dans le groupe suivant sur la même île
Geoff Patterson
la source
Merci d'être allé aussi loin pour produire la solution de travail. C'est un peu écrasant, pour être honnête. Je soupçonnais qu'il ne serait pas simple de fusionner des semaines partielles, mais je ne pouvais pas m'attendre à ce que ce soit si complexe. J'ai toujours l'espoir que cela pourrait être plus facile, mais je n'ai pas d'idée concrète.
Vladimir Baranov
Une vérification rapide confirme qu'elle produit le résultat attendu pour les données d'échantillon, ce qui est excellent, mais j'ai remarqué que certaines planifications ne sont pas gérées de manière optimale. Exemple le plus simple: (1214,12,'2016-05-06', 'Fri', 6), (1225,12,'2016-05-09', 'Mon', 2),. Il peut être représenté comme un intervalle, mais votre solution en produit deux. J'admets que cet exemple n'était pas dans les exemples de données et qu'il n'est pas critique. J'essaierai d'exécuter votre solution sur des données réelles.
Vladimir Baranov
J'apprécie votre réponse. Au moment où j'ai commencé la prime, je ne pensais pas au CTE récursif et Ziggy Crueltyfree Zeitgeister a été le premier à le proposer et à présenter une solution de travail. Strictement parlant, le CTE récursif n'est pas une solution basée sur un ensemble, mais il donne des résultats optimaux, est raisonnablement complexe et est relativement rapide. Votre réponse est basée sur un ensemble, mais s'avère trop compliquée, au point de ne pas être pratique. J'aimerais pouvoir partager la prime, mais malheureusement, ce n'est pas autorisé.
Vladimir Baranov
@VladimirBaranov Pas de problème, la prime est à 100% à utiliser comme vous le souhaitez. La raison pour laquelle j'aime les questions sur les primes est que la personne qui pose la question est généralement beaucoup plus engagée qu'une question normale. Ne vous souciez pas trop des points. Je suis totalement d'accord que cette solution n'est pas celle que j'utiliserais dans mon code de production; c'était une exploration d'une idée potentielle, mais a fini par être assez complexe.
Geoff Patterson
3

Je ne pouvais pas comprendre la logique du regroupement des semaines avec des écarts ou des semaines avec des week-ends (par exemple, quand il y a deux semaines consécutives avec un week-end, à quelle semaine le week-end va-t-il?).

La requête suivante produit la sortie souhaitée, sauf qu'elle ne regroupe que les jours de semaine consécutifs et regroupe les semaines du dimanche au samedi (plutôt que du lundi au dimanche). Bien que ce ne soit pas exactement ce que vous voulez, cela peut peut-être fournir des indices pour une stratégie différente. Le regroupement des jours vient d' ici . Les fonctions de fenêtrage utilisées devraient fonctionner avec SQLServer 2008, mais je n'ai pas cette version pour tester si c'est le cas.

WITH 
  mysrc AS (
    SELECT *, RANK() OVER (PARTITION BY ContractID ORDER BY DT) AS rank
    FROM @Src
    ),
  prepos AS (
    SELECT s.*, pos.ID AS posid
    FROM mysrc s
    LEFT JOIN mysrc pos ON (pos.ContractID = s.ContractID AND pos.rank = s.rank+1 AND (pos.DowInt = s.DowInt+1 OR pos.DowInt = 2 AND s.DowInt=6))
    ),
  grped AS (
    SELECT TOP 100 *, (SELECT COUNT(CASE WHEN posid IS NULL THEN 1 END) FROM prepos WHERE contractid = p.contractid AND rank < p.rank) as grp
    FROM prepos p
    ORDER BY ContractID, DT
    )
SELECT ContractID, min(dt) AS StartDT, max(dt) AS EndDT, count(*) AS DayCount,
       STUFF( (SELECT ', ' + dowchar
               FROM (
                 SELECT TOP 100 dowint, dowchar 
                 FROM grped 
                 WHERE ContractID = g.ContractID AND grp = g.grp 
                 GROUP BY dowint, dowchar 
                 ORDER BY 1
                 ) a 
               FOR XML PATH(''), TYPE).value('.','varchar(max)'), 1, 2, '') AS WeekDays
FROM grped g
GROUP BY ContractID, grp
ORDER BY 1, 2

Résultat

+------------+------------+------------+----------+-----------------------------------+
| ContractID | StartDT    | EndDT      | DayCount | WeekDays                          |
+------------+------------+------------+----------+-----------------------------------+
| 1          | 2/05/2016  | 13/05/2016 | 10       | Mon, Tue, Wed, Thu, Fri           |
| 2          | 5/05/2016  | 17/05/2016 | 9        | Mon, Tue, Wed, Thu, Fri           |
| 3          | 2/05/2016  | 2/05/2016  | 1        | Mon                               |
| 3          | 4/05/2016  | 4/05/2016  | 1        | Wed                               |
| 3          | 6/05/2016  | 9/05/2016  | 2        | Mon, Fri                          |
| 3          | 11/05/2016 | 11/05/2016 | 1        | Wed                               |
| 3          | 13/05/2016 | 16/05/2016 | 2        | Mon, Fri                          |
| 4          | 2/05/2016  | 6/05/2016  | 5        | Mon, Tue, Wed, Thu, Fri           |
| 4          | 10/05/2016 | 13/05/2016 | 4        | Tue, Wed, Thu, Fri                |
| 5          | 2/05/2016  | 6/05/2016  | 5        | Mon, Tue, Wed, Thu, Fri           |
| 5          | 10/05/2016 | 20/05/2016 | 9        | Mon, Tue, Wed, Thu, Fri           |
| 6          | 5/05/2016  | 17/05/2016 | 9        | Mon, Tue, Wed, Thu, Fri           |
| 6          | 6/06/2016  | 17/06/2016 | 10       | Mon, Tue, Wed, Thu, Fri           |
| 7          | 2/05/2016  | 7/05/2016  | 6        | Mon, Tue, Wed, Thu, Fri, Sat      |
| 7          | 8/05/2016  | 13/05/2016 | 6        | Sun, Mon, Tue, Wed, Thu, Fri      |
| 8          | 30/04/2016 | 30/04/2016 | 1        | Sat                               |
| 8          | 1/05/2016  | 7/05/2016  | 7        | Sun, Mon, Tue, Wed, Thu, Fri, Sat |
| 8          | 8/05/2016  | 14/05/2016 | 7        | Sun, Mon, Tue, Wed, Thu, Fri, Sat |
| 9          | 2/05/2016  | 4/05/2016  | 3        | Mon, Tue, Wed                     |
| 9          | 9/05/2016  | 10/05/2016 | 2        | Mon, Tue                          |
+------------+------------+------------+----------+-----------------------------------+
Ziggy Crueltyfree Zeitgeister
la source
La discussion sur cette réponse a été déplacée vers le chat .
Paul White réintègre Monica
3

Par souci d'exhaustivité, voici une gaps-and-islandsapproche en deux passes que j'ai moi-même essayée avant de poser cette question.

Comme je le testais sur les données réelles, j'ai trouvé peu de cas où il produisait des résultats incorrects et je l'ai corrigé.

Voici l'algorithme:

  • Générer des îles de dates consécutives ( CTE_ContractDays, CTE_DailyRN, CTE_DailyIslands) et calculer un numéro de la semaine pour chaque date de début et de fin d'une île. Ici, le numéro de semaine est calculé en supposant que le lundi est le premier jour de la semaine.
  • Si la planification a des dates non séquentielles dans la même semaine (comme dans l'exemple 3), l'étape précédente créera plusieurs lignes pour la même semaine. Les lignes de groupe doivent avoir une seule ligne par semaine ( CTE_Weeks).
  • Pour chaque ligne de l'étape précédente, créez une liste de jours de la semaine séparés par des virgules ( CTE_FirstResult).
  • Deuxième passage d'écarts et d'îles pour regrouper des semaines consécutives avec le même WeekDays( CTE_SecondRN, CTE_Schedules).

Il gère bien les cas où il n'y a pas de perturbation dans les schémas hebdomadaires (1, 7, 8, 10, 12). Il gère bien les cas où le modèle a des jours non séquentiels (3).

Mais, malheureusement, il génère des intervalles supplémentaires pour des semaines partielles (2, 3, 5, 6, 9, 11).

WITH
CTE_ContractDays
AS
(
    SELECT
         S.ContractID
        ,MIN(S.dt) OVER (PARTITION BY S.ContractID) AS ContractMinDT
        ,S.dt
        ,ROW_NUMBER() OVER (PARTITION BY S.ContractID ORDER BY S.dt) AS rn1
        ,DATEDIFF(day, '2001-01-01', S.dt) AS DayNumber
        ,S.dowChar
        ,S.dowInt
    FROM
        @Src AS S
)
,CTE_DailyRN
AS
(
    SELECT
        DayNumber - rn1 AS WeekGroupNumber
        ,ROW_NUMBER() OVER (
            PARTITION BY
                ContractID
                ,DayNumber - rn1
            ORDER BY dt) AS rn2
        ,ContractID
        ,ContractMinDT
        ,dt
        ,rn1
        ,DayNumber
        ,dowChar
        ,dowInt
    FROM CTE_ContractDays
)
,CTE_DailyIslands
AS
(
    SELECT
        ContractID
        ,ContractMinDT
        ,MIN(dt) AS MinDT
        ,MAX(dt) AS MaxDT
        ,COUNT(*) AS DayCount
        -- '2001-01-01' is Monday
        ,DATEDIFF(day, '2001-01-01', MIN(dt)) / 7 AS WeekNumberMin
        ,DATEDIFF(day, '2001-01-01', MAX(dt)) / 7 AS WeekNumberMax
    FROM CTE_DailyRN
    GROUP BY
        ContractID
        ,rn1-rn2
        ,ContractMinDT
)
,CTE_Weeks
AS
(
    SELECT
        ContractID
        ,ContractMinDT
        ,MIN(MinDT) AS MinDT
        ,MAX(MaxDT) AS MaxDT
        ,SUM(DayCount) AS DayCount
        ,WeekNumberMin
        ,WeekNumberMax
    FROM CTE_DailyIslands
    GROUP BY
        ContractID
        ,ContractMinDT
        ,WeekNumberMin
        ,WeekNumberMax
)
,CTE_FirstResult
AS
(
    SELECT
        ContractID
        ,ContractMinDT
        ,MinDT
        ,MaxDT
        ,DayCount
        ,CA_Data.XML_Value AS DaysOfWeek
        ,WeekNumberMin AS WeekNumber
        ,ROW_NUMBER() OVER(PARTITION BY ContractID ORDER BY MinDT) AS rn1
    FROM
        CTE_Weeks
        CROSS APPLY
        (
            SELECT CAST(CTE_ContractDays.dowChar AS varchar(8000)) + ',' AS dw
            FROM CTE_ContractDays
            WHERE
                    CTE_ContractDays.ContractID = CTE_Weeks.ContractID
                AND CTE_ContractDays.dt >= CTE_Weeks.MinDT
                AND CTE_ContractDays.dt <= CTE_Weeks.MaxDT
            GROUP BY
                CTE_ContractDays.dowChar
                ,CTE_ContractDays.dowInt
            ORDER BY CTE_ContractDays.dowInt
            FOR XML PATH(''), TYPE
        ) AS CA_XML(XML_Value)
        CROSS APPLY
        (
            SELECT CA_XML.XML_Value.value('.', 'VARCHAR(8000)')
        ) AS CA_Data(XML_Value)
)
,CTE_SecondRN
AS
(
    SELECT 
        ContractID
        ,ContractMinDT
        ,MinDT
        ,MaxDT
        ,DayCount
        ,DaysOfWeek
        ,WeekNumber
        ,rn1
        ,WeekNumber - rn1 AS SecondGroupNumber
        ,ROW_NUMBER() OVER (
            PARTITION BY
                ContractID
                ,DaysOfWeek
                ,DayCount
                ,WeekNumber - rn1
            ORDER BY MinDT) AS rn2
    FROM CTE_FirstResult
)
,CTE_Schedules
AS
(
    SELECT
        ContractID
        ,MIN(MinDT) AS StartDT
        ,MAX(MaxDT) AS EndDT
        ,SUM(DayCount) AS DayCount
        ,DaysOfWeek
    FROM CTE_SecondRN
    GROUP BY
        ContractID
        ,DaysOfWeek
        ,rn1-rn2
)
SELECT
    ContractID
    ,StartDT
    ,EndDT
    ,DayCount
    ,DaysOfWeek AS WeekDays
FROM CTE_Schedules
ORDER BY
    ContractID
    ,StartDT
;

Résultat

+------------+------------+------------+----------+------------------------------+
| ContractID |  StartDT   |   EndDT    | DayCount |           WeekDays           |
+------------+------------+------------+----------+------------------------------+
|          1 | 2016-05-02 | 2016-05-13 |       10 | Mon,Tue,Wed,Thu,Fri,         |
|          2 | 2016-05-05 | 2016-05-06 |        2 | Thu,Fri,                     |
|          2 | 2016-05-09 | 2016-05-13 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|          2 | 2016-05-16 | 2016-05-17 |        2 | Mon,Tue,                     |
|          3 | 2016-05-02 | 2016-05-13 |        6 | Mon,Wed,Fri,                 |
|          3 | 2016-05-16 | 2016-05-16 |        1 | Mon,                         |
|          4 | 2016-05-02 | 2016-05-06 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|          4 | 2016-05-10 | 2016-05-13 |        4 | Tue,Wed,Thu,Fri,             |
|          5 | 2016-05-02 | 2016-05-06 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|          5 | 2016-05-10 | 2016-05-13 |        4 | Tue,Wed,Thu,Fri,             |
|          5 | 2016-05-16 | 2016-05-20 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|          6 | 2016-05-05 | 2016-05-06 |        2 | Thu,Fri,                     |
|          6 | 2016-05-09 | 2016-05-13 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|          6 | 2016-05-16 | 2016-05-17 |        2 | Mon,Tue,                     |
|          6 | 2016-06-06 | 2016-06-17 |       10 | Mon,Tue,Wed,Thu,Fri,         |
|          7 | 2016-05-02 | 2016-05-13 |       12 | Sun,Mon,Tue,Wed,Thu,Fri,Sat, |
|          8 | 2016-04-30 | 2016-05-14 |       15 | Sun,Mon,Tue,Wed,Thu,Fri,Sat, |
|          9 | 2016-05-02 | 2016-05-11 |        6 | Mon,Tue,Wed,                 |
|          9 | 2016-05-16 | 2016-05-17 |        2 | Mon,Tue,                     |
|         10 | 2016-05-05 | 2016-05-22 |       12 | Sun,Thu,Fri,Sat,             |
|         11 | 2016-05-03 | 2016-05-10 |        2 | Tue,                         |
|         11 | 2016-05-17 | 2016-05-19 |        2 | Tue,Thu,                     |
|         11 | 2016-05-26 | 2016-06-02 |        2 | Thu,                         |
|         12 | 2016-05-02 | 2016-05-06 |        5 | Mon,Tue,Wed,Thu,Fri,         |
|         12 | 2016-05-16 | 2016-05-20 |        5 | Mon,Tue,Wed,Thu,Fri,         |
+------------+------------+------------+----------+------------------------------+

Solution basée sur le curseur

J'ai converti mon code C # en un algorithme basé sur le curseur, juste pour voir comment il se compare à d'autres solutions sur des données réelles. Elle confirme qu'elle est beaucoup plus lente que les autres approches basées sur des ensembles ou récursives, mais elle génère un résultat optimal.

CREATE TABLE #Dst_V2 (ContractID bigint, StartDT date, EndDT date, DayCount int, WeekDays varchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS);

SET NOCOUNT ON;

DECLARE @VarOldDateFirst int = @@DATEFIRST;
SET DATEFIRST 7;

DECLARE @iFS int;
DECLARE @VarCursor CURSOR;
SET @VarCursor = CURSOR FAST_FORWARD
FOR
    SELECT
        ContractID
        ,dt
        ,dowChar
        ,dowInt
    FROM #Src AS S
    ;

OPEN @VarCursor;

DECLARE @CurrContractID bigint = 0;
DECLARE @Currdt date;
DECLARE @CurrdowChar char(3);
DECLARE @CurrdowInt int;


DECLARE @VarCreateNewInterval bit = 0;
DECLARE @VarTempDT date;
DECLARE @VarTempdowInt int;

DECLARE @LastContractID bigint = 0;
DECLARE @LastStartDT date;
DECLARE @LastEndDT date;
DECLARE @LastDayCount int = 0;
DECLARE @LastWeekDays varchar(255);
DECLARE @LastMonCount int;
DECLARE @LastTueCount int;
DECLARE @LastWedCount int;
DECLARE @LastThuCount int;
DECLARE @LastFriCount int;
DECLARE @LastSatCount int;
DECLARE @LastSunCount int;


FETCH NEXT FROM @VarCursor INTO @CurrContractID, @Currdt, @CurrdowChar, @CurrdowInt;
SET @iFS = @@FETCH_STATUS;
IF @iFS = 0
BEGIN
    SET @LastContractID = @CurrContractID;
    SET @LastStartDT = @Currdt;
    SET @LastEndDT = @Currdt;
    SET @LastDayCount = 1;
    SET @LastMonCount = 0;
    SET @LastTueCount = 0;
    SET @LastWedCount = 0;
    SET @LastThuCount = 0;
    SET @LastFriCount = 0;
    SET @LastSatCount = 0;
    SET @LastSunCount = 0;
    IF @CurrdowInt = 1 SET @LastSunCount = @LastSunCount + 1;
    IF @CurrdowInt = 2 SET @LastMonCount = @LastMonCount + 1;
    IF @CurrdowInt = 3 SET @LastTueCount = @LastTueCount + 1;
    IF @CurrdowInt = 4 SET @LastWedCount = @LastWedCount + 1;
    IF @CurrdowInt = 5 SET @LastThuCount = @LastThuCount + 1;
    IF @CurrdowInt = 6 SET @LastFriCount = @LastFriCount + 1;
    IF @CurrdowInt = 7 SET @LastSatCount = @LastSatCount + 1;
END;

WHILE @iFS = 0
BEGIN

    SET @VarCreateNewInterval = 0;

    -- Contract changes -> start new interval
    IF @LastContractID <> @CurrContractID
    BEGIN
        SET @VarCreateNewInterval = 1;
    END;

    IF @VarCreateNewInterval = 0
    BEGIN
        -- check days of week
        -- are we still within the first week of the interval?
        IF DATEDIFF(day, @LastStartDT, @Currdt) > 6
        BEGIN
            -- we are beyond the first week, check day of the week
            -- have we seen @CurrdowInt before?
            -- we should start a new interval if this is the new day of the week that didn't exist in the first week
            IF @CurrdowInt = 1 AND @LastSunCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 2 AND @LastMonCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 3 AND @LastTueCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 4 AND @LastWedCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 5 AND @LastThuCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 6 AND @LastFriCount = 0 SET @VarCreateNewInterval = 1;
            IF @CurrdowInt = 7 AND @LastSatCount = 0 SET @VarCreateNewInterval = 1;

            IF @VarCreateNewInterval = 0
            BEGIN
                -- check the gap between current day and last day of the interval
                -- if the gap between current day and last day of the interval
                -- contains a day of the week that was included in the interval before,
                -- we should create new interval
                SET @VarTempDT = DATEADD(day, 1, @LastEndDT);
                WHILE @VarTempDT < @Currdt
                BEGIN
                    SET @VarTempdowInt = DATEPART(WEEKDAY, @VarTempDT);

                    IF @VarTempdowInt = 1 AND @LastSunCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 2 AND @LastMonCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 3 AND @LastTueCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 4 AND @LastWedCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 5 AND @LastThuCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 6 AND @LastFriCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;
                    IF @VarTempdowInt = 7 AND @LastSatCount > 0 BEGIN SET @VarCreateNewInterval = 1; BREAK; END;

                    SET @VarTempDT = DATEADD(day, 1, @VarTempDT);
                END;
            END;
        END;
        -- else
        -- we are still within the first week, so we can add this day to the interval
    END;

    IF @VarCreateNewInterval = 1
    BEGIN
        -- save the new interval into the final table
        SET @LastWeekDays = '';
        IF @LastSunCount > 0 SET @LastWeekDays = @LastWeekDays + 'Sun,';
        IF @LastMonCount > 0 SET @LastWeekDays = @LastWeekDays + 'Mon,';
        IF @LastTueCount > 0 SET @LastWeekDays = @LastWeekDays + 'Tue,';
        IF @LastWedCount > 0 SET @LastWeekDays = @LastWeekDays + 'Wed,';
        IF @LastThuCount > 0 SET @LastWeekDays = @LastWeekDays + 'Thu,';
        IF @LastFriCount > 0 SET @LastWeekDays = @LastWeekDays + 'Fri,';
        IF @LastSatCount > 0 SET @LastWeekDays = @LastWeekDays + 'Sat,';

        INSERT INTO #Dst_V2 
            (ContractID
            ,StartDT
            ,EndDT
            ,DayCount
            ,WeekDays)
        VALUES
            (@LastContractID
            ,@LastStartDT
            ,@LastEndDT
            ,@LastDayCount
            ,@LastWeekDays);

        -- init the new interval
        SET @LastContractID = @CurrContractID;
        SET @LastStartDT = @Currdt;
        SET @LastEndDT = @Currdt;
        SET @LastDayCount = 1;
        SET @LastMonCount = 0;
        SET @LastTueCount = 0;
        SET @LastWedCount = 0;
        SET @LastThuCount = 0;
        SET @LastFriCount = 0;
        SET @LastSatCount = 0;
        SET @LastSunCount = 0;
        IF @CurrdowInt = 1 SET @LastSunCount = @LastSunCount + 1;
        IF @CurrdowInt = 2 SET @LastMonCount = @LastMonCount + 1;
        IF @CurrdowInt = 3 SET @LastTueCount = @LastTueCount + 1;
        IF @CurrdowInt = 4 SET @LastWedCount = @LastWedCount + 1;
        IF @CurrdowInt = 5 SET @LastThuCount = @LastThuCount + 1;
        IF @CurrdowInt = 6 SET @LastFriCount = @LastFriCount + 1;
        IF @CurrdowInt = 7 SET @LastSatCount = @LastSatCount + 1;

    END ELSE BEGIN

        -- update last interval
        SET @LastEndDT = @Currdt;
        SET @LastDayCount = @LastDayCount + 1;
        IF @CurrdowInt = 1 SET @LastSunCount = @LastSunCount + 1;
        IF @CurrdowInt = 2 SET @LastMonCount = @LastMonCount + 1;
        IF @CurrdowInt = 3 SET @LastTueCount = @LastTueCount + 1;
        IF @CurrdowInt = 4 SET @LastWedCount = @LastWedCount + 1;
        IF @CurrdowInt = 5 SET @LastThuCount = @LastThuCount + 1;
        IF @CurrdowInt = 6 SET @LastFriCount = @LastFriCount + 1;
        IF @CurrdowInt = 7 SET @LastSatCount = @LastSatCount + 1;
    END;


    FETCH NEXT FROM @VarCursor INTO @CurrContractID, @Currdt, @CurrdowChar, @CurrdowInt;
    SET @iFS = @@FETCH_STATUS;
END;

-- save the last interval into the final table
IF @LastDayCount > 0
BEGIN
    SET @LastWeekDays = '';
    IF @LastSunCount > 0 SET @LastWeekDays = @LastWeekDays + 'Sun,';
    IF @LastMonCount > 0 SET @LastWeekDays = @LastWeekDays + 'Mon,';
    IF @LastTueCount > 0 SET @LastWeekDays = @LastWeekDays + 'Tue,';
    IF @LastWedCount > 0 SET @LastWeekDays = @LastWeekDays + 'Wed,';
    IF @LastThuCount > 0 SET @LastWeekDays = @LastWeekDays + 'Thu,';
    IF @LastFriCount > 0 SET @LastWeekDays = @LastWeekDays + 'Fri,';
    IF @LastSatCount > 0 SET @LastWeekDays = @LastWeekDays + 'Sat,';

    INSERT INTO #Dst_V2
        (ContractID
        ,StartDT
        ,EndDT
        ,DayCount
        ,WeekDays)
    VALUES
        (@LastContractID
        ,@LastStartDT
        ,@LastEndDT
        ,@LastDayCount
        ,@LastWeekDays);
END;

CLOSE @VarCursor;
DEALLOCATE @VarCursor;

SET DATEFIRST @VarOldDateFirst;

DROP TABLE #Dst_V2;
Vladimir Baranov
la source
2

J'ai été un peu surpris que la solution du curseur de Vladimir soit si lente, j'ai donc également essayé d'optimiser cette version. J'ai confirmé que l'utilisation d'un curseur était également très lente pour moi.

Cependant, au prix d'utiliser des fonctionnalités non documentées dans SQL Server en les ajoutant à une variable lors du traitement d'un ensemble de lignes, j'ai pu créer une version simplifiée de cette logique qui donne le résultat optimal et s'exécute beaucoup plus rapidement que le curseur et ma solution d'origine. . Donc, utilisez-le à vos risques et périls, mais je présenterai la solution au cas où cela l'intéresserait. Il serait également possible de mettre à jour la solution pour utiliser une WHILEboucle de un au numéro de ligne maximum, en cherchant le numéro de ligne suivant à chaque itération de la boucle. Cela resterait fidèle à une fonctionnalité entièrement documentée et fiable, mais violerait la contrainte déclarée (quelque peu artificielle) du problème selon lequel les WHILEboucles ne sont pas autorisées.

Notez que si l'utilisation de SQL 2014 était autorisée, il est probable qu'une procédure stockée compilée en mode natif qui boucle sur les numéros de ligne et accède à chaque numéro de ligne dans une table optimisée en mémoire serait une implémentation de cette même logique qui s'exécuterait plus rapidement.

Voici la solution complète , y compris l'extension des données d'essai définies à environ un demi-million de lignes. La nouvelle solution se termine en environ 3 secondes et à mon avis est beaucoup plus concise et lisible que la solution précédente que j'ai proposée. Je vais détailler les trois étapes impliquées ici:

Étape 1: prétraitement

Nous ajoutons d'abord un numéro de ligne à l'ensemble de données, dans l'ordre où nous traiterons les données. Ce faisant, nous convertissons également chaque dowInt en une puissance de 2 afin de pouvoir utiliser une image bitmap pour représenter les jours observés dans un groupe donné:

IF OBJECT_ID('tempdb..#srcWithRn') IS NOT NULL
    DROP TABLE #srcWithRn
GO
SELECT rn = IDENTITY(INT, 1, 1), ContractId, dt, dowInt,
    POWER(2, dowInt) AS dowPower, dowChar
INTO #srcWithRn
FROM #src
ORDER BY ContractId, dt
GO
ALTER TABLE #srcWithRn
ADD PRIMARY KEY (rn)
GO

Étape 2: boucle sur les jours du contrat afin d'identifier de nouveaux regroupements

Nous passons ensuite en boucle sur les données, dans l'ordre par numéro de ligne. Nous calculons uniquement la liste des numéros de ligne qui forment la limite d'un nouveau groupe, puis nous exportons ces numéros de ligne dans un tableau:

DECLARE @ContractId INT, @RnList VARCHAR(MAX), @NewGrouping BIT = 0, @DowBitmap INT = 0, @startDt DATE
SELECT TOP 1 @ContractId = ContractId, @startDt = dt, @RnList = ',' + CONVERT(VARCHAR(MAX), rn), @DowBitmap = DowPower
FROM #srcWithRn
WHERE rn = 1

SELECT 
    -- New grouping if new contract, or if we're observing a new day that we did
    -- not observe within the first 7 days of the grouping
    @NewGrouping = CASE
        WHEN ContractId <> @ContractId THEN 1
        WHEN DATEDIFF(DAY, @startDt, dt) > 6
            AND @DowBitmap & dowPower <> dowPower THEN 1
        ELSE 0
        END,
    @ContractId = ContractId,
    -- If this is a newly observed day in an existing grouping, add it to the bitmap
    @DowBitmap = CASE WHEN @NewGrouping = 0 THEN @DowBitmap | DowPower ELSE DowPower END,
    -- If this is a new grouping, reset the start date of the grouping
    @startDt = CASE WHEN @NewGrouping = 0 THEN @startDt ELSE dt END,
    -- If this is a new grouping, add this rn to the list of row numbers that delineate the boundary of a new grouping
    @RnList = CASE WHEN @NewGrouping = 0 THEN @RnList ELSE @RnList + ',' + CONVERT(VARCHAR(MAX), rn) END 
FROM #srcWithRn
WHERE rn >= 2
ORDER BY rn
OPTION (MAXDOP 1)

-- Split the list of grouping boundaries into a table
IF OBJECT_ID('tempdb..#newGroupingRns') IS NOT NULL
    DROP TABLE #newGroupingRns
SELECT splitListId AS rn
INTO #newGroupingRns
FROM dbo.f_delimitedIntListSplitter(SUBSTRING(@RnList, 2, 1000000000), DEFAULT)
GO
ALTER TABLE #newGroupingRns
ADD PRIMARY KEY (rn)
GO

Étape 3: Calcul des résultats finaux en fonction des numéros de ligne de chaque limite de regroupement

Nous calculons ensuite les groupes finaux en utilisant les limites identifiées dans la boucle ci-dessus pour agréger toutes les dates qui tombent dans chaque groupe:

IF OBJECT_ID('tempdb..#finalGroupings') IS NOT NULL
    DROP TABLE #finalGroupings
GO
SELECT MIN(s.ContractId) AS ContractId,
    MIN(dt) AS StartDT,
    MAX(dt) AS EndDT,
    COUNT(*) AS DayCount,
    CASE WHEN MAX(CASE WHEN dowChar = 'Sun' THEN 1 ELSE 0 END) = 1 THEN 'Sun,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Mon' THEN 1 ELSE 0 END) = 1 THEN 'Mon,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Tue' THEN 1 ELSE 0 END) = 1 THEN 'Tue,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Wed' THEN 1 ELSE 0 END) = 1 THEN 'Wed,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Thu' THEN 1 ELSE 0 END) = 1 THEN 'Thu,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Fri' THEN 1 ELSE 0 END) = 1 THEN 'Fri,' ELSE '' END + 
    CASE WHEN MAX(CASE WHEN dowChar = 'Sat' THEN 1 ELSE 0 END) = 1 THEN 'Sat,' ELSE '' END AS WeekDays
INTO #finalGroupings
FROM #srcWithRn s
CROSS APPLY (
    -- For any row, its grouping is the largest boundary row number that occurs at or before this row
    SELECT TOP 1 rn AS groupingRn
    FROM #newGroupingRns grp
    WHERE grp.rn <= s.rn
    ORDER BY grp.rn DESC
) g
GROUP BY g.groupingRn
ORDER BY g.groupingRn
GO
Geoff Patterson
la source
Je vous remercie. J'ai demandé de ne pas utiliser de curseurs ou de WHILEboucles, car je savais déjà comment le résoudre avec le curseur et je voulais trouver une solution basée sur un ensemble. En outre, je soupçonnais que le curseur serait lent (surtout avec une boucle imbriquée). Cette réponse est très intéressante en termes d'apprentissage de nouvelles astuces et j'apprécie vos efforts.
Vladimir Baranov
1

La discussion suivra le code.

declare @Helper table(
    rn tinyint,
    dowInt tinyint,
    dowChar char(3));
insert @Helper
values  ( 1,1,'Sun'),
        ( 2,2,'Mon'),
        ( 3,3,'Tue'),
        ( 4,4,'Wed'),
        ( 5,5,'Thu'),
        ( 6,6,'Fri'),
        ( 7,7,'Sat'),
        ( 8,1,'Sun'),
        ( 9,2,'Mon'),
        (10,3,'Tue'),
        (11,4,'Wed'),
        (12,5,'Thu'),
        (13,6,'Fri'),
        (14,7,'Sat');



with MissingDays as
(
    select
        h1.rn as rn1,
        h1.dowChar as StartDay,
        h2.rn as rn2,
        h2.dowInt as FollowingDayInt,
        h2.dowChar as FollowingDayChar
    from @Helper as h1
    inner join @Helper as h2
        on h2.rn > h1.rn
    where h1.rn < 8
    and h2.rn < h1.rn + 8
)
,Numbered as
(
    select
        a.*,
        ROW_NUMBER() over (partition by a.ContractID order by a.dt) as rn
    from #Src as a
)
,Incremented as
(
    select
        b.*,
        convert(varchar(max), b.dowChar)+',' as WeekDays,
        b.dt as IntervalStart
    from Numbered as b
    where b.rn = 1

    union all

    select
        c.*,
        case
            when
                (DATEDIFF(day, d.IntervalStart, c.dt) > 6)      -- interval goes beyond 7 days
            and (
                    (d.WeekDays not like '%'+c.dowChar+'%')     -- the new week day has not been seen before
                or 
                    (DATEDIFF(day, d.dt, c.dt) > 7)
                or 
                    (
                        (DATEDIFF(day, d.dt, c.dt) > 1)
                        and
                        (
                        exists( select
                                    e.FollowingDayChar
                                from MissingDays as e
                                where e.StartDay = d.dowChar
                                and rn2 < (select f.rn2 from MissingDays as f
                                            where f.StartDay = d.dowChar
                                            and f.FollowingDayInt = c.dowInt)
                                and d.WeekDays like '%'+e.FollowingDayChar+'%'
                            )
                        )
                    )
                )
            then convert(varchar(max),c.dowChar)+','
            else
                case
                    when d.WeekDays like '%'+c.dowChar+'%'
                    then d.WeekDays
                    else d.WeekDays+convert(varchar(max),c.dowChar)+','
                end
        end,
        case
            when
                (DATEDIFF(day, d.IntervalStart, c.dt) > 6)      -- interval goes beyond 7 days
            and (
                    (d.WeekDays not like '%'+c.dowChar+'%')     -- the new week day has not been seen before
                or
                    (DATEDIFF(day, d.dt, c.dt) > 7)             -- there is a one week gap
                or 
                    (
                        (DATEDIFF(day, d.dt, c.dt) > 1)         -- there is a gap..
                        and
                        (
                        exists( select                          -- .. and the omitted days are in the preceeding interval
                                    e.FollowingDayChar
                                from MissingDays as e
                                where e.StartDay = d.dowChar
                                and rn2 < (select f.rn2 from MissingDays as f
                                            where f.StartDay = d.dowChar
                                            and f.FollowingDayInt = c.dowInt)
                                and d.WeekDays like '%'+e.FollowingDayChar+'%'
                            )
                        )
                    )
                )
            then c.dt
            else d.IntervalStart
        end
    from Numbered as c
    inner join Incremented as d
    on d.ContractID = c.ContractID
    and d.rn = c.rn - 1
)
select
    g.ContractID,
    g.IntervalStart as StartDT,
    MAX(g.dt) as EndDT,
    COUNT(*) as DayCount,
    MAX(g.WeekDays) as WeekDays
from Incremented as g
group by
    g.ContractID,
    g.IntervalStart
order by
    ContractID,
    StartDT;

@Helper est de faire face à cette règle:

Si l'écart entre le jour en cours et le dernier jour de l'intervalle contient un jour de la semaine inclus dans l'intervalle précédent, nous devons créer un nouvel intervalle

Il me permet de lister les noms de jours, dans l'ordre des numéros de jours, entre deux jours donnés. Ceci est utilisé pour décider si un nouvel intervalle doit commencer. Je le remplis avec deux semaines de valeurs pour faciliter le codage d'un week-end.

Il existe des moyens plus propres de mettre en œuvre cela. Un tableau complet des «dates» en serait un. Il existe probablement un moyen intelligent avec le nombre de jours et l'arithmétique modulo.

Le CTE MissingDaysdoit générer une liste de noms de jours entre deux jours donnés. Il est géré de cette manière maladroite car le CTE récursif (suivant) n'autorise pas les agrégats, TOP () ou d'autres opérateurs. C'est inélégant, mais ça marche.

CTE Numbereddoit appliquer une séquence connue et sans lacune sur les données. Cela évite beaucoup de comparaisons plus tard.

CTE Incrementedest l'endroit où l'action se produit. En gros, j'utilise un CTE récursif pour parcourir les données et appliquer les règles. Le numéro de ligne généré dans Numbered(ci-dessus) est utilisé pour piloter le traitement récursif.

La graine du CTE récursif obtient simplement la première date pour chaque ContractID et initialise les valeurs qui seront utilisées pour décider si un nouvel intervalle est requis.

Décider si un nouvel intervalle doit commencer nécessite la date de début de l'intervalle actuel, la liste des jours et la longueur de tout intervalle dans les dates du calendrier. Ceux-ci peuvent être réinitialisés ou reportés, selon la décision. Par conséquent, la partie récursive est verbeuse et un peu répétitive, car nous devons décider de commencer ou non un nouvel intervalle pour plus d'une valeur de colonne.

La logique de décision pour les colonnes WeekDayset IntervalStartdoit avoir la même logique de décision - elle peut être copiée-collée entre elles. Si la logique de démarrage d'un nouvel intervalle devait changer, c'est le code à modifier. Idéalement, il serait donc abstrait; cela peut être difficile dans un CTE récursif.

La EXISTS()clause est la conséquence de ne pas pouvoir utiliser les fonctions d'agrégation dans un CTE récursif. Il ne fait que voir si les jours compris dans un intervalle sont déjà dans l'intervalle actuel.

Il n'y a rien de magique dans l'imbrication des clauses logiques. S'il est plus clair dans une autre conformation, ou en utilisant des CAS imbriqués, disons, il n'y a aucune raison de le garder de cette façon.

La dernière SELECTconsiste à donner la sortie dans le format souhaité.

Avoir le PK sous tension Src.IDn'est pas utile pour cette méthode. Un index clusterisé sur (ContractID,dt)serait bien, je pense.

Il y a quelques bords rugueux. Les jours ne sont pas renvoyés dans l'ordre du Dow, mais dans l'ordre du calendrier, ils apparaissent dans les données source. Tout ce qui concerne @Helper est maladroit et pourrait être lissé. J'aime l'idée d'utiliser un bit par jour et d'utiliser des fonctions binaires au lieu de LIKE. Séparer certains des CTE auxiliaires dans une table temporaire avec des index appropriés aiderait sans aucun doute.

L'un des défis à cela est qu'une "semaine" ne s'aligne pas sur un calendrier standard, mais est plutôt guidée par les données et se réinitialise lorsqu'il est déterminé qu'un nouvel intervalle doit commencer. Une "semaine", ou au moins un intervalle, peut durer un jour pour s'étendre à l'ensemble des données.


Par souci d'intérêt, voici les coûts estimés par rapport aux données d'exemple de Geoff (merci pour cela!) Après divers changements:

                                             estimated cost

My submission as is w/ CTEs, Geoff's data:      791682
Geoff's data, cluster key on (ContractID, dt):   21156.2
Real table for MissingDays:                      21156.2
Numbered as table UCI=(ContractID, rn):             16.6115    26s elapsed.
                  UCI=(rn, ContractID):             41.9845    26s elapsed.
MissingDays as refactored to simple lookup          16.6477    22s elapsed.
Weekdays as varchar(30)                             13.4013    30s elapsed.

Le nombre estimé et réel de lignes diffère énormément.

Le plan a une table spoo, probablement en raison de la CTE récursive. La plupart de l'action se trouve dans une table de travail qui se détache:

Table 'Worktable'.   Scan count       2, logical reads 4 196 269, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'MissingDays'. Scan count 464 116, logical reads   928 232, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Numbered'.    Scan count 484 122, logical reads 1 475 467, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Juste la façon dont le récursif est implémenté, je suppose!

Michael Green
la source
Je vous remercie. Il donne un résultat correct et optimal sur les échantillons de données. Je vais le vérifier sur des données réelles maintenant. Une remarque: MAX(g.IntervalStart)semble étrange, car g.IntervalStartest dans le GROUP BY. Je m'attendais à ce qu'il donne une erreur de syntaxe, mais cela fonctionne. Faut - il être juste g.IntervalStart as StartDTen SELECT? Ou g.IntervalStartne devrait pas être dans le GROUP BY?
Vladimir Baranov
J'ai essayé d'exécuter la requête sur des données réelles et j'ai dû l'arrêter après 10 minutes. Il est fort probable que si les CTE MissingDayset Numberedsont remplacés par des tables temporaires avec des index appropriés, cela pourrait avoir des performances décentes. Quels index recommanderiez-vous? Je pourrais l'essayer demain matin.
Vladimir Baranov
Je pense que le remplacement Numberedpar une table temporaire et un index cluster (ContractID, rn)vaut la peine. Sans un grand ensemble de données pour générer le plan correspondant, il est difficile de deviner. La MissingDatesmatérialisation avec des index (StartDay, FollowingDayInt)serait également une bonne chose.
Michael Green
Merci. Je ne peux pas l'essayer pour le moment, mais je le ferai demain matin.
Vladimir Baranov
J'ai essayé ceci sur un ensemble de données d'un demi-million de lignes (l'ensemble de données existant, répliqué 4 000 fois avec différents ContractIds). Il fonctionne depuis environ 15 minutes et a jusqu'à présent occupé 30 Go d'espace tempdb. Je pense donc qu'une optimisation supplémentaire pourrait être nécessaire. Voici les données de test étendues au cas où vous le jugeriez utile.
Geoff Patterson