Combinazione di intervalli separati in intervalli contigui più ampi possibili


20

Sto cercando di combinare più intervalli di date (il mio carico è di circa 500, la maggior parte dei casi 10) che possono o meno sovrapporsi ai più grandi intervalli di date contigui possibili. Per esempio:

Dati:

CREATE TABLE test (
  id SERIAL PRIMARY KEY NOT NULL,
  range DATERANGE
);

INSERT INTO test (range) VALUES 
  (DATERANGE('2015-01-01', '2015-01-05')),
  (DATERANGE('2015-01-01', '2015-01-03')),
  (DATERANGE('2015-01-03', '2015-01-06')),
  (DATERANGE('2015-01-07', '2015-01-09')),
  (DATERANGE('2015-01-08', '2015-01-09')),
  (DATERANGE('2015-01-12', NULL)),
  (DATERANGE('2015-01-10', '2015-01-12')),
  (DATERANGE('2015-01-10', '2015-01-12'));

La tabella si presenta come:

 id |          range
----+-------------------------
  1 | [2015-01-01,2015-01-05)
  2 | [2015-01-01,2015-01-03)
  3 | [2015-01-03,2015-01-06)
  4 | [2015-01-07,2015-01-09)
  5 | [2015-01-08,2015-01-09)
  6 | [2015-01-12,)
  7 | [2015-01-10,2015-01-12)
  8 | [2015-01-10,2015-01-12)
(8 rows)

Risultati desiderati:

         combined
--------------------------
 [2015-01-01, 2015-01-06)
 [2015-01-07, 2015-01-09)
 [2015-01-10, )

Rappresentazione visiva:

1 | =====
2 | ===
3 |    ===
4 |        ==
5 |         =
6 |             =============>
7 |           ==
8 |           ==
--+---------------------------
  | ====== == ===============>

Risposte:


22

Presupposti / Chiarimenti

  1. Non è necessario distinguere tra infinitye aprire il limite superiore ( upper(range) IS NULL). (Puoi averlo in entrambi i modi, ma è più semplice in questo modo.)

  2. Poiché dateè un tipo discreto, tutti gli intervalli hanno [)limiti predefiniti . Per documentazione:

    Il built-in tipi di intervallo int4range, int8rangee daterangetutto l'uso una forma canonica che comprende il limite inferiore ed esclude il limite superiore; cioè [).

    Per altri tipi (come tsrange!), Imporrei lo stesso, se possibile:

Soluzione con SQL puro

Con CTE per chiarezza:

WITH a AS (
   SELECT range
        , COALESCE(lower(range),'-infinity') AS startdate
        , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
   FROM   test
   )
, b AS (
   SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
   FROM   a
   )
, c AS (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM   b
   )
SELECT daterange(min(startdate), max(enddate)) AS range
FROM   c
GROUP  BY grp
ORDER  BY 1;

Oppure , lo stesso con le subquery, più veloce ma meno facile da leggere:

SELECT daterange(min(startdate), max(enddate)) AS range
FROM  (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM  (
      SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
      FROM  (
         SELECT range
              , COALESCE(lower(range),'-infinity') AS startdate
              , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
         FROM   test
         ) a
      ) b
   ) c
GROUP  BY grp
ORDER  BY 1;

O con un livello di subquery in meno, ma capovolgendo l'ordinamento:

SELECT daterange(min(COALESCE(lower(range), '-infinity')), max(enddate)) AS range
FROM  (
   SELECT *, count(nextstart > enddate OR NULL) OVER (ORDER BY range DESC NULLS LAST) AS grp
   FROM  (
      SELECT range
           , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
           , lead(lower(range)) OVER (ORDER BY range) As nextstart
      FROM   test
      ) a
   ) b
GROUP  BY grp
ORDER  BY 1;
  • Ordinare la finestra nel secondo passaggio con ORDER BY range DESC NULLS LAST(con NULLS LAST) per ottenere un ordinamento perfettamente invertito. Questo dovrebbe essere più economico (più facile da produrre, corrisponde perfettamente all'ordinamento dell'indice suggerito) e preciso per i casi angolari con rank IS NULL.

Spiegare

a: Durante l'ordine range, calcola il massimo corrente del limite superiore ( enddate) con una funzione di finestra.
Sostituisci i limiti NULL (senza limiti) con +/- infinitysolo per semplificare (nessun caso NULL speciale).

b: Nello stesso ordinamento, se il precedente enddateè precedente a startdateabbiamo un gap e iniziamo un nuovo intervallo ( step).
Ricorda, il limite superiore è sempre escluso.

c: Forma gruppi ( grp) contando i passaggi con un'altra funzione di finestra.

Nella SELECTbuild esterna va dal limite inferiore a quello superiore in ciascun gruppo. Ecco.
Risposta strettamente correlata su SO con ulteriori spiegazioni:

Soluzione procedurale con plpgsql

