Gruppieren Sie den Tagesplan in [Startdatum; Enddatum] Intervalle mit der Liste der Wochentage

18

Ich muss Daten zwischen zwei Systemen konvertieren.

Das erste System speichert Zeitpläne als einfache Liste von Daten. Jedes Datum, das im Zeitplan enthalten ist, ist eine Zeile. Es kann verschiedene Lücken in der Reihenfolge der Daten geben (Wochenenden, Feiertage und längere Pausen, einige Wochentage können vom Zeitplan ausgeschlossen sein). Es kann überhaupt keine Lücken geben, auch Wochenenden können eingeschlossen werden. Der Zeitplan kann bis zu 2 Jahre lang sein. Normalerweise dauert es einige Wochen.

Hier ist ein einfaches Beispiel für einen Zeitplan, der sich über zwei Wochen ohne Wochenenden erstreckt (das folgende Skript enthält kompliziertere Beispiele):

+----+------------+------------+---------+--------+
| 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 |
+----+------------+------------+---------+--------+

IDist eindeutig, aber nicht unbedingt sequentiell (es ist der Primärschlüssel). Termine sind in jedem Vertrag eindeutig (es gibt einen eindeutigen Index für (ContractID, dt)).

Das zweite System speichert Zeitpläne als Intervalle mit der Liste der Wochentage, die Teil des Zeitplans sind. Jedes Intervall wird durch sein Start- und Enddatum (einschließlich) und eine Liste der Wochentage definiert, die im Zeitplan enthalten sind. In diesem Format können Sie sich wiederholende wöchentliche Muster effizient definieren, z. B. Mo-Mi. Es wird jedoch zu einem Schmerz, wenn ein Muster beispielsweise durch einen Feiertag gestört wird.

So sieht das einfache Beispiel oben aus:

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

[StartDT;EndDT] Intervalle, die zum selben Vertrag gehören, sollten sich nicht überschneiden.

Ich muss Daten vom ersten System in das vom zweiten System verwendete Format konvertieren. Im Moment löse ich dies auf der Clientseite in C # für den einzelnen gegebenen Vertrag, aber ich möchte es in T-SQL auf der Serverseite für die Massenverarbeitung und den Export / Import zwischen Servern tun. Höchstwahrscheinlich könnte es mit CLR UDF gemacht werden, aber in diesem Stadium kann ich SQLCLR nicht verwenden.

Die Herausforderung hierbei besteht darin, die Liste der Intervalle so kurz und menschenfreundlich wie möglich zu gestalten.

Zum Beispiel dieser Zeitplan:

+-----+------------+------------+---------+--------+
| 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 |
+-----+------------+------------+---------+--------+

sollte dies werden:

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

,nicht das:

+------------+------------+------------+----------+----------------------+
| 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,             |
+------------+------------+------------+----------+----------------------+

Ich habe versucht, eine gaps-and-islandsHerangehensweise an dieses Problem anzuwenden . Ich habe es in zwei Durchgängen versucht. Im ersten Durchgang finde ich Inseln von einfachen aufeinanderfolgenden Tagen, dh das Ende der Insel ist eine Lücke in der Reihenfolge der Tage, sei es Wochenende, Feiertag oder etwas anderes. Für jede so gefundene Insel erstelle ich eine durch Kommas getrennte Liste mit verschiedenen WeekDays. Im zweiten Durchgang fand ich Inseln weiter, indem ich die Lücke in der Abfolge der Wochenzahlen oder eine Änderung der WeekDays.

Bei diesem Ansatz endet jede Teilwoche wie oben gezeigt als zusätzliches Intervall, da die WeekDaysÄnderung , auch wenn die Wochennummern aufeinander folgen. Außerdem kann es innerhalb einer Woche zu regelmäßigen Lücken kommen (siehe ContractID=3Beispieldaten, für die nur Daten vorliegen Mon,Wed,Fri,), und dieser Ansatz würde für jeden Tag in einem solchen Zeitplan separate Intervalle generieren. Auf der positiven Seite wird ein Intervall generiert, wenn der Zeitplan überhaupt keine Lücken aufweist (siehe ContractID=7Beispieldaten, die Wochenenden enthalten). In diesem Fall spielt es keine Rolle, ob die Anfangs- oder Endwoche partiell ist.

