sql >> Base de Datos >  >> RDS >> PostgreSQL

Más de mis consultas favoritas de PostgreSQL y por qué también son importantes

En una publicación de blog anterior Mis consultas favoritas de PostgreSQL y por qué son importantes, visité consultas interesantes significativas para mí a medida que aprendo, desarrollo y me convierto en un rol de desarrollador de SQL.

Uno de ellos, en particular, una ACTUALIZACIÓN de varias filas con una sola expresión CASE, provocó una conversación interesante en Hacker News.

En esta publicación de blog, quiero observar las comparaciones entre esa consulta en particular y una que involucra múltiples instrucciones UPDATE únicas. Para bien o para mal.

Especificaciones de la máquina/entorno:

  • CPU Intel(R) Core(TM) i5-6200U a 2,30 GHz
  • 8 GB de RAM
  • Almacenamiento de 1 TB
  • Xubuntu Linux 16.04.3 LTS (Xenial Xerus)
  • PostgreSQL 10.4

Nota:para empezar, creé una tabla de "puesta en escena" con todas las columnas de tipo TEXTO para cargar los datos.

El conjunto de datos de muestra que estoy usando se encuentra en este enlace aquí.

Pero tenga en cuenta que los datos en sí se usan en este ejemplo porque es un conjunto de tamaño decente con varias columnas. Cualquier 'análisis' o ACTUALIZACIÓN/INSERCIÓN de este conjunto de datos no refleja las operaciones GPS/GIS reales del 'mundo real' y no tiene la intención de serlo.

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)

Tenemos alrededor de medio millón de filas de datos en esta tabla.

Para esta primera comparación, ACTUALIZARÉ la columna proj_code.

Aquí hay una consulta exploratoria para determinar sus valores actuales:

location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
"70"
""
"72"
"71"
"51"
"15"
"16"
(7 rows)

Usaré recortar para eliminar las comillas de los valores y convertirlos en un INT y determinar cuántas filas existen para cada valor individual:

Usemos un CTE para eso, luego SELECCIONE de él:

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)

Antes de ejecutar estas pruebas, continuaré y ALTERARÉ la columna proj_code para escribir 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;

Y limpie ese valor de columna NULL (que está representado por ELSE '00' en la expresión CASE exploratoria anterior), estableciéndolo en un número arbitrario, 10, con esta ACTUALIZACIÓN:

UPDATE data_staging
SET proj_code = 10
WHERE proj_code IS NULL;

Ahora todas las columnas proj_code tienen un valor INTEGER.

Avancemos y ejecutemos una sola expresión CASE actualizando todos los valores de la columna proj_code y veamos qué informa el tiempo. Colocaré todos los comandos en un archivo fuente .sql para facilitar su manejo.

Aquí está el contenido del archivo:

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;

Ejecutemos este archivo y verifiquemos lo que informa el tiempo:

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

Poco más de medio millón de filas en más de 6 segundos.

Estos son los cambios reflejados en la tabla hasta el momento:

location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7070
1616
1010
7171
1515
7272
5151
(7 rows)

RETROCEDERÉ (no se muestra) estos cambios para poder ejecutar instrucciones INSERT individuales para probarlas también.

A continuación se reflejan las modificaciones al archivo fuente .sql para esta serie de comparaciones:

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;

Y esos resultados,

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

Comprobemos los valores:

location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7222
1616
7070
1010
7171
1515
5151
(7 rows)

Y el tiempo (Nota:haré los cálculos en una consulta ya que \timing no reportó segundos completos en esta ejecución):

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)

Los INSERT individuales tomaron aproximadamente la mitad del tiempo que el CASO individual.

Esta primera prueba incluía toda la tabla, con todas las columnas. Tengo curiosidad por las diferencias en una tabla con el mismo número de filas, pero menos columnas, de ahí la siguiente serie de pruebas.

Crearé una tabla con 2 columnas (compuesta por un tipo de datos SERIAL para PRIMARY KEY y un INTEGER para la columna proj_code) y pasaré a los datos:

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

(Nota:los comandos SQL del primer conjunto de operaciones se usan con las modificaciones apropiadas. Los estoy omitiendo aquí por razones de brevedad y visualización en pantalla )

Primero ejecutaré la expresión CASE única:

location=# \i /case_insert.sql
BEGIN
Timing is on.
UPDATE 546895
Time: 4355.332 ms (00:04.355)
SAVEPOINT
Time: 0.137 ms

Y luego las ACTUALIZACIONES individuales:

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)

