Jadikan jadwal harian grup menjadi [Tanggal mulai; Tanggal akhir] interval dengan daftar hari minggu

18

Saya perlu mengkonversi data antara dua sistem.

Sistem pertama menyimpan jadwal sebagai daftar tanggal yang jelas. Setiap tanggal yang termasuk dalam jadwal adalah satu baris. Mungkin ada berbagai kesenjangan dalam urutan tanggal (akhir pekan, hari libur nasional, dan jeda yang lebih lama, beberapa hari dalam seminggu dapat dikecualikan dari jadwal). Tidak ada celah sama sekali, bahkan akhir pekan bisa dimasukkan. Jadwalnya bisa sampai 2 tahun. Biasanya panjangnya beberapa minggu.

Berikut adalah contoh sederhana dari jadwal yang mencakup dua minggu tidak termasuk akhir pekan (ada contoh yang lebih rumit dalam skrip di bawah):

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

IDunik, tetapi tidak harus berurutan (itu adalah kunci primer). Tanggal unik dalam setiap Kontrak (ada indeks unik aktif (ContractID, dt)).

Sistem kedua menyimpan jadwal sebagai interval dengan daftar hari kerja yang merupakan bagian dari jadwal. Setiap interval ditentukan oleh tanggal mulai dan berakhirnya (inklusif) dan daftar hari kerja yang termasuk dalam jadwal. Dalam format ini, Anda dapat mendefinisikan pola mingguan berulang secara efisien, seperti Senin-Rabu, tetapi akan merepotkan ketika sebuah pola terganggu, misalnya oleh hari libur umum.

Berikut adalah contoh sederhana di atas akan terlihat seperti:

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

[StartDT;EndDT] interval yang termasuk dalam Kontrak yang sama tidak boleh tumpang tindih.

Saya perlu mengubah data dari sistem pertama ke format yang digunakan oleh sistem kedua. Saat ini saya sedang menyelesaikan ini di sisi klien di C # untuk Kontrak yang diberikan tunggal, tapi saya ingin melakukannya dalam T-SQL di sisi server untuk pemrosesan massal dan ekspor / impor antar server. Kemungkinan besar, itu bisa dilakukan menggunakan CLR UDF, tetapi pada tahap ini saya tidak dapat menggunakan SQLCLR.

Tantangannya di sini adalah membuat daftar interval sesingkat dan seramah mungkin.

Misalnya, jadwal ini:

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

harus menjadi ini:

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

,bukan ini:

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

Saya mencoba menerapkan gaps-and-islandspendekatan untuk masalah ini. Saya mencoba melakukannya dalam dua langkah. Pada pass pertama saya menemukan pulau-pulau yang terdiri dari hari-hari sederhana berturut-turut, yaitu ujung pulau adalah celah dalam urutan hari, baik itu akhir pekan, hari libur nasional atau yang lainnya. Untuk setiap pulau yang ditemukan seperti itu saya membuat daftar yang berbeda dengan koma WeekDays. Pada pass kedua saya grup menemukan pulau lebih jauh dengan melihat celah dalam urutan angka minggu atau perubahan dalam WeekDays.

Dengan pendekatan ini setiap minggu parsial berakhir sebagai interval tambahan seperti yang ditunjukkan di atas, karena meskipun angka minggu berturut-turut, WeekDaysperubahan. Selain itu, mungkin ada kesenjangan reguler dalam seminggu (lihat ContractID=3dalam data sampel, yang hanya memiliki data Mon,Wed,Fri,) dan pendekatan ini akan menghasilkan interval terpisah untuk setiap hari dalam jadwal tersebut. Sisi baiknya, ini menghasilkan satu interval jika jadwal tidak memiliki celah sama sekali (lihat ContractID=7dalam data sampel yang termasuk akhir pekan) dan dalam hal itu tidak masalah jika awal atau akhir minggu parsial.

