Conditional Logic in SQL

A few days ago Sven Weller has published an excellent post about writing conditional logic in SQL and it reminded me of an example I used in one of my presentations. It is also about conditional logic in SQL but maybe some less obvious use cases in context of function calls and sorting.

Assume we have a table ORDER_ITEMS.

SQL> SELECT * FROM order_items;

 ORDER_ID LINEITEM_ID STATUS   SHIP_DT  
--------- ----------- -------- ---------
        1           1 shipped  01-FEB-16
        1           2 shipped  01-JAN-16
        1           3 open              
        1           4 returned 01-DEC-15

Listing 1

The slightly artificial requirement is to report items “as is”, without grouping, along with the number of shipped and returned items and the first (oldest) shipping date of an item in “shipped” state. Per order, of course. So the result should be as follows:

ORDER_ID LINEITEM_ID STATUS   SHIP_DT     SHIP_CNT  RET_CNT FIRST_SHI
-------- ----------- -------- --------- ---------- -------- ---------
       1           2 shipped  01-JAN-16          2        1 01-JAN-16
       1           1 shipped  01-FEB-16          2        1 01-JAN-16
       1           4 returned 01-DEC-15          2        1 01-JAN-16
       1           3 open                        2        1 01-JAN-16

Listing 2

Well, since we don’t allowed to group but need to count, the first idea is to use analytic functions. But we need a kind of conditional count or two of them respectively: one for shipped and one for returned items. That is, we have to count rows only if the state has the particular value and skip all other rows. How we can do this?

Since the table we query can be huge, I assume we should avoid nesting and multiple scans of it. One of the possible solutions – and there are always a few – would be to use the described “conditional logic” when calling analytic functions as shown in Listing 3.

What we are counting is the expression returned by the CASE-operator. We should return any NOT NULL column if the condition is met or NULL otherwise. Why NULL? Because of a special handling of NULL values by the COUNT aggregate function. COUNT(expr) returns the number of rows where expr is not null. See also Nulls and aggregate functions for more information. You can achieve the same effect using SUM and summarizing 1 and 0 values, if you find it to be more intuitive.

SQL> SELECT oi.order_id, oi.lineitem_id, oi.status, oi.ship_dt 
  2  ,      COUNT(CASE WHEN status='shipped' 
  3                   THEN lineitem_id 
  4                   ELSE NULL 
  5               END)  
  6              OVER (PARTITION BY order_id ) ship_cnt
  7  ,      SUM(CASE WHEN status='shipped' 
  8                  THEN 1 
  9                  ELSE 0 
 10             END)  
 11              OVER (PARTITION BY order_id ) ship_cnt2            
 12  FROM order_items oi;

  ORDER_ID LINEITEM_ID STATUS   SHIP_DT     SHIP_CNT  SHIP_CNT2
---------- ----------- -------- --------- ---------- ----------
         1           1 shipped  01-FEB-16          2          2
         1           4 returned 01-DEC-15          2          2
         1           3 open                        2          2
         1           2 shipped  01-JAN-16          2          2

Listing 3

Well, this was the first part of the question. How to find the first shipping date of the items in shipped state? Let us first see how to do it wrong (Listing 4). 😉

Simply using FIRST_VALUE analytical function doesn’t return the correct date: we have found the oldest date, but the lineitem it belongs to is not in shipped state. Somehow we have to take the state into account. How about using the same approach as with counting rows (lines 6-11)? This returns null – why? What happens? We are ordering by SHIP_DT, the oldest date is ’01-DEC-15′, so we found the row in question, that is the line_item 4. Now what should the function FIRST_VALUE return from this row? We have said, the CASE expression should be returned and this CASE expression just evaluates to NULL, since the state of the row is “returned”, not “shipped”.


SQL> SELECT oi.order_id, oi.lineitem_id, oi.status, oi.ship_dt 
  2  ,      FIRST_VALUE(ship_dt) 
  3              OVER (PARTITION BY order_id 
  4                    ORDER BY ship_dt) first_shipment_wrong
  5  
  6  ,      FIRST_VALUE(CASE WHEN status='shipped' 
  7                          THEN ship_dt 
  8                          ELSE NULL 
  9                     END) 
 10              OVER (PARTITION BY order_id 
 11                    ORDER BY ship_dt) also_wrong
 12  FROM order_items oi
 13  ORDER BY order_id, lineitem_id;

  ORDER_ID LINEITEM_ID STATUS   SHIP_DT   FIRST_SHI ALSO_WRON
---------- ----------- -------- --------- --------- ---------
         1           1 shipped  01-FEB-16 01-DEC-15          
         1           2 shipped  01-JAN-16 01-DEC-15          
         1           3 open               01-DEC-15          
         1           4 returned 01-DEC-15 01-DEC-15          
        

Listing 4

