Dans un article de blog précédent Mes requêtes PostgreSQL préférées et pourquoi elles sont importantes, j'ai visité des requêtes intéressantes qui me sont significatives au fur et à mesure que j'apprends, me développe et évolue dans un rôle de développeur SQL.
L'un d'eux, en particulier, un UPDATE à plusieurs lignes avec une seule expression CASE, a déclenché une conversation intéressante sur Hacker News.
Dans cet article de blog, je souhaite observer des comparaisons entre cette requête particulière et une requête impliquant plusieurs instructions UPDATE uniques. Pour le meilleur ou pour le pire.
Spécifications de la machine/de l'environnement :
- Processeur Intel(R) Core(TM) i5-6200U à 2,30 GHz
- 8 Go de RAM
- 1 To de stockage
- Xubuntu Linux 16.04.3 LTS (Xenial Xerus)
- PostgreSQL 10.4
Remarque :Pour commencer, j'ai créé une table de "transformation" avec toutes les colonnes de type TEXT pour charger les données.
L'exemple d'ensemble de données que j'utilise se trouve sur ce lien ici.
Mais gardez à l'esprit que les données elles-mêmes sont utilisées dans cet exemple car il s'agit d'un ensemble de taille décente avec plusieurs colonnes. Toute « analyse » ou MISES À JOUR/INSERTS dans cet ensemble de données ne reflète pas les opérations GPS/SIG réelles « dans le monde réel » et n'est pas conçue comme telle.
location=# \d data_staging;
Table "public.data_staging"
Column | Type | Collation | Nullable | Default
---------------+---------+-----------+----------+---------
segment_num | text | | |
point_seg_num | text | | |
latitude | text | | |
longitude | text | | |
nad_year_cd | text | | |
proj_code | text | | |
x_cord_loc | text | | |
y_cord_loc | text | | |
last_rev_date | text | | |
version_date | text | | |
asbuilt_flag | text | | |
location=# SELECT COUNT(*) FROM data_staging;
count
--------
546895
(1 row)
Nous avons environ un demi-million de lignes de données dans ce tableau.
Pour cette première comparaison, je vais METTRE À JOUR la colonne proj_code.
Voici une requête exploratoire pour déterminer ses valeurs actuelles :
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
"70"
""
"72"
"71"
"51"
"15"
"16"
(7 rows)
Je vais utiliser trim pour supprimer les guillemets des valeurs et convertir en INT et déterminer le nombre de lignes existantes pour chaque valeur individuelle :
Utilisons un CTE pour cela, puis SELECT à partir de celui-ci :
location=# WITH cleaned_nums AS (
SELECT NULLIF(trim(both '"' FROM proj_code), '') AS p_code FROM data_staging
)
SELECT COUNT(*),
CASE
WHEN p_code::int = 70 THEN '70'
WHEN p_code::int = 72 THEN '72'
WHEN p_code::int = 71 THEN '71'
WHEN p_code::int = 51 THEN '51'
WHEN p_code::int = 15 THEN '15'
WHEN p_code::int = 16 THEN '16'
ELSE '00'
END AS proj_code_num
FROM cleaned_nums
GROUP BY p_code
ORDER BY p_code DESC;
count | proj_code_num
--------+---------------
353087 | 0
139057 | 72
25460 | 71
3254 | 70
1 | 51
12648 | 16
13388 | 15
(7 rows)
Avant d'exécuter ces tests, je vais continuer et MODIFIER la colonne proj_code pour taper INTEGER :
BEGIN;
ALTER TABLE data_staging ALTER COLUMN proj_code SET DATA TYPE INTEGER USING NULLIF(trim(both '"' FROM proj_code), '')::INTEGER;
SAVEPOINT my_save;
COMMIT;
Et nettoyez cette valeur de colonne NULL (qui est représentée par ELSE '00' dans l'expression CASE exploratoire ci-dessus), en la définissant sur un nombre arbitraire, 10, avec cette MISE À JOUR :
UPDATE data_staging
SET proj_code = 10
WHERE proj_code IS NULL;
Désormais, toutes les colonnes proj_code ont une valeur INTEGER.
Continuons et exécutons une seule expression CASE mettant à jour toutes les valeurs de la colonne proj_code et voyons ce que rapporte le timing. Je placerai toutes les commandes dans un fichier source .sql pour faciliter la manipulation.
Voici le contenu du fichier :
BEGIN;
\timing on
UPDATE data_staging
SET proj_code =
(
CASE proj_code
WHEN 72 THEN 7272
WHEN 71 THEN 7171
WHEN 15 THEN 1515
WHEN 51 THEN 5151
WHEN 70 THEN 7070
WHEN 10 THEN 1010
WHEN 16 THEN 1616
END
)
WHERE proj_code IN (72, 71, 15, 51, 70, 10, 16);
SAVEPOINT my_save;
Exécutons ce fichier et vérifions ce que rapporte le timing :
location=# \i /case_insert.sql
BEGIN
Time: 0.265 ms
Timing is on.
UPDATE 546895
Time: 6779.596 ms (00:06.780)
SAVEPOINT
Time: 0.300 ms
Un peu plus d'un demi-million de lignes en plus de 6 secondes.
Voici les modifications reflétées dans le tableau jusqu'à présent :
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7070
1616
1010
7171
1515
7272
5151
(7 rows)
Je vais ANNULER (non illustré) ces modifications afin de pouvoir exécuter des instructions INSERT individuelles pour les tester également.
Vous trouverez ci-dessous les modifications apportées au fichier source .sql pour cette série de comparaisons :
BEGIN;
\timing on
UPDATE data_staging
SET proj_code = 7222
WHERE proj_code = 72;
UPDATE data_staging
SET proj_code = 7171
WHERE proj_code = 71;
UPDATE data_staging
SET proj_code = 1515
WHERE proj_code = 15;
UPDATE data_staging
SET proj_code = 5151
WHERE proj_code = 51;
UPDATE data_staging
SET proj_code = 7070
WHERE proj_code = 70;
UPDATE data_staging
SET proj_code = 1010
WHERE proj_code = 10;
UPDATE data_staging
SET proj_code = 1616
WHERE proj_code = 16;
SAVEPOINT my_save;
Et ces résultats,
location=# \i /case_insert.sql
BEGIN
Time: 0.264 ms
Timing is on.
UPDATE 139057
Time: 795.610 ms
UPDATE 25460
Time: 116.268 ms
UPDATE 13388
Time: 239.007 ms
UPDATE 1
Time: 72.699 ms
UPDATE 3254
Time: 162.199 ms
UPDATE 353087
Time: 1987.857 ms (00:01.988)
UPDATE 12648
Time: 321.223 ms
SAVEPOINT
Time: 0.108 ms
Vérifions les valeurs :
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7222
1616
7070
1010
7171
1515
5151
(7 rows)
Et le timing (Remarque :je vais faire le calcul dans une requête puisque \timing n'a pas signalé de secondes entières cette exécution) :
location=# SELECT round((795.610 + 116.268 + 239.007 + 72.699 + 162.199 + 1987.857 + 321.223) / 1000, 3) AS seconds;
seconds
---------
3.695
(1 row)
Les INSERTS individuels ont pris environ la moitié du temps qu'un CASE unique.
Ce premier test comprenait l'ensemble du tableau, avec toutes les colonnes. Je suis curieux de connaître les différences dans un tableau avec le même nombre de lignes, mais moins de colonnes, d'où la prochaine série de tests.
Je vais créer une table avec 2 colonnes (composées d'un type de données SERIAL pour la PRIMARY KEY et d'un INTEGER pour la colonne proj_code) et déplacer les données :
location=# CREATE TABLE proj_nums(n_id SERIAL PRIMARY KEY, proj_code INTEGER);
CREATE TABLE
location=# INSERT INTO proj_nums(proj_code) SELECT proj_code FROM data_staging;
INSERT 0 546895
(À noter :les commandes SQL du premier ensemble d'opérations sont utilisées avec les modifications appropriées. Je les omet ici pour des raisons de brièveté et d'affichage à l'écran )
Je vais d'abord exécuter l'expression CASE unique :
location=# \i /case_insert.sql
BEGIN
Timing is on.
UPDATE 546895
Time: 4355.332 ms (00:04.355)
SAVEPOINT
Time: 0.137 ms
Et puis les MISES À JOUR individuelles :
location=# \i /case_insert.sql
BEGIN
Time: 0.282 ms
Timing is on.
UPDATE 139057
Time: 1042.133 ms (00:01.042)
UPDATE 25460
Time: 123.337 ms
UPDATE 13388
Time: 212.698 ms
UPDATE 1
Time: 43.107 ms
UPDATE 3254
Time: 52.669 ms
UPDATE 353087
Time: 2787.295 ms (00:02.787)
UPDATE 12648
Time: 99.813 ms
SAVEPOINT
Time: 0.059 ms
location=# SELECT round((1042.133 + 123.337 + 212.698 + 43.107 + 52.669 + 2787.295 + 99.813) / 1000, 3) AS seconds;
seconds
---------
4.361
(1 row)
Le timing est quelque peu égal entre les deux ensembles d'opérations sur la table avec seulement 2 colonnes.
Je dirai que l'utilisation de l'expression CASE est un peu plus facile à taper, mais pas nécessairement le meilleur choix en toutes occasions. Comme pour ce qui a été dit dans certains des commentaires sur le fil Hacker News référencé ci-dessus, cela "dépend simplement" de nombreux facteurs qui peuvent ou non être le choix optimal.
Je me rends compte que ces tests sont au mieux subjectifs. L'un d'eux, sur une table avec 11 colonnes tandis que l'autre n'avait que 2 colonnes, toutes deux d'un type de données numérique.
L'expression CASE pour les mises à jour de plusieurs lignes est toujours l'une de mes requêtes préférées, ne serait-ce que pour la facilité de saisie dans un environnement contrôlé où de nombreuses requêtes UPDATE individuelles sont l'autre alternative.
Cependant, je peux voir maintenant où ce n'est pas toujours le choix optimal alors que je continue à grandir et à apprendre.
Comme le dit ce vieil adage, "Une demi-douzaine dans une main, 6 dans l'autre ."
Une requête préférée supplémentaire - Utilisation de PLpgSQL CURSOR
J'ai commencé à stocker et à suivre toutes mes statistiques d'exercice (randonnée) avec PostgreSQL sur ma machine de développement local. Plusieurs tables sont impliquées, comme pour toute base de données normalisée.
Cependant, à la fin du mois, je souhaite stocker les statistiques de colonnes spécifiques, dans leur propre tableau séparé.
Voici le tableau "mensuel" que j'utiliserai :
fitness=> \d hiking_month_total;
Table "public.hiking_month_total"
Column | Type | Collation | Nullable | Default
-----------------+------------------------+-----------+----------+---------
day_hiked | date | | |
calories_burned | numeric(4,1) | | |
miles | numeric(4,2) | | |
duration | time without time zone | | |
pace | numeric(2,1) | | |
trail_hiked | text | | |
shoes_worn | text | | |
Je vais me concentrer sur les résultats de mai avec cette requête SELECT :
fitness=> SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
fitness-> FROM hiking_stats AS hs
fitness-> INNER JOIN hiking_trail AS ht
fitness-> ON hs.hike_id = ht.th_id
fitness-> INNER JOIN trail_route AS tr
fitness-> ON ht.tr_id = tr.trail_id
fitness-> INNER JOIN shoe_brand AS sb
fitness-> ON hs.shoe_id = sb.shoe_id
fitness-> WHERE extract(month FROM hs.day_walked) = 5
fitness-> ORDER BY hs.day_walked ASC;
Et voici 3 exemples de lignes renvoyées par cette requête :
day_walked | cal_burned | miles_walked | duration | mph | name | name_brand
------------+------------+--------------+----------+-----+------------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
2018-05-03 | 320.8 | 3.38 | 00:58:59 | 3.4 | Sandy Trail-Drive | New Balance Trail Runners-All Terrain
2018-05-04 | 291.3 | 3.01 | 00:53:33 | 3.4 | House-Power Line Route | Keen Koven WP(keen-dry)
(3 rows)
À vrai dire, je peux remplir la table randonnée_mois_total cible en utilisant la requête SELECT ci-dessus dans une instruction INSERT.
Mais où est le plaisir là-dedans ?
Je vais renoncer à l'ennui pour une fonction PLpgSQL avec un CURSEUR à la place.
Je suis venu avec cette fonction pour effectuer l'INSERT avec un CURSEUR :
CREATE OR REPLACE function monthly_total_stats()
RETURNS void
AS $month_stats$
DECLARE
v_day_walked date;
v_cal_burned numeric(4, 1);
v_miles_walked numeric(4, 2);
v_duration time without time zone;
v_mph numeric(2, 1);
v_name text;
v_name_brand text;
v_cur CURSOR for SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
WHERE extract(month FROM hs.day_walked) = 5
ORDER BY hs.day_walked ASC;
BEGIN
OPEN v_cur;
<<get_stats>>
LOOP
FETCH v_cur INTO v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand;
EXIT WHEN NOT FOUND;
INSERT INTO hiking_month_total(day_hiked, calories_burned, miles,
duration, pace, trail_hiked, shoes_worn)
VALUES(v_day_walked, v_cal_burned, v_miles_walked, v_duration, v_mph, v_name, v_name_brand);
END LOOP get_stats;
CLOSE v_cur;
END;
$month_stats$ LANGUAGE PLpgSQL;
Appelons la fonction month_total_stats() pour effectuer l'INSERT :
fitness=> SELECT monthly_total_stats();
monthly_total_stats
---------------------
(1 row)
Puisque la fonction est définie RETURNS void, nous pouvons voir qu'aucune valeur n'est renvoyée à l'appelant.
Pour le moment, je ne suis pas spécifiquement intéressé par les valeurs de retour,
seulement que la fonction effectue l'opération définie, en remplissant la table randonnée_mois_total.
Je vais demander un nombre d'enregistrements dans la table cible, en confirmant qu'elle contient des données :
fitness=> SELECT COUNT(*) FROM hiking_month_total;
count
-------
25
(1 row)
La fonction month_total_stats() fonctionne, mais peut-être qu'un meilleur cas d'utilisation pour un CURSOR est de faire défiler un grand nombre d'enregistrements. Peut-être une table avec environ un demi-million d'enregistrements ?
Ce prochain CURSOR est lié à une requête ciblant la table data_staging de la série de comparaisons de la section ci-dessus :
CREATE OR REPLACE FUNCTION location_curs()
RETURNS refcursor
AS $location$
DECLARE
v_cur refcursor;
BEGIN
OPEN v_cur for SELECT segment_num, latitude, longitude, proj_code, asbuilt_flag FROM data_staging;
RETURN v_cur;
END;
$location$ LANGUAGE PLpgSQL;
Ensuite, pour utiliser ce CURSEUR, opérez au sein d'une TRANSACTION (indiqué dans la documentation ici).
location=# BEGIN;
BEGIN
location=# SELECT location_curs();
location_curs
--------------------
<unnamed portal 1>
(1 row)
Alors que pouvez-vous faire avec ce "
Voici quelques éléments :
Nous pouvons renvoyer la première ligne à partir du CURSEUR en utilisant first ou ABSOLUTE 1 :
location=# FETCH first FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
location=# FETCH ABSOLUTE 1 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 3571" | " 29.0202942600" | " -90.2908612800" | 72 | "Y"
(1 row)
Vous voulez une rangée presque à mi-parcours de l'ensemble de résultats ? (En supposant que nous sachions qu'environ un demi-million de lignes sont liées au CURSEUR.)
Pouvez-vous être aussi "spécifique" avec un CURSEUR ?
Oui.
Nous pouvons positionner et FETCH les valeurs de l'enregistrement à la ligne 234888 (juste un nombre aléatoire que j'ai choisi) :
location=# FETCH ABSOLUTE 234888 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Une fois positionné là, on peut déplacer le CURSEUR 'backward one' :
location=# FETCH BACKWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
Qui est identique à :
location=# FETCH ABSOLUTE 234887 FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)
Ensuite, nous pouvons déplacer le CURSEUR vers l'ABSOLU 234888 avec :
location=# FETCH FORWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)
Astuce pratique :pour repositionner le CURSEUR, utilisez MOVE au lieu de FETCH si vous n'avez pas besoin des valeurs de cette ligne.
Voir ce passage de la documentation :
"MOVE repositionne un curseur sans récupérer aucune donnée. MOVE fonctionne exactement comme la commande FETCH, sauf qu'il ne fait que positionner le curseur et ne renvoie pas de lignes."
Le nom "
Je vais revoir mes données de statistiques de fitness pour écrire une fonction et nommer le CURSEUR, ainsi qu'un cas d'utilisation potentiel dans le "monde réel".
Le CURSEUR ciblera cette table supplémentaire, qui stocke les résultats non limités au mois de mai (essentiellement tout ce que j'ai collecté jusqu'à présent) comme dans l'exemple précédent :
fitness=> CREATE TABLE cp_hiking_total AS SELECT * FROM hiking_month_total WITH NO DATA;
CREATE TABLE AS
Ensuite, remplissez-le avec des données :
fitness=> INSERT INTO cp_hiking_total
SELECT hs.day_walked, hs.cal_burned, hs.miles_walked, hs.duration, hs.mph, tr.name, sb.name_brand
FROM hiking_stats AS hs
INNER JOIN hiking_trail AS ht
ON hs.hike_id = ht.th_id
INNER JOIN trail_route AS tr
ON ht.tr_id = tr.trail_id
INNER JOIN shoe_brand AS sb
ON hs.shoe_id = sb.shoe_id
ORDER BY hs.day_walked ASC;
INSERT 0 51
Maintenant avec la fonction PLpgSQL ci-dessous, CREATE a 'named' CURSOR :
CREATE OR REPLACE FUNCTION stats_cursor(refcursor)
RETURNS refcursor
AS $$
BEGIN
OPEN $1 FOR
SELECT *
FROM cp_hiking_total;
RETURN $1;
END;
$$ LANGUAGE plpgsql;
J'appellerai ce CURSEUR "statistiques" :
fitness=> BEGIN;
BEGIN
fitness=> SELECT stats_cursor('stats');
stats_cursor
--------------
stats
(1 row)
Supposons que je veuille que la '12ème' ligne soit liée au CURSEUR.
Je peux positionner le CURSEUR sur cette ligne, en récupérant ces résultats avec la commande ci-dessous :
fitness=> FETCH ABSOLUTE 12 FROM stats;
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.4 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
Pour les besoins de cet article de blog, imaginez que je sais de première main que la valeur de la colonne de rythme pour cette ligne est incorrecte.
Je me souviens précisément d'avoir été «mort sur mes pieds fatigué» ce jour-là et de n'avoir maintenu qu'un rythme de 3,0 pendant cette randonnée. (Hé ça arrive.)
D'accord, je vais juste METTRE À JOUR la table cp_hiking_total pour refléter ce changement.
Relativement simple sans doute. Ennuyeux…
Que diriez-vous d'utiliser les statistiques CURSEUR à la place ?
fitness=> UPDATE cp_hiking_total
fitness-> SET pace = 3.0
fitness-> WHERE CURRENT OF stats;
UPDATE 1
Pour rendre ce changement permanent, lancez COMMIT :
fitness=> COMMIT;
COMMIT
Interrogeons et voyons cette mise à jour reflétée dans la table cp_hiking_total :
fitness=> SELECT * FROM cp_hiking_total
fitness-> WHERE day_hiked = '2018-05-02';
day_hiked | calories_burned | miles | duration | pace | trail_hiked | shoes_worn
------------+-----------------+-------+----------+------+---------------------+---------------------------------------
2018-05-02 | 311.2 | 3.27 | 00:57:13 | 3.0 | Tree Trail-extended | New Balance Trail Runners-All Terrain
(1 row)
C'est cool ?
Se déplacer dans l'ensemble de résultats du CURSEUR et exécuter une MISE À JOUR si nécessaire.
Assez puissant si vous me demandez. Et pratique.
Quelques "avertissements" et informations de la documentation sur ce type de CURSEUR :
"Il est généralement recommandé d'utiliser FOR UPDATE si le curseur est destiné à être utilisé avec UPDATE ... WHERE CURRENT OF ou DELETE ... WHERE CURRENT OF. L'utilisation de FOR UPDATE empêche les autres sessions de modifier les lignes entre les heures ils sont récupérés et l'heure à laquelle ils sont mis à jour. Sans FOR UPDATE, une commande WHERE CURRENT OF ultérieure n'aura aucun effet si la ligne a été modifiée depuis la création du curseur.
Une autre raison d'utiliser FOR UPDATE est que sans lui, un WHERE CURRENT OF ultérieur pourrait échouer si la requête du curseur ne respecte pas les règles de la norme SQL pour être "simplement modifiable" (en particulier, le curseur doit référencer une seule table et pas utiliser le groupement ou ORDER BY). Les curseurs qui ne sont pas simplement modifiables peuvent fonctionner ou non, selon les détails du choix du plan ; donc dans le pire des cas, une application pourrait fonctionner en test puis échouer en production."
Avec le CURSOR que j'ai utilisé ici, j'ai suivi les règles standard SQL (des passages ci-dessus) dans l'aspect suivant :j'ai référencé une seule table, sans regroupement ni clause ORDER by.
Pourquoi c'est important.
Comme c'est le cas pour de nombreuses opérations, requêtes ou tâches dans PostgreSQL (et SQL en général), il existe généralement plusieurs façons d'accomplir et d'atteindre votre objectif final. C'est l'une des principales raisons pour lesquelles je suis attiré par SQL et que je m'efforce d'en savoir plus.
J'espère que grâce à ce billet de blog de suivi, j'ai expliqué pourquoi la MISE À JOUR à plusieurs lignes avec CASE a été incluse comme l'une de mes requêtes préférées, dans ce premier billet de blog qui l'accompagne. Le simple fait de l'avoir en option vaut la peine pour moi.
De plus, explorer CURSORS, pour parcourir de grands ensembles de résultats. Effectuer des opérations DML, comme UPDATES et/ou DELETES, avec le bon type de CURSOR, n'est que "la cerise sur le gâteau". Je suis impatient de les étudier plus avant pour plus de cas d'utilisation.