Doplňkový příklad k DB2, DBM2 - SQL analytické funkce

Martin Kryl (kryl@kiv.zcu.cz, keiras @Discord)

V řešených příkladech jsou použitá otevřená datová sada COVID-19: Celkový (kumulativní) počet osob s prokázanou nákazou dle krajských hygienických stanic včetně laboratoří, počet vyléčených, počet úmrtí a provedených testů (v2) poskytovaná Ministerstvem Zdravotnictví ČR k datu 2022-02-22. 「přehled datových sad」「lokální kopie」

Datový soubor byl nahrán do tabulky kryl.covid_daily v databázi Oracle, s kterou se pracuje na cvičení DB2. Strukturu tabulky lze zjistit příkazem DESC. Kumulativní datové atributy byly při nahrávání odstraněny. K tabulce bylo přidáno oprávnění GRANT SELECT TO public, tedy je čitelná pro každého uživatele.

DESC covid_daily 
;

Name                  Null     Type       
--------------------- -------- ---------- 
DATUM                 NOT NULL DATE       
POCET_NAKAZENYCH               NUMBER(10) 
POCET_VYLECENYCH               NUMBER(10) 
POCET_UMRTI                    NUMBER(10) 
PCR_TESTU                      NUMBER(10) 
AG_TESTU                       NUMBER(10) 

Pro elementární prozkoumání datasetu lze využít agregační funkce, nejčastěji COUNT(), MIN(), MAX(). Agregační funkce se aplikují na všechny záznamy, případně na podmnožiny rozlišené dle atributů v GROUP BY části dotazu, je-li uvedena. Hvězdička v argumentu COUNT(*) je specialita pro spočtení všech záznamů, častěji bývá jako argument použit název atributu nebo výpočet.

SELECT COUNT(*)
FROM covid_daily
;

  COUNT(*)
----------
       757

Pro zjištění extrémních hodnot v konkrétním sloupci tabulky lze použít funkce MAX a MIN s příslušným názvem sloupce. V argumentu lze kromě jednoho atributu použít i výpočet využívající data z více atributů. Pokud nás tedy zajímá maximální hodnota provedených testů bez jejich rozlišení na AG a PCR, lze formulovat požadavek jako MAX(ag_testu + pcr_testu).

Výsledek by byl odlišný, kdyby se sčítala maxima AG a PCR testů, jak je naznačeno v sum2 selectu. Sum1 vyjadřuje maximální denní počet testů, kdežto Sum2 sečte dohromady maxima z různých dnů.

SELECT 
  MAX(ag_testu) AS ag, 
  MAX(pcr_testu) AS pcr,
  MAX(ag_testu + pcr_testu) AS sum1,
  MAX(ag_testu) + MAX(pcr_testu) AS sum2
FROM covid_daily
;

        AG        PCR       SUM1       SUM2
---------- ---------- ---------- ----------
    390651     156558     416331     547209

Pomocí agregačních funkcí je sice možné najít konkrétní extrémní hodnotu, ale již není triviální najít záznam, ve kterém se extrémní hodnota nacházela. Pro řešení takové otázky se nabízí dva přístupy – dohledat zpětně hodnotu z výsledku selectu; místo agregační funkce vhodně použít řazení a omezení počtu záznamů ve výsledku.

K omezení počtu vrácených řádek je v Oracle speciální syntaxe FETCH NEXT x ROWS ONLY [SQL:2008] fungující jako ekvivalent klíčového slova LIMIT x [nestandardní], které se běžně vyskytuje v PostgreSQL, SQLite a jiných nejen relačních databázích. K seřazení výsledku se používá klíčové slovo ORDER BY za nímž následuje seznam atributů, podle kterých řadit. V ukázce je vidět 5 dnů s nejvyšším počtem spotřebovaných AG testů.

SELECT 
  datum, 
  pcr_testu, 
  ag_testu 
FROM covid_daily
ORDER BY ag_testu DESC
FETCH NEXT 5 ROWS ONLY
;

