Oblicz łączną liczbę wizyt

12

Próbuję napisać zapytanie, w którym muszę obliczyć liczbę wizyt dla klienta, dbając o nakładające się dni. Załóżmy, że data rozpoczęcia itemID 2009 jest 23, a data zakończenia 26, dlatego pozycja 20010 jest między tymi dniami, nie dodamy tej daty zakupu do naszej całkowitej liczby.

Przykładowy scenariusz:

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 powinien wynosić 7 VisitDays

Tabela wprowadzania danych:

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'  

Do tej pory próbowałem:

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      
AA.SC
źródło

Odpowiedzi:

5

To pierwsze zapytanie tworzy różne zakresy dat początkowych i końcowych bez nakładania się.

Uwaga:

  • Twoja próbka ( id=0) jest mieszana z próbką z Ypercube ( id=1)
  • To rozwiązanie może nie być dobrze skalowane przy dużej ilości danych dla każdego identyfikatora lub dużej liczby identyfikatorów. Ma to tę zaletę, że nie wymaga tabeli liczb. W przypadku dużego zestawu danych tablica liczb najprawdopodobniej zapewni lepszą wydajność.

Pytanie:

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;

Wynik:

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

Jeśli używasz tych Daty rozpoczęcia i Daty zakończenia z 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
...

Dane wyjściowe (z duplikatami) to:

  • 1, 4 i 2 dla identyfikatora 0 (twoja próbka => SUM=7)
  • 3, 2 i 5 dla id 1 (próbka Ypercube => SUM=10)

Następnie wystarczy połączyć wszystko razem za pomocą a SUMi 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;

Wynik:

id  Days
0   7
1   10

Dane używane z 2 różnymi identyfikatorami:

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')
Julien Vavasseur
źródło
8

Istnieje wiele pytań i artykułów na temat odstępów czasu pakowania. Na przykład: Interwały pakowania autorstwa Itzika Ben-Gana.

Możesz spakować interwały dla danego użytkownika. Po spakowaniu nie będzie nakładania się, więc możesz po prostu podsumować czas trwania upakowanych interwałów.


Jeśli twoje przedziały są datami bez czasu, skorzystałbym ze Calendarstołu. Ta tabela zawiera po prostu listę dat z kilkudziesięciu lat. Jeśli nie masz tabeli kalendarza, po prostu utwórz ją:

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

Istnieje wiele sposobów zapełniania takiej tabeli .

Na przykład 100 000 wierszy (~ 270 lat) od 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);

Zobacz także Dlaczego tabele liczb są „bezcenne”?

Gdy masz już Calendarstolik, oto jak go użyć.

Każdy oryginalny wiersz jest łączony z Calendartabelą, aby zwrócić tyle wierszy, ile jest dat pomiędzy StartDatei EndDate.

Następnie zliczamy różne daty, co usuwa nakładające się daty.

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
;

Wynik

TotalCount
7
Vladimir Baranov
źródło
7

Zdecydowanie zgadzam się, że Numbersi Calendarstół są bardzo przydatne, a jeśli problem ten można uprościć wiele ze stołem kalendarza.

Zasugeruję jednak inne rozwiązanie (które nie potrzebuje ani tabeli kalendarza, ani agregowanych okienek - tak jak niektóre odpowiedzi z posta z linkami autorstwa Itzika). Może nie być najskuteczniejszy we wszystkich przypadkach (lub może być najgorszy we wszystkich przypadkach!), Ale nie sądzę, że testowanie może zaszkodzić.

Działa najpierw poprzez znalezienie dat początkowych i końcowych, które nie pokrywają się z innymi przedziałami, a następnie umieszcza je w dwóch wierszach (osobno daty początkowe i końcowe) w celu przypisania im numerów wierszy i ostatecznie dopasowuje pierwszą datę początkową do pierwszej daty końcowej , 2. z 2., itp .:

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 ;

Przydałyby się dwa indeksy, włączony (CustID, StartDate, EndDate)i włączony (CustID, EndDate, StartDate), aby poprawić wydajność zapytania.

Zaletą w stosunku do kalendarza (być może jedynego) jest to, że można go łatwo dostosować do pracy z datetimewartościami i liczenia długości „upakowanych interwałów” z różną precyzją, większą (tygodnie, lata) lub mniejszą (godziny, minuty lub sekundy, milisekund itp.), a nie tylko zliczanie dat. Tabela kalendarza z dokładnością do minuty lub sekundy byłaby dość duża, a (krzyżowe) połączenie jej z dużym stołem byłoby dość ciekawym doświadczeniem, ale być może nie najbardziej wydajnym.

(dzięki Vladimir Baranov): Właściwe porównanie wydajności jest raczej trudne, ponieważ wydajność różnych metod prawdopodobnie zależy od dystrybucji danych. 1) jak długie są interwały - im krótsze interwały, tym lepsza byłaby tabela kalendarza, ponieważ długie interwały dawałyby dużo pośrednich rzędów 2) jak często interwały nakładają się - przeważnie nie nakładają się interwały w porównaniu do większości interwałów obejmujących ten sam zakres . Myślę, że od tego zależy wydajność rozwiązania Itzik. Mogą istnieć inne sposoby wypaczania danych i trudno powiedzieć, jak wpłynęłoby to na skuteczność różnych metod.

ypercubeᵀᴹ
źródło
1
Widzę 2 kopie. A może 3, jeśli liczymy anty-półjoiny jako 2 połówki;)
ypercubeᵀᴹ
1
@wBob, jeśli wykonałeś testy wydajności, dodaj je w swojej odpowiedzi. Z przyjemnością ich zobaczę i na pewno wielu innych. Tak działa strona.
ypercubeᵀᴹ
3
@wBob Nie trzeba być tak wojowniczym - nikt nie wyraził obaw o wydajność. Jeśli masz własne obawy, możesz przeprowadzić własne testy. Twój subiektywny pomiar tego, jak skomplikowana jest odpowiedź, nie jest powodem do odrzucenia opinii. Co powiesz na wykonanie własnych testów i poszerzenie własnej odpowiedzi zamiast obniżania kolejnej? Jeśli chcesz, uczyń swoją własną odpowiedź bardziej godną pozytywnych opinii, ale nie głosuj za innymi uzasadnionymi odpowiedziami.
Monkpit
1
lol bez walki tutaj @Monkpit. Idealnie uzasadnione powody i poważna rozmowa o wydajności.
wBob
2
@wBob, raczej trudno jest właściwie porównać wydajność, ponieważ wydajność różnych metod prawdopodobnie zależy od dystrybucji danych. 1) jak długie są interwały - im krótsze interwały, tym lepsza byłaby tabela kalendarza, ponieważ długie interwały dawałyby dużo pośrednich rzędów 2) jak często interwały nakładają się - przeważnie nie nakładają się interwały w porównaniu do większości interwałów obejmujących ten sam zakres . Myślę, że od tego zależy wydajność rozwiązania Itzik. Mogą istnieć inne sposoby wypaczania danych, to tylko kilka, które przychodzą na myśl.
Vladimir Baranov
2

Myślę, że byłoby to proste w przypadku tabeli kalendarza, np. Czegoś takiego:

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

Stanowisko testowe

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 ;
wBob
źródło
2
Chociaż działa to dobrze, powinieneś przeczytać to Złe nawyki, aby wykopać: niewłaściwe obchodzenie się z zapytaniami o datę / zakres : Podsumowanie 2. unikaj MIĘDZY zapytaniami o zakres względem DATETIME, SMALLDATETIME, DATETIME2 i DATETIMEOFFSET;
Julien Vavasseur