Find the ONE ... or the other

In his recent blog post “Code Pushdown” Paul Hardy, a long-time SAP community contributor described his first foray into HANA SQLScript programming.

Paul picked a very ABAP-y problem to solve: Finding records with fallback to a default value.

I won’t go into too much detail where and how that is done in SAP ABAP-based solutions, just this: For a group of records (like an order made up of order items), there are matching records in another table. The rule to get those matching records is to check whether there is a full match for an item in the table. If there is, this match should be used. If there isn’t a match, another, a default entry (that is guaranteed to be there) should be used.

Paul’s example is about orders with order-items that can have different payment terms. Each item can have its own payment terms but will use the default payment terms that are defined by the “header” order-item (which is item 00000000 in the order items table).

It’s not hard to think of other examples for something like that. Machine configurations could be such a thing. Or variations of pizza toppings.

Olden days and olden ways #

Paul explains that the typical ABAP approach was to simply try and read the specific payment terms for each order item and do a second read to get the default value if the first read did not get anything back.

That’s two ABAP <=> DB roundtrips for just one record. Worse, the application does not add any logic here; the database (not just HANA) can very well determine whether a SELECT returns something or not on its own. No confirmation or double-check from the application required.

Newer ABAP language versions provide some syntactic sugar for this scenario: they allow for specifying how a DEFAULT record should be computed. That’s an improvement but only for making the ABAP code better express the intention of the developer.

Paul is solving the problem #

Like probably many ABAPers would do, Paul created an AMDP (ABAP Managed Database Procedure) that mimics the ABAP logic 1:1.

As admitted in the post, this was his first SQLScript ever written. Considering this, it’s a success as it delivers the correct results… sometimes.

The code reads the data for an order item into an SQLScript table variable, like so:

bdata = select ... from <table> where <conditions>;

Then, assuming there will be at most one record in the result set, a column (part of a table) is “extracted” into a variable (a scalar value).

ed_payment_terms = :bdata.paymentterms[1];

IF :ed_payment_terms <> '' THEN
    RETURN;
END IF;

The problem here is that if there is no record in bdata, accessing the table variable on index 1 will fail.

The style faux-pas to use = instead of := can certainly be attributed to the lack of good programming guides/manuals for SQLScript developers.

I agree with Paul’s sentiment that the SAP documentation is not really helpful here (and it’s not really supposed to: SAP’s documentation is a legal document outlining the features and expected behaviours of the systems they sell).

The more SQL-y way of doing all this would be to use

select
     …
INTO ed_payment_terms
from <table>
where <conditions>;

This would also fail with a NO DATA FOUND error, if there was no matching record. One way to deal with this is to implement some error handling into the procedure, which is verbose and tedious (but required in pre-HANA 2.3).

With recent HANA versions one can use the DEFAULT VALUE clause to safeguard against this error (see what SAP did here?)

select
     …
INTO ed_payment_terms DEFAULT 'some default value'
from <table>
where <conditions>;

Don’t stop here though #

The problem with this very common approach of duplicating ABAP/application logic in SQLscript is that it often only reduces data transfers between application and database. It does not take advantage of what the SQL environment could do here to further increase performance.

One early commenter to the post provided a solution in ABAP CDS that performs multiple outer joins between the items table and the payment terms table (the commenter uses the actual ABAP tables, but that doesn’t matter here) to first figure out if an item has a “special” payment term or uses the default and then provide the correct join key to a second join that actually computes the result set.

This, also, solves the problem and provides an easy-to-use database view that shows all correct match-ups for all items. This can be easily consumed in ABAP, does not require creating a stored procedure (which always bears the overhead of invoking the SQLscript engine in HANA, which means there is a performance/resource penalty compared to “straight” SQL) and fits easily into the modern ABAP/CDS development flow.

So, that’s fine, isn’t it? #

For the scenario Paul described, the CDS view approach is probably fine, can be thought up and implemented in an hour or so and is an improvement over the old-style ABAP approach.

There are of course alternative options and I looked into some of them, mostly a modelling exercise for myself.

I thought of:

  • sqlscript select into with error handling i n case no data is found

    • only suitable for single records lookup
    • added SQLscript overhead
  • sqlscript select into with DEFAULT clause

    • only suitable for single record lookups
    • added SQLscript overhead
  • SELECT with LEFT OUTER JOIN and check for NULLs

    • potentially big overhead
  • UNION with best record select (i.e. MAX + JOIN or RANK/ROW_NUMBER() + FILTER)

    • potentially big overhead
    • also, UNION is not necessary as all DEFAULT/special entries are in a single table anyhow
  • keep track of non-default entries in table b, i.e. have a flag there to indicate that there is a non-default setting

    • would require additional updates, maybe via triggers (ref. constraints won’t do that. Alternatively the application code could do that)
    • would address the technical issue of not knowing if there should be a match before doing the join/select on table b
    • would keep storage requirements in table a and the number of records in table b low
  • “unfold” the default cases to ensure all records in table a have a distinct matching record in table b

    • leads to potentially many more records in table b (especially if records in table a most often use the default)
    • but column store compression could reduce this again