DATUM      PCR_TESTU   AG_TESTU
--------- ---------- ----------
06-APR-21      25680     390651
17-MAY-21      36490     362986
24-MAY-21      30291     335988
10-MAY-21      22225     335511
03-MAY-21      19691     329840

K druhému přístupu je potřeba vhodně použít výsledek poddotazu na maximální hodnotu, která má být následně nalezena a napárována. Pokud poddotaz vrací přesně jednu hodnotu, lze celý subdotaz použít jakou součást výrazu ve WHERE klauzuli. V tomto případě je použit operátor shodnosti.

Pokud poddotaz vrací více než jednu hodnotu (více sloupců, řádků nebo obojí) lze ho používat jako virtuální tabulku a pomocí JOIN ji napárovat na ostatní relevantní tabulky. V tomto případě je párována na původní covid_daily tabulku a to tak, aby se spočtená maximální hodnota nacházela v atributu ag_testu původní tabulky.

Nevýhoda tohoto přístupu oproti řazení a omezení počtu záznamů, je v nejistotě, kolik záznamů bude výsledkem dotazu. Uvažte situaci, kdy místo maxima je hledáno minimum. Takových dnů, kdy se neprovedl žádný AG test, je v datasetu 280. V předchozím případě lze triviálně zvolit, že v případě několika dnů s extrémní hodnotou bude vybrán například ten nejstarší (díky explicitnímu řazení). ORDER BY klauzuli a omezení počtu řádků lze samozřejmě použít i zde, ale pak je zřejmé, že dotaz je zbytečně komplikovaný.

SELECT 
  datum, 
  pcr_testu, 
  ag_testu 
FROM covid_daily
WHERE AG_TESTU = (
    SELECT MAX(ag_testu) FROM covid_daily
    )
;

SELECT 
  datum, 
  pcr_testu, 
  ag_testu 
FROM covid_daily d1 INNER JOIN
  (
  SELECT MAX(ag_testu) AS ag
  FROM covid_daily
  ) d2 ON d1.ag_testu = d2.ag
;

DATUM      PCR_TESTU   AG_TESTU
--------- ---------- ----------
06-APR-21      25680     390651

K použití agregátních funkcí na určité podmnožiny datasetu slouží GROUP BY část příkazu. Za klíčovým slovem následuje posloupnost atributů či výrazů, podle jejiž hodnot budou záznamy rozčleněny do skupin.

V řešeném příkladu bude hledán průměrný počet nově nakažených za celý měsíc, aby byly eliminovány denní výkyvy a byl poskytnut přehled o delších časových úsecích. Oracle má specifickou syntax pro získání měsíce z datetime hodnoty. EXTRACT(MONTH FROM datum) zpracuje hodnotu v atributu datum a vrátí číslo 1-12 příslušného měsíce. Podobně lze pomocí klíčových slov YEAR, DAY, HOUR, MINUTE, SECOND vrátit i jiné složky data.

Do projekce v dotazu je možné při použití GROUP BY umístit pouze výrazy, podle kterých se záznamy seskupují, a výrazy, které umožní z množiny záznamů spočíst právě jednu hodnotu (typicky agregátní funkce). V příkladu je použita funkce AVG() vracející průměr z příslušných hodnot. Funkce ROUND() spočtenou hodnotu zaokrouhlí na zvolený počet desetinných míst pro lepší čitelnost.

Za zmínku stojí speciální výraz ORDER BY 3, kterým je dán pokyn pro seřazení výsledku dle třetího sloupce. Tímto způsobem se tedy lze vyhnout opakováním zdlouhavého výrazu.

SELECT 
  EXTRACT(YEAR FROM datum) AS year,
  EXTRACT(MONTH FROM datum) AS month,
  ROUND(AVG(pocet_nakazenych),0) AS avg_pocet
FROM covid_daily
GROUP BY EXTRACT(YEAR FROM datum), 
  EXTRACT(MONTH FROM datum)
ORDER BY 3 DESC
;

      YEAR      MONTH  AVG_POCET
