PostgreSQL
 sql >> Base de données >  >> RDS >> PostgreSQL

Comment faites-vous des calculs de date qui ignorent l'année ?

Si vous ne vous souciez pas des explications et des détails, utilisez la "version magie noire" ci-dessous.

Toutes les requêtes présentées dans d'autres réponses jusqu'à présent fonctionnent avec des conditions qui ne sont pas sargables - ils ne peuvent pas utiliser d'index et doivent calculer une expression pour chaque ligne de la table de base pour trouver les lignes correspondantes. Peu importe avec de petites tables. Ça compte (beaucoup ) avec de grandes tables.

Étant donné le tableau simple suivant :

CREATE TABLE event (
  event_id   serial PRIMARY KEY
, event_date date
);

Requête

Les versions 1. et 2. ci-dessous peuvent utiliser un simple index de la forme :

CREATE INDEX event_event_date_idx ON event(event_date);

Mais toutes les solutions suivantes sont encore plus rapides sans index .

1. Version simplifiée

SELECT *
FROM  (
   SELECT ((current_date + d) - interval '1 year' * y)::date AS event_date
   FROM       generate_series( 0,  14) d
   CROSS JOIN generate_series(13, 113) y
   ) x
JOIN  event USING (event_date);

Sous-requête x calcule toutes les dates possibles sur une plage d'années donnée à partir d'un CROSS JOIN de deux generate_series() appels. La sélection se fait avec la jointure simple finale.

2. Version avancée

WITH val AS (
   SELECT extract(year FROM age(current_date + 14, min(event_date)))::int AS max_y
        , extract(year FROM age(current_date,      max(event_date)))::int AS min_y
   FROM   event
   )
SELECT e.*
FROM  (
   SELECT ((current_date + d.d) - interval '1 year' * y.y)::date AS event_date
   FROM   generate_series(0, 14) d
        ,(SELECT generate_series(min_y, max_y) AS y FROM val) y
   ) x
JOIN  event e USING (event_date);

La plage d'années est automatiquement déduite du tableau - minimisant ainsi les années générées.
Vous pourriez aller plus loin et distiller une liste des années existantes s'il y a des lacunes.

L'efficacité co-dépend de la répartition des dates. Quelques années avec de nombreuses lignes chacune rendent cette solution plus utile. De nombreuses années avec peu de lignes chacune le rendent moins utile.

Violon SQL simple jouer avec.

3. Version magie noire

Mise à jour en 2016 pour supprimer une "colonne générée", qui bloquerait H.O.T. mises à jour; fonction plus simple et plus rapide.
Mise à jour 2018 pour calculer MMDD avec IMMUTABLE expressions pour permettre l'inlining de la fonction.

Créer une fonction SQL simple pour calculer un integer du motif 'MMDD' :

CREATE FUNCTION f_mmdd(date) RETURNS int LANGUAGE sql IMMUTABLE AS
'SELECT (EXTRACT(month FROM $1) * 100 + EXTRACT(day FROM $1))::int';

J'avais to_char(time, 'MMDD') au début, mais est passé à l'expression ci-dessus qui s'est avérée la plus rapide lors de nouveaux tests sur Postgres 9.6 et 10 :

db<>jouez ici

Il permet l'inlining de la fonction car EXTRACT (xyz FROM date) est implémenté avec le IMMUTABLE fonction date_part(text, date) intérieurement. Et il doit être IMMUTABLE pour permettre son utilisation dans l'index d'expression multicolonne essentiel suivant :

CREATE INDEX event_mmdd_event_date_idx ON event(f_mmdd(event_date), event_date);

Multicolonne pour un certain nombre de raisons :
Peut aider avec ORDER BY ou en sélectionnant parmi des années données. Lisez ici. À presque aucun coût supplémentaire pour l'index. Une date correspond aux 4 octets qui seraient autrement perdus à cause du remplissage en raison de l'alignement des données. Lire ici.
De plus, puisque les deux colonnes d'index référencent la même colonne de table, aucun inconvénient en ce qui concerne H.O.T. mises à jour. Lisez ici.

Une fonction de table PL/pgSQL pour les gouverner toutes

Bifurquez vers l'une des deux requêtes pour couvrir le tournant de l'année :

CREATE OR REPLACE FUNCTION f_anniversary(date = current_date, int = 14)
  RETURNS SETOF event AS