Weitere Beispiele finden Sie im folgenden Skript, um eine bessere Vorstellung davon zu bekommen, wonach ich suche. Wie Sie sehen, sind Wochenenden häufig ausgeschlossen, andere Wochentage können jedoch ebenfalls ausgeschlossen werden. In Beispiel 3 nur Mon, Wedund Friist Teil des Plans. Außerdem können Wochenenden einbezogen werden, wie in Beispiel 7. Die Lösung sollte alle Wochentage gleich behandeln. Jeder Wochentag kann in den Zeitplan aufgenommen oder daraus ausgeschlossen werden.

Um zu überprüfen, ob die generierte Liste der Intervalle den angegebenen Zeitplan korrekt beschreibt, können Sie den folgenden Pseudocode verwenden:

  • Alle Intervalle durchlaufen
  • für jede Intervallschleife durch alle Kalenderdaten zwischen Start- und Enddatum (einschließlich).
  • Überprüfen Sie für jedes Datum, ob der Wochentag in der Liste aufgeführt ist WeekDays. Wenn ja, ist dieses Datum im Zeitplan enthalten.

Hoffentlich wird dadurch klargestellt, in welchen Fällen ein neues Intervall erstellt werden soll. In den Beispielen 4 und 5 wird ein Montag ( 2016-05-09) aus der Mitte des Zeitplans entfernt, und ein solcher Zeitplan kann nicht durch ein einzelnes Intervall dargestellt werden. In Beispiel 6 gibt es eine große Lücke im Zeitplan, daher werden zwei Intervalle benötigt.

Intervalle stellen wöchentliche Muster im Zeitplan dar und wenn ein Muster unterbrochen / geändert wird, muss das neue Intervall hinzugefügt werden. In Beispiel 11 haben die ersten drei Wochen ein Muster Tue, dann ändert sich dieses Muster zu Thu. Daher benötigen wir zwei Intervalle, um einen solchen Zeitplan zu beschreiben.


Ich verwende momentan SQL Server 2008, daher sollte die Lösung in dieser Version funktionieren. Wenn eine Lösung für SQL Server 2008 mithilfe von Funktionen aus späteren Versionen vereinfacht / verbessert werden kann, ist dies ein Bonus. Zeigen Sie dies bitte auch.

Ich habe eine CalendarTabelle (Liste der Daten) und eine NumbersTabelle (Liste der Ganzzahlen ab 1), daher ist es in Ordnung, sie bei Bedarf zu verwenden. Es ist auch in Ordnung, temporäre Tabellen zu erstellen und mehrere Abfragen zu haben, die Daten in mehreren Schritten verarbeiten. Die Anzahl der Stufen in einem Algorithmus muss jedoch festgelegt werden, da Cursor und explizite WHILESchleifen nicht in Ordnung sind.


Skript für Beispieldaten und erwartete Ergebnisse

-- @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;

Vergleich der Antworten

Die reale Tabelle @Srchat 403,555Zeilen mit 15,857unterschiedlichen ContractIDs. Alle Antworten liefern korrekte Ergebnisse (zumindest für meine Daten) und alle sind relativ schnell, unterscheiden sich jedoch in der Optimalität. Je weniger Intervalle generiert werden, desto besser. Ich habe nur aus Neugier Laufzeiten angegeben. Das Hauptaugenmerk liegt auf dem richtigen und optimalen Ergebnis, nicht auf der Geschwindigkeit (es sei denn, es dauert zu lange - ich habe die nicht-rekursive Abfrage von Ziggy Crueltyfree Zeitgeister nach 10 Minuten gestoppt).