---------- ---------- ----------
      2022          2      22823
      2022          1      18093
      2021         11      13545
      2021         12       9933
      2021          3       9696
       ...        ...        ...      

Častou úlohou pojící se s časovými řadami bývá určení rozdílu dvou hodnot v následujících obdobích k identifikaci trendu. V SQL lze tohoto docílit v podstatě dvěma způsoby.

První možností je pomocí JOIN spojit datovou tabulku samu se sebou a využít spojující klauzuli k provedení zamýšleného časového posunu. Asi nejčastěji je při spojování k vidění kód a JOIN b ON a.fk_id = b.id pro prosté spojení dle cizího klíče a příslušného primárního klíče v druhé tabulce. Nicméně v spojení je možné použít jednak jiné operátory než shodnost, jednak i složitější výrazy namísto prostého názvu atributu. V příkladu je dosaženo spojení se záznamem přesně jeden den starým pomocí odečtení jednoho dne od datumu.

Alternativou je využití Window funkcí [SQL:2003], což jsou analytické funkce pracující s klíčovým slovem OVER v projekční části dotazu. Za tímto slovem následuje definice pro virtuální seřazení, či seskupení používaného datasetu ve zbytku dotazu. Samotnou použitou funkcí mohou být buď klasické agregační funkce (viz dále) nebo speciální funkce pracující pouze s klíčovým slovem OVER.

V řešeném příkladu lze hodnotu z předcházejícího řádku získat pomocí volání LAG(ag_testu, 1) OVER (ORDER BY datum), které vybere hodnotu ag_testu, která je posunuta o jeden řádek vůči současnému řádku při řazení dle datumu. Za zmínku stojí, že v tomto případě není nutné, aby celý dotaz byl řazen podle datumu. Dokonce je možné, aby řazení celého dotazu bylo jiné, než řazení uvnitř window funkce.

SELECT 
  c1.datum,
  c1.ag_testu AS ag,
  c2.ag_testu AS ag_prev,
  c1.ag_testu - c2.ag_testu AS diff
  
FROM covid_daily c1 LEFT JOIN covid_daily c2 
  ON c1.datum -1 = c2.datum
;   

SELECT 
  datum,
  ag_testu AS ag,
  LAG(ag_testu, 1) OVER 
   (ORDER BY datum) AS ag_prev,
  ag_testu - LAG(ag_testu, 1) OVER 
   (ORDER BY datum) AS diff
  
FROM covid_daily
;

DATUM             AG    AG_PREV       DIFF
--------- ---------- ---------- ----------
      ...        ...        ...        ...
02-NOV-20        188          0        188
03-NOV-20        302        188        114
04-NOV-20        633        302        331
05-NOV-20        839        633        206
06-NOV-20        563        839       -276
      ...        ...        ...        ...

Při práci s daty často bývá problém se zohledněním výkyvů v důsledku krátkých cyklů – např. doprava je hustější v ranní a odpolední špičce, či v tomto případě se o víkendech méně testuje než ve všední dny. Jedna ze strategií k potlačení tohoto je sledované hodnoty zprůměrovat za určité vhodně zvolené období definované jako okolí současné zpracovávané hodnoty.

Vytvoření sedmidenního klouzavého průměru by v SQL bez window funkce bylo poměrně krkolomné. Například by bylo možné spojit tabulku 6x se sebou s různým odpočtem dnů podobně jako v předchozím příkladu, a následně sečíst sledované hodnoty z těchto tabulek a vydělit sedmi.

Použití window funkce pro vyčíslení 7denního průměru počtu nakažených je v ukázce. Oproti předchozímu příkladu je zde použita syntaxe ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING určující, že ve výpočtu mají být zohledněny vždy 3 předcházející, současný a 3 následující záznamy při řazení dle datumu.

