PostgreSQL
 sql >> Base de Dados >  >> RDS >> PostgreSQL

Mais das minhas consultas favoritas do PostgreSQL - e por que elas também são importantes


Em uma postagem anterior do blog Minhas consultas favoritas do PostgreSQL e por que elas são importantes, visitei consultas interessantes significativas para mim à medida que aprendo, desenvolvo e cresço em uma função de desenvolvedor SQL.

Um deles, em particular, um UPDATE de várias linhas com uma única expressão CASE, desencadeou uma conversa interessante no Hacker News.

Nesta postagem do blog, quero observar comparações entre essa consulta específica e uma que envolve várias instruções UPDATE únicas. Para o bem ou para o mal.

Especificações da máquina/ambiente:
  • CPU Intel(R) Core(TM) i5-6200U @ 2,30 GHz
  • 8 GB de RAM
  • 1 TB de armazenamento
  • Xubuntu Linux 16.04.3 LTS (Xenial Xerus)
  • PostgreSQL 10.4

Observação:para começar, criei uma tabela de 'staging' com todas as colunas do tipo TEXT para carregar os dados.

O conjunto de dados de exemplo que estou usando pode ser encontrado neste link aqui.

Mas lembre-se de que os dados em si são usados ​​neste exemplo porque são um conjunto de tamanho decente com várias colunas. Qualquer 'análise' ou ATUALIZAÇÕES/INSERÇÕES para este conjunto de dados não reflete as operações reais de GPS/GIS do 'mundo real' e não se destina a tal.
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)

Temos cerca de meio milhão de linhas de dados nesta tabela.

Para esta primeira comparação, atualizarei a coluna proj_code.

Aqui está uma consulta exploratória para determinar seus valores atuais:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
"70"
""
"72"
"71"
"51"
"15"
"16"
(7 rows)

Usarei trim para remover aspas dos valores e converter para um INT e determinar quantas linhas existem para cada valor individual:

Vamos usar um CTE para isso, então SELECT dele:
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 executar esses testes, vou em frente e altero a coluna proj_code para digitar 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;

E limpe esse valor de coluna NULL (que é representado pelo ELSE '00' na expressão CASE exploratória acima), configurando-o para um número arbitrário, 10, com este UPDATE:
UPDATE data_staging
SET proj_code = 10
WHERE proj_code IS NULL;

Agora todas as colunas proj_code têm um valor INTEGER.

Vamos executar uma única expressão CASE atualizando todos os valores da coluna proj_code e ver o que o tempo relata. Colocarei todos os comandos em um arquivo de origem .sql para facilitar o manuseio.

Aqui está o conteúdo do arquivo:
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;

Vamos executar este arquivo e verificar o que o tempo informa:
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

Pouco mais de meio milhão de linhas em mais de 6 segundos.

Aqui estão as mudanças refletidas na tabela até agora:
location=# SELECT DISTINCT proj_code FROM data_staging;
proj_code
-----------
7070
1616
1010
7171
1515
7272
5151
(7 rows)

Vou ROLLBACK (não mostrado) essas alterações para que eu possa executar instruções INSERT individuais para testá-las também.

Abaixo reflete as modificações no arquivo de origem .sql para esta série de comparações:
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;

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

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

E o tempo (Nota:vou fazer as contas em uma consulta, pois \timing não relatou segundos inteiros nesta execução):
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)

Os INSERT's individuais levaram cerca de metade do tempo que o CASE único.

Este primeiro teste incluiu a tabela inteira, com todas as colunas. Estou curioso sobre as diferenças em uma tabela com o mesmo número de linhas, mas menos colunas, daí a próxima série de testes.

Vou criar uma tabela com 2 colunas (composta por um tipo de dados SERIAL para a PRIMARY KEY e um INTEGER para a coluna proj_code) e passar por cima dos dados:
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

(Observação:os comandos SQL do primeiro conjunto de operações são usados ​​com as modificações apropriadas. Estou omitindo-os aqui para brevidade e exibição na tela )

Vou executar a expressão CASE única primeiro:
location=# \i /case_insert.sql
BEGIN
Timing is on.
UPDATE 546895
Time: 4355.332 ms (00:04.355)
SAVEPOINT
Time: 0.137 ms

E então as ATUALIZAÇÕES individuais:
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)

O tempo é um pouco uniforme entre os dois conjuntos de operações na mesa com apenas 2 colunas.

