Reputation: 1342
I have the following query which returns totals from a transactions tables grouped by month and year:
SELECT
DATEPART(month, T.Date) AS Month,
DATEPART(year, T.Date) AS Year,
ISNULL(SUM(Amount) ,0) AS Total
FROM
Transactions AS T
GROUP BY
DATEPART(month, T.Date), DATEPART(year, T.Date)
This query will only return monthly totals for those months which have transactions for that month and year in the transactions table.
How can I modify the query above so that it will return 0 for all months and years between two dates?
I suspect I need to cross reference the query above with a table that needs to be built dynamically containing all the months and years but I'm not sure how to go about this.
Upvotes: 0
Views: 76
Reputation: 13965
You actually don't need to have an additional table, if all you want is months and years. You can simulate one, with a recursive CTE (Common Table Expression).
DECLARE @startDate datetime = '1/1/2020';
DECLARE @endDate datetime = '1/1/2023';
-- This is a recursive Common Table Expression. They allow you to generate
-- sets of records from seemingly nothing.
-- A recursive CTE involves two SELECT statements joined with a UNION ALL statement.
-- The first statement is the "anchor". It is usually the start of the sequence
-- you're trying to generate. In our case, we say "select our start date
-- as the first record in the sequence we're generating".
-- The second SELECT statement refers recursively to the first one. (I
-- think it may be called "the generator clause", but I'm not sure.) We
-- refer to the record or records selected in the first statement, but
-- add or modify the date to produce the second and subsequent records.
-- So, in our case, we're saying, "Select record(s) from the CTE, and
-- this time, derive our value ("ReferenceDate") by adding a month to
-- the value we're reading."
-- This will continue indefinitely, if you query it. That's why we need
-- a test (the WHERE clause) to limit the records we return, or you'll
-- quickly reach the year 10,000!
WITH Calendar AS (
SELECT @startDate as ReferenceDate
UNION ALL
SELECT DateAdd(month, 1, c.ReferenceDate)
FROM Calendar c
WHERE c.ReferenceDate < @endDate
),
-- The Calendar CTE is a list of the first date of every month. But
-- for our report we need the year and the month, separately. So,
-- let's produce a second CTE (akin to a derived table) that returns
-- the year and month for every date in Calendar.
YearMonths AS (
SELECT datepart(year, ReferenceDate) as [Year]
, datepart(month, ReferenceDate) as [Month]
FROM Calendar
),
-- This CTE is your summary data. We're going to want to join it
-- to YearMonths, and we need it already summarized.
-- (We could have done this as a derived table in the final select,
-- but IMO that would have complicated it without giving any advantage.
-- CTEs perform like subqueries or derived tables.)
SummaryTransactions AS (
select
DATEPART(month, T.Date) as Month,
DATEPART(year, T.Date) as Year,
ISNULL(SUM(Amount) ,0) As Total
from Transactions AS T
GROUP BY
DATEPART(month, T.Date),
DATEPART(year, T.Date)
)
-- The final query!
SELECT ym.Year
, ym.Month
, ISNULL(st.Total, 0) as Total
FROM YearMonths ym
LEFT JOIN
SummaryTransactions st
ON ym.Year = st.Year
AND ym.Month = st.Month
That was from memory, so I can't guarantee it is free from typos, but it ought to work.
The Calendar
CTE acts as the "table in memory" you talk about: it will generate a set of records for the first of each month between the start and end dates, without actual records being created anywhere. It's a handy trick!
Note that you'll need to set a date range for your Calendar
CTE: you need a fixed start date, for the first record in your CTE, and if you don't set an end date it will continue indefinitely!
Upvotes: 1