Alternativou by bylo nevytvářet okno -3 až +3, ale -6 až současná řádka. To by mělo za výhodu, že i pro poslední záznam by bylo možné stanovit hodnotu klouzavého průměru a nebylo by potřeba čekat další 3 dny. Této variantě by odpovídala syntax BETWEEN 6 PRECEDING AND CURRENT ROW nebo též zkratkou jen jako 6 PRECEDING.

V definici intervalu window funkce lze namísto konkrétní hodnoty počtu řádek uvést klíčové slovo UNBOUNDED znamenající "až do začátku/konce". To lze elegantně využít k výpočtu kumulativního součtu atributu, např. počtu nakažených od začátku evidence. Nevím, jak jinak by šlo v SQL bez tohoto kumulativní součet získat.

K procvičení: vytvořte dotaz, který vypočte, ve kterém dni došlo k překročení milníku 1M potvrzených nákaz.

SELECT 
  datum,
  pocet_nakazenych,
  ROUND(AVG(pocet_nakazenych) OVER 
    (
      ORDER BY datum 
      ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
    ), 1) AS "7D_AVG"
    
FROM covid_daily
ORDER BY datum
;

DATUM     POCET_NAKAZ     7D_AVG
--------- ----------- ----------
      ...           0        ...
29-FEB-20           0         .7
01-MAR-20           3         .9
02-MAR-20           0        1.3
03-MAR-20           2        2.9
04-MAR-20           1        3.9
05-MAR-20           3        4.3
06-MAR-20          11        5.1

Jednou z užitečných funkcí je RANK(), který vrátí pořadí dané řádky při využití příslušného členění a řazení. V předchozím příkladu byla hledána extrémní hodnota počtu testů. S využitím následující techniky by bylo možné řešit i obecnější otázky jako např. v jakých dnech byla extrémní hodnota v jednotlivých letech.

V ukázce je použit blok OVER(PARTITION BY EXTRACT(year FROM datum) ORDER BY pocet_nakazenych DESC). Dochází tedy k sestavení virtuální tabulky (neboli okna) členěné podle roku a řazené sestupně dle počtu nakažených v daný den. RANK(), jak již bylo zmíněno, vrátí hodnotu 1 - 356 vyjadřující, na kolikátém místě se v daný rok tento počet nakažených umístil.

Jelikož není způsob jak výsledek window funkce použít ve filtraci příslušného dotazu, je tento dotaz obalen pomocným SELECT *, jehož jediný úkol je vybrat řádky, jejichž umístěni odpovídalo hodnotě 1, tedy maximu daného roku.

Tato otázka by šla opět řešit pomocí GROUP BY a zpětného napárování výsledku dotazu.

K procvičení: vytvořte alternativní dotaz bez použití window funkcí, který vyřeší tuto otázku. Je možné bez window funkcí najít pro každý rok 5 dnů s největšími přírůstky? Lze bez window funkce najít v každém roce dny s 10. nejvyšším přírůstkem?

SELECT *
FROM(
  
  SELECT 
    datum, 
    pocet_nakazenych,
    RANK() OVER 
      (
        PARTITION BY EXTRACT(year FROM datum) 
        ORDER BY pocet_nakazenych DESC
      ) AS rank
  
  FROM covid_daily 
  
)
WHERE rank = 1
;


DATUM     POCET_NAKAZ       RANK
--------- ----------- ----------
30-DEC-20       17124          1
25-NOV-21       27937          1
01-FEB-22       57235          1

Při tvorbě složitějších analytických dotazů často dochází k tvorbě poddotazů do několika hierarchických úrovní. Takový dotaz může být obtížné přečíst a pochopit, co vlastně koná. Podobně jako v programování lze místo one-lineru mezivýsledek uložit do proměnné a pokračovat později, i SQL má techniku umožňující něco podobného.

Common Table Expressions (CTE) je v podstatě pojmenovaný dočasný result set, s kterým je možné v rámci dotazu pracovat jako s tabulkou. S touto technikou se lze setkat u hierarchických a rekurzivních dotazů, nicméně i pro v tomto ohledu obyčejný dotaz lze CTE uplatnit.

