Обчисліть загальну кількість відвідувань


12

Я намагаюся написати запит, де мені доведеться підрахувати кількість відвідувань клієнта, піклуючись про дні, що перетинаються. Припустимо, дата початку itemID 2009 - 23-а, а кінцева - 26-а, тому предмет 20010 знаходиться між цими днями, ми не будемо додавати цю дату покупки до загальної кількості.

Приклад сценарію:

Item ID Start Date   End Date   Number of days     Number of days Candidate for visit count
20009   2015-01-23  2015-01-26     4                      4
20010   2015-01-24  2015-01-24     1                      0
20011   2015-01-23  2015-01-26     4                      0
20012   2015-01-23  2015-01-27     5                      1
20013   2015-01-23  2015-01-27     5                      0
20014   2015-01-29  2015-01-30     2                      2

У OutPut має бути 7 днів відвідування

Таблиця введення:

CREATE TABLE #Items    
(
CustID INT,
ItemID INT,
StartDate DATETIME,
EndDate DATETIME
)           


INSERT INTO #Items
SELECT 11205, 20009, '2015-01-23',  '2015-01-26'  
UNION ALL 
SELECT 11205, 20010, '2015-01-24',  '2015-01-24'    
UNION ALL  
SELECT 11205, 20011, '2015-01-23',  '2015-01-26' 
UNION ALL  
SELECT 11205, 20012, '2015-01-23',  '2015-01-27'  
UNION ALL  
SELECT 11205, 20012, '2015-01-23',  '2015-01-27'   
UNION ALL  
SELECT 11205, 20012, '2015-01-28',  '2015-01-29'  

Я намагався поки що:

CREATE TABLE #VisitsTable
    (
      StartDate DATETIME,
      EndDate DATETIME
    )

INSERT  INTO #VisitsTable
        SELECT DISTINCT
                StartDate,
                EndDate
        FROM    #Items items
        WHERE   CustID = 11205
        ORDER BY StartDate ASC