Direi que usar a expressão CASE é um pouco mais fácil de digitar, mas não necessariamente a melhor escolha em todas as ocasiões. Como com o que foi afirmado em alguns dos comentários no tópico Hacker News mencionado acima, normalmente "só depende" de muitos fatores que podem ou não ser a escolha ideal.

Eu percebo que esses testes são subjetivos na melhor das hipóteses. Um deles, em uma tabela com 11 colunas, enquanto o outro tinha apenas 2 colunas, ambas do tipo de dados numéricos.

A expressão CASE para atualizações de várias linhas ainda é uma das minhas consultas favoritas, mesmo que seja apenas pela facilidade de digitação em um ambiente controlado onde muitas consultas UPDATE individuais são a outra alternativa.

No entanto, posso ver agora onde nem sempre é a escolha ideal à medida que continuo a crescer e aprender.

Como diz aquele velho ditado, "Meia dúzia em uma mão, 6 na outra ."

Uma consulta favorita adicional - usando PLpgSQL CURSOR's


Comecei a armazenar e rastrear todas as estatísticas do meu exercício (caminhadas em trilhas) com o PostgreSQL na minha máquina de desenvolvimento local. Existem várias tabelas envolvidas, como em qualquer banco de dados normalizado.

No entanto, no final dos meses, quero armazenar estatísticas de colunas específicas, em sua própria tabela separada.

Aqui está a tabela 'mensal' que vou 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                   |           |          |

Vou me concentrar nos resultados de maio com 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;

E aqui estão 3 linhas de amostra retornadas dessa 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)

Verdade seja dita, posso preencher a tabela de destino hike_month_total usando a consulta SELECT acima em uma instrução INSERT.

Mas onde está a graça nisso?

Vou renunciar ao tédio para uma função PLpgSQL com um CURSOR.

Eu criei esta função para realizar o INSERT com um 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;

Vamos chamar a função month_total_stats() para realizar o INSERT:
fitness=> SELECT monthly_total_stats();
monthly_total_stats
---------------------
(1 row)

Como a função é definida como RETURNS void, podemos ver que nenhum valor é retornado ao chamador.

Neste momento, não estou especificamente interessado em quaisquer valores de retorno,

apenas que a função realiza a operação definida, preenchendo a tabela hiking_month_total.

Vou consultar uma contagem de registros na tabela de destino, confirmando se ela tem dados:
fitness=> SELECT COUNT(*) FROM hiking_month_total;
count
-------
25
(1 row)

A função month_total_stats() funciona, mas talvez um caso de uso melhor para um CURSOR seja rolar por um grande número de registros. Talvez uma tabela com cerca de meio milhão de registros?

Este próximo CURSOR está vinculado a uma consulta direcionada à tabela data_staging da série de comparações na seção acima:
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;

Então, para usar este CURSOR, opere dentro de uma TRANSAÇÃO (apontada na documentação aqui).
location=# BEGIN;
BEGIN
location=# SELECT location_curs();
location_curs 
--------------------
<unnamed portal 1>
(1 row)

Então, o que você pode fazer com este ""?

Aqui estão apenas algumas coisas:

Podemos retornar a primeira linha do CURSOR usando 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)

Quer uma linha quase na metade do conjunto de resultados? (Supondo que sabemos que cerca de meio milhão de linhas estão vinculadas ao CURSOR.)

Você pode ser tão 'específico' com um CURSOR?

Sim.

Podemos posicionar e FETCH os valores para o registro na linha 234888 (apenas um número aleatório que escolhi):
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)

Uma vez posicionado lá, podemos mover o CURSOR 'para trá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 é o mesmo 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)

Então podemos mover o CURSOR de volta para o ABSOLUTE 234888 com:
location=# FETCH FORWARD FROM "<unnamed portal 1>";
segment_num | latitude | longitude | proj_code | asbuilt_flag 
-------------+------------------+-------------------+-----------+--------------
" 11261" | " 28.1159541400" | " -90.7778003500" | 10 | "Y"
(1 row)

Dica prática:para reposicionar o CURSOR, use MOVE em vez de FETCH se você não precisar dos valores dessa linha.

Veja esta passagem da documentação:

"MOVE reposiciona um cursor sem recuperar nenhum dado. MOVE funciona exatamente como o comando FETCH, exceto que apenas posiciona o cursor e não retorna linhas."

O nome "" é genérico e pode, na verdade, ser 'nomeado'.