V příkladu je použit dotaz na výpočet plovoucího 7denního průměru. Pro přehlednost byl pomocí WITH avgs AS vytvořen CTE pojmenovaný jako "avgs" s tímto výpočtem. V následném dotazu, který hledá 5 dnů s největším mezidenním přírůstkem, je CTE odkazováno jako FROM avgs a s výsledkem poddotazu je pracováno jako s klasickou tabulkou.

Samotné vyhodnocení nejvyšších přírůstků využívá koncepty představné již dříve. Za zmínku stojí rozšířené volání LAG("7d_avg", 1, 0), které vrátí hodnotu na předchízejícím řádku a pokud takový řádek nebude existovat, vrátí defaultní hodnotu 0. Tímto způsobem se odstraní problém s NULL hodnotami u prvních dnů v datasetu.

Alternativou k CTE by mohlo být založit databázový pohled obsahující poddotaz nebo dočasnou tabulku obsahující výsledek poddotazu. Nevýhodou těchto řešení oproti CTE je vzniknutí nového objektu v databázi a nutnost provést více příkazů (CTE je součástí dotazu).

WITH avgs AS (
SELECT 
  datum,
  pocet_nakazenych,
  ROUND(AVG(pocet_nakazenych) OVER 
  (
    ORDER BY datum 
    ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING 
  ), 0) AS "7d_avg"
  
FROM covid_daily
ORDER BY datum
) 
  
  
SELECT 
  datum, 
  "7d_avg",
  LAG("7d_avg", 1, 0) 
    OVER(ORDER BY datum) AS "7d_avg_prev",
  "7d_avg" - LAG("7d_avg", 1, 0) 
    OVER(ORDER BY datum) AS diff 
FROM avgs
ORDER BY diff DESC
FETCH NEXT 5 ROWS ONLY
;



DATUM         7D_AVG 7D_AVG_PREV       DIFF
--------- ---------- ----------- ----------
23-JAN-22      29935       25838       4097
25-JAN-22      34010       31060       2950
29-JAN-22      37971       35457       2514
15-JAN-22      14534       12230       2304
17-JAN-22      18791       16613       2178

Otázka: kolik bylo v jednotlivých měsících růstových, resp. klesajících dnů. Růstovým dnem se rozumí situace, kdy se 7denní průměr v daném dnu zvýšil oproti stejnému dni v předcházejícím týdnu.

Analýza: z otázky přímo vyplývají dva koncepty, které se v řešení budou vyskytovat. Prvním z nich je kalkulace 7denního klouzavého průměru, což již bylo řešeno dříve pomocí window funkce za použití definice ROWS BETWEEN a obalení příslušnou aggregační funkcí. Druhým konceptem je srovnávání hodnoty oproti hodnotě stejného významu v časově předchozím záznamu. To bylo v textu výše demonstrováno pomocí window funkce LAG, případně vhodným použitím JOIN.

Ze zadání dále lze vyvodit požadavek na označení řádku dvěma různými popisky - růstový, či klesající. V zadání není explicitně řečeno, jak se zachovat u stagnujícího stavu, např. na začátku sledovaného období, kdy denní přírůstky byly nulové. V praxi to znamená, že bude potřeba spočtenou hodnotu rozdílu převést na hodnotu indikující sledovaný stav. K aplikaci této podmínečné logiky lze použít v projekci výraz CASE WHEN ... THEN ....

Za zvážení stojí 2 přístupy - vytvořit jeden stavový atribut nabývající např. texty "růst", "pokles", "stagnace"; či vytvořit 3 atributy (růst, pokles, stagnace) obsahující logickou hodnotu, zda situace v daný den nastala.

Posledním poznatkem je nutnost vytvořit agregace na úrovni kalendářních měsíců počítající dny, kdy nastala jedna ze sledovaných situací. Na mysl by tedy měla přijít agregační funkce COUNT a funkce pro práci s částmi datumů EXTRACT, která bude využita v GROUP BY klauzuli.