Silakan lihat contoh lain dalam skrip di bawah ini untuk mendapatkan ide yang lebih baik tentang apa yang saya kejar. Anda dapat melihat bahwa akhir pekan cukup sering dikecualikan, tetapi hari-hari lain dalam seminggu juga bisa dikecualikan. Dalam contoh 3 saja Mon, Weddan Frimerupakan bagian dari jadwal. Selain itu, akhir pekan dapat dimasukkan, seperti dalam contoh 7. Solusinya harus memperlakukan semua hari dalam seminggu secara merata. Setiap hari dalam seminggu dapat dimasukkan atau dikecualikan dari jadwal.

Untuk memverifikasi bahwa daftar interval yang dihasilkan menggambarkan jadwal yang diberikan dengan benar, Anda dapat menggunakan kode semu berikut:

  • loop melalui semua interval
  • untuk setiap putaran interval melalui semua tanggal kalender antara tanggal Mulai dan Akhir (termasuk).
  • untuk setiap tanggal periksa apakah hari dalam seminggu terdaftar di WeekDays. Jika ya, maka tanggal ini termasuk dalam jadwal.

Semoga, ini menjelaskan dalam kasus apa interval baru harus dibuat. Dalam contoh 4 dan 5 satu Senin ( 2016-05-09) dihapus dari tengah jadwal dan jadwal tersebut tidak dapat diwakili oleh satu interval. Dalam contoh 6 ada kesenjangan yang panjang dalam jadwal, sehingga diperlukan dua interval.

Interval mewakili pola mingguan dalam jadwal dan ketika suatu pola terganggu / diubah, interval baru harus ditambahkan. Dalam contoh 11 tiga minggu pertama memiliki pola Tue, maka pola ini berubah menjadi Thu. Sebagai hasilnya, kita membutuhkan dua interval untuk menggambarkan jadwal tersebut.


Saya menggunakan SQL Server 2008 saat ini, jadi solusinya harus berfungsi dalam versi ini. Jika solusi untuk SQL Server 2008 dapat disederhanakan / ditingkatkan menggunakan fitur dari versi yang lebih baru, itu bonus, tolong tunjukkan juga.

Saya memiliki Calendartabel (daftar tanggal) dan Numberstabel (daftar angka integer mulai dari 1), jadi tidak masalah untuk menggunakannya, jika perlu. Ini juga OK untuk membuat tabel sementara dan memiliki beberapa permintaan yang memproses data dalam beberapa tahap. Jumlah tahapan dalam suatu algoritma harus diperbaiki, kursor dan WHILEloop eksplisit tidak OK.


Skrip untuk data sampel dan hasil yang diharapkan

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

Perbandingan jawaban

Tabel sebenarnya @Srcmemiliki 403,555baris dengan yang 15,857berbeda ContractIDs. Semua jawaban menghasilkan hasil yang benar (setidaknya untuk data saya) dan semuanya cukup cepat, tetapi berbeda dalam optimalitas. Semakin sedikit interval yang dihasilkan, semakin baik. Saya memasukkan waktu lari hanya untuk rasa ingin tahu. Fokus utama adalah hasil yang benar dan optimal, bukan kecepatan (kecuali jika terlalu lama - saya menghentikan permintaan non-rekursif oleh Ziggy Crueltyfree Zeitgeister setelah 10 menit).