Revisitarei meus dados de estatísticas de condicionamento físico para escrever uma função e nomear o CURSOR, juntamente com um possível caso de uso do 'mundo real'.

O CURSOR terá como alvo esta tabela adicional, que armazena resultados não limitados ao mês de maio (basicamente tudo o que coletei até agora) como no exemplo anterior:
fitness=> CREATE TABLE cp_hiking_total AS SELECT * FROM hiking_month_total WITH NO DATA;
CREATE TABLE AS

Em seguida, preencha-o com os dados:
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

Agora com a função PLpgSQL abaixo, CRIE um CURSOR 'nomeado':
CREATE OR REPLACE FUNCTION stats_cursor(refcursor)
RETURNS refcursor
AS $$
BEGIN
OPEN $1 FOR
SELECT *
FROM cp_hiking_total;
RETURN $1;
END;
$$ LANGUAGE plpgsql;

Vou chamar esse CURSOR de 'stats':
fitness=> BEGIN;
BEGIN
fitness=> SELECT stats_cursor('stats');
stats_cursor 
--------------
stats
(1 row)

Suponha que eu queira a linha '12' vinculada ao CURSOR.

Posso posicionar o CURSOR nessa linha, recuperando esses resultados com o comando abaixo:
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 os propósitos desta postagem no blog, imagine que eu saiba em primeira mão que o valor da coluna de ritmo para esta linha está incorreto.

Lembro-me especificamente de estar 'morto de cansaço' naquele dia e só mantive um ritmo de 3,0 durante essa caminhada. (Ei, acontece.)

Ok, vou ATUALIZAR a tabela cp_hiking_total para refletir essa mudança.

Relativamente simples, sem dúvida. Entediante…

Que tal com o CURSOR de estatísticas em vez disso?
fitness=> UPDATE cp_hiking_total
fitness-> SET pace = 3.0
fitness-> WHERE CURRENT OF stats;
UPDATE 1

Para tornar essa alteração permanente, emita COMMIT:
fitness=> COMMIT;
COMMIT

Vamos consultar e ver esse UPDATE refletido na tabela 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ão legal é isso?

Movendo-se dentro do conjunto de resultados do CURSOR, e execute um UPDATE se necessário.

Bastante poderoso se você me perguntar. E conveniente.

Alguns ‘cuidados’ e informações da documentação sobre este tipo de CURSOR:

"Geralmente é recomendado usar FOR UPDATE se o cursor for usado com UPDATE ... WHERE CURRENT OF ou DELETE ... WHERE CURRENT OF. Usar FOR UPDATE evita que outras sessões alterem as linhas entre o tempo eles são buscados e a hora em que são atualizados. Sem FOR UPDATE, um comando WHERE CURRENT OF subsequente não terá efeito se a linha foi alterada desde que o cursor foi criado.

Outra razão para usar FOR UPDATE é que sem ele, um WHERE CURRENT OF subsequente pode falhar se a consulta do cursor não atender às regras do padrão SQL por ser “simplesmente atualizável” (em particular, o cursor deve referenciar apenas uma tabela e não use agrupamento ou ORDER BY). Cursores que não são simplesmente atualizáveis ​​podem funcionar ou não, dependendo dos detalhes da escolha do plano; então, na pior das hipóteses, um aplicativo pode funcionar no teste e depois falhar na produção."

Com o CURSOR que usei aqui, segui as regras padrão do SQL (das passagens acima) no aspecto de:Referenciei apenas uma tabela, sem agrupamento ou cláusula ORDER by.

Por que isso importa.

Como acontece com várias operações, consultas ou tarefas no PostgreSQL (e no SQL em geral), normalmente há mais de uma maneira de realizar e alcançar seu objetivo final. Essa é uma das principais razões pelas quais sou atraído pelo SQL e me esforço para aprender mais.

Espero que, por meio desta postagem de blog de acompanhamento, tenha fornecido algumas dicas sobre por que o UPDATE de várias linhas com CASE foi incluído como uma das minhas consultas favoritas, nessa primeira postagem de blog que a acompanha. Apenas tê-lo como uma opção vale a pena para mim.

Além disso, explorando CURSORS, para percorrer grandes conjuntos de resultados. Realizar operações DML, como UPDATES e/ou DELETES, com o tipo correto de CURSOR, é apenas 'cereja no bolo'. Estou ansioso para estudá-los ainda mais para mais casos de uso.