Méthode la plus rapide pour compter le nombre de plages de dates couvrant chaque date de la série

12

J'ai une table (dans PostgreSQL 9.4) qui ressemble à ceci:

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

Maintenant, je veux calculer pour les dates données et pour chaque type, dans combien de lignes de dates_rangeschaque date tombe. Des zéros pourraient éventuellement être omis.

Résultat désiré:

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

J'ai trouvé deux solutions, une avec LEFT JOINetGROUP 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

et un avec LATERAL, qui est légèrement plus rapide:

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

Je me demande si c'est une meilleure façon d'écrire cette requête? Et comment inclure des paires date-kind avec 0 compte?

En réalité, il existe plusieurs types distincts, une période pouvant aller jusqu'à cinq ans (1800 dates) et environ 30 000 lignes dans le dates_rangestableau (mais cela pourrait augmenter considérablement).

Il n'y a pas d'index. Pour être précis dans mon cas, c'est le résultat d'une sous-requête, mais j'ai voulu limiter la question à un seul problème, c'est donc plus général.

BartekCh
la source
Que faites-vous si les plages du tableau ne se chevauchent pas ou ne se touchent pas? Par exemple, si vous avez une plage où (type, début, fin) = (1,2018-01-01,2018-01-15)et (1,2018-01-20,2018-01-25)voulez-vous en tenir compte lorsque vous déterminez le nombre de dates qui se chevauchent?
Evan Carroll
Je suis également confus pourquoi votre table est petite? Pourquoi pas 2018-01-31ou 2018-01-30ou 2018-01-29en quand la première gamme a tous?
Evan Carroll
Les dates @EvanCarroll dans generate_seriessont des paramètres externes - elles ne couvrent pas nécessairement toutes les plages du dates_rangestableau. Quant à la première question, je suppose que je ne la comprends pas - les lignes dates_rangessont indépendantes, je ne veux pas déterminer les chevauchements.
BartekCh

Réponses:

4

La requête suivante fonctionne également si les "zéros manquants" sont 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;

mais ce n'est pas plus rapide que la lateralversion avec le petit ensemble de données. Cependant, il peut être plus évolutif, car aucune jointure n'est requise, mais la version ci-dessus est agrégée sur toutes les lignes, elle peut donc y perdre à nouveau.

La requête suivante tente d'éviter un travail inutile en supprimant toutes les séries qui ne se chevauchent pas de toute façon:

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;

- et je dois utiliser l' overlapsopérateur! Notez que vous devez ajouter interval '1 day'à droite car l'opérateur de chevauchements considère que les périodes sont ouvertes à droite (ce qui est assez logique car une date est souvent considérée comme un horodatage avec une composante horaire de minuit).

Colin 't Hart
la source
Bien, je ne savais pas generate_seriesqu'on pouvait l'utiliser comme ça. Après quelques tests, j'ai les observations suivantes. Votre requête évolue en effet très bien avec la longueur de plage sélectionnée - il n'y a donc pratiquement aucune différence entre une période de 3 ans et 10 ans. Cependant, pour des périodes plus courtes (1 an), mes solutions sont plus rapides - je suppose que la raison en est qu'il existe de très longues plages dates_ranges(comme 2010-2100), qui ralentissent votre requête. Limiter start_dateet end_dateà l'intérieur de la requête interne devrait cependant aider. J'ai besoin de faire quelques tests supplémentaires.
BartekCh
6

Et comment inclure des paires date-kind avec 0 compte?

Construisez une grille de toutes les combinaisons puis LATERAL joignez-vous à votre table, comme ceci:

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;

Devrait également être aussi rapide que possible.

J'ai eu LEFT JOIN LATERAL ... on trueau début, mais il y a un agrégat dans la sous-requête c, donc nous obtenons toujours une ligne et pouvons également l'utiliser CROSS JOIN. Aucune différence de performance.

Si vous avez un tableau contenant tous les types pertinents , utilisez-le au lieu de générer la liste avec la sous-requête k.

La conversion en integerest facultative. Sinon, vous obtenez bigint.

Les index seraient utiles, en particulier un index multicolonne sur (kind, start_date, end_date). Étant donné que vous construisez sur une sous-requête, cela peut être possible ou non.

L'utilisation de fonctions de retour de set comme generate_series()dans la SELECTliste n'est généralement pas recommandée dans les versions Postgres antérieures à 10 (sauf si vous savez exactement ce que vous faites). Voir:

Si vous avez beaucoup de combinaisons avec peu ou pas de lignes, ce formulaire équivalent peut être plus rapide:

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
la source
En ce qui concerne les fonctions de retour d'ensemble dans la SELECTliste - j'ai lu que ce n'est pas conseillé, mais il semble que cela fonctionne très bien, s'il n'y a qu'une seule de ces fonctions. Si je suis sûr qu'il n'y en aura qu'un, quelque chose pourrait-il mal tourner?
BartekCh
@BartekCh: un seul SRF dans la SELECTliste fonctionne comme prévu. Ajoutez peut-être un commentaire pour déconseiller d'en ajouter un autre. Ou déplacez-le dans la FROMliste pour commencer dans les anciennes versions de Postgres. Pourquoi risquer des complications? (C'est aussi du SQL standard et ne confondra pas les personnes venant d'autres SGBDR.)
Erwin Brandstetter
1

Utilisation du daterangetype

PostgreSQL a un daterange. Son utilisation est assez simple. À partir de vos exemples de données, nous passons à l'utilisation du type sur la table.

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)

Je veux calculer pour les dates données et pour chaque type, en combien de lignes de dates_ranges chaque date tombe.

Maintenant, pour l'interroger, nous inversons la procédure et générons une série de dates, mais voici le problème que la requête elle-même peut utiliser l' @>opérateur containment ( ) pour vérifier que les dates sont dans la plage, à l' aide d'un index.

Remarque que nous utilisons timestamp without time zone(pour arrêter les dangers 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;

Quels sont les jours-chevauchements détaillés sur l'indice.

En bonus, avec le type daterange, vous pouvez arrêter les insertions de plages qui se chevauchent avec d'autres en utilisant unEXCLUDE CONSTRAINT

Evan Carroll
la source
Quelque chose ne va pas avec votre requête, il semble qu'il compte les lignes plusieurs fois, une de JOINtrop je suppose.
BartekCh
@BartekCh non, vous avez des lignes qui se chevauchent, vous pouvez contourner cela en supprimant les plages qui se chevauchent (suggéré) ou en utilisantcount(DISTINCT kind)
Evan Carroll
mais je veux des lignes qui se chevauchent. Par exemple, pour la 1date type se 2018-01-01trouve dans les deux premières lignes de dates_ranges, mais votre requête donne 8.
BartekCh
oucount(DISTINCT kind) avez-vous ajouté le DISTINCTmot clé à cet endroit?
Evan Carroll
Malheureusement, avec le DISTINCTmot clé, cela ne fonctionne toujours pas comme prévu. Il compte des types distincts pour chaque date, mais je veux compter toutes les lignes de chaque type pour chaque date.
BartekCh