V systémech, které podporují vícerozměrná pole, může být serializace (a zrovna tak deserializace) objektů, když ne triviální, tak jednoduchá. Ovšem riskujeme výkonnostní problémy – SQL databáze jsou optimalizované na jiný (normalizovaný) datový model. A to nemluvím o obtížích, které bychom s tímto datovým modelem měli, pokud bychom chtěli navrhovat ad-hoc SQL dotazy. Je to paradox – ORM systémy pole prakticky nepoužívají.
Výjimkou, která potvrzuje pravidlo o ukládání polí v databázích, jsou časové řady. Minimálně v PostgreSQL jsou pole jediným efektivním prostředkem pro ukládání časových řad (jediným, který je dostupný běžnému uživateli). S výjimkou časových řad to opravdu skoro vypadá tak, že pole a relační databáze nejdou k sobě. Opak je pravdou. Podpora polí je zásadní pro SQL uložené procedury. Obecně – v kterémkoliv procedurálním jazyce se bez polí neobejdeme – a jazyky uložených SQL procedur nejsou výjimkou. Pokud podpora polí chybí (např. T-SQL), tak je to na úkor funkčnosti prostředí – chybějící funkčnost se musí všelijak obcházet – což se zákonitě musí projevit na efektivitě vývojáře, čitelnosti kódu i výkonu aplikace.
Implementace polí v PostgreSQL je poměrně unikátní, a to jak v porovnání s ostatními OSS databázemi, tak v porovnání s proprietárními databázovými systémy. S použitím několika málo funkcí můžeme řešit úlohy, které bychom v jiných prostředích řešili pracněji nebo méně efektivně.
Datový typ pole
PostgreSQL podporuje vícerozměrná (tedy i jednorozměrná) pole hodnot skalárních nebo složených typů. V PostgreSQL jsou pole dynamická. Proměnná (sloupec) typu pole se deklaruje pomocí dvojice hranatých závorek „[]“ zapsaných za libovolný skalární typ. Specifikovat lze i velikost pole, nicméně tato hodnota se později ignoruje:
CREATE TABLE test(a varchar[]);
-- starý zapis
INSERT INTO test VALUES('{a,b,c}');
-- novější zápis s konstruktorem pole
INSERT INTO test VALUES(ARRAY['a','b','c']);
--Pozor - horní index pole se lze zapsat, ale nikam se neuloží a stejně tak se nepoužívá
postgres=# CREATE TABLE test(a varchar[2]);
CREATE TABLE
postgres=# INSERT INTO test VALUES(ARRAY['a','b','c']);
INSERT 0 1
postgres=# \d test
Table "public.test"
Column | Type | Modifiers
--------+---------------------+-----------
a | character varying[] |
Pole se indexují od jedné (pokud neurčíme jinak). Prostřednictvím indexů můžeme přistupovat k jednotlivým prvkům pole, případně, pomocí intervalu, k podpoli:
postgres=# SELECT * FROM test;
a
---------
{a,b,c}
(1 row)
postgres=# SELECT a[1], a[2:3] FROM test;
a | a
---+-------
a | {b,c}
(1 row)
Základní funkce pro operace s datovým typem pole
PostgreSQL je, jako ostatně každá SQL databáze, silná v operacích nad množinami (tabulkami). Proto je častým trikem převedení pole na tabulku, provedení určité operace, a převod výsledné množiny zpět na pole. K transformaci pole na tabulku slouží funkce unnest. Agregační funkcí array_agg získáme pole z (pod)množiny hodnot.
Typickým vstupem je seznam hodnot oddělených vybraným znakem (separátorem – oddělovačem). K operacím nad seznamy hodnot uložených v řetězci můžeme použít funkce string_to_array (transformuje řetězec na pole) a array_to_string (generuje řetězec z pole). Pokud na pole převádíme kompletní výsledek dotazu, můžeme použít konstruktor pole z poddotazu – ARRAY(subselect).
postgres=# SELECT unnest(a) FROM test;
unnest
--------
a
b
c
CREATE TABLE jmena(a varchar);
INSERT INTO jmena VALUES('Pavel'),('Petr'),('Jan'),('Zbyšek'),('Bohuslav');
postgres=# SELECT * FROM jmena;
a
----------
Pavel
Petr
Jan
Zbyšek
Bohuslav
(5 rows)
postgres=# SELECT array_agg(a) FROM jmena;
array_agg
----------------------------------
{Pavel,Petr,Jan,Zbyšek,Bohuslav}
(1 row)
postgres=# SELECT array_to_string(array_agg(a),'|') FROM jmena;
array_to_string
--------------------------------
Pavel|Petr|Jan|Zbyšek|Bohuslav
(1 row)
-- seřazený generovaný seznam
postgres=# SELECT array_to_string(ARRAY(SELECT a FROM jmena ORDER BY a),'|');
array_to_string
--------------------------------
Bohuslav|Jan|Pavel|Petr|Zbyšek
(1 row)
CREATE TABLE prefixes(country varchar, p varchar);
INSERT INTO prefixes VALUES('cs','724,777,728');
postgres=# SELECT * FROM prefixes ;
country | p
---------+-------------
cs | 724,777,728
(1 row)
-- normalizace
postgres=# SELECT country, unnest(string_to_array(p,',')) FROM prefixes ;
country | unnest
---------+--------
cs | 724
cs | 777
cs | 728
(3 rows)
-- přepis funkce unnest do SQL
CREATE OR REPLACE FUNCTION myunnest(anyarray)
RETURNS SETOF anyelement AS $
SELECT $1[i] FROM generate_subscripts($1,1) g(i)
$ LANGUAGE sql;
postgres=# SELECT myunnest(ARRAY[3,4,5]);
myunnest
----------
3
4
5
(3 rows)
Výše uvedené funkce jsou dostačující. Představme si, že dostaneme soubor ve formátu xls, který obsahuje registrovaná předčíslí národních telefonních operátorů ve tvaru kód země a seznamu předčíslí (prefixů) oddělených čárkou. Takový soubor skutečně existuje. Je docela dobře možné, že xls-ko je nejpoužívanějším formátem pro přenos databázových dat – bohužel nebo bohudík. Díky xls nemáme problémy s kódováním – kdo pamatuje FoxPro, ví, co mám na mysli. Na druhou stranu – data z dokumentů ve formátu xls lze jen výjimečně použít bez předchozího čištění.
Vlastní převod dat do PostgreSQL je otázkou několika minut. V prvním kroku vyčistíme tabulku od komentářů, nadpisů a případného dalšího balastu a soubor převedeme do formátu csv. Příkaz COPY formát csv podporuje (Pozor – v případě importu csv vytvořeném v Microsoft Excelu s nastaveným českým prostředím je nezbytné použít klauzuli DELIMITER (výchozí oddělovač formátu csv je čárka, která je (v české mutaci Excelu) nahrazena středníkem)).
Pokud bychom měli ze svých dat generovat data v podobném tvaru, pak stačí použít funkce: array_agg a array_to_string. Jako bonus můžeme prefixy seřadit:
postgres=# SELECT country, array_to_string(ARRAY(SELECT unnest(string_to_array(p,','))
ORDER BY 1),',')
FROM prefixes ;
country | array_to_string
---------+-----------------
cs | 724,728,777
(1 row)
případně v kombinaci s příkazem COPY:
postgres=# COPY (SELECT country, array_to_string(ARRAY(SELECT unnest(string_to_array(p,','))
ORDER BY 1
),
',')
FROM prefixes
)
TO stdout CSV;
cs,"724,728,777"
Postup: vstupní seznam hodnot oddělených čárkou byl převeden na pole, dále na tabulku, seřazen prostřednictvím standardní klauzule ORDER BY. Seřazená tabulka se převedla opět na pole a pole se převedlo zpět na text. V Microsoft Excelu nebo v Open Office Calcu můžeme exportované csv-čko převést do formátu xls.
Fantazii se meze nekladou. Složitější SQL dotazy si můžeme zjednodušit zapouzdřením bloků SQL do tzv vlastních SQL funkcí. Příkladem může být funkce unpack_domains. Tato funkce generuje hierarchii doménových jmen. Např. pro kix.fsv.cvut.cz – (kix.fsv.cvut.cz, fsv.cvut.cz, cvut.cz, cz).
CREATE OR REPLACE FUNCTION unpack_domains(text)
RETURNS SETOF text AS $
SELECT array_to_string(a.f[ i : array_upper(a.f,1) ],'.')
FROM generate_subscripts(string_to_array($1,'.'),1,true) g(i),
(SELECT string_to_array($1,'.')) a(f)
$ LANGUAGE sql;
postgres=# SELECT unpack_domains('kix.fsv.cvut.cz');
unpack_domains
-----------------
cz
cvut.cz
fsv.cvut.cz
kix.fsv.cvut.cz
(4 rows)
K čemu je to dobré? Představme si, že máme zpracovat log přístupů obsahující doménové adresy klientů, přičemž chceme vědět, z jakých domén a v jakém počtu se na naše zařízení přistupovalo.
CREATE TABLE log(a varchar);
INSERT INTO log VALUES('kix.fsv.cvut.cz'),('lmc.eu'),('inway.cz'),('gmail.com'),('josef.fsv.cvut.cz');
postgres=# SELECT * FROM log;
a
------------------
kix.fsv.cvut.cz
lmc.eu
inway.cz
gmail.com
josef.fsv.cvut.cz
(4 rows)
postgres=# SELECT count(*), unpack_domains(a) as domain
FROM log
GROUP BY unpack_domains(a)
ORDER BY 1 desc;
count | domain
-------+-------------------
3 | cz
2 | cvut.cz
2 | fsv.cvut.cz
1 | eu
1 | josef.fsv.cvut.cz
1 | gmail.com
1 | inway.cz
1 | com
1 | lmc.eu
1 | kix.fsv.cvut.cz
(10 rows)
S takovým reportem bychom nejspíš neuspěli. Chtělo by to lépe jej uspořádat, a to alespoň podle obráceného názvu domény:
Zde nastane první problém. V PostgreSQL nemáme funkci pro zrcadlové prohození znaků v řetězci. Můžeme si ji však napsat v PL/pgSQL, Perlu, Pythonu, v jazyce C, a nebo v jazyce SQL:
CREATE OR REPLACE FUNCTION rvrs(text)
RETURNS text AS $
SELECT array_to_string(array_agg(a.f[i]),'')
FROM generate_subscripts(regexp_split_to_array($1,''),1, true) g(i),
(SELECT regexp_split_to_array($1,'')) a(f)
$ LANGUAGE sql;
postgres=# select rvrs('ahoj');
rvrs
------
joha
(1 row)
Funkce generate_subscripts generuje indexy pro zadené pole. Pokud je třetí (volitelný) parametr roven hodnotě true, pak jsou indexy generovány v sestupném pořadí. Funkce regexp_split_to_array generuje pole na základě shody s regulárním výrazem. Pokud není regulární výraz zadán, pak prvek pole odpovídá znaku v řetězci.
postgres=# SELECT count(*), unpack_domains(a) as domain
FROM log
GROUP BY unpack_domains(a)
ORDER BY rvrs(unpack_domains(a));
count | domain
-------+-------------------
1 | com
1 | gmail.com
1 | eu
1 | lmc.eu
4 | cz
3 | cvut.cz
3 | fsv.cvut.cz
1 | josef.fsv.cvut.cz
1 | kix.fsv.cvut.cz
1 | inway.cz
(10 rows)
Už je to skoro ono – jen je tu určité riziko – řadí se od konce názvů, nikoliv od začátku. Chtělo by to spíše reverz celého pole:
/* ukázka polymorfní funkce - pro libovolné pole */
CREATE OR REPLACE FUNCTION rvrs(anyarray)
RETURNS anyarray AS $
SELECT ARRAY(SELECT $1[i]
FROM generate_subscripts($1, 1, true) g(i))
$ LANGUAGE sql;
postgres=# SELECT rvrs(string_to_array('kix.fsv.cvut.cz','.'));
rvrs
-------------------
{cz,cvut,fsv,kix}
(1 row)
postgres=# SELECT rvrs('kix.fsv.cvut.cz');
rvrs
-----------------
zc.tuvc.vsf.xik
(1 row)
/* + ukázka přetížení funkce rvrs */
postgres=# SELECT count(*), unpack_domains(a) as domain
FROM log
GROUP BY unpack_domains(a)
ORDER BY (rvrs(string_to_array(unpack_domains(a),'.')))[1],
(rvrs(string_to_array(unpack_domains(a),'.')))[2] nulls first,
(rvrs(string_to_array(unpack_domains(a),'.')))[3] nulls first,
(rvrs(string_to_array(unpack_domains(a),'.')))[4] nulls first;
count | domain
-------+-------------------
1 | com
1 | gmail.com
4 | cz
3 | cvut.cz
3 | fsv.cvut.cz
1 | josef.fsv.cvut.cz
1 | kix.fsv.cvut.cz
1 | inway.cz
1 | eu
1 | lmc.eu
(10 rows)
S tímto pořadím již můžeme být spokojeni.
Pole a dynamické SQL
Při sestavování dynamického SQL příkazu je vhodné na SQL identifikátory aplikovat funkci quote_ident. Tím zabezpečujeme své aplikace proti SQL injektáži a případně i proti syntaktickým chybám, pokud je identifikátor nevhodně navržen (např. obsahuje mezery, tečky a pod.).
postgres=# SELECT quote_ident('aaaaa');
quote_ident
-------------
aaaaa
(1 row)
postgres=# SELECT quote_ident('aaa''aa');
quote_ident
-------------
"aaa'aa"
(1 row)
postgres=# SELECT quote_ident('aaa aa');
quote_ident
-------------
"aaa aa"
(1 row)
Bohužel funkce quote_ident si neporadí se schématy. Viz výsledek volání funkce,
postgres=# SELECT quote_ident('tabulka.schema');
quote_ident
------------------
"tabulka.schema"
(1 row)
který je nepoužitelný. Korektní identifikátor je v tomto případě „tabulka“.„schema“. Řešením, které ovšem není 100% (má problémy s tečkou uvnitř identifikátoru), je transformace do pole, a aplikace funkce quote_ident na každý prvek pole.
CREATE OR REPLACE FUNCTION quote_schema_ident(text)
RETURNS text AS $
SELECT array_to_string(ARRAY(SELECT quote_ident(unnest(string_to_array($1,'.')))),'.')
$ LANGUAGE sql;
postgres=# select quote_schema_ident('hloupy nazev schematu.tabulka');
quote_schema_ident
---------------------------------
"hloupy nazev schematu".tabulka
(1 row)
Přetypováním na typ regclass lze jednoduše ověřit identifikátor tabulky. To je poměrně snadný způsob odhalení pokusů o SQL injektáž. Tím můžeme předejít standardním chybovým hlášením poskytujícím útočníkům další cenné informace:
postgres=# \dt
List of relations
Schema | Name | Type | Owner
--------+------------------+-------+-------
public | jmena | table | pavel
public | log | table | pavel
public | prefixes | table | pavel
public | test | table | pavel
(12 rows)
postgres=# SELECT 'omega'::regclass;
regclass
----------
omega
(1 row)
postgres=# SELECT 'omega a'::regclass;
ERROR: invalid name syntax
LINE 1: SELECT 'omega a'::regclass;
^
postgres=# SELECT 'omegaa'::regclass;
ERROR: relation "omegaa" does not exist
LINE 1: SELECT 'omegaa'::regclass;
^
S ověřováním validity SQL identifikátorů a s použitím klauzule USING mohou být naše dynamické SQL příkazy neprůstřelné.
Pole, tabulka, pole
Seznam funkcí pro práci s poli je poměrně omezený – nicméně potřebné funkce si můžeme jednoduše napsat sami. Základní strategie je převod pole na tabulku, provedení určité množinové operace a převod tabulky zpět na pole. Příklad: Zrušení duplicit v poli. V PostgreSQL neexistuje funkce, která by rušila duplicitní prvky pole. Ovšem příkaz SELECT podporuje klauzuli DISTINCT, která zajistí výpis pouze unikátních záznamů. Jsme na stopě:
CREATE OR REPLACE FUNCTION array_distinct(anyarray) RETURNS anyarray AS $ SELECT ARRAY(SELECT DISTINCT unnest($1)) $ LANGUAGE sql;
Datový typ anyarray představuje libovolné pole. Jedná se o tzv. polymorfní typ. V okamžiku volání funkce se polymorfní typ nahradí skutečným typem, podle typu hodnoty parametru (tak trochu jako templates v C++). Další příklad – spojení dvou polí:
CREATE OR REPLACE FUNCTION array_union(anyarray, anyarray) RETURNS anyarray AS $ SELECT ARRAY(SELECT unnest($1) UNION ALL unnest($2)) $ LANGUAGE sql;
Nativní implementace spojení polí v C bude ještě o něco rychlejší, k ale tomu potřebujeme hodně dobré znalosti PostgreSQL a C. Implementace v SQL je rychlostně v pohodě. Jednak je řádově rychlejší než implementace v PL/pgSQL a hlavně, hrdlem databázových operací je přístup na disk – CPU se fláká. Další příklad – dohledání prvku v poli:
CREATE OR REPLACE FUNCTION indexof(anyarray, anyelement, pos int = NULL)
RETURNS int AS $
SELECT i
FROM generate_subscripts($1,1) g(i)
WHERE $1[i] = $2
AND i >= COALESCE($1, array_lower($1,1))
$ LANGUAGE sql;
postgres=# SELECT indexof(array[1,3,4,2,3,4,1],1,2);
indexof
---------
7
(1 row)
postgres=# SELECT indexof(array[1,3,4,2,3,4,1],1);
indexof
---------
1
(1 row)
postgres=# SELECT indexof(array[1,3,4,2,3,4,1],2);
indexof
---------
4
(1 row)
postgres=# SELECT indexof(array[1,3,4,2,3,4,1],1,2);
indexof
---------
7
(1 row)
Nejčastějším operací je seřazení pole:
CREATE OR REPLACE FUNCTION array_sort(anyarray) RETURNS anyarray AS $ SELECT ARRAY(SELECT unnest($1) ORDER BY 1) $ LANGUAGE sql;
Pole převedeme na jednosloupcovou tabulku, necháme seřadit podle prvního sloupce a výsledek převedeme zpět na pole. Díky tomu, že se použijí interní rutiny pro řazení (quick sort) je funkce array_sort rychlá i pro velmi velká pole (nad 100 000 prvků):
-- samotné generovaní pole o velikosti 100 000 prvků
postgres=# SELECT ARRAY(SELECT random()*10000 FROM generate_series(1,100000));
-------------------------------------------------------------------------------------------
{2729.45704869926,13.0388513207436,2540.07804207504,5272.97182939947, 270.577119663358,4648.89997150...
(1 row)
Time: 339,738 ms
-- sort
postgres=# SELECT array_sort(ARRAY(SELECT random()*10000 FROM generate_series(1,100000)));
------------------------------------------------------------------------------------------
{0.00012572854757309,0.16817357391119,0.260430388152599, 0.391206704080105,0.494923442602158,0.69868....
(1 row)
Time: 560,945 ms
Pole a statistické funkce
Určení běžných statistik jako je průměr, minimum, maximum je v SQL díky vestavěným agregačním funkcím jednoduché. Problematické jsou statistiky založené na pozici v seřazeném souboru dat – kvantily, percentily a například medián. ANSI SQL 2001 obsahuje funkci row_number. Tato funkce se nyní objevuje i v PostgreSQL – konkrétně ve verzi 8.4. Určení mediánu pak není problém (metoda Joe Celka):
CREATE TABLE x(a integer);
INSERT INTO x SELECT (random()*10000)::int FROM generate_series(1,10000);
postgres=# SELECT avg(a)::float
FROM (SELECT a, row_number() OVER (ORDER BY a asc) AS hi,
count(*) OVER () + 1 - row_number() OVER (ORDER BY a) AS lo
FROM x) qs
WHERE hi IN (lo-1,lo,lo+1);
avg
------
4936
(1 row)
Time: 112,469 ms
Ve starších verzích PostgreSQL bylo několik možností:
- použít SELF JOIN alchymii
- použít kurzor
- použít pole
Varianta b bude nejrychlejší, varianta c naopak nejjednodušší a k tomu řádově rychlejší než varianta a. Funkce pro určení mediánu z pole může vypadat následovně:
CREATE OR REPLACE FUNCTION array_median(float[])
RETURNS float AS $
SELECT ((a.v[l/2+1] + a.v[(l+1)/2])/2.0)
FROM (SELECT ARRAY(SELECT unnest($1) ORDER BY 1),
array_upper($1,1) - array_lower($1,1) + 1) a(v,l)
$ LANGUAGE sql;
postgres=# select array_median(ARRAY(SELECT a FROM x));
array_median
---------------
4936
(1 row)
Time: 68,625 ms
Pokud bychom dopředu znali velikost tabulky, pak medián určíme dotazem:
postgres=# SELECT avg(a)::float FROM (SELECT a FROM x ORDER BY 1 OFFSET 5000-1 LIMIT 2) s; avg ------ 4936 (1 row) Time: 22,212 ms
Pole a variadické funkce
Variadické funkce jsou funkce, které nemají pevný počet parametrů. V PostgreSQL je několik takových funkcí – coalesce, greatest, least. Počínaje verzí 8.4 můžeme navrhovat vlastní variadické funkce. A jelikož se variadické parametry předávají funkci jako pole, můžeme uplatnit veškeré výše uvedené postupy. Začnu ukázkou dvou jednoduchých funkcí concat a myleast:
CREATE OR REPLACE FUNCTION concat(VARIADIC str text[])
RETURNS text AS $
SELECT array_to_string($1,'')
$ LANGUAGE sql;
CREATE OR REPLACE FUNCTION concat_ws(separator text, VARIADIC str text[])
RETURNS text AS $
SELECT array_to_string($2,$1)
$ LANGUAGE sql;
postgres=# SELECT concat_ws('.', 'kix','fsv','cvut','cz');
concat_ws
-----------------
kix.fsv.cvut.cz
(1 row)
postgres=# SELECT concat_ws('.','cvut','cz');
concat_ws
-----------
cvut.cz
(1 row)
CREATE OR REPLACE FUNCTION myleast(VARIADIC anyarray)
RETURNS anyelement AS $
SELECT min(v) FROM unnest($1) u(v)
$ LANGUAGE sql;
postgres=# SELECT myleast(1,2,3,-1);
myleast
---------
-1
(1 row)
postgres=# SELECT myleast('A'::text,'B','C');
myleast
---------
A
(1 row)
V MySQL je jedna docela zajímavá funkce field. Vrací pořadové číslo parametru, který se shoduje se zadanou hodnotou. Lze ji použít v klauzuli ORDER BY pro explicitní určení pořadí:
mysql> SELECT FIELD('ej', 'Hej', 'ej', 'Heja', 'hej', 'foo');
-> 2
mysql> SELECT FIELD('fo', 'Hej', 'ej', 'Heja', 'hej', 'foo');
-> 0
CREATE OR REPLACE FUNCTION field(str text, VARIADIC strn text[])
RETURNS int AS $
SELECT i
FROM generate_subscripts($2,1) g(i)
WHERE $2[i] = $1
UNION ALL
SELECT 0
LIMIT 1$ LANGUAGE sql;
Aby byla implementace úplná, je třeba ještě přidat číselnou variantu:
CREATE OR REPLACE FUNCTION field(str numeric, VARIADIC strn numeric[]) RETURNS int AS $ SELECT i FROM generate_subscripts($2,1) g(i) WHERE $2[i] = $1 UNION ALL SELECT 0 LIMIT 1$ LANGUAGE sql;
Spuštěním příkladů z dokumentace MySQL si můžeme ověřit funkčnost naší funkce field:
postgres=# SELECT field('ej', 'Hej', 'ej', 'Heja', 'hej', 'foo');
field
-------
2
(1 row)
postgres=# SELECT field('fo', 'Hej', 'ej', 'Heja', 'hej', 'foo');
field
-------
0
(1 row)
Funkce elt je komplementární k funkci field. Vrací n-tý parametr funkce:
mysql> SELECT ELT(1, 'ej', 'Heja', 'hej', 'foo');
-> 'ej'
mysql> SELECT ELT(4, 'ej', 'Heja', 'hej', 'foo');
-> 'foo'
Implementace této funkce je krásně triviální:
CREATE OR REPLACE FUNCTION elt(n int, VARIADIC strn text[]) RETURNS text AS $ SELECT $2[$1] $ LANGUAGE sql; postgres=# SELECT elt(1, 'ej', 'Heja', 'hej', 'foo'); ----- ej (1 row) postgres=# SELECT elt(4, 'ej', 'Heja', 'hej', 'foo'); elt ----- foo (1 row)
Odkazy
Některé ze zde uvedených příkladů můžeme najít na webu http://www.postgres.cz/…hp/SQL_Triky. Další příklady a další inspiraci najdeme v archivu fragmentů kódu http://wiki.postgresql.org/…ory:Snippets.