IF EXISTS (SELECT TOP 1 1 FROM #VisitsTable) 
BEGIN 


SELECT  ISNULL(SUM(VisitDays),1)
FROM    ( SELECT DISTINCT
                    abc.StartDate,
                    abc.EndDate,
                    DATEDIFF(DD, abc.StartDate, abc.EndDate) + 1 VisitDays
          FROM      #VisitsTable abc
                    INNER JOIN #VisitsTable bc ON bc.StartDate NOT BETWEEN abc.StartDate AND abc.EndDate      
        ) Visits

END



--DROP TABLE #Items 
--DROP TABLE #VisitsTable      

Відповіді:


5

Цей перший запит створює різні діапазони дати початку та дати закінчення без перекриттів.

Примітка:

  • Ваш зразок ( id=0) змішується з зразком Ypercube ( id=1)
  • Це рішення може не бути масштабним з величезною кількістю даних для кожного id або величезною кількістю id. Це має перевагу в тому, що не потрібна таблиця номерів. З великим набором даних таблиця чисел, швидше за все, дасть кращі показники.

Запит:

SELECT DISTINCT its.id
    , Start_Date = its.Start_Date 
    , End_Date = COALESCE(DATEADD(day, -1, itmax.End_Date), CASE WHEN itmin.Start_Date > its.End_Date THEN itmin.Start_Date ELSE its.End_Date END)
    --, x1=itmax.End_Date, x2=itmin.Start_Date, x3=its.End_Date
FROM @Items its
OUTER APPLY (
    SELECT Start_Date = MAX(End_Date) FROM @Items std
    WHERE std.Item_ID <> its.Item_ID AND std.Start_Date < its.Start_Date AND std.End_Date > its.Start_Date
) itmin
OUTER APPLY (
    SELECT End_Date = MIN(Start_Date) FROM @Items std
    WHERE std.Item_ID <> its.Item_ID+1000 AND std.Start_Date > its.Start_Date AND std.Start_Date < its.End_Date
) itmax;

Вихід:

id  | Start_Date                    | End_Date                      
0   | 2015-01-23 00:00:00.0000000   | 2015-01-23 00:00:00.0000000   => 1
0   | 2015-01-24 00:00:00.0000000   | 2015-01-27 00:00:00.0000000   => 4
0   | 2015-01-29 00:00:00.0000000   | 2015-01-30 00:00:00.0000000   => 2
1   | 2016-01-20 00:00:00.0000000   | 2016-01-22 00:00:00.0000000   => 3
1   | 2016-01-23 00:00:00.0000000   | 2016-01-24 00:00:00.0000000   => 2
1   | 2016-01-25 00:00:00.0000000   | 2016-01-29 00:00:00.0000000   => 5

Якщо ви використовуєте ці дати початку та кінцеву дату з DATEDIFF:

SELECT DATEDIFF(day
    , its.Start_Date 
    , End_Date = COALESCE(DATEADD(day, -1, itmax.End_Date), CASE WHEN itmin.Start_Date > its.End_Date THEN itmin.Start_Date ELSE its.End_Date END)
) + 1
...

Вихід (з дублікатами):

  • 1, 4 і 2 для id 0 (ваш зразок => SUM=7)
  • 3, 2 і 5 для id 1 (зразок Ypercube => SUM=10)

Ви тільки тоді потрібно покласти все разом з SUMі GROUP BY:

SELECT id 
    , Days = SUM(
        DATEDIFF(day, Start_Date, End_Date)+1
    )
FROM (
    SELECT DISTINCT its.id
         , Start_Date = its.Start_Date 
        , End_Date = COALESCE(DATEADD(day, -1, itmax.End_Date), CASE WHEN itmin.Start_Date > its.End_Date THEN itmin.Start_Date ELSE its.End_Date END)
    FROM @Items its
    OUTER APPLY (
        SELECT Start_Date = MAX(End_Date) FROM @Items std
        WHERE std.Item_ID <> its.Item_ID AND std.Start_Date < its.Start_Date AND std.End_Date > its.Start_Date
    ) itmin
    OUTER APPLY (
        SELECT End_Date = MIN(Start_Date) FROM @Items std
        WHERE std.Item_ID <> its.Item_ID AND std.Start_Date > its.Start_Date AND std.Start_Date < its.End_Date
    ) itmax
) as d
GROUP BY id;

Вихід:

id  Days
0   7
1   10

Дані, використовувані з двома різними ідентифікаторами:

INSERT INTO @Items
    (id, Item_ID, Start_Date, End_Date)
VALUES 
    (0, 20009, '2015-01-23', '2015-01-26'),
    (0, 20010, '2015-01-24', '2015-01-24'),
    (0, 20011, '2015-01-23', '2015-01-26'),
    (0, 20012, '2015-01-23', '2015-01-27'),
    (0, 20013, '2015-01-23', '2015-01-27'),
    (0, 20014, '2015-01-29', '2015-01-30'),

    (1, 20009, '2016-01-20', '2016-01-24'),
    (1, 20010, '2016-01-23', '2016-01-26'),
    (1, 20011, '2016-01-25', '2016-01-29')

8

Є багато запитань та статей про часові інтервали упаковки. Наприклад, упаковка інтервалу Іціка Бен-Гана.

Ви можете запакувати свої інтервали для даного користувача. Після упаковки не буде перекриттів, тому ви можете просто підбити тривалість упакованих інтервалів.


Якщо ваші інтервали - це дати без разів, я б використав Calendarтаблицю. У цій таблиці просто є список дат на кілька десятиліть. Якщо у вас немає таблиці календаря, просто створіть її:

CREATE TABLE [dbo].[Calendar](
    [dt] [date] NOT NULL,
CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED 
(
    [dt] ASC
));

Існує багато способів заповнити таку таблицю .

Наприклад, 100 К рядків (~ 270 років) з 1900-01-01:

INSERT INTO dbo.Calendar (dt)
SELECT TOP (100000) 
    DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '19000101') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);

Див. Також Чому таблиці чисел "неоцінені"?

Після того, як у вас є Calendarтаблиця, ось як її використовувати.

Кожен вихідний рядок поєднується з Calendarтаблицею, щоб повернути стільки рядків, скільки є дат між StartDateі EndDate.

Тоді ми підраховуємо різні дати, що видаляє дати, що перетинаються.

SELECT COUNT(DISTINCT CA.dt) AS TotalCount
FROM
    #Items AS T
    CROSS APPLY
    (
        SELECT dbo.Calendar.dt
        FROM dbo.Calendar
        WHERE
            dbo.Calendar.dt >= T.StartDate
            AND dbo.Calendar.dt <= T.EndDate
    ) AS CA
WHERE T.CustID = 11205
;

Результат

TotalCount
7

7