Komentář řešení: jelikož je dotaz rozsáhlý a vyžaduje několik mezivýpočtů, volím pro čitelnost vynesení poddotazů jako CTE. Pokud je potřeba v jednom dotazu uvést vícero CTE, klíčové slovo WITH je použito pouze jednou a jednotlivé CTE jsou oddělené čárkou.

Výpočet avgs je shodný jako v předchozím textu. U nastavení okna pro klouzavý průměr volím ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING, tedy každou hodnotu průměruji pomocí 3 časově předcházejících a 3 časově následujících hodnot k vyhlazení týdenních cyklů. Alternativou by bylo uvažovat předcházejících 6 hodnot, ale to ve výsledku ovlivní jen zařazení hraničních hodnot do příslušných měsíců.

Výpočet states je podobný jako hlavní dotaz v předchozím textu. Původní atribut diff není zde v projekci explicitně zmíněn, ale jeho výpočet je použit v CASE WHEN bloku k přepsání kladných hodnot na text +++, záporných na --- a zbylých (nula) na x.

Výpočet by_months by sám o sobě mohl být odpovědí na řešenou otázku, jelikož seskupuje výsledek výpočtu states dle roku-měsíce a počítá výskyty různých stavů. Každý záznam odpovídá kombinaci roku-měsíce a stavu, např. 2021, 1, '+++', 12. I když tento formát není špatný, pro další zpracování se může hodit spíše forma podobné kontingenční tabulky v tabulkovém procesoru, kdy v jednom záznamu by byla jak informace o počtu růstových, tak klesajících dnů.

K transformaci tabulky lze použít Oracle nativní operaci PIVOT [nestandardní]. V samotném příkazu se udává, jaké hodnoty má zavést jako samostatné sloupce výsledku FOR state IN ('+++', '---'), tedy hodnoty růstu a poklesu (nikoliv stagnace) z atributu state. Kromě toho je nutné uvést, jakou hodnotu má do nových sloupců dosadit. Zde MAX(c) je použit kvůli tomu, že hodnoty jsou již předpočítané z předchozí agregace. Zbylé položky projekce, tedy y a m jsou implicitně chápány jako atributy, které PIVOT používá ke grupování výsledných záznamů.

V jiných systémech nemusí existovat ekvivalent PIVOT a je nutné použít jako alternativu CASE nebo FILTER [SQL:2003] na úrovni projekce viz 「alternativy a matice kompatibility」.

WITH avgs AS (
SELECT 
  datum,
  pocet_nakazenych,
  ROUND(AVG(pocet_nakazenych) OVER 
  (
    ORDER BY datum 
    ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING 
  ), 0) AS "7d_avg"
  
FROM covid_daily
ORDER BY datum
),

states AS(
SELECT 
  datum, 
  CASE WHEN "7d_avg" - LAG("7d_avg", 7, 0) OVER 
         (ORDER BY datum) > 0 THEN '+++'
       WHEN "7d_avg" - LAG("7d_avg", 7, 0) OVER 
         (ORDER BY datum) < 0 THEN '---'
       ELSE 'x' END as state

FROM avgs
),

by_months AS(
SELECT 
  EXTRACT(year FROM datum) as y, 
  EXTRACT(month FROM datum) as m,
  state,
  count(*) as c

FROM states
GROUP BY (EXTRACT(year FROM datum), 
  EXTRACT(month FROM datum), state) 
)


SELECT * 
FROM by_months
PIVOT 
(
   MAX(c)
   FOR state IN ('+++', '---')
)

ORDER BY y, m
;



         Y          M      '+++'      '---'
---------- ---------- ---------- ----------
      2021          1         12         19
      2021          2         28           
      2021          3          4         27
      2021          4          1         29
      2021          5                    31
      2021          6          4         26
      2021          7         16         15
      2021          8         22          9
      2021          9         30           
      2021         10         31           
      2021         11         28          2
      2021         12                    31

Otázka: jaká byla nejdelší období růstu, resp. poklesů denních nákaz? Uvažuje se opět rozdíl 7denních průměrů stejného dnu v po sobě následujících týdnech.