$func$
DECLARE
   d  int := f_mmdd($1);
   d1 int := f_mmdd($1 + $2 - 1);  -- fix off-by-1 from upper bound
BEGIN
   IF d1 > d THEN
      RETURN QUERY
      SELECT *
      FROM   event e
      WHERE  f_mmdd(e.event_date) BETWEEN d AND d1
      ORDER  BY f_mmdd(e.event_date), e.event_date;

   ELSE  -- wrap around end of year
      RETURN QUERY
      SELECT *
      FROM   event e
      WHERE  f_mmdd(e.event_date) >= d OR
             f_mmdd(e.event_date) <= d1
      ORDER  BY (f_mmdd(e.event_date) >= d) DESC, f_mmdd(e.event_date), event_date;
      -- chronological across turn of the year
   END IF;
END
$func$  LANGUAGE plpgsql;

Appeler en utilisant les valeurs par défaut :14 jours commençant "aujourd'hui" :

SELECT * FROM f_anniversary();

Appelez pendant 7 jours à partir du 23/08/2014 :

SELECT * FROM f_anniversary(date '2014-08-23', 7);

Violon SQL comparant EXPLAIN ANALYZE .

29 février

Lorsque vous traitez des anniversaires ou des "anniversaires", vous devez définir comment traiter le cas spécial "29 février" dans les années bissextiles.

Lors du test des plages de dates, Feb 29 est généralement inclus automatiquement, même si l'année en cours n'est pas une année bissextile . La plage de jours est allongée de 1 rétroactivement lorsqu'elle couvre ce jour.
En revanche, si l'année en cours est une année bissextile et que vous souhaitez rechercher 15 jours, vous risquez d'obtenir des résultats pour 14 jours. jours dans les années bissextiles si vos données proviennent d'années non bissextiles.

Supposons que Bob soit né le 29 février :
Ma requête 1. et 2. n'inclut le 29 février que dans les années bissextiles. Bob n'a d'anniversaire que tous les ~ 4 ans.
Ma requête 3. inclut le 29 février dans la plage. Bob fête son anniversaire chaque année.

Il n'y a pas de solution magique. Vous devez définir ce que vous voulez pour chaque cas.

Tester

Pour étayer mon propos, j'ai effectué un test approfondi avec toutes les solutions présentées. J'ai adapté chacune des requêtes à la table donnée et pour obtenir des résultats identiques sans ORDER BY .

La bonne nouvelle :elles sont toutes correctes et donnent le même résultat - à l'exception de la requête de Gordon qui comportait des erreurs de syntaxe et de la requête de @wildplasser qui échoue à la fin de l'année (facile à corriger).

Insérez 108 000 lignes avec des dates aléatoires du 20e siècle, ce qui est similaire à un tableau de personnes vivantes (13 ans ou plus).

INSERT INTO  event (event_date)
SELECT '2000-1-1'::date - (random() * 36525)::int
FROM   generate_series (1, 108000);

Supprimez ~ 8 % pour créer des tuples morts et rendre le tableau plus "réel".

DELETE FROM event WHERE random() < 0.08;
ANALYZE event;

Mon cas de test avait 99289 lignes, 4012 hits.

C - Appeau

WITH anniversaries as (
   SELECT event_id, event_date
         ,(event_date + (n || ' years')::interval)::date anniversary
   FROM   event, generate_series(13, 113) n
   )
SELECT event_id, event_date -- count(*)   --
FROM   anniversaries
WHERE  anniversary BETWEEN current_date AND current_date + interval '14' day;

C1 - L'idée de Catcall réécrite

Mis à part les optimisations mineures, la principale différence est d'ajouter uniquement le nombre exact d'années date_trunc('year', age(current_date + 14, event_date)) pour obtenir l'anniversaire de cette année, ce qui évite complètement le besoin d'un CTE :

SELECT event_id, event_date
FROM   event
WHERE (event_date + date_trunc('year', age(current_date + 14, event_date)))::date
       BETWEEN current_date AND current_date + 14;

D - Daniel

SELECT *   -- count(*)   -- 
FROM   event
WHERE  extract(month FROM age(current_date + 14, event_date))  = 0
AND    extract(day   FROM age(current_date + 14, event_date)) <= 14;

E1 - Erwin 1

Voir "1. Version simplifiée" ci-dessus.

E2 - Erwin 2