+--------------------------------------------------------+-----------+---------+
|                         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
sumber
Bukankah (11,'2016-05-03', '2016-05-17', 3, 'Tue,'), (11,'2016-05-19', '2016-06-02', 3, 'Thu,');di @Dst harus satu baris dengan Tue, Thu,?
Kin Shah
@ Kin, Contoh 11 harus memiliki (setidaknya) dua interval (dua baris @Dst). Dua minggu pertama dari jadwal hanya memiliki Tue, jadi Anda tidak dapat memiliki WeekDays=Tue,Thu,untuk minggu ini. Dua minggu terakhir dari jadwal hanya memiliki Thu, sehingga Anda tidak dapat lagi WeekDays=Tue,Thu,untuk minggu ini. Solusi yang kurang optimal untuk itu adalah tiga baris: hanya Tueuntuk dua minggu pertama, kemudian Tue,Thu,untuk minggu ketiga yang memiliki keduanya Tuedan Thu, kemudian hanya Thuuntuk dua minggu terakhir.
Vladimir Baranov
1
Bisakah Anda jelaskan algoritme dengan mana kontrak 11 "secara optimal" dibagi menjadi dua interval. Sudahkah Anda mencapai ini di aplikasi C #? Bagaimana?
Michael Green
@MichaelGreen, maaf saya tidak bisa membalas lebih awal. Ya, kode C # membagi Kontrak 11 menjadi dua interval. Algoritma kasar: Saya mengulangi tanggal yang dijadwalkan, satu-per-satu, mencatat hari-hari dalam seminggu yang saya temui sejauh ini sejak awal interval dan menentukan apakah saya harus memulai interval baru: jika ContractIDperubahan, jika interval melampaui 7 hari dan hari minggu baru belum terlihat sebelumnya, jika ada celah dalam daftar hari yang dijadwalkan.
Vladimir Baranov
@MichaelGreen, saya mengubah kode C # saya menjadi algoritma berbasis kursor, hanya untuk melihat bagaimana perbandingannya dengan solusi lain pada data nyata. Saya menambahkan kode sumber ke jawaban dan hasil saya ke tabel ringkasan dalam pertanyaan.
Vladimir Baranov

Jawaban:

6

Yang ini menggunakan CTE rekursif. Hasilnya identik dengan contoh dalam pertanyaan . Itu adalah mimpi buruk untuk datang dengan ... Kode termasuk komentar untuk memudahkan melalui logika yang berbelit-belit.

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)


Strategi lain

Yang ini harus secara signifikan lebih cepat daripada yang sebelumnya karena tidak bergantung pada CTE rekursif terbatas lambat di SQL Server 2008, meskipun menerapkan lebih atau kurang strategi yang sama.

Ada satu WHILEpengulangan (saya tidak bisa menemukan cara untuk menghindarinya), tetapi berlaku untuk pengurangan jumlah iterasi (jumlah urutan tertinggi (minus satu) pada kontrak yang diberikan).

Ini adalah strategi yang sederhana, dan dapat digunakan untuk urutan yang lebih pendek atau lebih lama dari satu minggu (menggantikan setiap kemunculan konstanta 7 untuk nomor lainnya, dan dowBitdihitung dari MODULUS x DayNodaripada DATEPART(wk)) dan hingga 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
sumber
@VladimirBaranov Saya menambahkan strategi baru, yang seharusnya lebih cepat. Beri tahu saya tarifnya dengan data nyata Anda!
Ziggy Crueltyfree Zeitgeister
2
@ZiggyCrueltyfreeZeitgeister, saya memeriksa solusi terakhir Anda dan menambahkannya ke daftar semua jawaban dalam pertanyaan. Ini menghasilkan hasil yang benar dan jumlah interval yang sama seperti CTE rekursif dan kecepatannya juga sangat dekat. Seperti yang saya katakan, kecepatannya tidak kritis selama itu masuk akal. 1 detik atau 10 detik tidak terlalu berarti bagi saya.
Vladimir Baranov
Jawaban lain juga bagus dan bermanfaat, dan saya berharap saya bisa memberi hadiah lebih dari satu jawaban. Saya memilih jawaban ini, karena pada saat saya memulai pemberian hadiah, saya tidak memikirkan CTE rekursif dan jawaban ini adalah yang pertama menyarankannya dan memiliki solusi yang berfungsi. Sebenarnya, CTE rekursif bukanlah solusi berbasis set, tetapi memberikan hasil yang optimal dan cukup cepat. Sebuah jawaban oleh @GeoffPatterson besar, tetapi memberikan hasil yang kurang optimal dan, terus terang, terlalu rumit.
Vladimir Baranov
5