Thinking good, testing better #

Then I went and did a test implementation for the ones that I found promising. This step - implementing a version and testing it out - is something I can only recommend.

It is so easy to fall in love with a clever solution in your head. If it does not survive a test, it shouldn’t get into production.

Test setup #

Like Paul, I want some data to play with. I went, however, for some more data than he did (MONSTER table naming inspired by Paul’s post):

create column table MONSTER_BUSINESS_ITEMS
 ( client      NVARCHAR( 3) NOT NULL
 , order_number  NVARCHAR(10) NOT NULL
 , item_number NVARCHAR( 8) NOT NULL
 , item_info  NVARCHAR(80) NOT NULL DEFAULT ''
 , primary key  (client, order_number, item_number));

create column table MONSTER_BUSINESS_ITEM_DATA
 ( client      NVARCHAR( 3) NOT NULL
 , order_number  NVARCHAR(10) NOT NULL
 , item_number NVARCHAR( 8) NOT NULL
 , payment_terms NVARCHAR(10) NOT NULL
 , primary key  (client, order_number, item_number));

--- generate some test data
--- 10000 orders with 20 items each
insert into MONSTER_BUSINESS_ITEMS (
 select
    '007' as client
  , ABAP_NUMC(orders.generated_period_start, 10) as order_number
  , ABAP_NUMC(items.generated_period_start, 8) as item_number
  , 'info for item '
   || orders.generated_period_start
   ||'/'|| items.generated_period_start as item_info
 from
  series_generate_integer (1, 1, 10001) orders
  cross join
  series_generate_integer (1, 0, 20) items
  );

