sqloracle-databaseconnect-by

How to deal with a NULL in Connect By Level without the query going on forever?


I have a leases table, with start and end date of each lease. Some leases don't have an end date, and the field is NULL. I want to generate a list of the payment dates/amounts over the duration of the lease. For the leases with an end date, I'm using CONNECT BY LEVEL \<= TRUNC(MONTHS_BETWEEN(pl.END_DATE, pl.START_DATE)+1,0).

However if I try to handle the case when the end_date is NULL, then the query goes on forever.

Sample data, lease 2 works fine and generates 13 rows, while lease 4 just goes on forever.

Lease_Id Start_Date End_Date
4 16/Jun/2023 NULL
2 20/May/2024 14/Jun/2025

I've tried various iterations of replacing the NULL with the current date, as an output field and in the results.

SELECT  CASE WHEN rownum = 1 THEN pl.START_DATE ELSE ADD_MONTHS(trunc(pl.START_DATE, 'mon') + pl.PAYMENT_DAY -1, rownum -1) END AS PAYMENT_DATE
         , pl.*
         , rownum AS pm_num
FROM    PM_LEASES pl  
WHERE   pl.LEASE_ID = 4
CONNECT BY LEVEL <= TRUNC(MONTHS_BETWEEN(nvl(pl.END_DATE, trunc(sysdate)), pl.START_DATE)+1,0)

Also tried reading the Oracle documentation, however it focuses on actual hierarchies.I'm using this as a workaround for the moment, which works if the lease is a 100 payments or less

SELECT  pl.lease_id
        , CASE WHEN lvl = 1 THEN pl.first_payment_amt ELSE pl.lease_amt END AS lease_amt
        , CASE WHEN lvl = 1 THEN pl.start_date ELSE ADD_MONTHS(trunc(pl.START_DATE, 'mon') + pl.PAYMENT_DAY -1, lvl-1) END AS payment_date
FROM    pm_leases pl
LEFT JOIN (select level as lvl from dual connect by level <= 100) ON 1=1
WHERE   add_months(pl.start_date, lvl) <= nvl(pl.end_date, add_months(trunc(sysdate), 1))

Solution

  • From Oracle 12, you can LATERAL join to a row generator and use COALESCE(end_date, SYSDATE) to get the final day:

    SELECT  CASE m.months
            WHEN 0
            THEN pl.start_date
            ELSE ADD_MONTHS(
                   TRUNC(pl.start_date, 'MM') + pl.payment_date - 1,
                   m.months
                 )
            END AS PAYMENT_DATE,
            pl.*
    FROM    PM_LEASES pl
            CROSS JOIN LATERAL(
              SELECT LEVEL - 1 AS months
              FROM   DUAL
              CONNECT BY LEVEL <= MONTHS_BETWEEN(
                                    COALESCE(pl.end_date, SYSDATE),
                                    TRUNC(pl.start_date, 'MM') + pl.payment_date - 1
                                  ) + 1
            ) m
    -- WHERE   pl.LEASE_ID = 4
    

    Which, for the sample data:

    CREATE TABLE pm_leases (Lease_Id, Payment_date, Start_Date, End_Date) AS
    SELECT 4, 12, DATE '2023-07-16', NULL FROM DUAL UNION ALL
    SELECT 2, 28, DATE '2024-05-20', DATE '2025-06-14' FROM DUAL;
    

    Outputs:

    PAYMENT_DATE LEASE_ID PAYMENT_DATE START_DATE END_DATE
    2023-07-16 00:00:00 4 12 2023-07-16 00:00:00 null
    2023-08-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2023-09-12 00:00:00 4 12 2023-07-16 00:00:00 null
    ... ... ... ... ...
    2024-04-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-05-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-06-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-05-20 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2024-06-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2024-07-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    ... ... ... ... ...
    2025-05-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2024-05-20 00:00:00 1 12 2024-05-20 00:00:00 2024-06-10 00:00:00
    2024-05-20 00:00:00 3 22 2024-05-20 00:00:00 2024-06-21 00:00:00

    or, depending on how you want to handle payment dates that may occur in the same month as the starting month:

    SELECT  GREATEST(
              ADD_MONTHS(
                TRUNC(pl.start_date - pl.payment_date + 1, 'MM')
                + pl.payment_date - 1,
                m.months
              ),
              pl.start_date
            ) AS PAYMENT_DATE,
            pl.*
    FROM    PM_LEASES pl
            CROSS JOIN LATERAL(
              SELECT LEVEL - 1 AS months
              FROM   DUAL
              CONNECT BY ADD_MONTHS(
                           TRUNC(pl.start_date - pl.payment_date + 1, 'MM')
                           + pl.payment_date - 1,
                           LEVEL - 1
                        ) <= COALESCE(pl.end_date, SYSDATE)
            ) m
    --WHERE   pl.LEASE_ID = 4
    

    Which outputs:

    PAYMENT_DATE LEASE_ID PAYMENT_DATE START_DATE END_DATE
    2023-07-16 00:00:00 4 12 2023-07-16 00:00:00 null
    2023-08-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2023-09-12 00:00:00 4 12 2023-07-16 00:00:00 null
    ... ... ... ... ...
    2024-04-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-05-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-06-12 00:00:00 4 12 2023-07-16 00:00:00 null
    2024-05-20 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2024-05-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2024-06-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    ... ... ... ... ...
    2025-03-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2025-04-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00
    2025-05-28 00:00:00 2 28 2024-05-20 00:00:00 2025-06-14 00:00:00

    Note: You may need something more robust if payment_date is set to 29-31 as not all months have that many days.

    fiddle