SQL HowTo: construir cadenas usando funciones de ventana

A veces, al analizar datos, surge el problema de distinguir las "cadenas" en una muestra , es decir, secuencias ordenadas de registros, para cada una de las cuales se cumple una determinada condición .

Esto puede ser una condición en los datos del registro en sí o una expresión compleja en relación con uno o más registros anteriores, por ejemplo, la duración del intervalo entre muestras de tiempo de cierre.



Las soluciones tradicionales proporcionan diferentes opciones para "autounirse", cuando la muestra está conectada a sí misma, o el uso de ciertos hechos "fuera de los datos" - por ejemplo, que los registros deben tener un paso estrictamente definido (N + 1, "para cada día", ... )

La primera opción a menudo conduce a la complejidad cuadrática del algoritmo en términos del número de registros, lo cual es inaceptable en muestras grandes , y la segunda puede fácilmente "desmoronarse" si de repente no hay muestras en los datos de origen.

Pero esta tarea nos ayudará a resolver eficazmente las funciones de ventana en PostgreSQL.

Tarea: contar el dinero de otras personas


Considere el caso más simple de una cadena: cuando la condición de continuidad está determinada por los datos del registro en sí.

Todas las operaciones adicionales no tienen que hacerse por separado. Pero en aras de la claridad del algoritmo, lo dividiré en pasos sucesivos y, al final, mostraré qué y cómo optimizarlo .

Imaginemos que tenemos un pequeño banco que mantiene los saldos de las cuentas de los clientes en la tabla. Tan pronto como se produce la transacción de recibo y gasto, esta fecha también fija el monto total de la factura al final del día.
Después de un largo feriado de Año Nuevo, el banco decidió recompensar a sus clientes, y cada persona que abrió una cuenta este año acumula además + 1% del saldo diario promedio durante el período continuo más largo cuando la cuenta no se "restableció" .
Aquí está nuestro criterio para la continuidad de la "cadena". Bueno, el orden de los datos estará determinado por las fechas de los saldos.

Nos trajeron un CSV de este tipo y nos pidieron que calculen rápidamente quién y cuánto debería obtener esa generosidad del banco:

date;client;balance 01.01.2020;;150 01.01.2020;;100 02.01.2020;;100 02.01.2020;;150 03.01.2020;;200 05.01.2020;;0 06.01.2020;;50 08.01.2020;;0 08.01.2020;;200 09.01.2020;;0 09.01.2020;;0 10.01.2020;;5 

Solo tenga en cuenta algunos hechos que se notan en estos datos:

  • 01.01 era feriado y el banco no funcionaba. Por lo tanto, ninguno de los clientes tiene registros de cambios en el saldo ese día, pero tienen dinero en sus cuentas. Es decir, los algoritmos de "fuerza bruta" que iteran por día no funcionarán normalmente.
  • 04.01 Alice no realizó ninguna operación, por lo que no hay entrada. Pero antes del 05.01, el monto en su cuenta no era cero; esto deberá tenerse en cuenta en el análisis.
  • Llevamos a cabo el análisis el 01.01-12.01, pero el saldo de la cuenta de Alice al final de este período no es cero. También tenemos en cuenta la necesidad de limitar el período.

CSV a la mesa


La mejor manera de importar desde CSV es usar el operador COPY . Pero intentaremos hacer esto mediante expresiones regulares para calentar:

 CREATE TEMPORARY TABLE tbl AS SELECT to_date(prt[1], 'DD.MM.YYYY') dt , prt[2] client , prt[3]::numeric(32,2) balance FROM ( SELECT regexp_split_to_array(str, ';') prt FROM ( SELECT regexp_split_to_table( $$ date;client;balance 01.01.2020;;150 01.01.2020;;100 02.01.2020;;100 02.01.2020;;150 03.01.2020;;200 05.01.2020;;0 06.01.2020;;50 08.01.2020;;0 08.01.2020;;200 09.01.2020;;0 09.01.2020;;0 10.01.2020;;5 $$ , E'\\n') str ) T WHERE str <> '' OFFSET 1 ) T; 