Funziona con qualsiasi nome di tabella / colonna, ma solo per il tipo daterange.
Le soluzioni procedurali con loop sono in genere più lente, ma in questo caso speciale mi aspetto che la funzione sia sostanzialmente più veloce poiché richiede solo una singola scansione sequenziale :

CREATE OR REPLACE FUNCTION f_range_agg(_tbl text, _col text)
  RETURNS SETOF daterange AS
$func$
DECLARE
   _lower     date;
   _upper     date;
   _enddate   date;
   _startdate date;
BEGIN
   FOR _lower, _upper IN EXECUTE
      format($$SELECT COALESCE(lower(t.%2$I),'-infinity')  -- replace NULL with ...
                    , COALESCE(upper(t.%2$I), 'infinity')  -- ... +/- infinity
               FROM   %1$I t
               ORDER  BY t.%2$I$$
            , _tbl, _col)
   LOOP
      IF _lower > _enddate THEN     -- return previous range
         RETURN NEXT daterange(_startdate, _enddate);
         SELECT _lower, _upper  INTO _startdate, _enddate;

      ELSIF _upper > _enddate THEN  -- expand range
         _enddate := _upper;

      -- do nothing if _upper <= _enddate (range already included) ...

      ELSIF _enddate IS NULL THEN   -- init 1st round
         SELECT _lower, _upper  INTO _startdate, _enddate;
      END IF;
   END LOOP;

   IF FOUND THEN                    -- return last row
      RETURN NEXT daterange(_startdate, _enddate);
   END IF;
END
$func$  LANGUAGE plpgsql;

Chiamata:

SELECT * FROM f_range_agg('test', 'range');  -- table and column name

La logica è simile alle soluzioni SQL, ma possiamo accontentarci di un singolo passaggio.

SQL Fiddle.

Relazionato:

Il solito drill per gestire l'input dell'utente in SQL dinamico:

Indice

Per ognuna di queste soluzioni un indice btree semplice (predefinito) su rangesarebbe strumentale per le prestazioni in grandi tavoli:

CREATE INDEX foo on test (range);

Un indice btree è di utilità limitata per i tipi di intervallo , ma possiamo ottenere dati preordinati e forse anche una scansione di soli indici.


@Villiers: sarei molto interessato a come ciascuna di queste soluzioni si comporta con i tuoi dati. Forse puoi pubblicare un'altra risposta con i risultati dei test e alcune informazioni sul design e sulle cardinalità del tuo tavolo? Meglio con EXPLAIN ( ANALYZE, TIMING OFF)e confrontare il meglio di cinque.
Erwin Brandstetter,

La chiave di questo tipo di problemi è la funzione di ritardo SQL (può anche essere usato il lead) che confronta i valori delle righe ordinate. Ciò ha eliminato la necessità di self-join che possono anche essere utilizzati per unire intervalli sovrapposti in un singolo intervallo. Invece dell'intervallo, qualsiasi problema che coinvolge due colonne some_star, some_end può utilizzare questa strategia.
Kemin Zhou,

@ErwinBrandstetter Ehi, sto cercando di capire questa query (quella con CTE), ma non riesco a capire a cosa serva (CTE A) max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate? Non può essere giusto COALESCE(upper(range), 'infinity') as enddate? AFAIK max() + over (order by range)tornerà proprio upper(range)qui.
user606521

1
@ user606521: Ciò che si osserva è il caso in cui il limite superiore aumenta in modo continuo quando ordinato per intervallo, il che può essere garantito per alcune distribuzioni di dati e quindi è possibile semplificare come suggerito. Esempio: intervalli di lunghezza fissi. Ma per intervalli di lunghezza arbitraria l'intervallo successivo può avere un limite inferiore maggiore, ma comunque un limite superiore inferiore. Quindi finora abbiamo bisogno del massimo limite superiore di tutte le gamme.
Erwin Brandstetter,

6

Ho pensato a questo:

DO $$                                                                             
DECLARE 
    i date;
    a daterange := 'empty';
    day_as_range daterange;
    extreme_value date := '2100-12-31';
BEGIN
    FOR i IN 
        SELECT DISTINCT 
             generate_series(
                 lower(range), 
                 COALESCE(upper(range) - interval '1 day', extreme_value), 
                 interval '1 day'
             )::date
        FROM rangetest 
        ORDER BY 1
    LOOP
        day_as_range := daterange(i, i, '[]');
        BEGIN
            IF isempty(a)
            THEN a := day_as_range;
            ELSE a = a + day_as_range;
            END IF;
        EXCEPTION WHEN data_exception THEN
            RAISE INFO '%', a;
            a = day_as_range;
        END;
    END LOOP;

    IF upper(a) = extreme_value + interval '1 day'
    THEN a := daterange(lower(a), NULL);
    END IF;

    RAISE INFO '%', a;
END;
$$;