Я повністю погоджуюся, що Numbersта та Calendarтаблиця дуже корисні, і якщо цю проблему можна значно спростити за допомогою таблиці Календар.

Я б запропонував інше рішення (для цього не потрібно ні календарної таблиці, ні віконних агрегатів - як це роблять деякі відповіді із пов'язаного допису Ітзіка). Це може бути не найефективнішим у всіх випадках (або може бути найгіршим у всіх випадках!), Але я не думаю, що це шкодить тестуванню.

Він працює, спочатку знаходячи дати початку та кінця, які не перетинаються з іншими інтервалами, а потім розміщує їх у два ряди (окремо дати початку та кінця), щоб присвоїти їм номери рядків і, нарешті, збігати першу дату початку з 1-ю датою закінчення , 2-е з 2-м та ін .:

WITH 
  start_dates AS
    ( SELECT CustID, StartDate,
             Rn = ROW_NUMBER() OVER (PARTITION BY CustID 
                                     ORDER BY StartDate)
      FROM items AS i
      WHERE NOT EXISTS
            ( SELECT *
              FROM Items AS j
              WHERE j.CustID = i.CustID
                AND j.StartDate < i.StartDate AND i.StartDate <= j.EndDate 
            )
      GROUP BY CustID, StartDate
    ),
  end_dates AS
    ( SELECT CustID, EndDate,
             Rn = ROW_NUMBER() OVER (PARTITION BY CustID 
                                     ORDER BY EndDate) 
      FROM items AS i
      WHERE NOT EXISTS
            ( SELECT *
              FROM Items AS j
              WHERE j.CustID = i.CustID
                AND j.StartDate <= i.EndDate AND i.EndDate < j.EndDate 
            )
      GROUP BY CustID, EndDate
    )
SELECT s.CustID, 
       Result = SUM( DATEDIFF(day, s.StartDate, e.EndDate) + 1 )
FROM start_dates AS s
  JOIN end_dates AS e
    ON  s.CustID = e.CustID
    AND s.Rn = e.Rn 
GROUP BY s.CustID ;

Два індекси, увімкнення (CustID, StartDate, EndDate)та включення, (CustID, EndDate, StartDate)були б корисні для підвищення продуктивності запиту.

Перевага перед Календарем (можливо, єдиним) полягає в тому, що він може легко адаптуватися до роботи зі datetimeзначеннями та підраховувати довжину "упакованих інтервалів" з різною точністю, більшою (тижні, роки) або меншою (години, хвилини чи секунди, мілісекунд тощо) і не тільки підрахунок дат. Таблиця календарної хвилини або секунди точності була б досить великою, і (перехресне) приєднання її до великого столу було б досить цікавим досвідом, але, можливо, не найефективнішим.

(спасибі Володимиру Баранову): Досить важко правильно порівнювати ефективність, адже ефективність різних методів, ймовірно, залежатиме від розподілу даних. 1) наскільки тривалі інтервали - чим коротші інтервали, тим краще буде виконана таблиця Календаря, оскільки довгі інтервали створюють багато проміжних рядків 2) наскільки часто інтервали перетинаються - переважно інтервали, що не накладаються, порівняно з більшістю інтервалів, що охоплюють той самий діапазон . Я думаю, що від цього залежить ефективність рішення Іціка. Можуть бути й інші способи перекоси даних, і важко сказати, як впливатиме ефективність різних методів.


1
Бачу 2 примірники. Або, можливо, 3, якщо рахувати анти-напівз'єднання як 2 половини;)
ypercubeᵀᴹ

1
@wBob, якщо ви робили тести на ефективність, додайте їх у відповідь. Буду радий бачити їх і, напевно, багатьох інших. Ось так працює сайт ..
ypercubeᵀᴹ

3
@wBob Не потрібно бути таким бойовим - ніхто не висловлював занепокоєння щодо продуктивності. Якщо у вас є свої проблеми, ви можете запустити власні тести. Ваше суб'єктивне вимірювання того, наскільки складною є відповідь, не є причиною для протистояння. Як щодо того, як ви виконуєте власні тести та розширюєте власну відповідь, замість того, щоб зводити іншу відповідь? Зробіть свою власну відповідь більш гідною обґрунтуванням, якщо хочете, але не забороняйте інших законних відповідей.
Monkpit

1
тут не бій @Monkpit. Цілком вагомі причини та серйозна розмова щодо виступу.
wBob