El tiempo es algo parejo entre ambos conjuntos de operaciones en la tabla con solo 2 columnas.

Diré que usar la expresión CASE es un poco más fácil de escribir, pero no necesariamente la mejor opción en todas las ocasiones. Al igual que con lo que se indicó en algunos de los comentarios sobre el hilo de Hacker News mencionado anteriormente, normalmente "solo depende" de muchos factores que pueden o no ser la opción óptima.

Me doy cuenta de que estas pruebas son subjetivas en el mejor de los casos. Uno de ellos, en una tabla con 11 columnas, mientras que el otro tenía solo 2 columnas, ambas de tipo numérico.

La expresión CASE para actualizaciones de múltiples filas sigue siendo una de mis consultas favoritas, aunque solo sea por la facilidad de escribir en un entorno controlado donde muchas consultas UPDATE individuales son la otra alternativa.

Sin embargo, ahora puedo ver dónde no siempre es la opción óptima a medida que sigo creciendo y aprendiendo.

Como dice el viejo refrán, "Media docena en una mano, 6 en la otra ."

Una consulta favorita adicional:uso de PLpgSQL CURSOR

Comencé a almacenar y rastrear todas mis estadísticas de ejercicio (senderismo) con PostgreSQL en mi máquina de desarrollo local. Hay varias tablas involucradas, como con cualquier base de datos normalizada.

Sin embargo, al final del mes, quiero almacenar estadísticas de columnas específicas, en su propia tabla separada.

Aquí está la tabla 'mensual' que usaré:

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

Me concentraré en los resultados de mayo con esta consulta 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;

Y aquí hay 3 filas de muestra devueltas de esa consulta:

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)

A decir verdad, puedo completar la tabla de destino hiking_month_total usando la consulta SELECT anterior en una instrucción INSERT.

Pero, ¿dónde está la diversión en eso?

Renunciaré al aburrimiento por una función PLpgSQL con un CURSOR en su lugar.

Se me ocurrió esta función para realizar el INSERTAR con un CURSOR:

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;

Llamemos a la función month_total_stats() para realizar el INSERTAR:

fitness=> SELECT monthly_total_stats();
monthly_total_stats
---------------------
(1 row)

Dado que la función está definida como DEVOLUCIONES nulas, podemos ver que no se devuelve ningún valor a la persona que llama.

En este momento, no estoy específicamente interesado en ningún valor devuelto,

solo que la función lleva a cabo la operación definida, llenando la tabla hiking_month_total.

Consultaré un recuento de registros en la tabla de destino y confirmaré que tiene datos:

fitness=> SELECT COUNT(*) FROM hiking_month_total;
count
-------
25
(1 row)

La función month_total_stats() funciona, pero quizás un mejor caso de uso para un CURSOR es desplazarse por una gran cantidad de registros. ¿Quizás una tabla con alrededor de medio millón de registros?

El próximo CURSOR está vinculado con una consulta dirigida a la tabla data_staging de la serie de comparaciones en la sección anterior:

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;

Luego, para usar este CURSOR, opere dentro de una TRANSACCIÓN (señalado en la documentación aquí).

location=# BEGIN;
BEGIN
location=# SELECT location_curs();
location_curs 
--------------------
<unnamed portal 1>
(1 row)

Entonces, ¿qué puedes hacer con este ""?

Aquí hay algunas cosas:

Podemos devolver la primera fila del CURSOR usando primero o ABSOLUTO 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)

¿Quiere una fila casi a la mitad del conjunto de resultados? (Suponiendo que sabemos que aproximadamente medio millón de filas están vinculadas al CURSOR).

¿Puedes ser tan 'específico' con un CURSOR?

Sí.

Podemos posicionar y OBTENER los valores para el registro en la fila 234888 (solo un número aleatorio que elegí):

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)

Una vez posicionados allí, podemos mover el CURSOR 'hacia atrás':

location=# FETCH BACKWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag 
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159358200" | " -90.7778242300" | 10 | "Y"
(1 row)

Que es lo mismo que:

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)

Entonces podemos mover el CURSOR de regreso al ABSOLUTO 234888 con:

location=# FETCH FORWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag 
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)

Consejo práctico:para cambiar la posición del CURSOR, use MOVER en lugar de FETCH si no necesita los valores de esa fila.

Ver este pasaje de la documentación:

"MOVE reposiciona un cursor sin recuperar ningún dato. MOVE funciona exactamente como el comando FETCH, excepto que solo coloca el cursor y no devuelve filas".

El nombre "" es genérico y en realidad puede ser 'nombrado' en su lugar.