Tidak persis apa yang Anda cari tetapi mungkin bisa menarik bagi Anda.

Permintaan menciptakan minggu dengan string yang dipisahkan koma untuk hari yang digunakan dalam setiap minggu. Kemudian menemukan pulau-pulau minggu berturut-turut yang menggunakan pola yang sama di 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);

Hasil:

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 = 2menunjukkan apa perbedaan dalam hasil dibandingkan dengan apa yang Anda inginkan. Minggu pertama dan terakhir akan diperlakukan sebagai periode terpisah karena WeekDaysberbeda.

Mikael Eriksson
sumber
Saya punya ide ini, tetapi tidak memiliki kesempatan untuk mencobanya. Terima kasih telah memberikan kueri yang berfungsi. Saya suka bagaimana ini memberikan hasil yang lebih terstruktur. Dalam mengelompokkan data menjadi beberapa minggu sisi bawah berkurang fleksibilitasnya (dalam pendekatan kesenjangan dan pulau harian yang sederhana, contoh 7 dan 8 akan diciutkan menjadi satu interval), tetapi ini adalah sisi baiknya pada saat yang bersamaan - kami mengurangi kerumitan masalah. Jadi, masalah terbesar dengan pendekatan ini adalah parsial minggu pada awal dan akhir jadwal. Minggu parsial seperti itu menghasilkan interval tambahan ...
Vladimir Baranov
Bisakah Anda memikirkan cara untuk menambahkan / mengelompokkan / menggabungkan sebagian minggu ini ke dalam jadwal utama? Saya hanya memiliki gagasan yang sangat samar pada tahap ini. Jika kami menemukan cara untuk menggabungkan sebagian minggu dengan benar, hasil akhirnya akan sangat mendekati optimal.
Vladimir Baranov
@VladimirBaranov Tidak yakin bagaimana itu akan dilakukan. Saya akan memperbarui jawabannya jika ada sesuatu yang terlintas di benak saya.
Mikael Eriksson
Gagasan samar saya adalah ini: hanya ada 7 hari dalam seminggu, begitu WeekDaysjuga angka 7-bit. Hanya 128 kombinasi. Hanya ada 128 * 128 = 16384 pasangan yang memungkinkan. Bangun tabel temp dengan semua pasangan yang memungkinkan, kemudian cari algoritme berbasis set yang akan menandai pasangan mana yang dapat digabungkan: pola satu minggu "ditutupi" oleh pola minggu berikutnya. Bergabung sendiri dengan hasil mingguan saat ini (karena tidak ada LAGpada tahun 2008) dan gunakan tabel temp itu untuk memutuskan pasangan mana yang akan digabung ... Tidak yakin apakah ide ini memiliki kelebihan.
Vladimir Baranov
5

Saya berakhir dengan pendekatan yang menghasilkan solusi optimal dalam kasus ini dan saya pikir akan baik-baik saja secara umum. Namun, solusinya cukup panjang, jadi akan menarik untuk melihat apakah orang lain memiliki pendekatan berbeda yang lebih ringkas.

Berikut ini skrip yang berisi solusi lengkap .

Dan di sini adalah garis besar algoritma:

  • Putar set data sehingga ada satu baris yang mewakili setiap minggu
  • Hitung pulau-pulau minggu dalam masing-masing ContractId
  • Gabungkan setiap minggu yang berdekatan yang termasuk dalam yang sama ContractIddan samaWeekDays
  • Untuk setiap minggu tunggal (belum bergabung) di mana pengelompokan sebelumnya berada di pulau yang sama dan WeekDaysdalam satu minggu cocok dengan subset terkemuka dari WeekDayspengelompokan sebelumnya, bergabung ke pengelompokan sebelumnya
  • Untuk setiap minggu tunggal (belum digabung) di mana pengelompokan berikutnya berada di pulau yang sama dan WeekDaysdalam satu minggu cocok dengan subset trailing dari WeekDayspengelompokan berikutnya, bergabung ke pengelompokan berikutnya
  • Untuk dua minggu yang berdekatan di pulau yang sama di mana tidak ada yang digabungkan, gabungkan keduanya jika keduanya merupakan minggu parsial yang dapat digabungkan (mis., "Sen, Sel, Rab, Kam," dan "Rab, Kam, Sab," )
  • Untuk setiap minggu yang tersisa (belum bergabung), jika mungkin membagi minggu menjadi dua bagian dan menggabungkan kedua bagian, bagian pertama menjadi pengelompokan sebelumnya di pulau yang sama, dan bagian kedua ke dalam pengelompokan berikut di pulau yang sama
