Cross Join with min and max condition

Viewed 401

I have customerid column and monthly billed_date. I want to create a cross join between two where I would only have billed date for every customer_id from min(billed_date) to max(billed_date) from every customer. this is a sample table to explain what I want to achieve -

enter image description here

4 Answers

You can get the desired output using a recursive CTE like this:

with cte as (
  select
    cust_id,
    min(bill_date) as bill_date,
    max(bill_date) as max_bill_date
  from mytable group by cust_id
  union all
  select cust_id, DATEADD(month, 1, bill_date), max_bill_date
  from cte where bill_date < max_bill_date
)
select c.cust_id, c.bill_date, coalesce(m.amount,0)
from cte c left join mytable m
on c.cust_id=m.cust_id and c.bill_date=m.bill_date
order by 1, 2
  1. Get the period for each cust_id using MIN and MAX.
  2. Get a list of all months for each cust_id using the recursive CTE with UNION ALL.
  3. Join a list of all months and your table.

Make a calendar table (do this one time only), this one courtesy of sqlskull.com:

DECLARE @Year INT = '2000';
DECLARE @YearCnt INT = 50 ;
DECLARE @StartDate DATE = DATEFROMPARTS(@Year, 1, 1)
DECLARE @EndDate DATE = DATEADD(DAY, -1, DATEADD(YEAR, @YearCnt, @StartDate));

;WITH Cal(n) AS
(
SELECT 0 UNION ALL SELECT n + 1 FROM Cal
WHERE n < DATEDIFF(DAY, @StartDate, @EndDate)
),
FnlDt(d) AS
(
SELECT DATEADD(DAY, n, @StartDate), n FROM Cal
),
FinalCte AS
(
SELECT
[D] = CONVERT(DATE,d),
[Dy] = DATEPART(DAY, d),
[Mo] = DATENAME(MONTH, d),
[Yr] = DATEPART(YEAR, d),
[DN] = DATENAME(WEEKDAY, d),
N

FROM FnlDt
)
SELECT * 
INTO tally
FROM finalCte
ORDER BY [Date]
OPTION (MAXRECURSION 0);

Now you have a dates and numbers table you can use forever more, like this:

    SELECT *
    FROM
      (SELECT
          cust_id,
          min(bill_date) minbill,
          max(bill_date) maxbill
      FROM t
    ) r
    INNER JOIN tally x ON x.Dy = DAY(minbill) AND x.d BETWEEN r.minbill AND r.maxbill
    LEFT JOIN t ON x.d = t.bill_date and r.cust_id = t.cust_id

The grouping query inner joined to the tally table is your cross join (it can't be a cross because it does have some predicate, namely that the tally rows be between min and max). This will cause each grouped-per-customer row to multiply up to the number of dates in the tally between the min and max - it gives you a cross join of every customer id joined to every date between min and max.

Then you just need to connect it back to the cust data table using a left join to get either the dates that do have data or dates that don't (and leave the tally date in for those days that don't have any customer data - convert the null to a 0)

If you want just handle dates in your table you can use this way,

First create a table that contains all dates in your table and then cross join with your table:

SELECT DISTINCT bill_date INTO #dates FROM YourTable;

SELECT t.Cust_id,
       t.bill_date,
       MAX(t.Amount) Amount
FROM
(
    SELECT Cust_id,
           bill_date = CASE
                           WHEN #dates.bill_date <> YourTable.bill_date THEN
                               #dates.bill_date
                           ELSE
                               YourTable.bill_date
                       END,
           Amount = CASE
                        WHEN #dates.bill_date <> YourTable.bill_date THEN
                            0
                        ELSE
                            YourTable.Amount
                    END
    FROM YourTable,
         #dates
) t
GROUP BY t.Cust_id,
         t.bill_date
ORDER BY t.Cust_id,
         t.bill_date;

A recursive CTE is the right way to solve the problem. However, there is no need to join back to the original table. The recursive CTE can do all the work:

with cte as (
      select cust_id, bill_date, amount, 
             dateadd(month, -1,
                     lead(bill_date) over (partition by cust_id order by bill_date)
                    ) as next_bill_date
      from t
      union all
      select cust_id, date_add(month, 1, bill_date), 0,
             next_bill_date
      from cte
      where bill_date < next_bill_date
     )
select *
from cte
order by cust_id, bill_date;

Basically, this constructs the additional rows as needed for each existing row. The lead() gets the end date and the recursive CTE just adds in the rows. I would expect this to have better performance -- lead() should have better performance than group by and it eliminates the join entirely.

Related