+--------------------------------------------------------+-----------+---------+
|                         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
quelle
Sollte (11,'2016-05-03', '2016-05-17', 3, 'Tue,'), (11,'2016-05-19', '2016-06-02', 3, 'Thu,');im @Dst nicht eine Zeile mit stehen Tue, Thu,?
Kin Shah
@Kin, Beispiel 11 muss (mindestens) zwei Intervalle (zwei Zeilen in @Dst) haben. Die ersten zwei Wochen des Zeitplans haben nur Tue, so dass Sie WeekDays=Tue,Thu,für diese Wochen nicht haben können . Die letzten zwei Wochen des Zeitplans haben nur Thu, so dass Sie WeekDays=Tue,Thu,für diese Wochen wieder nicht haben können . Suboptimale Lösung dafür wären drei Reihen: nur Tuefür die ersten zwei Wochen, dann Tue,Thu,für die dritte Woche, die beides hat, Tueund Thudann nur Thufür die letzten zwei Wochen.
Vladimir Baranov
1
Können Sie bitte den Algorithmus erläutern, mit dem Vertrag 11 "optimal" in zwei Intervalle aufgeteilt wird? Haben Sie dies in der C # -Anwendung erreicht? Wie?
Michael Green
@MichaelGreen, sorry ich konnte nicht früher antworten. Ja, der C # -Code teilt Vertrag 11 in zwei Intervalle auf. Der grobe Algorithmus: Ich durchlaufe die geplanten Daten nacheinander, notiere mir die Wochentage, die ich seit Beginn des Intervalls erlebt habe, und bestimme, ob ich ein neues Intervall beginnen soll: Wenn sich ein ContractIDIntervall ändert, wenn ein Intervall Überschreitet 7 Tage und der neue Wochentag wurde zuvor nicht gesehen, wenn in der Liste der geplanten Tage eine Lücke vorhanden ist.
Vladimir Baranov
@MichaelGreen, ich habe meinen C # -Code in einen Cursorbasierten Algorithmus umgewandelt, um zu sehen, wie er mit anderen Lösungen für echte Daten verglichen wird. Ich habe den Quellcode zu meiner Antwort und den Ergebnissen zur Übersichtstabelle in der Frage hinzugefügt.
Vladimir Baranov

Antworten:

6

Dieser verwendet einen rekursiven CTE. Das Ergebnis ist identisch mit dem Beispiel in der Frage . Es war ein Albtraum, sich etwas einfallen zu lassen ... Der Code enthält Kommentare, die durch seine verschlungene Logik erleichtert werden.

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)


Eine andere Strategie

Dieser sollte deutlich schneller als der vorherige sein, da er nicht auf dem langsam begrenzten rekursiven CTE in SQL Server 2008 basiert, obwohl er mehr oder weniger dieselbe Strategie implementiert.

Es gibt eine WHILESchleife (ich konnte mir keinen Weg ausdenken, sie zu umgehen), aber es wird weniger iteriert (die höchste Anzahl von Sequenzen (minus eins) in einem bestimmten Vertrag).

Es ist eine einfache Strategie und kann für Sequenzen verwendet werden, die entweder kürzer oder länger als eine Woche sind (wobei jedes Vorkommen der Konstante 7 durch eine andere Zahl ersetzt wird und die dowBitaus MODULUS x von DayNostatt berechnet wird DATEPART(wk)) und bis zu 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
quelle
@VladimirBaranov Ich habe eine neue Strategie hinzugefügt, die viel schneller sein sollte. Lassen Sie mich wissen, wie es mit Ihren realen Daten abschneidet!
Ziggy Crueltyfree Zeitgeister
2
@ZiggyCrueltyfreeZeitgeister, ich habe Ihre letzte Lösung überprüft und zur Liste aller Antworten in der Frage hinzugefügt. Es liefert korrekte Ergebnisse und die gleiche Anzahl von Intervallen wie der rekursive CTE und seine Geschwindigkeit ist auch sehr nahe. Wie gesagt, die Geschwindigkeit ist nicht kritisch, solange es vernünftig ist. 1 Sekunde oder 10 Sekunden sind für mich eigentlich egal.
Vladimir Baranov
Andere Antworten sind ebenfalls großartig und nützlich, und ich wünschte, ich könnte das Kopfgeld an mehr als eine Antwort vergeben. Ich habe diese Antwort gewählt, weil ich zu dem Zeitpunkt, als ich mit dem Kopfgeld begann, nicht über rekursiven CTE nachgedacht habe und diese Antwort die erste war, die sie vorschlug und eine funktionierende Lösung hat. Streng genommen ist rekursiver CTE keine satzbasierte Lösung, liefert jedoch optimale Ergebnisse und ist relativ schnell. Eine Antwort von @GeoffPatterson ist großartig, liefert jedoch weniger optimale Ergebnisse und ist ehrlich gesagt viel zu kompliziert.
Vladimir Baranov
5