Voir "2. Version avancée" ci-dessus.

E3 - Erwin 3

Voir "3. Version magie noire" ci-dessus.

G-Gordon

SELECT * -- count(*)   
FROM  (SELECT *, to_char(event_date, 'MM-DD') AS mmdd FROM event) e
WHERE  to_date(to_char(now(), 'YYYY') || '-'
                 || (CASE WHEN mmdd = '02-29' THEN '02-28' ELSE mmdd END)
              ,'YYYY-MM-DD') BETWEEN date(now()) and date(now()) + 14;

H - un_cheval_sans_nom

WITH upcoming as (
   SELECT event_id, event_date
         ,CASE 
            WHEN date_trunc('year', age(event_date)) = age(event_date)
                 THEN current_date
            ELSE cast(event_date + ((extract(year FROM age(event_date)) + 1)
                      * interval '1' year) AS date) 
          END AS next_event
   FROM event
   )
SELECT event_id, event_date
FROM   upcoming
WHERE  next_event - current_date  <= 14;

W - sauvage

CREATE OR REPLACE FUNCTION this_years_birthday(_dut date) RETURNS date AS
$func$
DECLARE
    ret date;
BEGIN
    ret :=
    date_trunc( 'year' , current_timestamp)
        + (date_trunc( 'day' , _dut)
         - date_trunc( 'year' , _dut));
    RETURN ret;
END
$func$ LANGUAGE plpgsql;

Simplifié pour retourner le même que tous les autres :

SELECT *
FROM   event e
WHERE  this_years_birthday( e.event_date::date )
        BETWEEN current_date
        AND     current_date + '2weeks'::interval;

W1 - requête de wildplasser réécrite

Ce qui précède souffre d'un certain nombre de détails inefficaces (au-delà de la portée de cet article déjà important). La version réécrite est beaucoup plus rapide :

CREATE OR REPLACE FUNCTION this_years_birthday(_dut INOUT date) AS
$func$
SELECT (date_trunc('year', now()) + ($1 - date_trunc('year', $1)))::date
$func$ LANGUAGE sql;

SELECT *
FROM   event e
WHERE  this_years_birthday(e.event_date)
        BETWEEN current_date
        AND    (current_date + 14);

Résultats des tests

J'ai exécuté ce test avec une table temporaire sur PostgreSQL 9.1.7. Les résultats ont été recueillis avec EXPLAIN ANALYZE , meilleur des 5.

Résultats

Without index
C:  Total runtime: 76714.723 ms
C1: Total runtime:   307.987 ms  -- !
D:  Total runtime:   325.549 ms
E1: Total runtime:   253.671 ms  -- !
E2: Total runtime:   484.698 ms  -- min() & max() expensive without index
E3: Total runtime:   213.805 ms  -- !
G:  Total runtime:   984.788 ms
H:  Total runtime:   977.297 ms
W:  Total runtime:  2668.092 ms
W1: Total runtime:   596.849 ms  -- !

With index
E1: Total runtime:    37.939 ms  --!!
E2: Total runtime:    38.097 ms  --!!

With index on expression
E3: Total runtime:    11.837 ms  --!!

Toutes les autres requêtes fonctionnent de la même manière avec ou sans index car elles utilisent non-sargable expressions.

Conclusion

  • Jusqu'à présent, la requête de @Daniel a été la plus rapide.

  • L'approche @wildplassers (réécrite) fonctionne également de manière acceptable.

  • La version de @ Catcall ressemble à l'approche inverse de la mienne. Les performances deviennent rapidement incontrôlables avec des tables plus grandes.
    La version réécrite fonctionne cependant plutôt bien. L'expression que j'utilise est quelque chose comme une version plus simple de this_years_birthday() de @wildplassser fonction.

  • Ma "version simple" est plus rapide même sans index , car il nécessite moins de calculs.

  • Avec index, la "version avancée" est à peu près aussi rapide que la "version simple", car min() et max() devenir très pas cher avec un index. Les deux sont nettement plus rapides que les autres qui ne peuvent pas utiliser l'index.

  • Ma "version magie noire" est la plus rapide avec ou sans index . Et c'est très simple à appeler.

  • Avec une table de la vie réelle un index rendra encore plus grand différence. Plus de colonnes rendent la table plus grande et l'analyse séquentielle plus coûteuse, tandis que la taille de l'index reste la même.