Ha ancora bisogno di un po 'di affinamento, ma l'idea è la seguente:

  1. esplodere gli intervalli a singole date
  2. facendo questo, sostituisci il limite superiore infinito con un valore estremo
  3. basato sull'ordine da (1), inizia a costruire gli intervalli
  4. quando l'unione ( +) fallisce, restituisce l'intervallo già costruito e reinizializza
  5. infine, restituisci il resto: se viene raggiunto il valore estremo predefinito, sostituiscilo con NULL per ottenere un limite superiore infinito

Mi sembra piuttosto costoso correre generate_series()per ogni fila, soprattutto se ci possono essere range aperti ...
Erwin Brandstetter

@ErwinBrandstetter sì, questo è un problema che volevo testare (dopo che il mio primo estremo era 9999-12-31 :). Allo stesso tempo, mi chiedo perché la mia risposta abbia più voti della tua. Questo è probabilmente più facile da capire ... Quindi, futuri elettori: la risposta di Erwin è superiore alla mia! Vota lì!
dezso,

3

Alcuni anni fa ho testato diverse soluzioni (tra cui alcune simili a quelle di @ErwinBrandstetter) per unire periodi sovrapposti su un sistema Teradata e ho trovato il seguente il più efficiente (utilizzando le funzioni analitiche, la versione più recente di Teradata ha funzioni integrate per quel compito).

  1. ordina le righe per data di inizio
  2. trova la data di fine massima di tutte le righe precedenti: maxEnddate
  3. se questa data è inferiore alla data di inizio corrente, hai trovato un gap. Mantieni solo quelle righe più la prima riga all'interno della PARTITION (che è indicata da un NULL) e filtra tutte le altre righe. Ora ottieni la data di inizio per ciascun intervallo e la data di fine dell'intervallo precedente.
  4. Poi si ottiene semplicemente la riga successiva sta maxEnddateutilizzando LEADe il gioco è quasi fatto. Solo per l'ultima riga LEADrestituisce a NULL, per risolvere questo calcolo calcolare la data di fine massima di tutte le righe di una partizione nel passaggio 2 e COALESCEesso.

Perché è stato più veloce? A seconda dei dati effettivi, il passaggio 2 potrebbe ridurre notevolmente il numero di righe, quindi il passaggio successivo deve operare solo su un piccolo sottoinsieme, inoltre rimuove l'aggregazione.

violino

SELECT
   daterange(startdate
            ,COALESCE(LEAD(maxPrevEnddate) -- next row's end date
                      OVER (ORDER BY startdate) 
                     ,maxEnddate)          -- or maximum end date
            ) AS range

FROM
 (
   SELECT
      range
     ,COALESCE(LOWER(range),'-infinity') AS startdate

   -- find the maximum end date of all previous rows
   -- i.e. the END of the previous range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER (ORDER BY range
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS maxPrevEnddate

   -- maximum end date of this partition
   -- only needed for the last range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER () AS maxEnddate
   FROM test
 ) AS dt
WHERE maxPrevEnddate < startdate -- keep the rows where a range start
   OR maxPrevEnddate IS NULL     -- and keep the first row
ORDER BY 1;  

Dato che questo è stato il più veloce su Teradata, non so se sia lo stesso per PostgreSQL, sarebbe bello ottenere alcuni numeri di prestazioni reali.


È sufficiente ordinare solo per l'inizio dell'intervallo? Funziona se hai tre intervalli ciascuno con lo stesso inizio ma con fine variabile?
Salman A

1
Funziona solo con la data di inizio, non è necessario aggiungere la data di fine ordinata in ordine decrescente (si controlla solo il divario, quindi qualunque sia la prima riga per una determinata data corrisponderà)
dnoeth

-1

Per divertimento, ci ho provato. Ho trovato questo per essere il metodo più veloce e pulito per farlo. Per prima cosa definiamo una funzione che si fonde se c'è una sovrapposizione o se i due ingressi sono adiacenti, se non c'è sovrapposizione o adiacenza restituiamo semplicemente il primo daterange. Suggerimento +è un'unione di intervalli nel contesto di intervalli.

CREATE FUNCTION merge_if_adjacent_or_overlaps (d1 daterange, d2 daterange)
RETURNS daterange AS $$
  SELECT
    CASE WHEN d1 && d2 OR d1 -|- d2
    THEN d1 + d2
    ELSE d1
    END;
$$ LANGUAGE sql
IMMUTABLE;

Quindi lo usiamo così,

SELECT DISTINCT ON (lower(cumrange)) cumrange
FROM (
  SELECT merge_if_adjacent_or_overlaps(
    t1.range,
    lag(t1.range) OVER (ORDER BY t1.range)
  ) AS cumrange
  FROM test AS t1
) AS t
ORDER BY lower(cumrange)::date, upper(cumrange)::date DESC NULLS first;

1
La funzione finestra considera solo due valori adiacenti alla volta e manca catene. Prova con ('2015-01-01', '2015-01-03'), ('2015-01-03', '2015-01-05'), ('2015-01-05', '2015-01-06').
Erwin Brandstetter,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.