Nicht genau das, wonach Sie suchen, aber vielleicht für Sie von Interesse sein.

Die Abfrage erstellt Wochen mit einer durch Kommas getrennten Zeichenfolge für die in jeder Woche verwendeten Tage. Es werden dann die Inseln von aufeinanderfolgenden Wochen gefunden, die dasselbe Muster in verwenden 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);

Ergebnis:

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 = 2zeigt, was der Unterschied im Ergebnis zu dem ist, was Sie wollen. Die erste und die letzte Woche werden als separate Zeiträume behandelt, da dies WeekDaysunterschiedlich ist.

Mikael Eriksson
quelle
Ich hatte diese Idee, aber keine Chance, es zu versuchen. Vielen Dank für die Bereitstellung einer funktionierenden Abfrage. Mir gefällt, wie es zu einem strukturierteren Ergebnis kommt. Bei der Gruppierung der Daten in Wochen verringert sich die Flexibilität (bei einem einfachen täglichen Gaps-and-Islands-Ansatz würden die Beispiele 7 und 8 in einem Intervall zusammengefasst), aber es handelt sich gleichzeitig um die positive Seite - wir reduzieren die Komplexität von das Problem. Das größte Problem bei diesem Ansatz sind also teilweise Wochen zu Beginn und am Ende des Zeitplans. Solche Teilwochen erzeugen ein zusätzliches Intervall ...
Vladimir Baranov
Können Sie sich eine Möglichkeit vorstellen, diese Teilwochen an den Hauptzeitplan anzuhängen, zu gruppieren oder zusammenzuführen? Ich habe zu diesem Zeitpunkt nur eine sehr vage Vorstellung. Wenn wir einen Weg finden, Teilwochen korrekt zusammenzuführen, wäre das Endergebnis nahezu optimal.
Vladimir Baranov
@VladimirBaranov Nicht sicher, wie das gemacht werden würde. Ich werde die Antwort aktualisieren, wenn mir etwas einfällt.
Mikael Eriksson
Meine vage Idee ist folgende: Es gibt nur 7 Tage in einer Woche, ebenso WeekDayswie eine 7-Bit-Zahl. Nur 128 Kombinationen. Es gibt nur 128 * 128 = 16384 mögliche Paare. Erstellen Sie eine temporäre Tabelle mit allen möglichen Paaren und finden Sie dann einen satzbasierten Algorithmus, der angibt, welche Paare zusammengeführt werden können: Ein Muster von einer Woche wird durch ein Muster der nächsten Woche "abgedeckt". Verbinden Sie sich selbst mit dem aktuellen wöchentlichen Ergebnis (da es LAG2008 kein Ergebnis gibt ) und verwenden Sie diese temporäre Tabelle, um zu entscheiden, welche Paare zusammengeführt werden sollen ... Sie sind sich nicht sicher, ob diese Idee von Nutzen ist.
Vladimir Baranov
5

Am Ende habe ich einen Ansatz gefunden, der in diesem Fall die optimale Lösung liefert, und ich denke, er wird im Allgemeinen gut abschneiden. Die Lösung ist jedoch recht langwierig, daher wäre es interessant zu sehen, ob jemand anderes einen anderen Ansatz verfolgt, der präziser ist.

Hier ist ein Skript, das die vollständige Lösung enthält .

Und hier ist ein Überblick über den Algorithmus:

  • Drehen Sie den Datensatz so, dass jede Woche eine einzelne Zeile dargestellt wird
  • Berechnen Sie die Inseln der Wochen in jedem ContractId
  • Führen Sie alle angrenzenden Wochen zusammen, die zu denselben gehören ContractIdund dieselben habenWeekDays
  • Für einzelne Wochen (noch nicht zusammengeführt), in denen sich die vorherige Gruppierung auf derselben Insel befindet und die WeekDaysder einzelnen Woche mit einer führenden Untergruppe WeekDaysder vorherigen Gruppierung übereinstimmt , führen Sie sie zu dieser vorherigen Gruppierung zusammen
  • Für einzelne Wochen (noch nicht zusammengeführt), in denen sich die nächste Gruppierung auf derselben Insel befindet und die WeekDaysder einzelnen Woche mit einer WeekDaysnachfolgenden Untergruppe der nächsten Gruppierung übereinstimmt , führen Sie sie zu dieser nächsten Gruppierung zusammen
  • Für jeweils zwei benachbarte Wochen auf derselben Insel, auf der keine der beiden zusammengeführt wurde, führen Sie sie zusammen, wenn es sich um Teilwochen handelt, die kombiniert werden können (z. B. "Mo, Di, Mi, Do" und "Mi, Do, Sa"). )
  • Teilen Sie für verbleibende einzelne Wochen (noch nicht zusammengeführt) die Woche nach Möglichkeit in zwei Teile auf und führen Sie beide Teile zusammen, den ersten Teil in der vorherigen Gruppierung auf derselben Insel und den zweiten Teil in der folgenden Gruppierung auf derselben Insel