Analýza: otázka vychází ze stejného výpočtu jako příklad #1. Lze tedy využít výpočet předcházející seskupení záznamů podle roku-měsíce.

Samotný problém spočívá v určení, kolik předchozích dnů vůči současnému mělo stejný stav, tj. růst, pokles, či stagnace. Zároveň je potřeba najít, kdy momentální období končí. V ideálním světě by bylo možné pomocí syntaxe nastavení velikosti okna window funkce nějakou podmínkou požadovat, aby okno takový interval samo maximalizovalo a následně pak stačilo jen zavolat funkci COUNT. Mám za to, že taková možnost bohužel neexistuje ani v SQL standardu, ani jako nestandardní funkce běžných relačních databází.

Je tedy potřeba přistoupit k řešení trikem. Se stávajícími nástroji je možné identifikovat dny, ve kterých dojde ke změně trendu, a vyfiltrovat je do pomocné tabulky. Následně bude možné pro každý den zlomu najít nejbližší další den zlomu, čímž bude vymezen trendový interval. Se znalostí intervalů je triviální spočíst jejich velikost v počtu dnů a vhodně seřadit.

Komentář řešení: výpočty klouzavých průměrů avgs a trendových stavů v daný den states jsou přejaté z předchozího příkladu.

Výpočet changes slouží k nalezení změny trendu pomocí logické podmínky, že hodnota state na předcházející řádce je odlišná od té, na současném řádku LAG(state, 1) OVER(ORDER BY datum) <> state. Tím, že není uveden ELSE blok, bude většina záznamů obsahovat NULL v tomto sloupci.

Výpočet state_durations nejprve vyfiltruje záznamy, které mají vyplněn odvozený atribut prev z předchozího výpočtu a tedy jsou hraniční při změně trendu. Následně pomocí funkce LEAD nalezne další záznam s vyplněnou hodnotou a spočte jejich časový rozdíl v dnech. Jako konec intervalu datum_to je určen den předcházející nalezené hodnotě. (pozn. místo LEAD by bylo možné použít LAG s zápornou hodnotou -1 v argumentu, případně s obráceným řazením)

Samotný dotaz pouze seřadí předchozí výpočet a vyřadí neukončené intervaly, tedy nejnovější aktivní trend v datech. Zajímavým okem pozorovatelným výstupem je, že se zdá, že může existovat roční cyklus ohledně nejdelších rostoucí a klesající intervalů – dlouhé růsty začínají na konci srpna; dlouhé poklesy začínají v první půlce dubna.

WITH avgs AS ( ... ),
states AS( ... ),

changes AS(
SELECT 
  datum, 
  state,
  CASE WHEN LAG(state, 1) OVER
     (ORDER BY datum) <> state 
     THEN 'change' END as prev  

FROM states
),

state_durations AS(
SELECT 
  datum AS datum_from,
  LEAD(datum, 1) OVER
    (ORDER BY datum) -1 AS datum_to,
  state,
  LEAD(datum, 1) OVER
    (ORDER BY datum) - datum AS days
FROM changes
WHERE prev IS NOT NULL
)

SELECT
  datum_from,
  datum_to,
  state,
  days
FROM state_durations
WHERE days IS NOT NULL
ORDER BY days DESC
;


DATUM_FROM DATUM_TO   STA   DAYS
---------- ---------  --- ------
23-AUG-21  28-NOV-21  +++     98
11-APR-21  26-JUN-21  –––     77
18-AUG-20  28-OCT-20  +++     72
04-APR-20  12-MAY-20  –––     39
04-DEC-20  09-JAN-21  +++     37
29-OCT-20  03-DEC-20  –––     36
05-MAR-21  09-APR-21  –––     36
29-FEB-20  03-APR-20  +++     35
29-JAN-21  04-MAR-21  +++     35
29-NOV-21  31-DEC-21  –––     33
01-JAN-22  02-FEB-22  +++     33