2

I wrote the query that gives me time-series over some date range and interval that shows revenue for each time interval:

SELECT
    interval_date,
    coalesce(campaign_revenue,0) AS campaign_revenue,
FROM
    -- generate_series helps fill the empty gaps in the following JOIN
    generate_series(
        $2::timestamp,
        $3::timestamp,
        $4) AS interval_date -- could be '1 day', '1 hour' or '1 minute'.
LEFT OUTER JOIN
    -- This SELECT gets all timeseries rows that have data
    (SELECT
        date_trunc($4, s.created) AS interval,
        SUM(s.revenue) campaign_revenue
    FROM
        sale_event AS s
    WHERE
        s.campaignid = $1 AND s.created BETWEEN $2 AND $3 AND s.event_type = 'session_closed'
    GROUP BY
        interval) results
ON
    (results.interval = interval_date);

The query takes every row of sale_event table, truncates the created date to some interval (aligns the created timestamp with the time-series wanted granularity), groups by this time interval and sums up the revenue columns on the rows where event_type is session_closed.

This works very well and gives me the revenue in the specified interval. The result may look like:

interval_date   |   campaign_revenue
------------------------------------
 2018-08-05     |   0.0
 2018-08-06     |   1.5
 2018-08-07     |   0.0
 2018-08-08     |   0.5
 2018-08-09     |   1.0

When the provided range is 2018-08-05 - 2018-08-09 and interval = '1 day'.

I want to add to the result the sum of revenue up to that date. So if before 2018-08-05 there a total revenue of 10.0, the result would be:

interval_date   |   campaign_revenue   |   total_campaign_revenue
-----------------------------------------------------------------
 2018-08-05     |   0.0                |   10.0
 2018-08-06     |   1.5                |   11.5
 2018-08-07     |   0.0                |   11.5
 2018-08-08     |   0.5                |   12.0
 2018-08-09     |   1.0                |   13.0
2
  • How is the 10$ calculated, can we assume it is a constant? Commented Aug 10, 2018 at 9:54
  • Not a constant. The 10.0 would be the sum of all sales_event.revenue up to and including 2018-08-05.
    – Alechko
    Commented Aug 10, 2018 at 9:56

2 Answers 2

3

If I get it right you can just add a window function outside of your query like:

SELECT interval_date, campaign_revenue
     , SUM(campaign_revenue) OVER (ORDER BY interval_date) 
      + (SELECT SUM(revenue) 
         FROM sale_event
         WHERE s.campaignid = $1
           AND s.created < $2
           AND s.event_type = 'session_closed') as total_campaign_revenue
FROM (
    SELECT interval_date
         , coalesce(campaign_revenue,0) AS campaign_revenue
    FROM
        -- generate_series helps fill the empty gaps in the following JOIN
        ...
        interval) results
    ON (results.interval = interval_date)
);

Another option is to apply the window function directly, and use a FILTER clause for campaign_revenue

2
  • Great, I will give it a go and update with the results! Do you think it should impact performance heavily, beyond the need to traverse the rows up to the first date in the range?
    – Alechko
    Commented Aug 10, 2018 at 10:14
  • 1
    My guess is that the sub-select for earlier revenues will have the heaviest cost, but the best way to find out is to look at the execution plan and compare. Commented Aug 10, 2018 at 10:19
4

It's typically faster to read all relevant rows from the underlying table in a single scan.
You can run a window function over an aggregate function in the same SELECT.

Test with EXPLAIN (ANALYZE, TIMING OFF) to verify it's actually faster:

SELECT interval_ts
     , coalesce(revenue      , 0) AS campaign_revenue
     , coalesce(total_revenue, 0) AS total_campaign_revenue    
FROM   generate_series($2::timestamp, $3::timestamp, $4) AS interval_ts
LEFT   JOIN (
   SELECT date_trunc($4, created) AS interval_ts
        , sum(revenue)                                              AS revenue
        , sum(sum(revenue)) OVER (ORDER BY date_trunc($4, created)) AS total_revenue
   FROM   sale_event AS s
   WHERE  campaignid = $1
   AND    created <= $3                   -- read all relevant rows in one scan
   AND    event_type = 'session_closed'
   GROUP  BY date_trunc($4, created)
   ) results USING (interval_ts);

The JOIN excludes leading surplus rows in the subquery automatically.

sum(sum(revenue)) OVER (ORDER BY date_trunc($4, created)) works because, quoting the manual:

The default framing option is RANGE UNBOUNDED PRECEDING, which is the same as RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. With ORDER BY, this sets the frame to be all rows from the partition start up through the current row's last ORDER BY peer.

Exactly what you need.

Related:

Remaining weakness: The total is missing for intervals with no revenue. If that's not acceptable, we can use this technique to fix:

SELECT interval_ts, campaign_revenue, total_revenue
     , coalesce(first_value(total_revenue) OVER (PARTITION BY grp ORDER BY interval_ts), 0) AS total_campaign_revenue    
FROM  (
   SELECT interval_ts
        , coalesce(revenue, 0) AS campaign_revenue
        , total_revenue
        , count(total_revenue) OVER (ORDER BY interval_ts) AS grp
   FROM  (
      SELECT interval_ts
           , coalesce(revenue, 0) AS campaign_revenue
           , count(total_revenue) OVER (ORDER BY interval_ts) AS grp
      FROM   generate_series($2::timestamp, $3::timestamp, $4) AS interval_ts
      LEFT   JOIN (
         SELECT date_trunc($4, created) AS interval_ts
              , sum(revenue) AS revenue
              , sum(sum(revenue)) OVER (ORDER BY date_trunc($4, created)) AS total_revenue
         FROM   sale_event AS s
         WHERE  campaignid = $1
         AND    created <= $3                   -- read all relevant rows in one scan
         AND    event_type = 'session_closed'
         GROUP  BY date_trunc($4, created)
         ) results USING (interval_ts)
      ) sub1
   ) sub2;

With the added overhead, I am not sure it can compete. Still might if your selection is small and the table is big.

Minor notes:

  • You don't need parentheses around join conditions.

  • Don't call your timestamp "date". That's misleading. I use interval_ts instead of interval_date.

  • I'd rather not use the SQL keyword interval as column alias - even if that's allowed in Postgres.

  • Working with the same column alias interval_ts to allow the shorter USING syntax - which does require parentheses. This only exposes one instance of the joined columns interval_ts to the outer query, so the unqualified name still isn't ambiguous.

  • Don't omit the AS key word for column aliases (while that's OK for table aliases).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.