2
@wBob, досить складно провести належне порівняння продуктивності, оскільки ефективність різних методів, ймовірно, залежатиме від розподілу даних. 1) наскільки тривалі інтервали - чим коротші інтервали, тим краще буде виконана таблиця Календаря, оскільки довгі інтервали створюють багато проміжних рядків 2) як часто інтервали перетинаються - в основному інтервали, що не накладаються, порівняно з більшістю інтервалів, що охоплюють той же діапазон . Я думаю, що від цього залежить ефективність рішення Іціка. Можуть бути й інші способи перекоси даних, це лише деякі з них, які приходять в голову.
Володимир Баранов

2

Я думаю, що це було б просто з календарною таблицею, наприклад, щось подібне:

SELECT i.CustID, COUNT( DISTINCT c.calendarDate ) days
FROM #Items i
    INNER JOIN calendar.main c ON c.calendarDate Between i.StartDate And i.EndDate
GROUP BY i.CustID

Випробувальна установка

USE tempdb
GO

-- Cutdown calendar script
IF OBJECT_ID('dbo.calendar') IS NULL
BEGIN

    CREATE TABLE dbo.calendar (
        calendarId      INT IDENTITY(1,1) NOT NULL,
        calendarDate    DATE NOT NULL,

        CONSTRAINT PK_calendar__main PRIMARY KEY ( calendarDate ASC ) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY],
        CONSTRAINT UK_calendar__main UNIQUE NONCLUSTERED ( calendarId ASC ) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
    ) ON [PRIMARY]
END
GO


-- Populate calendar table once only
IF NOT EXISTS ( SELECT * FROM dbo.calendar )
BEGIN

    -- Populate calendar table
    WITH cte AS
    (
    SELECT 0 x
    UNION ALL
    SELECT x + 1
    FROM cte
    WHERE x < 11323 -- Do from year 1 Jan 2000 until 31 Dec 2030 (extend if required)
    )
    INSERT INTO dbo.calendar ( calendarDate )
    SELECT
        calendarDate
    FROM
        (
        SELECT 
            DATEADD( day, x, '1 Jan 2010' ) calendarDate,
            DATEADD( month, -7, DATEADD( day, x, '1 Jan 2010' ) ) academicDate
        FROM cte
        ) x
    WHERE calendarDate < '1 Jan 2031'
    OPTION ( MAXRECURSION 0 )

    ALTER INDEX ALL ON dbo.calendar REBUILD

END
GO





IF OBJECT_ID('tempdb..Items') IS NOT NULL DROP TABLE Items
GO

CREATE TABLE dbo.Items
    (
    CustID INT NOT NULL,
    ItemID INT NOT NULL,
    StartDate DATE NOT NULL,
    EndDate DATE NOT NULL,

    INDEX _cdx_Items CLUSTERED ( CustID, StartDate, EndDate )
    )
GO

INSERT INTO Items ( CustID, ItemID, StartDate, EndDate )
SELECT 11205, 20009, '2015-01-23',  '2015-01-26'  
UNION ALL 
SELECT 11205, 20010, '2015-01-24',  '2015-01-24'    
UNION ALL  
SELECT 11205, 20011, '2015-01-23',  '2015-01-26' 
UNION ALL  
SELECT 11205, 20012, '2015-01-23',  '2015-01-27'  
UNION ALL  
SELECT 11205, 20012, '2015-01-23',  '2015-01-27'   
UNION ALL  
SELECT 11205, 20012, '2015-01-28',  '2015-01-29'
GO


-- Scale up : )
;WITH cte AS (
SELECT TOP 1000000 ROW_NUMBER() OVER ( ORDER BY ( SELECT 1 ) ) rn
FROM master.sys.columns c1
    CROSS JOIN master.sys.columns c2
    CROSS JOIN master.sys.columns c3
)
INSERT INTO Items ( CustID, ItemID, StartDate, EndDate )
SELECT 11206 + rn % 999, 20012 + rn, DATEADD( day, rn % 333, '1 Jan 2015' ), DATEADD( day, ( rn % 333 ) + rn % 7, '1 Jan 2015' )
FROM cte
GO
--:exit



-- My query: Pros: simple, one copy of items, easy to understand and maintain.  Scales well to 1 million + rows.
-- Cons: requires calendar table.  Others?
SELECT i.CustID, COUNT( DISTINCT c.calendarDate ) days
FROM dbo.Items i
    INNER JOIN dbo.calendar c ON c.calendarDate Between i.StartDate And i.EndDate