Geoff Patterson
sumber
Terima kasih telah berusaha keras untuk menghasilkan solusi yang berfungsi. Sejujurnya, agak berlebihan. Saya curiga tidak mudah untuk menggabungkan sebagian minggu, tetapi saya tidak bisa berharap itu menjadi sangat rumit. Saya masih memiliki harapan bahwa itu bisa dilakukan dengan lebih mudah, tetapi saya tidak punya ide konkret.
Vladimir Baranov
Pemeriksaan cepat mengonfirmasi bahwa itu menghasilkan hasil yang diharapkan untuk data sampel, yang bagus, tetapi, saya perhatikan bahwa jadwal tertentu tidak ditangani secara optimal. Contoh paling sederhana: (1214,12,'2016-05-06', 'Fri', 6), (1225,12,'2016-05-09', 'Mon', 2),. Itu bisa direpresentasikan sebagai satu interval, tetapi solusi Anda menghasilkan dua. Saya akui, contoh ini tidak ada dalam data sampel dan tidak kritis. Saya akan mencoba menjalankan solusi Anda pada data nyata.
Vladimir Baranov
Saya menghargai jawaban Anda. Pada saat saya memulai pemberian hadiah, saya tidak memikirkan CTE rekursif dan Ziggy Crueltyfree Zeitgeister adalah yang pertama menyarankan dan menghadirkan solusi yang berfungsi. Sebenarnya, CTE rekursif bukanlah solusi berbasis set, tetapi memberikan hasil yang optimal, cukup kompleks, dan cukup cepat. Jawaban Anda berdasarkan set, tetapi ternyata terlalu rumit, sampai-sampai tidak praktis. Saya berharap bisa membagi hadiah, tapi sayangnya itu tidak diperbolehkan.
Vladimir Baranov
@VladimirBaranov Tidak masalah, hadiahnya adalah 100% milik Anda untuk digunakan sesuai keinginan. Alasan saya menyukai pertanyaan karunia adalah karena orang yang mengajukan pertanyaan biasanya jauh lebih sibuk daripada pertanyaan biasa. Jangan terlalu peduli dengan poinnya. Saya sepenuhnya setuju bahwa solusi ini bukan yang akan saya gunakan dalam kode produksi saya; itu adalah eksplorasi ide potensial, tetapi akhirnya menjadi agak rumit.
Geoff Patterson
3

Saya tidak dapat memahami logika di balik pengelompokan minggu dengan kesenjangan, atau minggu dengan akhir pekan (misalnya ketika ada dua minggu berturut-turut dengan akhir pekan, minggu mana akhir pekan pergi ke?).

Kueri berikut menghasilkan output yang diinginkan kecuali hanya mengelompokkan hari kerja berturut-turut, dan mengelompokkan minggu Sun-Sat (daripada Senin-Minggu). Meskipun tidak persis apa yang Anda inginkan, mungkin ini dapat memberikan beberapa petunjuk untuk strategi yang berbeda. Pengelompokan hari datang dari sini . Fungsi-fungsi windowing yang digunakan harus bekerja dengan SQLServer 2008, tapi saya tidak punya versi untuk menguji apakah itu benar-benar.

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

Hasil

