Search code examples
sqlstored-procedurescursorsql-server-2012

Can this cursor be replaced


I am currently using a cursor in my sql server procedure. Wanted to know if there is anyway to replace it with a better approach. Process is

  1. Customer pays some money, and I create an entry for it in the payment table.
  2. I start a cursor that selects all payments of that customer that have an available balance, from PAYMENT TABLE
  3. Then I start an inner cursor that fetches all the bills of that customer which are still unpaid, from BILL TABLE
  4. I pay off each bill till the current payment is exhausted and then repeat the process

How can I remove the cursors for a more effective way in steps 2 and 3. Also does use of cursors mean that the PAYMENT and BILL tables remain locked till the procedure runs?

Tx


Solution

  • Here's one way it can be done, with made up tables and data since we don't know what yours look like. I'm putting some narrative in in places but all of the code should be run as one single script.

    Data setup:

    declare @bills table (billid int, balance decimal(38,4))
    declare @payments table (paymentid int, balance decimal(38,4))
    
    insert into @bills (billid, balance) values
    (1,0), (2,22.50), (3,12.75), (4,19.20)
    insert into @payments (paymentid,balance) values
    (1,20.19),(2,5.50),(3,20)
    
    declare @newpayments table (billid int, paymentid int,
                                paymentamount decimal(38,4))
    

    I've assumed that the bills and payments tables have a column, called balance which shows any amounts not dealt with as yet. Alternatively, you may have to calculate this from a couple of columns. But no sample data in your question means I get to make up an easy structure :-)

    Query to populate @newpayments with which bills should be paid from which (partial) payments1:

    ; With unpaidbills as (
        select billid,balance,
            ROW_NUMBER() OVER (ORDER BY billid) as rn,
            SUM(balance) OVER (ORDER BY billid
                          ROWS BETWEEN UNBOUNDED PRECEDING
                               AND CURRENT ROW) as endbalance,
            SUM(balance) OVER (ORDER BY billid
                          ROWS BETWEEN UNBOUNDED PRECEDING
                               AND CURRENT ROW) - balance as startbalance
        from @bills
        where balance > 0
    ), unusedpayments as (
        select paymentid,balance,
            ROW_NUMBER() OVER (ORDER BY paymentid) as rn,
            SUM(balance) OVER (ORDER BY paymentid
                          ROWS BETWEEN UNBOUNDED PRECEDING
                               AND CURRENT ROW) as endbalance,
            SUM(balance) OVER (ORDER BY paymentid
                          ROWS BETWEEN UNBOUNDED PRECEDING
                               AND CURRENT ROW) - balance as startbalance
        from @payments
        where balance > 0
    ), overlaps as (
        select
            billid,paymentid,
            CASE WHEN ub.startbalance < up.startbalance
                    THEN up.startbalance ELSE ub.startbalance END as overlapstart,
            CASE WHEN ub.endbalance > up.endbalance
                    THEN up.endbalance ELSE ub.endbalance END as overlapend
        from
            unpaidbills ub
                inner join
            unusedpayments up
                on
                    ub.startbalance < up.endbalance and
                    up.startbalance < ub.endbalance
    )
    insert into @newpayments(billid,paymentid,paymentamount)
    select billid,paymentid,overlapend - overlapstart as paymentamount
    from overlaps
    

    At this point, @newpayments can be used to generate transaction history, etc

    And then, finally we update the original tables to mark the amounts used:

    ;With totalpaid as (
        select billid,SUM(paymentamount) as payment from @newpayments
        group by billid
    )
    update b
    set b.balance = b.balance - tp.payment
    from @bills b
        inner join
        totalpaid tp
        on b.billid = tp.billid
    
    ;With totalused as (
        select paymentid,SUM(paymentamount) as payment from @newpayments
        group by paymentid
    )
    update p
    set p.balance = p.balance - tu.payment
    from @payments p
        inner join
        totalused tu
        on p.paymentid = tu.paymentid
    

    The key part was to use SUM() with window functions to calculate the running totals of the amounts owed (bills) or amounts available (payments), in both cases using a column (billid or paymentid) to determine in what order each of these items should be dealt with. E.g. the unpaidbills CTE produces a result set like this:

    billid      balance   rn                   endbalance    startbalance
    ----------- --------- -------------------- ------------- -------------
    2           22.5000   1                    22.5000       0.0000
    3           12.7500   2                    35.2500       22.5000
    4           19.2000   3                    54.4500       35.2500
    

    and unusedpayments looks like this:

    paymentid   balance    rn                   endbalance   startbalance
    ----------- ---------- -------------------- ------------ -------------
    1           20.1900    1                    20.1900      0.0000
    2           5.5000     2                    25.6900      20.1900
    3           20.0000    3                    45.6900      25.6900
    

    We then create the overlaps CTE which finds overlaps2 between the bills and payments where (part of) a payment can be used to satisfy (part of) a bill. The region of the overlap is the actual amount to pay for that bill.


    1 The ROW_NUMBER() calls aren't really needed. In an early part of writing this query, I thought I was going to use these but it turned out to be unnecessary. But removing them doesn't shorten things enough to allow SO to stop scrolling that query anyway, and so I may as well leave them in (and not have to edit the result sets shown lower down also)

    2 Many people trying to find overlaps make things absurdly complicated and deal with many special cases to find all overlaps. This can usually be done far more simply in the way that I show in the overlaps CTE - two ranges overlap if the first range starts before the second range ends, and the second range starts before the first range ends.

    The only tricky thing to do is to decide whether you want to deal with two ranges that abut (the first one's end value is exactly equal to the second one's start or vice versa) but that just leads to a decision on whether to use < or <= in the comparisons.

    In this instance, we don't care if a payment exactly paid off the previous bill so we use < to avoid treating such situations as an overlap.