Geoff Patterson
quelle
Vielen Dank, dass Sie sich so viel Mühe gegeben haben, um die funktionierende Lösung herzustellen. Es ist ein bisschen überwältigend, um ehrlich zu sein. Ich vermutete, dass es nicht einfach wäre, Teilwochen zusammenzuführen, aber ich konnte nicht erwarten, dass es so kompliziert sein würde. Ich habe immer noch die Hoffnung, dass es einfacher geht, aber ich habe keine konkrete Idee.
Vladimir Baranov
Die schnelle Überprüfung bestätigt, dass das erwartete Ergebnis für die Beispieldaten erzielt wird. Das ist großartig. Ich habe jedoch festgestellt, dass bestimmte Zeitpläne nicht optimal verarbeitet werden. Einfachstes Beispiel: (1214,12,'2016-05-06', 'Fri', 6), (1225,12,'2016-05-09', 'Mon', 2),. Es könnte als ein Intervall dargestellt werden, aber Ihre Lösung erzeugt zwei. Ich gebe zu, dieses Beispiel war nicht in den Beispieldaten und es ist nicht kritisch. Ich werde versuchen, Ihre Lösung auf realen Daten auszuführen.
Vladimir Baranov
Ich freue mich über Ihre Antwort. Als ich mit dem Kopfgeld begann, dachte ich nicht an rekursiven CTE und Ziggy Crueltyfree Zeitgeister war der erste, der dies vorschlug und eine funktionierende Lösung vorstellte. Streng genommen ist rekursiver CTE keine satzbasierte Lösung, liefert jedoch optimale Ergebnisse, ist relativ komplex und relativ schnell. Ihre Antwort basiert auf Mengen, erweist sich jedoch als zu kompliziert, so dass sie nicht mehr praktikabel ist. Ich wünschte, ich könnte das Kopfgeld aufteilen, aber leider ist es nicht erlaubt.
Vladimir Baranov
@VladimirBaranov Kein Problem, Sie können das Kopfgeld zu 100% nach Belieben verwenden. Der Grund, warum ich Kopfgeldfragen mag, ist, dass die Person, die die Frage stellt, in der Regel viel engagierter ist als eine normale Frage. Kümmere dich nicht zu sehr um die Punkte. Ich stimme voll und ganz zu, dass diese Lösung nicht in meinem Produktionscode verwendet wird. Es war eine Erkundung einer möglichen Idee, die sich jedoch als ziemlich komplex herausstellte.
Geoff Patterson
3

Ich konnte die Logik hinter der Gruppierung von Wochen mit Lücken oder Wochen mit Wochenenden nicht verstehen (z. B. wenn es zwei aufeinanderfolgende Wochen mit einem Wochenende gibt, auf welche Woche geht das Wochenende?).

Die folgende Abfrage erzeugt die gewünschte Ausgabe, mit der Ausnahme, dass nur aufeinanderfolgende Wochentage und Gruppenwochen von Sonntag bis Samstag (anstelle von Montag bis Sonntag) gruppiert werden. Dies ist zwar nicht genau das, was Sie wollen, kann aber möglicherweise Hinweise auf eine andere Strategie liefern. Die Gruppierung der Tage erfolgt von hier aus . Die verwendeten Fensterfunktionen sollten mit SQL Server 2008 funktionieren, aber ich habe diese Version nicht zum Testen, ob dies tatsächlich der Fall ist.

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

Ergebnis

+------------+------------+------------+----------+-----------------------------------+
| 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
quelle
Die Diskussion zu dieser Antwort wurde in den Chat verschoben .
Paul White sagt GoFundMonica
3