+------------+------------+------------+----------+-----------------------------------+
| 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
sumber
Diskusi tentang jawaban ini telah dipindahkan ke obrolan .
Paul White mengatakan GoFundMonica
3

Demi kelengkapan, ini adalah dua pass gaps-and-islands yang saya coba sendiri sebelum mengajukan pertanyaan ini.

Ketika saya mengujinya pada data nyata saya menemukan beberapa kasus ketika itu menghasilkan hasil yang salah dan memperbaikinya.

Berikut algoritanya:

  • Menghasilkan pulau-pulau tanggal berturut-turut ( CTE_ContractDays, CTE_DailyRN,CTE_DailyIslands ) dan menghitung jumlah minggu untuk setiap tanggal mulai dan berakhir dari sebuah pulau. Di sini angka minggu dihitung dengan asumsi bahwa Senin adalah hari pertama dalam seminggu.
  • Jika jadwal memiliki tanggal non-berurutan dalam minggu yang sama (seperti dalam contoh 3), tahap sebelumnya akan membuat beberapa baris untuk minggu yang sama. Baris grup hanya memiliki satu baris per minggu (CTE_Weeks ).
  • Untuk setiap baris dari tahap sebelumnya, buat daftar hari minggu yang dipisahkan dengan koma ( CTE_FirstResult).
  • Lewat kedua celah dan pulau ke grup secara berurutan dengan waktu yang sama WeekDays( CTE_SecondRN, CTE_Schedules).

Ini menangani kasus dengan baik ketika tidak ada gangguan dalam pola mingguan (1, 7, 8, 10, 12). Ini menangani kasus dengan baik ketika pola memiliki hari non-berurutan (3).