Revisaré mis datos de estadísticas de fitness para escribir una función y nombrar el CURSOR, junto con un posible caso de uso del 'mundo real'.

El CURSOR apuntará a esta tabla adicional, que almacena resultados no limitados al mes de mayo (básicamente todo lo que he recopilado hasta ahora) como en el ejemplo anterior:

fitness=> CREATE TABLE cp_hiking_total AS SELECT * FROM hiking_month_total WITH NO DATA;
CREATE TABLE AS

Luego rellénelo con datos:

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

Ahora, con la siguiente función PLpgSQL, CREA un CURSOR 'nombrado':

CREATE OR REPLACE FUNCTION stats_cursor(refcursor)
RETURNS refcursor
AS $$
BEGIN
OPEN $1 FOR
SELECT *
FROM cp_hiking_total;
RETURN $1;
END;
$$ LANGUAGE plpgsql;

Llamaré a este CURSOR 'estadísticas':

fitness=> BEGIN;
BEGIN
fitness=> SELECT stats_cursor('stats');
stats_cursor 
--------------
stats
(1 row)

Supongamos que quiero que la fila '12' esté vinculada al CURSOR.

Puedo colocar el CURSOR en esa fila, recuperando esos resultados con el siguiente comando:

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)

Para los propósitos de esta publicación de blog, imagine que sé de primera mano que el valor de la columna de ritmo para esta fila es incorrecto.

Recuerdo específicamente estar 'muerto de cansancio' ese día y solo mantuve un ritmo de 3.0 durante esa caminata. (Oye, sucede.)

Bien, simplemente ACTUALIZARÉ la tabla cp_hiking_total para reflejar ese cambio.

Relativamente sencillo sin duda. Aburrido…

¿Qué tal si usamos el CURSOR de estadísticas en su lugar?

fitness=> UPDATE cp_hiking_total
fitness-> SET pace = 3.0
fitness-> WHERE CURRENT OF stats;
UPDATE 1

Para que este cambio sea permanente, emita COMMIT:

fitness=> COMMIT;
COMMIT

Consultemos y veamos esa ACTUALIZACIÓN reflejada en la tabla 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)

¿Qué tan genial es eso?

Moverse dentro del conjunto de resultados del CURSOR y ejecutar una ACTUALIZACIÓN si es necesario.

Bastante poderoso si me preguntas. Y conveniente.

Algunas 'precauciones' e información de la documentación sobre este tipo de CURSOR:

"Por lo general, se recomienda usar FOR UPDATE si el cursor está diseñado para usarse con UPDATE... DONDE ACTUAL DE o ELIMINAR... DONDE ACTUAL DE. El uso de FOR UPDATE evita que otras sesiones cambien las filas entre el tiempo se recuperan y la hora en que se actualizan. Sin FOR UPDATE, un comando WHERE CURRENT OF posterior no tendrá efecto si la fila se cambió desde que se creó el cursor.

Otra razón para usar FOR UPDATE es que, sin él, un WHERE CURRENT OF subsiguiente podría fallar si la consulta del cursor no cumple con las reglas del estándar SQL para ser "simplemente actualizable" (en particular, el cursor debe hacer referencia a una sola tabla y no utilice agrupación u ORDEN POR). Los cursores que no son simplemente actualizables pueden funcionar o no, según los detalles de la elección del plan; por lo que, en el peor de los casos, una aplicación podría funcionar en las pruebas y luego fallar en la producción".

Con el CURSOR que he usado aquí, he seguido las reglas estándar de SQL (de los pasajes anteriores) en el aspecto de:Hice referencia solo a una tabla, sin agrupar ni ORDER por cláusula.

Por qué es importante.

Al igual que con numerosas operaciones, consultas o tareas en PostgreSQL (y SQL en general), normalmente hay más de una forma de lograr y alcanzar su objetivo final. Cuál es una de las principales razones por las que me atrae SQL y me esfuerzo por aprender más.

Espero que a través de esta publicación de blog de seguimiento, haya proporcionado una idea de por qué la ACTUALIZACIÓN de varias filas con CASE se incluyó como una de mis consultas favoritas, en esa primera publicación de blog adjunta. Solo tenerlo como una opción vale la pena para mí.

Además, explorar CURSORS, para recorrer grandes conjuntos de resultados. Realizar operaciones DML, como ACTUALIZACIONES y/o ELIMINACIONES, con el tipo correcto de CURSOR, es simplemente "la guinda del pastel". Estoy ansioso por estudiarlos más a fondo para más casos de uso.