Somme glissante / nombre / moyenne sur l'intervalle de dates

20

Dans une base de données de transactions couvrant des milliers d'entités sur 18 mois, je voudrais exécuter une requête pour regrouper chaque période de 30 jours possible entity_idavec un SOMME de leurs montants de transaction et COUNT de leurs transactions au cours de cette période de 30 jours, et retourner les données d'une manière que je peux ensuite interroger. Après de nombreux tests, ce code accomplit une grande partie de ce que je veux:

SELECT id, trans_ref_no, amount, trans_date, entity_id,
    SUM(amount) OVER(PARTITION BY entity_id, date_trunc('month',trans_date) ORDER BY entity_id, trans_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS trans_total,
    COUNT(id)   OVER(PARTITION BY entity_id, date_trunc('month',trans_date) ORDER BY entity_id, trans_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS trans_count
  FROM transactiondb;

Et je vais utiliser dans une requête plus large structurée quelque chose comme:

SELECT * FROM (
  SELECT id, trans_ref_no, amount, trans_date, entity_id,
      SUM(amount) OVER(PARTITION BY entity_id, date_trunc('month',trans_date) ORDER BY entity_id, trans_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS trans_total,
      COUNT(id)   OVER(PARTITION BY entity_id, date_trunc('month',trans_date) ORDER BY entity_id, trans_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS trans_count
    FROM transactiondb ) q
WHERE trans_count >= 4
AND trans_total >= 50000;

Le cas que cette requête ne couvre pas, c'est quand le nombre de transactions s'étendra sur plusieurs mois, mais toujours dans les 30 jours les uns des autres. Ce type de requête est-il possible avec Postgres? Si c'est le cas, je salue toute contribution. La plupart des autres rubriques traitent de «l' exécution » des agrégats, et non du roulement .

Mise à jour

Le CREATE TABLEscript:

CREATE TABLE transactiondb (
    id integer NOT NULL,
    trans_ref_no character varying(255),
    amount numeric(18,2),
    trans_date date,
    entity_id integer
);

Des exemples de données peuvent être trouvés ici . J'utilise PostgreSQL 9.1.16.

La production idéale comprendrait SUM(amount)et COUNT()de toutes les transactions sur une période continue de 30 jours. Voir cette image, par exemple:

Exemple de lignes qui seraient idéalement incluses dans un "ensemble" mais ne le sont pas parce que mon ensemble est statique par mois.

Le surlignage de la date verte indique ce qui est inclus par ma requête. La surbrillance de la ligne jaune indique les enregistrements de ce que j'aimerais faire partie de l'ensemble.

Lecture précédente:

tufelkinder
la source
1
En every possible 30-day period by entity_idvous , la période peut commencer une journée, donc 365 périodes possibles en un an (non bissextile)? Ou souhaitez-vous uniquement considérer les jours avec une transaction réelle comme le début d'une période individuellement pour chacun entity_id ? Dans tous les cas, veuillez fournir votre définition de table, la version Postgres, quelques exemples de données et le résultat attendu pour l'échantillon.
Erwin Brandstetter
En théorie, je voulais dire n'importe quel jour, mais dans la pratique, il n'est pas nécessaire de considérer les jours où il n'y a pas de transactions. J'ai publié les exemples de données et la définition de table.
tufelkinder
Vous voulez donc accumuler des lignes de la même chose entity_iddans une fenêtre de 30 jours à partir de chaque transaction réelle. Peut-il y avoir plusieurs transactions pour la même chose (trans_date, entity_id)ou cette combinaison est-elle définie comme unique? Votre définition de table n'a pas de UNIQUEcontrainte PK ou PK, mais les contraintes semblent manquer ...
Erwin Brandstetter
La seule contrainte concerne idla clé primaire. Il peut y avoir plusieurs transactions par entité et par jour.
tufelkinder
À propos de la distribution des données: y a-t-il des entrées (par entity_id) pour la plupart des jours?
Erwin Brandstetter

Réponses:

26

La requête que vous avez

Vous pouvez simplifier votre requête à l'aide d'une WINDOWclause, mais cela ne fait que raccourcir la syntaxe, pas changer le plan de requête.

SELECT id, trans_ref_no, amount, trans_date, entity_id
     , SUM(amount) OVER w AS trans_total
     , COUNT(*)    OVER w AS trans_count
FROM   transactiondb
WINDOW w AS (PARTITION BY entity_id, date_trunc('month',trans_date)
             ORDER BY trans_date
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);
  • Utilisant également le légèrement plus rapide count(*), car idest certainement défini NOT NULL?
  • Et vous n'en avez pas besoin ORDER BY entity_idpuisque vous l'avez déjàPARTITION BY entity_id

Vous pouvez simplifier davantage, cependant:
n'ajoutez rien ORDER BYà la définition de la fenêtre, cela n'est pas pertinent pour votre requête. Ensuite, vous n'avez pas besoin de définir un cadre de fenêtre personnalisé, soit:

SELECT id, trans_ref_no, amount, trans_date, entity_id
     , SUM(amount) OVER w AS trans_total
     , COUNT(*)    OVER w AS trans_count
FROM   transactiondb
WINDOW w AS (PARTITION BY entity_id, date_trunc('month',trans_date);

Plus simple, plus rapide, mais toujours juste une meilleure version de ce que vous avez , avec des mois statiques .

La requête que vous voudrez peut-être

... n'est pas clairement défini, je vais donc construire sur ces hypothèses:

Comptez les transactions et le montant pour chaque période de 30 jours au cours de la première et de la dernière transaction entity_id. Exclure les périodes de début et de fin sans activité, mais inclure toutes les périodes de 30 jours possibles dans ces limites extérieures.

SELECT entity_id, trans_date
     , COALESCE(sum(daily_amount) OVER w, 0) AS trans_total
     , COALESCE(sum(daily_count)  OVER w, 0) AS trans_count
FROM  (
   SELECT entity_id
        , generate_series (min(trans_date)::timestamp
                         , GREATEST(min(trans_date), max(trans_date) - 29)::timestamp
                         , interval '1 day')::date AS trans_date
   FROM   transactiondb 
   GROUP  BY 1
   ) x
LEFT JOIN (
   SELECT entity_id, trans_date
        , sum(amount) AS daily_amount, count(*) AS daily_count
   FROM   transactiondb
   GROUP  BY 1, 2
   ) t USING (entity_id, trans_date)
WINDOW w AS (PARTITION BY entity_id ORDER BY trans_date
             ROWS BETWEEN CURRENT ROW AND 29 FOLLOWING);

Cela répertorie toutes les périodes de 30 jours pour chacune entity_idavec vos agrégats et avec trans_datele premier jour (incl.) De la période. Pour obtenir des valeurs pour chaque ligne individuelle, joignez à nouveau la table de base ...

La difficulté de base est la même que celle discutée ici:

La définition du cadre d'une fenêtre ne peut pas dépendre des valeurs de la ligne actuelle.

Et plutôt appeler generate_series()avec timestampentrée:

La requête que vous voulez réellement

Après la mise à jour de la question et la discussion:
accumulez des lignes de la même entity_iddans une fenêtre de 30 jours à partir de chaque transaction réelle.

Étant donné que vos données sont réparties de manière clairsemée, il devrait être plus efficace d'exécuter une auto-jointure avec une condition de plage , d'autant plus que Postgres 9.1 n'a pas de LATERALjointures, pourtant:

SELECT t0.id, t0.amount, t0.trans_date, t0.entity_id
     , sum(t1.amount) AS trans_total, count(*) AS trans_count
FROM   transactiondb t0
JOIN   transactiondb t1 USING (entity_id)
WHERE  t1.trans_date >= t0.trans_date
AND    t1.trans_date <  t0.trans_date + 30  -- exclude upper bound
-- AND    t0.entity_id = 114284  -- or pick a single entity ...
GROUP  BY t0.id  -- is PK!
ORDER  BY t0.trans_date, t0.id

SQL Fiddle.

Une fenêtre mobile ne pouvait avoir de sens (en termes de performances) qu'avec des données pour la plupart des jours.

Cela n'agrège pas les doublons (trans_date, entity_id)par jour, mais toutes les lignes du même jour sont toujours incluses dans la fenêtre de 30 jours.

Pour une grande table, un indice de couverture comme celui-ci pourrait aider un peu:

CREATE INDEX transactiondb_foo_idx
ON transactiondb (entity_id, trans_date, amount);

La dernière colonne amountn'est utile que si vous en obtenez des analyses d'index uniquement. Sinon, laissez tomber.

Mais cela ne sera pas utilisé pendant que vous sélectionnez la table entière de toute façon. Il prendrait en charge les requêtes pour un petit sous-ensemble.

Erwin Brandstetter
la source
Cela semble vraiment bien, le tester sur les données maintenant et essayer de comprendre tout ce que votre requête fait réellement ...
tufelkinder
@tufelkinder: Ajout d'une solution pour la question mise à jour.
Erwin Brandstetter
Je l'examine maintenant. Je suis intrigué qu'il s'exécute dans SQL Fiddle ... Lorsque j'essaie de l'exécuter directement sur ma transactiondb, il se column "t0.amount" must appear in the GROUP BY clause...
trompe
@tufelkinder: J'ai réduit le cas de test à 100 lignes. sqlfiddle limite la taille des données de test. Jake (l'auteur) a réduit la limite des limites il y a quelques mois afin que le site soit moins facilement bloqué.
Erwin Brandstetter
1
Désolé pour le retard, nécessaire pour le tester sur la base de données complète. Votre réponse était superbement approfondie et éducative, comme toujours. Je vous remercie!
tufelkinder