-- create DEFAULT entries for all orders (=> item_number = '00000000'
-- as different orders may have different DEFAULTs, just add variety

insert into MONSTER_BUSINESS_ITEM_DATA (
 select
     client
   , order_number
   , item_number
   , map(mod(order_number, 3)
       , 0, 'DEFAULT0'
       , 1, 'DEFAULT1'
       , 2, 'DEFAULT2'
          , 'DEFAULTX') as payment_terms
 from
  monster_business_items
 where
  item_number = ABAP_NUMC(0, 8));

select count(*) from  monster_business_items; -- 200.000

-- create NON-DEFAULT entries for 5% of all items with item_number != '00000000'
-- remember to insert NON-DEFAULT values!

insert into MONSTER_BUSINESS_ITEM_DATA (
 select
     client
   , order_number
   , item_number
   , 'SPECIAL'
 from
  monster_business_items TABLESAMPLE BERNOULLI ( 5 )
 where
  item_number != ABAP_NUMC(0, 8)
 order by
  client, order_number, item_number
);
--  - Rows Affected: 9443

Note: I deliberately made use of the SERIES_GENERATE_INTEGER, ABAP_NUMC and TABLESAMPLE functions, as these belong to the functions that have been available in HANA for some time but that barely gets used. Even worse, developers build their own versions of such functions, because they are not aware of the existing ones.

Ready, steady, go #

Now, we have 200.000 order_items and about 5% of them have non-default payment terms.

The data looks like this:

CLIENT ORDER_NUMBER ITEM_NUMBER ITEM_INFO
007    0000000001   00000019    info for item 1/19
007    0000000001   00000018    info for item 1/18
007    0000000001   00000017    info for item 1/17
007    0000000001   00000016    info for item 1/16
007    0000000001   00000015    info for item 1/15
007    0000000001   00000014    info for item 1/14
007    0000000001   00000012    info for item 1/12
007    0000000001   00000011    info for item 1/11
007    0000000001   00000010    info for item 1/10
007    0000000001   00000009    info for item 1/9
007    0000000001   00000007    info for item 1/7
007    0000000001   00000006    info for item 1/6
007    0000000001   00000005    info for item 1/5
007    0000000001   00000004    info for item 1/4
007    0000000001   00000003    info for item 1/3
007    0000000001   00000002    info for item 1/2
007    0000000001   00000001    info for item 1/1
007    0000000001   00000000    info for item 1/0
007    0000000001   00000008    info for item 1/8
007    0000000001   00000013    info for item 1/13
[...]

CLIENT ORDER_NUMBER ITEM_NUMBER PAYMENT_TERMS
007    0000000001   00000000    DEFAULT1
007    0000000001   00000013    SPECIAL
007    0000000001   00000008    SPECIAL

007    0000000002   00000000    DEFAULT2
007    0000000002   00000017    SPECIAL
007    0000000003   00000000    DEFAULT0
007    0000000004   00000000    DEFAULT1
007    0000000005   00000000    DEFAULT2
007    0000000006   00000000    DEFAULT0
007    0000000007   00000000    DEFAULT1
[...]

E.g. for ORDER_NUMBER 1 all but ITEM_NUMBER 8 and 13 have the DEFAULT1 payment terms.

OPTION: BEST MATCH #

  • best record select (i.e. MAX + JOIN or RANK/ROW_NUMBER + FILTER)
  • requires that there is an entry for the item_number to work
  • this utilizes the ordering of item_numbers and 000000000 indicating the default item
  • basically, it sorts all item_numbers of a specific order number and picks the “first” one
  • which means the “smallest” value as we order in ascending order and put NULLS last
select
     bi.client, bi.order_number, bi.item_number
   , bid.payment_terms    as pay_terms_by_simple_join

 , MAP (bid.payment_terms
   , NULL
   , FIRST_VALUE(bid.payment_terms)
     OVER (PARTITION BY
      bi.client, bi.order_number
           ORDER BY
         bi.client, bi.order_number, bi.item_number ASC
        NULLS LAST)
   , bid.payment_terms ) as pay_terms_with_default_fallback

from
     monster_business_items     bi
 left outer join monster_business_item_data bid
 on (bi.client, bi.order_number, bi.item_number) =
  (bid.client, bid.order_number, bid.item_number)
order by
 bi.client, bi.order_number, bi.item_number;

CLIENT ORDER_NUMBER ITEM_NUMBER PAY_TERMS_BY_SIMPLE_JOIN PAY_TERMS_WITH_DEFAULT_FALLBACK
007    0000000001   00000000    DEFAULT1                 DEFAULT1
007    0000000001   00000001    ?                        DEFAULT1
007    0000000001   00000002    ?                        DEFAULT1
007    0000000001   00000003    ?                        DEFAULT1
007    0000000001   00000004    ?                        DEFAULT1
007    0000000001   00000005    ?                        DEFAULT1
007    0000000001   00000006    ?                        DEFAULT1
007    0000000001   00000007    ?                        DEFAULT1
007    0000000001   00000008    SPECIAL                  SPECIAL
007    0000000001   00000009    ?                        DEFAULT1
007    0000000001   00000010    ?                        DEFAULT1
007    0000000001   00000011    ?                        DEFAULT1
007    0000000001   00000012    ?                        DEFAULT1
007    0000000001   00000013    SPECIAL                  SPECIAL
007    0000000001   00000014    ?                        DEFAULT1
007    0000000001   00000015    ?                        DEFAULT1
007    0000000001   00000016    ?                        DEFAULT1
007    0000000001   00000017    ?                        DEFAULT1
007    0000000001   00000018    ?                        DEFAULT1
007    0000000001   00000019    ?                        DEFAULT1
[...]

This approach performs only a single LEFT OUTER JOIN and then uses MAP (same thing as CASE) to handle the empty-matches.

For those records, where there was no specific entry in the ITEM_DATA table, we want to use whatever value is used for the “header-item” with ITEM_NUMBER 0. This is here done with the WINDOW clause and the FIRST_VALUE analytic function.

This function creates a “window” for the current row that includes all rows with the same CLIENT and ORDER_NUMBER (PARTITION BY).

Then this data is sorted by CLIENT, ORDER_NUMBER and ITEM_NUMBER in ascending order with all NULL values at the end of the sorted data ( ORDER BY … NULLS LAST).

Finally, the FIRST_VALUE function picks, well, the FIRST_VALUE which will be the default payment term for this ORDER_NUMBER.

When packaged up into a view this can be used like a normal table:

create view monster_business_items_pay_terms_WINDOW as
(select
   bi.client, bi.order_number, bi.item_number
   , bid.payment_terms    as pay_terms_by_simple_join

 , MAP (bid.payment_terms
   , NULL
   , FIRST_VALUE(bid.payment_terms)
     OVER (PARTITION BY
      bi.client, bi.order_number
           ORDER BY
         bi.client, bi.order_number, bi.item_number ASC
        NULLS LAST)
   , bid.payment_terms ) as pay_terms_with_default_fallback

from
     monster_business_items     bi
 left outer join monster_business_item_data bid
 on (bi.client, bi.order_number, bi.item_number) =
  (bid.client, bid.order_number, bid.item_number)
);

select
    CLIENT, ORDER_NUMBER, ITEM_NUMBER
     , PAY_TERMS_BY_SIMPLE_JOIN
     , PAY_TERMS_WITH_DEFAULT_FALLBACK
from
 monster_business_items_pay_terms_WINDOW
where
  client = '007'
 and order_number = ABAP_NUMC(1, 10)
 and item_number = ABAP_NUMC(3, 8); -- (ABAP_NUMC (13, 8))

CLIENT ORDER_NUMBER ITEM_NUMBER PAY_TERMS_BY_SIMPLE_JOIN PAY_TERMS_WITH_DEFAULT_FALLBACK
007    0000000100   00000003    ?                        DEFAULT1

CLIENT ORDER_NUMBER ITEM_NUMBER PAY_TERMS_BY_SIMPLE_JOIN PAY_TERMS_WITH_DEFAULT_FALLBACK
007    0000000001   00000013    SPECIAL                  SPECIAL

Attacking the core problem - Loss of knowledge #

Taking a step back from fancy SQL implementation tricks, the core of this problem is in fact not the “double-dipping” of the application code. That really is only the symptom of the actual issue.

What we have here is a situation of knowledge loss: When the user of the application decided that ITEM_NUMBER 13 should have its own payment terms, the system and the user “knew” explicitly that this was the case.

The user would not have to look up again if there is a special payment term for item 13 to recall this fact.

Yet, the application (and data-) designers decided to throw away this information and rely on looking it up.

What if - for some reason - that entry for ITEM_NUMBER 13 got removed from the MONSTER_BUSINESS_ITEM_DATA table? Nobody would ever know that there was supposed to be an entry.

And all our implementation efforts up to here basically tried to work out this little piece of information (whether the default or the special terms apply) and then fetch the data accordingly.

What if we never lost that information? Could we do better?

Of course, we can!

All it takes is a sort of indicator or flag for every ITEM_NUMBER that represents this bit of information. This indicator could easily be maintained by the application that works on this data anyhow; when inserting data into the ITEM_DATA table it could just update the flag. Alternatively, one could retrofit database triggers (no, no, no!) that would change the flag whenever there are inserts/deletes on the ITEM_DATA table.

Let’s assume the maintenance has been taken care of and we can use the flag. What would that look like?

Add the flag - “default” for using the default conditions #

alter table monster_business_items
 add (DEFAULT_CONDITIONS_APPLY NVARCHAR(1) NOT NULL DEFAULT 'X');

Delete flag for those with special conditions #

update monster_business_items bi
 set
    bi.default_conditions_apply = ''
 where
  exists (select *
         from
            monster_business_item_data bid
         where
            (bi.client , bi.order_number , bi.item_number)
          = (bid.client, bid.order_number, bid.item_number));

Finally, joining data with confidence #

-- now we know by the flag if we need to join on '00000000' or the item_number
select
     bi.client, bi.order_number, bi.item_number
    , bi.default_conditions_apply
    , bid.payment_terms    as pay_terms_by_simple_join
from
            monster_business_items     bi
 inner join monster_business_item_data bid
         on (bi.client, bi.order_number)
          = (bid.client, bid.order_number)
        and bid.item_number = MAP (bi.default_conditions_apply
                                  , 'X', ABAP_NUMC(0, 8)
                                  , '', bi.item_number)
order by
    bi.client, bi.order_number, bi.item_number;

Again, I put this into a view:

create view monster_business_items_pay_terms_FLAG as
(select
     bi.client, bi.order_number, bi.item_number
    , bi.default_conditions_apply
    , bid.payment_terms    as pay_terms_by_simple_join
from
            monster_business_items     bi
 inner join monster_business_item_data bid
         on (bi.client, bi.order_number)
          = (bid.client, bid.order_number)
        and bid.item_number = MAP (bi.default_conditions_apply
                                  , 'X', ABAP_NUMC(0, 8)
                                  , '', bi.item_number)
);

and check the result:

CLIENT ORDER_NUMBER ITEM_NUMBER PAY_TERMS_BY_SIMPLE_JOIN DEFAULT_CONDITIONS_APPLY
007    0000000001   00000003    DEFAULT1                 X

CLIENT ORDER_NUMBER ITEM_NUMBER PAY_TERMS_BY_SIMPLE_JOIN DEFAULT_CONDITIONS_APPLY
007    0000000001   00000013    SPECIAL

Comparing the approaches #

With PlanViz this “FLAG” approach used ~1.8 ms/1.3 MB.

The former approach with FIRST_VALUE clocked in at ~3.0 ms/1.5 MB.

Note: all values are reduced to a simple average - do not mistake that for proper performance testing!)]

That is quite a difference for a statement that just returns a single record.

I have not measured the approaches presented in the blog post but I dare say they would probably be less efficient.

Closing #

Now, I hear you asking, what does DEFAULT_CONDITIONS_APPLY column cost us? HANA memory is expensive and we cannot waste it… Since the column only has two distinct values ('X' and '') it is highly compressible.

For the 200.000 entries in my MONSTER_BUSINESS_ITEMS table (and 5% special conditions used), this column takes a whopping 55KB.

That, of course, is outrageous, if you plan to manage the data on your C64 but you would already be OK if your parents get you that awesome AMIGA 500+ 😄

There you go, now you know.