web-dev-qa-db-fra.com

Le moyen le plus rapide de compter le nombre de plages de dates couvrant chaque date de la série

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, en combien de lignes de dates_ranges chaque 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 JOIN et GROUP 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 ~ 30 000 lignes dans dates_ranges table (mais elle 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, donc c'est plus général.

12
BartekCh

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 version lateral avec le petit ensemble de données. Cependant, il peut évoluer mieux, car aucune jointure n'est requise, mais la version ci-dessus agrège sur toutes les lignes, il 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 j'ai pu utiliser l'opérateur overlaps! Notez que vous devez ajouter interval '1 day' vers la droite car l'opérateur de chevauchement considère que les périodes sont ouvertes sur la droite (ce qui est assez logique car une date est souvent considérée comme un horodatage avec une composante horaire de minuit).

4
Colin 't Hart

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

Construisez une grille de toutes les combinaisons puis LATERAL rejoignez 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'avais LEFT JOIN LATERAL ... on true Au début, mais il y a un agrégat dans la sous-requête c, donc nous toujours obtenons une ligne et peut également utiliser CROSS JOIN. Aucune différence de performance.

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

La conversion en integer est 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 renvoyant un ensemble comme generate_series() dans la liste SELECT est généralement déconseillé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;
6

Utilisation du type daterange

PostgreSQL a un daterange . Son utilisation est assez simple. À partir de vos exemples de données, nous allons utiliser le 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érer une série de dates mais voici le crochet que la requête elle-même peut utiliser le confinement (@>) pour vérifier que les dates sont dans la plage, à l'aide d'un index.

Notez que nous utilisons timestamp without time zone (pour arrêter les risques 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 insertions de plages qui se chevauchent avec d'autres en utilisant un EXCLUDE CONSTRAINT

1
Evan Carroll