Este es un método "deshonesto" en el sentido de que no se digiere correctamente, por ejemplo, protegiendo un separador en el cuerpo de un campo. Pero para la mayoría de las aplicaciones simples: adecuado.

Paso 1: corrige la condición de la aplicación


En nuestro caso, la condición de continuidad de la cadena es un equilibrio distinto de cero. Lo mostramos como un campo separado, para mayor claridad, ordenando cronológicamente por cliente:

 SELECT * , balance > 0 cond FROM tbl ORDER BY client, dt; 

 dt | client | balance | cond ------------------------------------ 2020-01-01 |  | 150.00 | t 2020-01-02 |  | 100.00 | t 2020-01-03 |  | 200.00 | t 2020-01-05 |  | 0.00 | f 2020-01-06 |  | 50.00 | t 2020-01-08 |  | 0.00 | f 2020-01-09 |  | 0.00 | f 2020-01-10 |  | 5.00 | t 2020-01-01 |  | 100.00 | t 2020-01-02 |  | 150.00 | t 2020-01-08 |  | 200.00 | t 2020-01-09 |  | 0.00 | f 

Paso 2: Calcule lo que falta


Tenga en cuenta que la cantidad de Bob no cambió de 02.01 a 08.01. Y de acuerdo con las condiciones del problema, debemos calcular el resto diario promedio , es decir, necesitamos información sobre estos días "perdidos". O al menos el número de días en que el valor se mantuvo igual:

 coalesce(lead(dt) OVER(PARTITION BY client ORDER BY dt), '2020-01-12') - dt days 

 dt | client | balance | cond | days ------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 2020-01-02 |  | 100.00 | t | 1 2020-01-03 |  | 200.00 | t | 2 2020-01-05 |  | 0.00 | f | 1 2020-01-06 |  | 50.00 | t | 2 2020-01-08 |  | 0.00 | f | 1 2020-01-09 |  | 0.00 | f | 1 2020-01-10 |  | 5.00 | t | 2 2020-01-01 |  | 100.00 | t | 1 2020-01-02 |  | 150.00 | t | 6 2020-01-08 |  | 200.00 | t | 1 2020-01-09 |  | 0.00 | f | 3 

Usando la función de ventana lead (), aprendimos la fecha del siguiente registro en orden, y a través de la fusión limitamos el intervalo para el último. Al mismo tiempo, utilizaron la propiedad útil de que la diferencia de dos fechas en PostgreSQL devuelve un número entero de días entre ellas.

Como un bono casi gratis, obtuvimos la misma información para registros con saldo cero. Pero si hay muchas líneas con una condición de incumplimiento que no nos interesan, tiene sentido conducir tales cálculos bajo CASE para ahorrar recursos del servidor.

Paso 3: Encuentra los puntos de ruptura


El comienzo de cada cadena que nos interesa es el punto en el que el valor de la condición calculada previamente cambia en relación con el registro anterior . Usaremos la función lag () para encontrar estos puntos:

 lag(cond) OVER(PARTITION BY client ORDER BY dt) IS DISTINCT FROM cond chain_start 

 dt | client | balance | cond | days | chain_start --------------------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 | t 2020-01-02 |  | 100.00 | t | 1 | f 2020-01-03 |  | 200.00 | t | 2 | f 2020-01-05 |  | 0.00 | f | 1 | t 2020-01-06 |  | 50.00 | t | 2 | t 2020-01-08 |  | 0.00 | f | 1 | t 2020-01-09 |  | 0.00 | f | 1 | f 2020-01-10 |  | 5.00 | t | 2 | t 2020-01-01 |  | 100.00 | t | 1 | t 2020-01-02 |  | 150.00 | t | 6 | f 2020-01-08 |  | 200.00 | t | 1 | f 2020-01-09 |  | 0.00 | f | 3 | t 

Usando el operador IS DISTINCT FROM en lugar de <>, evitamos los problemas de comparar con NULL para los primeros registros de cada cliente. En consecuencia, todas las líneas donde el valor VERDADERO es el comienzo de una nueva cadena, y FALSO es su continuación.

Paso 4: encadena los enlaces


Para agrupar datos dentro de cada cadena individual, es más fácil asignar el mismo identificador a todos sus registros. El número de serie de la cadena en sí es perfecto para ello. Y es exactamente igual al número de "comienzos" de cadenas que se encontraron más arriba en la muestra.

