La forma más rápida de contar cuántos rangos de fechas cubren cada fecha de la serie

12

Tengo una tabla (en PostgreSQL 9.4) que se ve así:

CREATE TABLE dates_ranges (kind int, start_date date, end_date date);
INSERT INTO dates_ranges VALUES 
    (1, '2018-01-01', '2018-01-31'),
    (1, '2018-01-01', '2018-01-05'),
    (1, '2018-01-03', '2018-01-06'),
    (2, '2018-01-01', '2018-01-01'),
    (2, '2018-01-01', '2018-01-02'),
    (3, '2018-01-02', '2018-01-08'),
    (3, '2018-01-05', '2018-01-10');

Ahora quiero calcular para las fechas dadas y para cada tipo, en cuántas filas de dates_rangescada fecha cae. Los ceros podrían posiblemente omitirse.

Resultado deseado:

+-------+------------+----+
|  kind | as_of_date |  n |
+-------+------------+----+
|     1 | 2018-01-01 |  2 |
|     1 | 2018-01-02 |  2 |
|     1 | 2018-01-03 |  3 |
|     2 | 2018-01-01 |  2 |
|     2 | 2018-01-02 |  1 |
|     3 | 2018-01-02 |  1 |
|     3 | 2018-01-03 |  1 |
+-------+------------+----+

Se me ocurrieron dos soluciones, una con LEFT JOINyGROUP BY

SELECT
kind, as_of_date, COUNT(*) n
FROM
    (SELECT d::date AS as_of_date FROM generate_series('2018-01-01'::timestamp, '2018-01-03'::timestamp, '1 day') d) dates
LEFT JOIN
    dates_ranges ON dates.as_of_date BETWEEN start_date AND end_date
GROUP BY 1,2 ORDER BY 1,2

y uno con LATERAL, que es un poco más rápido:

SELECT
    kind, as_of_date, n
FROM
    (SELECT d::date AS as_of_date FROM generate_series('2018-01-01'::timestamp, '2018-01-03'::timestamp, '1 day') d) dates,
LATERAL
    (SELECT kind, COUNT(*) AS n FROM dates_ranges WHERE dates.as_of_date BETWEEN start_date AND end_date GROUP BY kind) ss
ORDER BY kind, as_of_date

Me pregunto si hay alguna forma mejor de escribir esta consulta. ¿Y cómo incluir pares date-kind con 0 count?

En realidad, hay algunos tipos distintos, un período de hasta cinco años (1800 fechas) y ~ 30k filas en la dates_rangestabla (pero podría crecer significativamente).

No hay índices Para ser precisos en mi caso, es el resultado de una subconsulta, pero he querido limitar la pregunta a un problema, por lo que es más general.

BartekCh
fuente
¿Qué hacer si los rangos de la tabla no se superponen o se tocan? Por ejemplo, si tiene un rango donde (kind, start, end) = (1,2018-01-01,2018-01-15)y (1,2018-01-20,2018-01-25)desea tenerlo en cuenta al determinar cuántas fechas superpuestas tiene?
Evan Carroll
También estoy confundido por qué tu mesa es pequeña? ¿Por qué no es 2018-01-31o 2018-01-30o 2018-01-29en él cuando la primera gama cuenta con todos ellos?
Evan Carroll
Las fechas de @EvanCarroll generate_seriesson parámetros externos; no necesariamente cubren todos los rangos de la dates_rangestabla. En cuanto a la primera pregunta, supongo que no la entiendo: las filas dates_rangesson independientes, no quiero determinar la superposición.
BartekCh 01 de

Respuestas:

4

La siguiente consulta también funciona si los "ceros faltantes" están bien:

select *
from (
  select
    kind,
    generate_series(start_date, end_date, interval '1 day')::date as d,
    count(*)
  from dates_ranges
  group by 1, 2
) x
where d between date '2018-01-01' and date '2018-01-03'
order by 1, 2;

pero no es más rápido que la lateralversión con el pequeño conjunto de datos. Sin embargo, podría escalar mejor, ya que no se requiere unión, pero la versión anterior se agrega a todas las filas, por lo que puede perder nuevamente.

La siguiente consulta intenta evitar el trabajo innecesario eliminando cualquier serie que no se superponga de todos modos:

select
  kind,
  generate_series(greatest(start_date, date '2018-01-01'), least(end_date, date '2018-01-03'), interval '1 day')::date as d,
  count(*)
from dates_ranges
where (start_date, end_date + interval '1 day') overlaps (date '2018-01-01', date '2018-01-03' + interval '1 day')
group by 1, 2
order by 1, 2;

- ¡Y tengo que usar el overlapsoperador! Tenga en cuenta que debe agregar interval '1 day'a la derecha ya que el operador de superposición considera que los períodos de tiempo están abiertos a la derecha (lo cual es bastante lógico porque a menudo se considera que una fecha es una marca de tiempo con un componente de hora de medianoche).

Colin 't Hart
fuente
Bien, no sabía que generate_seriesse puede usar así. Después de algunas pruebas tengo las siguientes observaciones. De hecho, su consulta se escala muy bien con la longitud del rango seleccionado; prácticamente no hay diferencia entre un período de 3 años y 10 años. Sin embargo, durante períodos más cortos (1 año), mis soluciones son más rápidas. Supongo que la razón es que hay algunos rangos realmente largos dates_ranges(como 2010-2100), que están ralentizando su consulta. Sin embargo, limitar start_datey end_datedentro de la consulta interna debería ayudar. Necesito hacer algunas pruebas más.
BartekCh
6

¿Y cómo incluir pares date-kind con 0 count?

Construya una cuadrícula de todas las combinaciones y luego LATERAL únase a su mesa, así:

SELECT k.kind, d.as_of_date, c.n
FROM  (SELECT DISTINCT kind FROM dates_ranges) k
CROSS  JOIN (
   SELECT d::date AS as_of_date
   FROM   generate_series(timestamp '2018-01-01', timestamp '2018-01-03', interval '1 day') d
   ) d
CROSS  JOIN LATERAL (
   SELECT count(*)::int AS n
   FROM   dates_ranges
   WHERE  kind = k.kind
   AND    d.as_of_date BETWEEN start_date AND end_date
   ) c
ORDER  BY k.kind, d.as_of_date;

También debe ser lo más rápido posible.

Al LEFT JOIN LATERAL ... on trueprincipio tenía , pero hay un agregado en la subconsulta c, por lo que siempre obtenemos una fila y también podemos usarla CROSS JOIN. No hay diferencia en el rendimiento.

Si tiene una tabla que contiene todos los tipos relevantes , úsela en lugar de generar la lista con subconsulta k.

El reparto a integeres opcional. De lo contrario tienes bigint.

Los índices ayudarían, especialmente un índice de varias columnas en (kind, start_date, end_date). Como está construyendo sobre una subconsulta, esto puede o no ser posible de lograr.

El uso de funciones de devolución de conjuntos como generate_series()en la SELECTlista generalmente no es aconsejable en las versiones de Postgres anteriores a la 10 (a menos que sepa exactamente lo que está haciendo). Ver:

Si tiene muchas combinaciones con pocas o ninguna fila, esta forma equivalente puede ser más rápida:

SELECT k.kind, d.as_of_date, count(dr.kind)::int AS n
FROM  (SELECT DISTINCT kind FROM dates_ranges) k
CROSS JOIN (
   SELECT d::date AS as_of_date
   FROM   generate_series(timestamp '2018-01-01', timestamp '2018-01-03', interval '1 day') d
   ) d
LEFT   JOIN dates_ranges dr ON dr.kind = k.kind
                           AND d.as_of_date BETWEEN dr.start_date AND dr.end_date
GROUP  BY 1, 2
ORDER  BY 1, 2;
Erwin Brandstetter
fuente
En cuanto a las funciones de devolución de conjuntos en la SELECTlista, he leído que no es recomendable, sin embargo, parece que funciona bien, si solo hay una de esas funciones. Si estoy seguro de que solo habrá uno, ¿podría salir algo mal?
BartekCh
@BartekCh: un solo SRF en la SELECTlista funciona como se esperaba. Tal vez agregue un comentario para advertir en contra de agregar otro. O muévalo a la FROMlista para comenzar en versiones anteriores de Postgres. ¿Por qué arriesgar complicaciones? (Eso también es SQL estándar y no confundirá a las personas que vienen de otros RDBMS).
Erwin Brandstetter
1

Usando el daterangetipo

PostgreSQL tiene un daterange. Usarlo es bastante simple. Comenzando con sus datos de muestra, nos movemos para usar el tipo en la tabla.

BEGIN;
  ALTER TABLE dates_ranges ADD COLUMN myrange daterange;
  UPDATE dates_ranges
    SET myrange = daterange(start_date, end_date, '[]');
  ALTER TABLE dates_ranges
    DROP COLUMN start_date,
    DROP COLUMN end_date;
COMMIT;

-- Now you can create GIST index on it...
CREATE INDEX ON dates_ranges USING gist (myrange);

TABLE dates_ranges;
 kind |         myrange         
------+-------------------------
    1 | [2018-01-01,2018-02-01)
    1 | [2018-01-01,2018-01-06)
    1 | [2018-01-03,2018-01-07)
    2 | [2018-01-01,2018-01-02)
    2 | [2018-01-01,2018-01-03)
    3 | [2018-01-02,2018-01-09)
    3 | [2018-01-05,2018-01-11)
(7 rows)

Quiero calcular para las fechas dadas y para cada tipo, en cuántas filas de date_ranges cae cada fecha.

Ahora, para consultarlo, revertimos el procedimiento y generamos una serie de fechas, pero aquí está el problema de que la consulta en sí misma puede usar el @>operador de contención ( ) para verificar que las fechas estén dentro del rango, usando un índice.

Tenga en cuenta que usamos timestamp without time zone(para detener los riesgos de DST)

SELECT d1.kind, day::date, count(d2.kind)
FROM dates_ranges AS d1
CROSS JOIN LATERAL generate_series(
  lower(myrange)::timestamp without time zone,
  upper(myrange)::timestamp without time zone,
  '1 day'
) AS gs(day)
INNER JOIN dates_ranges AS d2
  ON d2.myrange @> day::date
GROUP BY d1.kind, day;

Cuál es la superposición de día detallada en el índice.

Como beneficio adicional, con el tipo de rango de fechas puede detener las inserciones de rangos que se superponen con otros utilizando unEXCLUDE CONSTRAINT

Evan Carroll
fuente
Algo está mal con su consulta, parece que está contando filas varias veces, una JOINdemasiado, supongo.
BartekCh
@BartekCh no, tienes filas superpuestas, puedes evitar esto eliminando los rangos superpuestos (sugerido) o usandocount(DISTINCT kind)
Evan Carroll
pero quiero superponer filas. Por ejemplo, la 1fecha de tipo 2018-01-01está dentro de las dos primeras filas desde dates_ranges, pero su consulta proporciona 8.
BartekCh
o usandocount(DISTINCT kind) ¿agregó la DISTINCTpalabra clave allí?
Evan Carroll
Lamentablemente, con la DISTINCTpalabra clave todavía no funciona como se esperaba. Cuenta distintos tipos para cada fecha, pero quiero contar todas las filas de cada tipo para cada fecha.
BartekCh