Der Vollständigkeit halber ist hier ein gaps-and-islandsAnsatz mit zwei Schritten , den ich selbst ausprobiert habe, bevor ich diese Frage gestellt habe.

Als ich es an den realen Daten testete, fand ich nur wenige Fälle, in denen es zu falschen Ergebnissen kam, und korrigierte sie.

Hier ist der Algorithmus:

  • Generieren Inseln von aufeinanderfolgenden Terminen ( CTE_ContractDays, CTE_DailyRN, CTE_DailyIslands) und berechnen eine Woche Nummer für jedes Start- und Enddatum einer Insel. Hier wird die Wochennummer unter der Annahme berechnet, dass Montag der erste Wochentag ist.
  • Wenn der Zeitplan nicht aufeinanderfolgende Daten innerhalb derselben Woche enthält (wie in Beispiel 3), werden in der vorherigen Phase mehrere Zeilen für dieselbe Woche erstellt. Gruppieren Sie Zeilen, um nur eine Zeile pro Woche zu haben ( CTE_Weeks).
  • Erstellen Sie für jede Zeile aus der vorherigen Phase eine durch Kommas getrennte Liste mit Wochentagen ( CTE_FirstResult).
  • Zweiter Durchgang von Lücken und Inseln, um aufeinanderfolgende Wochen mit denselben WeekDays( CTE_SecondRN, CTE_Schedules) zu gruppieren .

Es behandelt gut Fälle, in denen die wöchentlichen Muster nicht gestört sind (1, 7, 8, 10, 12). Es behandelt gut Fälle, in denen das Muster nicht aufeinanderfolgende Tage hat (3).