Tapi, sayangnya, ini menghasilkan interval ekstra untuk minggu parsial (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
;

Hasil

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

Solusi berbasis kursor

Saya mengubah kode C # saya menjadi algoritma berbasis kursor, hanya untuk melihat bagaimana perbandingannya dengan solusi lain pada data nyata. Ini menegaskan bahwa itu jauh lebih lambat daripada pendekatan berbasis set atau rekursif lainnya, tetapi menghasilkan hasil yang 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
sumber
2

Saya sedikit terkejut bahwa solusi kursor Vladimir sangat lambat, jadi saya juga mencoba untuk mengoptimalkan versi itu. Saya mengkonfirmasi bahwa menggunakan kursor juga sangat lambat bagi saya.

Namun, dengan biaya menggunakan fungsi tidak berdokumen dalam SQL Server dengan menambahkan variabel saat memproses rowset, saya dapat membuat versi sederhana dari logika ini yang menghasilkan hasil yang optimal dan mengeksekusi lebih cepat daripada kursor dan solusi asli saya . Jadi gunakan dengan risiko Anda sendiri, tetapi saya akan menyajikan solusinya jika itu menarik. Juga dimungkinkan untuk memperbarui solusi untuk menggunakan WHILEloop dari satu ke nomor baris maksimum, mencari ke nomor baris berikutnya pada setiap iterasi loop. Ini akan menempel pada fungsionalitas yang sepenuhnya terdokumentasi dan dapat diandalkan, tetapi akan melanggar kendala (agak buatan) yang dinyatakan dari masalah ituWHILE loop tidak diizinkan.

Perhatikan bahwa jika menggunakan SQL 2014 diizinkan, kemungkinan bahwa a prosedur tersimpan yang disusun secara asli yang melompati nomor baris dan mengakses setiap nomor baris dalam tabel yang dioptimalkan memori akan menjadi implementasi dari logika yang sama ini yang akan berjalan lebih cepat.

Inilah solusi lengkapnya , termasuk memperluas data uji coba yang ditetapkan menjadi sekitar setengah juta baris. Solusi baru selesai dalam waktu sekitar 3 detik dan menurut saya jauh lebih ringkas dan mudah dibaca daripada solusi sebelumnya yang saya tawarkan. Saya akan memecah tiga langkah yang terlibat di sini:

Langkah 1: pra-pemrosesan

Kami pertama-tama menambahkan nomor baris ke kumpulan data, dalam urutan kami akan memproses data. Sambil melakukan hal itu, kami juga mengubah setiap dowInt menjadi kekuatan 2 sehingga kami dapat menggunakan bitmap untuk mewakili hari-hari yang telah diamati dalam pengelompokan apa pun yang diberikan:

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

Langkah 2: Looping melalui hari-hari kontrak untuk mengidentifikasi pengelompokan baru

Kami selanjutnya mengulangi data, berdasarkan nomor baris. Kami hanya menghitung daftar nomor baris yang membentuk batas pengelompokan baru, lalu menampilkan nomor-nomor baris itu ke dalam tabel:

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

Langkah 3: Menghitung hasil akhir berdasarkan nomor baris setiap batas pengelompokan

Kami kemudian menghitung pengelompokan terakhir dengan menggunakan batas-batas yang diidentifikasi dalam loop di atas untuk menggabungkan semua tanggal yang termasuk dalam setiap pengelompokan:

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
sumber
Terima kasih. Saya meminta untuk tidak menggunakan kursor atau WHILEloop, karena saya sudah tahu bagaimana menyelesaikannya dengan kursor dan saya ingin menemukan solusi berbasis set. Selain itu, saya menduga kursor akan lambat (terutama dengan loop bersarang di dalamnya). Jawaban ini sangat menarik dalam hal mempelajari trik baru dan saya menghargai upaya Anda.
Vladimir Baranov
1

Diskusi akan mengikuti kode.

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 adalah untuk mengatasi aturan ini:

Jika jarak antara hari saat ini dan hari terakhir dari interval berisi hari dalam seminggu yang termasuk dalam interval sebelumnya, kita harus membuat interval baru

Ini memungkinkan saya untuk membuat daftar nama hari, dalam urutan nomor hari, antara dua hari yang diberikan. Ini digunakan ketika memutuskan apakah interval baru harus dimulai. Saya mengisinya dengan nilai dua minggu untuk membuat pembungkus akhir pekan lebih mudah dikodekan.

Ada cara yang lebih bersih untuk mengimplementasikan ini. Tabel "tanggal" penuh akan menjadi satu. Mungkin ada cara pintar dengan nomor hari dan modulo aritmatika juga.

CTE MissingDays adalah untuk menghasilkan daftar nama hari antara dua hari yang diberikan. Ini ditangani dengan cara yang kikuk karena CTE rekursif (berikut) tidak memungkinkan agregat, TOP (), atau operator lain. Ini tidak berlaku, tetapi berhasil.

CTE Numbered adalah untuk menegakkan urutan yang diketahui bebas celah pada data. Ini menghindari banyak perbandingan nanti.

CTE Incrementedadalah tempat terjadinya tindakan. Intinya saya menggunakan CTE rekursif untuk melangkah melalui data dan menegakkan aturan. Nomor baris yang dihasilkanNumbered ( atas) digunakan untuk mendorong pemrosesan rekursif.

Benih CTE rekursif hanya mendapatkan tanggal pertama untuk setiap ContractID dan menginisialisasi nilai yang akan digunakan untuk memutuskan apakah interval baru diperlukan.

Memutuskan apakah interval baru harus dimulai memerlukan tanggal mulai interval saat ini, daftar hari dan panjang setiap kesenjangan dalam tanggal kalender. Ini dapat diatur ulang atau diteruskan, tergantung pada keputusan. Oleh karena itu bagian rekursif adalah verbose dan sedikit pengulangan, karena kita harus memutuskan apakah akan memulai interval baru untuk lebih dari satu nilai kolom.

Logika keputusan untuk kolom WeekDaysdanIntervalStart harus memiliki logika keputusan yang sama - dapat dipotong dan disisipkan di antara mereka. Jika logika untuk memulai interval baru adalah untuk mengubah ini adalah kode untuk diubah. Idealnya itu akan diabstraksi, oleh karena itu; melakukan ini dalam CTE rekursif mungkin sulit.

The EXISTS()klausa adalah pembuangan tidak mampu untuk menggunakan fungsi agregat dalam CTE rekursif. Yang perlu dilakukan adalah melihat apakah hari-hari yang berada dalam celah sudah dalam interval saat ini.

Tidak ada yang ajaib tentang bersarangnya klausa logika. Jika lebih jelas dalam konformasi lain, atau menggunakan KASUS bersarang, katakanlah, tidak ada alasan untuk tetap seperti ini.

Yang terakhir SELECTadalah memberikan output dalam format yang diinginkan.

Mengaktifkan PK Src.IDtidak berguna untuk metode ini. Indeks berkerumun aktif(ContractID,dt) akan menyenangkan, saya pikir.

Ada beberapa sisi yang kasar. Hari-hari tidak dikembalikan dalam urutan dow, tetapi dalam urutan kalender mereka muncul di sumber data. Semuanya harus dilakukan dengan @Helper klunky dan bisa dihaluskan. Saya suka ide menggunakan satu bit per hari dan menggunakan fungsi biner, bukan LIKE. Memisahkan beberapa CTE tambahan ke dalam tabel temp dengan indeks yang tepat tidak diragukan lagi akan membantu.

Salah satu tantangan dengan ini adalah bahwa "minggu" tidak sejajar dengan kalender standar, tetapi didorong oleh data, dan me-reset ketika ditentukan bahwa interval baru harus dimulai. "Seminggu", atau setidaknya satu interval, bisa dari satu hari panjang hingga mencakup seluruh dataset.


Demi kepentingan, inilah perkiraan biaya terhadap data sampel Geoff (terima kasih untuk itu!) Setelah berbagai perubahan:

                                             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.

Perkiraan dan jumlah aktual dari baris sangat berbeda.

Rencana tersebut memiliki spoo meja, kemungkinan akibat CTE rekursif. Sebagian besar tindakan adalah di meja kerja yang datang dari itu:

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.

Hanya cara rekursif diterapkan, saya kira!

Michael Green
sumber
Terima kasih. Ini memberikan hasil yang benar dan optimal pada data sampel. Saya akan memeriksanya pada data nyata sekarang. Catatan: MAX(g.IntervalStart)sepertinya aneh, karena g.IntervalStartada di GROUP BY. Saya berharap ini memberikan kesalahan sintaks, tetapi berhasil. Haruskah itu g.IntervalStart as StartDTmasuk SELECT? Atau g.IntervalStarttidak seharusnya di GROUP BY?
Vladimir Baranov
Saya mencoba menjalankan kueri pada data nyata dan saya harus menghentikannya setelah 10 menit. Sangat mungkin bahwa jika CTE MissingDaysdan Numbereddiganti dengan tabel temp dengan indeks yang tepat, itu bisa memiliki kinerja yang layak. Indeks apa yang akan Anda rekomendasikan? Saya bisa mencobanya besok pagi.
Vladimir Baranov
Saya pikir mengganti Numbereddengan tabel temp & indeks berkerumun (ContractID, rn)akan layak untuk dicoba. Tanpa dataset besar untuk menghasilkan rencana yang sesuai sulit untuk ditebak. Fisik MissingDatesdengan indeks (StartDay, FollowingDayInt)juga bagus.
Michael Green
Terima kasih. Saya tidak dapat mencobanya sekarang, tetapi saya akan besok pagi.
Vladimir Baranov
Saya mencoba ini pada set data setengah juta baris (set data yang ada, direplikasi 4.000 kali dengan berbagai ContractIds). Sudah berjalan sekitar 15 menit dan sejauh ini telah mengambil ruang tempdb 30GB. Jadi saya pikir beberapa optimasi lebih lanjut mungkin diperlukan. Berikut adalah data uji yang diperluas jika Anda merasa terbantu.
Geoff Patterson