it-swarm.com.de

Kombinieren Sie separate Bereiche zu größtmöglichen zusammenhängenden Bereichen

Ich versuche, mehrere Datumsbereiche zu kombinieren (meine Last beträgt maximal 500, in den meisten Fällen 10), die sich möglicherweise mit den größtmöglichen zusammenhängenden Datumsbereichen überschneiden oder nicht. Zum Beispiel:

Daten :

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'));

Tabelle sieht aus wie:

 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)

Gewünschte Ergebnisse :

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

Visuelle Darstellung :

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

Annahmen/Klarstellungen

  1. Es ist nicht erforderlich, zwischen infinity und offener Obergrenze (upper(range) IS NULL) zu unterscheiden. (Sie können es so oder so haben, aber es ist einfacher so.)

  2. Da date ein diskreter Typ ist, haben alle Bereiche den Standardwert [) Grenzen. Pro Dokumentation :

    Die integrierten Bereichstypen int4range, int8range und daterange verwenden alle eine kanonische Form, die die Untergrenze einschließt und die Obergrenze ausschließt; das ist, [).

    Für andere Typen (wie tsrange!) Würde ich dasselbe nach Möglichkeit erzwingen:

Lösung mit reinem SQL

Mit CTEs zur Klarheit:

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;

Oder , dasselbe mit Unterabfragen, schneller, aber weniger einfach zu lesen:

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;

Oder mit einer Unterabfrageebene weniger, aber umgedrehter Sortierreihenfolge:

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;
  • Sortieren Sie das Fenster im zweiten Schritt mit ORDER BY range DESC NULLS LAST (mit NULLS LAST) um perfekt umgekehrte Sortierreihenfolge zu erhalten. Dies sollte billiger sein (einfacher zu erstellen, entspricht der Sortierreihenfolge des vorgeschlagenen Index perfekt) und genau für Eckfälle mit rank IS NULL.

Erklären

a: Berechnen Sie beim Bestellen nach range das laufende Maximum der Obergrenze (enddate) mit einer Fensterfunktion.
Ersetzen Sie NULL-Grenzen (unbegrenzt) durch +/- infinity, um dies zu vereinfachen (keine speziellen NULL-Fälle).

b: In derselben Sortierreihenfolge, wenn das vorherige enddate früher als startdate we ist eine Lücke haben und einen neuen Bereich starten (step).
Denken Sie daran, dass die Obergrenze immer ausgeschlossen ist.

c: Bilden Sie Gruppen (grp), indem Sie die Schritte mit einer anderen Fensterfunktion zählen.

Im äußeren SELECT Build reicht der Bereich von der unteren bis zur oberen Grenze in jeder Gruppe. Voilá.
Eng verwandte Antwort auf SO mit mehr Erklärung:

Verfahrenslösung mit plpgsql

Funktioniert für jeden Tabellen-/Spaltennamen, jedoch nur für den Typ daterange.
Prozedurale Lösungen mit Schleifen sind normalerweise langsamer , aber in diesem speziellen Fall erwarte ich, dass die Funktion wesentlich ist schneller , da nur ein einzelner sequentieller Scan benötigt wird :

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;

Anruf:

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

Die Logik ähnelt den SQL-Lösungen, aber wir können mit einem einzigen Durchgang auskommen.

SQL Fiddle.

Verbunden:

Die übliche Übung zum Behandeln von Benutzereingaben in dynamischem SQL:

Index

Für jede dieser Lösungen wäre ein einfacher (Standard-) Btree-Index für range für die Leistung in großen Tabellen von entscheidender Bedeutung:

CREATE INDEX foo on test (range);

Ein btree-Index ist für Bereichstypen von begrenztem Nutzen , aber wir können vorsortierte Daten und möglicherweise sogar einen Nur-Index-Scan erhalten.

23

Ich habe mir Folgendes ausgedacht:

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

Muss noch etwas geschliffen werden, aber die Idee ist folgende:

  1. explodieren Sie die Bereiche auf einzelne Daten
  2. ersetzen Sie dabei die unendliche Obergrenze durch einen Extremwert
  3. beginnen Sie basierend auf der Reihenfolge von (1) mit dem Aufbau der Bereiche
  4. wenn die Gewerkschaft (+) schlägt fehl, gibt den bereits erstellten Bereich zurück und initialisiert neu
  5. geben Sie zum Schluss den Rest zurück. Wenn der vordefinierte Extremwert erreicht ist, ersetzen Sie ihn durch NULL, um eine unendliche Obergrenze zu erhalten
6
dezso

Vor einigen Jahren habe ich verschiedene Lösungen (unter anderem ähnliche wie die von @ErwinBrandstetter) zum Zusammenführen überlappender Zeiträume auf einem Teradata-System getestet und festgestellt, dass die folgende die effizienteste ist (mithilfe von Analysefunktionen hat eine neuere Version von Teradata integrierte Funktionen für diese Aufgabe).

  1. sortieren Sie die Zeilen nach Startdatum
  2. finden Sie das maximale Enddatum aller vorherigen Zeilen: maxEnddate
  3. wenn dieses Datum unter dem aktuellen Startdatum liegt, haben Sie eine Lücke gefunden. Behalten Sie nur diese Zeilen plus die erste Zeile in der PARTITION (die durch NULL gekennzeichnet ist) und filtern Sie alle anderen Zeilen. Jetzt erhalten Sie das Startdatum für jeden Bereich und das Enddatum des vorherigen Bereichs.
  4. Dann erhalten Sie einfach die maxEnddate der nächsten Zeile mit LEAD und Sie sind fast fertig. Nur für die letzte Zeile LEAD wird ein NULL zurückgegeben. Um dies zu lösen, berechnen Sie das maximale Enddatum aller Zeilen einer Partition in Schritt 2 und COALESCE it.

Warum war es schneller? Abhängig von den tatsächlichen Daten kann Schritt 2 die Anzahl der Zeilen erheblich reduzieren, sodass der nächste Schritt nur für eine kleine Teilmenge ausgeführt werden muss. Außerdem wird die Aggregation entfernt.

Geige

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;  

Da dies bei Teradata am schnellsten war, weiß ich nicht, ob es für PostgreSQL dasselbe ist. Es wäre schön, einige tatsächliche Leistungszahlen zu erhalten.

3
dnoeth