Leider werden jedoch zusätzliche Intervalle für Teilwochen generiert (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
;

Ergebnis

+------------+------------+------------+----------+------------------------------+
| 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,         |
+------------+------------+------------+----------+------------------------------+

Cursor-basierte Lösung

Ich habe meinen C # -Code in einen Cursorbasierten Algorithmus konvertiert, um zu sehen, wie er mit anderen Lösungen für reale Daten verglichen wird. Es bestätigt, dass es viel langsamer als andere satzbasierte oder rekursive Ansätze ist, aber ein optimales Ergebnis erzielt.

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
quelle
2

Ich war ein bisschen überrascht, dass die Cursor-Lösung von Vladimir so langsam war, und habe auch versucht, diese Version zu optimieren. Ich habe bestätigt, dass die Verwendung eines Cursors auch für mich sehr langsam war.

Auf Kosten der Verwendung undokumentierter Funktionen in SQL Server durch Anhängen an eine Variable während der Verarbeitung eines Rowsets konnte ich jedoch eine vereinfachte Version dieser Logik erstellen, die das optimale Ergebnis liefert und viel schneller ausgeführt wird als der Cursor und meine ursprüngliche Lösung . Die Verwendung erfolgt also auf eigenes Risiko, aber ich werde die Lösung vorstellen, falls dies von Interesse ist. Es wäre auch möglich, die Lösung zu aktualisieren, um eine WHILESchleife von einer auf die maximale Zeilennummer zu verwenden, wobei bei jeder Iteration der Schleife nach der nächsten Zeilennummer gesucht wird. Dies würde sich an eine vollständig dokumentierte und zuverlässige Funktionalität halten, würde jedoch die (etwas künstlich) angegebene Einschränkung des Problems verletzen, dass WHILESchleifen nicht zulässig sind.

Wenn die Verwendung von SQL 2014 zulässig wäre, wäre es wahrscheinlich, dass eine nativ kompilierte gespeicherte Prozedur , die die Zeilennummern durchläuft und auf jede Zeilennummer in einer speicheroptimierten Tabelle zugreift, eine Implementierung derselben Logik ist, die schneller ausgeführt wird.

Hier finden Sie die vollständige Lösung , einschließlich der Erweiterung der Testdaten auf etwa eine halbe Million Zeilen. Die neue Lösung ist in ca. 3 Sekunden fertig und meiner Meinung nach viel prägnanter und lesbarer als die vorherige Lösung, die ich angeboten habe. Ich werde die folgenden drei Schritte ausführen:

Schritt 1: Vorverarbeitung

Wir fügen dem Datensatz zunächst eine Zeilennummer hinzu, in der Reihenfolge, in der wir die Daten verarbeiten. Dabei konvertieren wir auch jedes dowInt in eine Potenz von 2, sodass wir mithilfe einer Bitmap darstellen können, welche Tage in einer bestimmten Gruppierung beobachtet wurden:

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

Schritt 2: Durchlaufen der Vertragstage, um neue Gruppierungen zu identifizieren

Als nächstes durchlaufen wir die Daten in der Reihenfolge der Zeilennummern. Wir berechnen nur die Liste der Zeilennummern, die die Grenze einer neuen Gruppierung bilden, und geben diese Zeilennummern in eine Tabelle aus:

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

Schritt 3: Berechnen der Endergebnisse basierend auf den Zeilennummern jeder Gruppierungsgrenze

Wir berechnen dann die endgültigen Gruppierungen, indem wir die in der obigen Schleife angegebenen Grenzen verwenden, um alle Daten zu aggregieren, die in die einzelnen Gruppierungen fallen:

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
quelle
Vielen Dank. Ich habe darum gebeten, keine Cursor oder WHILESchleifen zu verwenden, da ich bereits wusste, wie man sie mit dem Cursor löst, und ich wollte eine satzbasierte Lösung finden. Außerdem hatte ich den Verdacht, dass der Cursor langsam ist (insbesondere mit einer verschachtelten Schleife). Diese Antwort ist sehr interessant, um neue Tricks zu lernen, und ich schätze Ihre Bemühungen.
Vladimir Baranov
1

Die Diskussion folgt dem 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 ist mit dieser Regel umzugehen:

Wenn die Lücke zwischen dem aktuellen Tag und dem letzten Tag des Intervalls einen Wochentag enthält, der im vorherigen Intervall enthalten war, sollten wir ein neues Intervall erstellen

Damit kann ich Tagesnamen in der Reihenfolge der Tagesnummern zwischen zwei beliebigen Tagen auflisten. Dies wird verwendet, um zu entscheiden, ob ein neues Intervall beginnen soll. Ich fülle es mit Werten im Wert von zwei Wochen, um das Umschließen eines Wochenendes einfacher zu programmieren.

Es gibt sauberere Möglichkeiten, dies umzusetzen. Eine vollständige "Datteltabelle" wäre eine. Es gibt wahrscheinlich auch einen klugen Weg mit der Tagzahl und der Modulo-Arithmetik.

Der CTE MissingDays generiert eine Liste von Tagesnamen zwischen zwei beliebigen Tagen. Es wird auf diese umständliche Weise gehandhabt, da der rekursive CTE (folgend) keine Aggregate, TOP () oder andere Operatoren zulässt. Das ist unelegant, aber es funktioniert.

CTE Numbered soll eine bekannte, lückenlose Sequenz für die Daten erzwingen. Es vermeidet viele spätere Vergleiche.

CTE Incrementedist der Ort, an dem die Aktion stattfindet. Im Wesentlichen verwende ich einen rekursiven CTE, um die Daten zu durchlaufen und die Regeln durchzusetzen. Die in generierte ZeilennummerNumbered (oben) wird verwendet, um die rekursive Verarbeitung zu steuern.

Der Startwert des rekursiven CTE erhält einfach das erste Datum für jede ContractID und initialisiert Werte, anhand derer entschieden wird, ob ein neues Intervall erforderlich ist.

Die Entscheidung, ob ein neues Intervall beginnen soll, erfordert das Startdatum des aktuellen Intervalls, die Tagesliste und die Länge einer eventuellen Lücke in den Kalenderdaten. Diese können je nach Entscheidung zurückgesetzt oder übertragen werden. Daher ist der rekursive Teil ausführlich und ein wenig repetitiv, da wir entscheiden müssen, ob für mehr als einen Spaltenwert ein neues Intervall gestartet werden soll.

Die Entscheidungslogik für Spalten WeekDaysund IntervalStartsollte dieselbe Entscheidungslogik haben - sie kann zwischen ihnen ausgeschnitten und eingefügt werden. Wenn sich die Logik zum Starten eines neuen Intervalls ändern sollte, ist dies der zu ändernde Code. Im Idealfall wäre es daher abstrahiert; Dies in einem rekursiven CTE zu tun, kann eine Herausforderung sein.

Die EXISTS()Klausel ist die Folge davon, dass Aggregatfunktionen in einem rekursiven CTE nicht verwendet werden können. Es wird lediglich geprüft, ob die in eine Lücke fallenden Tage bereits im aktuellen Intervall liegen.

Es ist nichts Magisches an der Verschachtelung der Logikklauseln. Wenn es in einer anderen Konformation klarer ist oder geschachtelte CASEs verwendet werden, gibt es keinen Grund, dies so beizubehalten.

Das Letzte SELECTist, die Ausgabe in dem gewünschten Format zu geben.

Das Aktivieren der PK Src.IDist für diese Methode nicht hilfreich. Ein Clustered-Index (ContractID,dt)wäre schön, finde ich.

Es gibt ein paar Ecken und Kanten. Die Tage werden nicht in der angegebenen Reihenfolge zurückgegeben, sondern in der Kalendersequenz in den Quelldaten. Alles was mit @Helper zu tun hat ist klobig und könnte geglättet werden. Ich mag die Idee, ein Bit pro Tag und stattdessen binäre Funktionen zu verwenden LIKE. Es wäre zweifellos hilfreich, einige der zusätzlichen CTEs in eine temporäre Tabelle mit geeigneten Indizes zu unterteilen.

Eine der Herausforderungen dabei ist, dass eine "Woche" nicht mit einem Standardkalender übereinstimmt, sondern von den Daten gesteuert wird und zurückgesetzt wird, wenn bestimmt wird, dass ein neues Intervall beginnen soll. Eine "Woche" oder zumindest ein Intervall kann einen Tag lang sein und den gesamten Datensatz umfassen.


Aus Gründen des Interesses sind hier die geschätzten Kosten für Geoffs Beispieldaten (danke dafür!) Nach verschiedenen Änderungen:

                                             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.

Die geschätzte und tatsächliche Anzahl der Zeilen ist sehr unterschiedlich.

Der Plan enthält einen Tabellenfehler, der wahrscheinlich auf den rekursiven CTE zurückzuführen ist. Der Großteil der Action findet in einem Arbeitstisch statt:

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.

Genau so, wie es rekursiv implementiert ist, denke ich!

Michael Green
quelle
Vielen Dank. Es gibt ein korrektes und optimales Ergebnis für die Probendaten. Ich werde es jetzt auf reale Daten überprüfen. Eine Randnotiz: kommt MAX(g.IntervalStart)mir komisch vor, weil g.IntervalStartin der GROUP BY. Ich habe erwartet, dass es einen Syntaxfehler gibt, aber es funktioniert. Sollte es nur g.IntervalStart as StartDTin sein SELECT? Oder g.IntervalStartsollte das nicht sein GROUP BY?
Vladimir Baranov
Ich habe versucht, die Abfrage für echte Daten auszuführen und musste sie nach 10 Minuten stoppen. Es ist sehr wahrscheinlich , dass , wenn CTEs MissingDaysund Numberedmit temporären Tabellen mit dem richtigen Indizes ersetzt werden, könnte es ordentliche Leistung hat. Welche Indizes würden Sie empfehlen? Ich könnte es morgen früh versuchen.
Vladimir Baranov
Ich würde denken, dass das Ersetzen Numbereddurch eine temporäre Tabelle und einen Clustered-Index einen Versuch (ContractID, rn)wert wäre. Ohne einen großen Datensatz, um den entsprechenden Plan zu generieren, ist es schwierig zu erraten. Auch das Physikalisieren MissingDatesmit Indizes (StartDay, FollowingDayInt)wäre gut.
Michael Green
Vielen Dank. Ich kann es jetzt nicht versuchen, aber ich werde es morgen früh tun.
Vladimir Baranov
Ich habe dies an einer halben Million Zeilendatensätzen ausprobiert (dem vorhandenen Datensatz, der 4.000 Mal mit verschiedenen ContractIds repliziert wurde). Es läuft seit ungefähr 15 Minuten und hat bisher 30 GB Speicherplatz in Anspruch genommen. Ich denke also, dass eine weitere Optimierung notwendig sein könnte. Hier sind die erweiterten Testdaten für den Fall, dass Sie es hilfreich finden.
Geoff Patterson