We can correct this at least in two ways. First, there is an option to find non-null values: IGNORE NULLS clause, which has been introduced in 11.2. But be aware, with FIRST_VALUE we will also get wrong results because of Oracle’s default windowing clause "RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW" (Listing 5 lines 2-6). We simply don’t allow the function to look ahead and find non-null values. Provide the correct windowing clause to fix this (lines 8-15). Another way is to use conditional expression while ordering. Doing so we only consider shipping dates with correct state (lines 17-23).


SQL> SELECT oi.order_id, oi.lineitem_id, oi.status, oi.ship_dt 
  2  ,       FIRST_VALUE(CASE WHEN status='shipped' 
  3                           THEN ship_dt 
  4                           ELSE NULL 
  5                      END ) IGNORE NULLS
  6          OVER (PARTITION BY order_id ORDER BY ship_dt) still_wrong
  7  
  8  ,       FIRST_VALUE(CASE WHEN status='shipped' 
  9                           THEN ship_dt 
 10                           ELSE NULL 
 11                      END ) IGNORE NULLS
 12              OVER (PARTITION BY order_id 
 13                    ORDER BY ship_dt 
 14                    ROWS BETWEEN UNBOUNDED PRECEDING 
 15                             AND UNBOUNDED FOLLOWING) now_correct
 16  
 17  ,       FIRST_VALUE(ship_dt) 
 18              OVER (PARTITION BY order_id 
 19                ORDER BY CASE WHEN status='shipped' 
 20                              THEN ship_dt 
 21                              ELSE NULL 
 22                         END 
 23                NULLS LAST) also_correct
 24  FROM order_items oi
 25  ORDER BY order_id, lineitem_id;

  ORDER_ID LINEITEM_ID STATUS   SHIP_DT   STILL_WRO NOW_CORRE ALSO_CORR
---------- ----------- -------- --------- --------- --------- ---------
         1           1 shipped  01-FEB-16 01-JAN-16 01-JAN-16 01-JAN-16
         1           2 shipped  01-JAN-16 01-JAN-16 01-JAN-16 01-JAN-16
         1           3 open               01-JAN-16 01-JAN-16 01-JAN-16
         1           4 returned 01-DEC-15           01-JAN-16 01-JAN-16
 

Listing 5

Putting it all together, we get the required result set (Listing 6). Putting the logic next to the function calls instead of the WHERE clause allows us to combine calculations in one query which otherwise would be spread across multiple queries with their own WHERE clauses.


SQL> SELECT oi.order_id, oi.lineitem_id, oi.status, oi.ship_dt 
  2  ,      COUNT(CASE WHEN status='shipped' 
  3                    THEN lineitem_id 
  4                    ELSE NULL 
  5               END)  
  6         OVER (PARTITION BY order_id ) ship_cnt
  7  
  8  ,      COUNT(CASE WHEN status='returned' 
  9                    THEN lineitem_id 
 10                    ELSE NULL 
 11               END)  
 12         OVER (PARTITION BY order_id ) ret_cnt
 13  
 14  ,      FIRST_VALUE(ship_dt) 
 15              OVER (PARTITION BY order_id 
 16                ORDER BY CASE WHEN status='shipped' 
 17                              THEN ship_dt 
 18                              ELSE NULL 
 19                         END 
 20                NULLS LAST) first_shipment
 21  FROM o12test.order_items oi
 22  ;


ORDER_ID LINEITEM_ID STATUS   SHIP_DT     SHIP_CNT    RET_CNT FIRST_SHI
-------- ----------- -------- --------- ---------- ---------- ---------
       1           2 shipped  01-JAN-16          2          1 01-JAN-16
       1           1 shipped  01-FEB-16          2          1 01-JAN-16
       1           4 returned 01-DEC-15          2          1 01-JAN-16
       1           3 open                        2          1 01-JAN-16

Listing 6

Well, I admit, SQL statements of this kind are not so easy to understand and are difficult to maintain. I don’t want to judge whether they should be used. Now and then the performance gain can clearly outweigh the maintenance headaches, in other situation you can have no performance gain at all. As always, it depends. And I think one should always critically question the requirements, whether the complex things are really need to be done. I just wanted to show the approach as it clearly has to do with conditional logic in SQL. If you nevertheless end up with such SQL, remember it should be extensively commented and documented from day one. Believe me, next day you will not grasp any more what exactly your SQL should do.

One thought on “Conditional Logic in SQL

  1. Stew Ashton

    Starting with 12c, MATCH_RECOGNIZE provides an alternative. The conditional logic in the DEFINE clause applies to rows, not columns, so it doesn’t have to be repeated.

    select * from order_items
    match_recognize(
    partition by order_id order by ship_dt
    measures
    final count(s.*) ship_cnt,
    final count(r.*) return_cnt,
    first(s.ship_dt) first_shipment
    all rows per match
    pattern( (s|o|r)+ )
    define s as status = ‘shipped’,
    o as status = ‘open’,
    r as status = ‘returned’
    );

    Best regards, Stew

    Reply

Leave a Reply to Stew Ashton Cancel reply

Your email address will not be published. Required fields are marked *