A maneira mais rápida de contar quantos intervalos de datas cobrem cada data das séries

12

Eu tenho uma tabela (no PostgreSQL 9.4) que se parece com isso:

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');

Agora, quero calcular para as datas especificadas e para todo tipo, em quantas linhas de dates_rangescada data caem. Zeros podem ser omitidos.

Resultado desejado:

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

Eu vim com duas soluções, uma com LEFT JOINeGROUP 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

e um com LATERAL, que é um pouco mais 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

Gostaria de saber se existe alguma maneira melhor de escrever esta consulta? E como incluir pares do tipo data com contagem de 0?

Na realidade, existem alguns tipos distintos, período de até cinco anos (1800 datas) e ~ 30k linhas na dates_rangestabela (mas pode crescer significativamente).

Não há índices. Para ser preciso, no meu caso, é resultado da subconsulta, mas eu queria limitar a pergunta a um problema, por isso é mais geral.

BartekCh
fonte
O que você faz se os intervalos da tabela não se sobrepõem ou se tocam. Por exemplo, se você tem um intervalo em que (tipo, início, fim) = (1,2018-01-01,2018-01-15)e (1,2018-01-20,2018-01-25)deseja levar isso em consideração ao determinar quantas datas sobrepostas você tem?
Evan Carroll
Também estou confuso por que sua mesa é pequena? Por que não é 2018-01-31ou 2018-01-30ou 2018-01-29em que quando a primeira faixa tem todos eles?
Evan Carroll
As datas do @EvanCarroll generate_seriessão parâmetros externos - eles não cobrem necessariamente todos os intervalos da dates_rangestabela. Quanto à primeira pergunta, suponho que não entendi - as linhas dates_rangessão independentes, não quero determinar a sobreposição.
precisa saber é o seguinte

Respostas:

4

A consulta a seguir também funciona se "zeros ausentes" estiverem OK:

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;

mas não é mais rápido que a lateralversão com o pequeno conjunto de dados. Porém, pode ser melhor dimensionado, pois não é necessária nenhuma junção, mas a versão acima é agregada em todas as linhas; portanto, ela pode se perder novamente.

A consulta a seguir tenta evitar trabalhos desnecessários removendo qualquer série que não se sobreponha:

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;

- e eu tenho que usar o overlapsoperador! Observe que você deve adicionar interval '1 day'à direita, pois o operador de sobreposições considera que os períodos de tempo estão abertos à direita (o que é bastante lógico, porque uma data geralmente é considerada um carimbo de data e hora com o componente de hora da meia-noite).

Colin 't Hart
fonte
Bom, eu não sabia que generate_seriespoderia ser usado assim. Após alguns testes, tenho as seguintes observações. Sua consulta realmente se adapta muito bem ao comprimento do intervalo selecionado - praticamente não há diferença entre o período de 3 e 10 anos. No entanto, por períodos mais curtos (1 ano), minhas soluções são mais rápidas - acho que o motivo é que existem alguns intervalos realmente longos dates_ranges(como 2010-2100), que estão desacelerando sua consulta. Limitar start_datee end_datedentro da consulta interna deve ajudar. Eu preciso fazer mais alguns testes.
BarrekCh
6

E como incluir pares do tipo data com contagem 0?

Crie uma grade de todas as combinações e LATERAL junte-se à sua mesa, assim:

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;

Também deve ser o mais rápido possível.

Eu tinha LEFT JOIN LATERAL ... on trueno começo, mas há um agregado na subconsulta c, então sempre temos uma linha e podemos usar CROSS JOINtambém. Não há diferença no desempenho.

Se você possui uma tabela com todos os tipos relevantes , use-a em vez de gerar a lista com subconsulta k.

O elenco para integeré opcional. Senão você recebe bigint.

Os índices ajudariam, especialmente um índice com várias colunas (kind, start_date, end_date). Como você está construindo uma subconsulta, isso pode ou não ser possível.

Usar funções de retorno de conjunto como generate_series()na SELECTlista geralmente não é aconselhável nas versões do Postgres anteriores a 10 (a menos que você saiba exatamente o que está fazendo). Vejo:

Se você tiver muitas combinações com poucas ou nenhuma linha, esse formulário equivalente poderá ser mais rápido:

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
fonte
Quanto às funções de retorno de conjunto na SELECTlista - li que não é aconselhável, no entanto, parece que funciona muito bem, se houver apenas uma dessas funções. Se tenho certeza de que haverá apenas um, algo pode dar errado?
BartekCh
@BartekCh: Um único SRF na SELECTlista funciona conforme o esperado. Talvez adicione um comentário para alertar contra a adição de outro. Ou mova-o para a FROMlista para começar nas versões mais antigas do Postgres. Por que arriscar complicações? (Isso também é SQL padrão e não as pessoas confundem vindo de outros RDBMS.)
Erwin Brandstetter
1

Usando o daterangetipo

O PostgreSQL possui um daterange. Usá-lo é bastante simples. Começando com os dados de amostra, passamos a usar o tipo na tabela.

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)

Quero calcular para as datas especificadas e para todo tipo, em quantas linhas de datas_ranjas cada data cai.

Agora, para consultá-lo, invertemos o procedimento e geramos uma série de datas, mas aqui está o problema: a própria consulta pode usar o @>operador containsment ( ) para verificar se as datas estão dentro do intervalo, usando um índice.

Observe que usamos timestamp without time zone(para interromper os perigos do horário de verão)

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;

Quais são as sobreposições diárias discriminadas no índice.

Como bônus adicional, com o tipo de daterange, você pode interromper inserções de intervalos que se sobrepõem a outros usando umEXCLUDE CONSTRAINT

Evan Carroll
fonte
Algo está errado com Sua consulta, parece que ele está contando linhas várias vezes, JOINacho que é demais.
precisa saber é o seguinte
@BartekCh não ter sobreposição de linhas, você pode contornar este problema, removendo os intervalos sobrepostos (sugerido) ou usandocount(DISTINCT kind)
Evan Carroll
mas quero linhas sobrepostas. Por exemplo, a 1data do tipo 2018-01-01está nas duas primeiras linhas de dates_ranges, mas Sua consulta fornece 8.
precisa saber é o seguinte
ou usandocount(DISTINCT kind) você adicionou a DISTINCTpalavra-chave lá?
Evan Carroll
Infelizmente, com a DISTINCTpalavra-chave, ela ainda não funciona conforme o esperado. Ele conta tipos distintos para cada data, mas quero contar todas as linhas de cada tipo para cada data.
precisa saber é o seguinte