GROUP BY i.CustID
--ORDER BY i.CustID
GO


-- Vladimir query: Pros: Effectively same as above
-- Cons: I wouldn't use CROSS APPLY where it's not necessary.  Fortunately optimizer simplifies avoiding RBAR (I think).
-- Point of style maybe, but in terms of queries being self-documenting I prefer number 1.
SELECT T.CustID, COUNT( DISTINCT CA.calendarDate ) AS TotalCount
FROM
    Items AS T
    CROSS APPLY
    (
        SELECT c.calendarDate
        FROM dbo.calendar c
        WHERE
            c.calendarDate >= T.StartDate
            AND c.calendarDate <= T.EndDate
    ) AS CA
GROUP BY T.CustID
--ORDER BY T.CustID
--WHERE T.CustID = 11205
GO


/*  WARNING!! This is commented out as it can't compete in the scale test.  Will finish at scale 100, 1,000, 10,000, eventually.  I got 38 mins for 10,0000.  Pegs CPU.  

-- Julian:  Pros; does not require calendar table.
-- Cons: over-complicated (eg versus Query 1 in terms of number of lines of code, clauses etc); three copies of dbo.Items table (we have already shown
-- this query is possible with one); does not scale (even at 100,000 rows query ran for 38 minutes on my test rig versus sub-second for first two queries).  <<-- this is serious.
-- Indexing could help.
SELECT DISTINCT
    CustID,
     StartDate = CASE WHEN itmin.StartDate < its.StartDate THEN itmin.StartDate ELSE its.StartDate END
    , EndDate = CASE WHEN itmax.EndDate > its.EndDate THEN itmax.EndDate ELSE its.EndDate END
FROM Items its
OUTER APPLY (
    SELECT StartDate = MIN(StartDate) FROM Items std
    WHERE std.ItemID <> its.ItemID AND (
        (std.StartDate <= its.StartDate AND std.EndDate >= its.StartDate)
        OR (std.StartDate >= its.StartDate AND std.StartDate <= its.EndDate)
    )
) itmin
OUTER APPLY (
    SELECT EndDate = MAX(EndDate) FROM Items std
    WHERE std.ItemID <> its.ItemID AND (
        (std.EndDate >= its.StartDate AND std.EndDate <= its.EndDate)
        OR (std.StartDate <= its.EndDate AND std.EndDate >= its.EndDate)
    )
) itmax
GO
*/

-- ypercube:  Pros; does not require calendar table.
-- Cons: over-complicated (eg versus Query 1 in terms of number of lines of code, clauses etc); four copies of dbo.Items table (we have already shown
-- this query is possible with one); does not scale well; at 1,000,000 rows query ran for 2:20 minutes on my test rig versus sub-second for first two queries.
WITH 
  start_dates AS
    ( SELECT CustID, StartDate,
             Rn = ROW_NUMBER() OVER (PARTITION BY CustID 
                                     ORDER BY StartDate)
      FROM items AS i
      WHERE NOT EXISTS
            ( SELECT *
              FROM Items AS j
              WHERE j.CustID = i.CustID
                AND j.StartDate < i.StartDate AND i.StartDate <= j.EndDate 
            )
      GROUP BY CustID, StartDate
    ),
  end_dates AS
    ( SELECT CustID, EndDate,
             Rn = ROW_NUMBER() OVER (PARTITION BY CustID 
                                     ORDER BY EndDate) 
      FROM items AS i
      WHERE NOT EXISTS
            ( SELECT *
              FROM Items AS j
              WHERE j.CustID = i.CustID
                AND j.StartDate <= i.EndDate AND i.EndDate < j.EndDate 
            )
      GROUP BY CustID, EndDate
    )
SELECT s.CustID, 
       Result = SUM( DATEDIFF(day, s.StartDate, e.EndDate) + 1 )
FROM start_dates AS s
  JOIN end_dates AS e
    ON  s.CustID = e.CustID
    AND s.Rn = e.Rn 
GROUP BY s.CustID ;

2
Хоча це працює чудово, вам слід прочитати цю шкідливу звичку: виправляти запити щодо дати / діапазону : Підсумок 2. уникайте ПЕРЕМОГО для запитів діапазону проти DATETIME, SMALLDATETIME, DATETIME2 та DATETIMEOFFSET;
Жульєн Вавассер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.