Se pueden calcular a través de la suma de "ventana" de la suma de valores bool ({boolean} :: integer) o contando el número de registros que coinciden con la condición count (*) FILTER (WHERE {boolean}. Utilizaremos la segunda opción:

 count(*) FILTER(WHERE chain_start) OVER(PARTITION BY client ORDER BY dt) grpid 

 dt | client | balance | cond | days | chain_start | grpid ----------------------------------------------------------------- 2020-01-01 |  | 150.00 | t | 1 | t | 1 2020-01-02 |  | 100.00 | t | 1 | f | 1 2020-01-03 |  | 200.00 | t | 2 | f | 1 2020-01-06 |  | 50.00 | t | 2 | t | 2 2020-01-10 |  | 5.00 | t | 2 | t | 3 2020-01-01 |  | 100.00 | t | 1 | t | 1 2020-01-02 |  | 150.00 | t | 6 | f | 1 2020-01-08 |  | 200.00 | t | 1 | f | 1 

En este paso, ya conocemos la longitud de todos los enlaces en cada cadena, ya no necesitamos registros "poco interesantes", por lo que simplemente los filtramos.

Paso 5: poner cadenas


Para calcular el promedio de todos los días en una cadena, necesitamos el número total de días y el saldo "integral":

 SELECT client , min(dt) chain_dt , sum(days * balance) balance , sum(days) days FROM ... GROUP BY 1, grpid ORDER BY 1, grpid; 

 client | chain_dt | balance | days -------------------------------------  | 2020-01-01 | 650.00 | 4  | 2020-01-06 | 100.00 | 2  | 2020-01-10 | 10.00 | 2  | 2020-01-01 | 1200.00 | 8 

Paso 6: buscando máximos aplicados


Usando DISTINCT ON, dejaremos un único registro (con un valor máximo de días) para cada cliente:

 SELECT DISTINCT ON(client) * FROM ... ORDER BY client, days DESC; 

 client | chain_dt | balance | days -------------------------------------  | 2020-01-01 | 650.00 | 4  | 2020-01-01 | 1200.00 | 8 

En realidad, eso es todo, todo lo que queda es ...

Combinamos y optimizamos


Solicitud de resumen
 WITH step123 AS ( SELECT * , CASE WHEN cond THEN lag(cond) OVER(w) IS DISTINCT FROM cond END chain_start , CASE WHEN cond THEN coalesce(lead(dt) OVER(w), '2020-01-12') - dt END days FROM tbl , LATERAL(SELECT balance > 0 cond) T WINDOW w AS (PARTITION BY client ORDER BY dt) ) , step4 AS ( SELECT * , count(*) FILTER(WHERE chain_start) OVER(PARTITION BY client ORDER BY dt) grpid FROM step123 WHERE cond ) SELECT DISTINCT ON(client) client , sum(days) OVER(w) days , min(dt) OVER(w) chain_dt , sum(days * balance) OVER(w) balance FROM step4 WINDOW w AS (PARTITION BY client, grpid) ORDER BY 1, 2 DESC; 

Aquí combinamos y optimizamos los primeros tres pasos:

  • La subconsulta LATERAL nos permitió calcular un campo adicional sin pasar innecesariamente por la selección e inmediatamente usarlo en la función
  • la eliminación de una definición general en WINDOW ayuda a PostgreSQL a no hacer una doble clasificación para formar una "ventana" y calcular ambas funciones en un nodo WindowAgg
  • El cálculo de la función "perezosa" en CASE reduce el número de operaciones realizadas

Del mismo modo, combinamos los siguientes dos pasos. Pero el orden de la "ventana" de cálculo de agregados (cliente, grpid) y de unificación (cliente, suma (días)) no coincidió, por lo tanto, todavía hay dos nodos Ordenar en el último bloque: antes de WindowAgg y antes de Unique.


[mira explicar.tensor.ru]

Observo que al numerar cadenas , la condición WHERE se cumple por primera vez , por lo que los números generados por la función de ventana resultan ser secuenciales.

Source: https://habr.com/ru/post/483460/


All Articles