Combining Object Services and Change Documents

This is a repost of an article published on the SAP Community Network.


How can I combine Object Services (OS, aka persistent classes) with Change Documents (CDO)?

This question has been asked a number of times in various places before, and as of yet, I’ve not come across an answer that might serve as an implementation guideline. Since we will need to combine the two frameworks for a development project, I’ve decided to do a quick feasibility analysis and share some details and pitfalls with the community. For this article you’ll need a basic understanding of both the object services and change documents and a fairly solid understanding of basic object orientation principles.

The first important point is to remember that the object services and change documents work on different levels of abstraction. The persistent classes generated by object services operate on the technical representation of the entities in question – for example, the invoice header or a single position of an invoice. The general rule of thumb is “one table, one class”. For change documents, that’s different. Usually, you will find change document objects for logical objects rather than the technical representations. One change document object will ususally comprise multiple table definitions that are needed to store the logical object. For example, you might find a change document object for an invoice that records both changes to the invoice header and the corresponding positions. In this respect, change documents resemble lock objects. There’s already a document by Katan Patel available that covers some of the basic aspects of this approach.

In this article, I use a basic model of some arbitrary header data with some arbitrary items. The setup contains two tables with associated persistent classes and looks approximately like this:

ABAP-OS-CDO-Basics_mw.png

Note that I have left out the agent, base agent and standard class and interface methods for a better overview.

The object services provide a means to place the entire key handling in the hands of the generated persistency class. You simply have to use the data type OS_GUID for a primary key field. While this might be handy in some cases, do not use this feature if you want to record change documents based on the data tables. OS_GUID is a RAW type, and this causes all kinds of trouble within the change document generator and the reports used to display the change documents. In this example, I’ve used a composite business key comprising the company code and an arbitrary key – probably a GUID, but that’s irrelevant in this case.

When the tables are present and active, it is possible to create the change document object and generate the associated objects. There’s nothing special to observe in this case: the header is passed as a singular entry while the items are passed as a table:

ABAP-OS-CDO-Def_mw.png

The function module to write the change documents can then be generated using the default parameters. In this case, I’ve used the default name ZOSCDO_WRITE_DOCUMENT.

Now before we can turn to the actual change document magic, there’s a slight problem to resolve. Change documents are used to record creation, changes and deletion of business objects. Creation and changes are easy, but there’s a problem concerning deleted objects. The generated agent/base agent combination provides an event named IF_OS_FACTORY~DELETED that is raised whenever an object is deleted. Unfortunately, the event is raised after the object instance is invalidated, and therefore any further attempt to get the business key from the object yields an exception. The interface IF_OS_FACTORY also provides an event named TO_BE_DELETED that appears to be just the solution for this problem, but at least in my scenario, that event is never raised by the generated agents. I was only able to find some references to this event in older agent implementations – it looks like this event was removed from the default implementation for some reason. Fortunately it is rather easy to reenable it – I just had to redefine the method EXT_PM_DELETED_PERSISTENT in each of the agents and add the following implementation:

METHOD ext_pm_deleted_persistent.

  DATA: ls_backup_object_info  TYPE typ_object_info,
        l_backup_object_index  TYPE typ_index,
        lr_backup_object_iref  TYPE typ_object_iref.

  CALL METHOD super->ext_pm_deleted_persistent.

  ls_backup_object_info  = current_object_info.
  l_backup_object_index  = current_object_index.
  lr_backup_object_iref  = current_object_iref.

  RAISE EVENT if_os_factory~to_be_deleted
    EXPORTING
      object = current_object_iref.

  current_object_info  = ls_backup_object_info.
  current_object_index = l_backup_object_index.
  current_object_iref  = lr_backup_object_iref.

ENDMETHOD.

It is important to save and restore some of the attributes as shown above – otherwise the read access required by the event handler later on would change these variables and again lead to a short dump.

With these preparations in place, it is possible to tackle the actual change document generation. In this example, I’ve used a separate class called ZCL_OSCDO_CHANGE_DOC_WRITER to wrap the event handling and data processing. Inside this class, I use the following data types to keep track of the changes to the data:

   TYPES:
      BEGIN OF t_item_data,
             item_number TYPE zoscdo_item_number,
             change      TYPE cdchngind,
             old_data    TYPE zoscdo_item,
             new_data    TYPE zoscdo_item,
           END OF t_item_data .
    TYPES:
      tt_item_data TYPE SORTED TABLE OF t_item_data
                        WITH UNIQUE KEY item_number .
    TYPES:
      BEGIN OF t_header_data,
             company_code TYPE bukrs,
             header_id    TYPE zoscdo_header_id,
             change       TYPE cdchngind,
             old_data     TYPE zoscdo_header,
             new_data     TYPE zoscdo_header,
             items        TYPE tt_item_data,
           END OF t_header_data .
    TYPES:
      tt_header_data TYPE SORTED TABLE OF t_header_data
                          WITH UNIQUE KEY company_code header_id.

This is basically an in-memory representation of the header with its associated items. The class has a global attribute GT_CHANGE_DATA TYPE TT_HEADER_DATA that is used to keep track of the changes. This attribute is populated using the following two methods:

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->RECORD_HEADER_STATE
* +-------------------------------------------------------------------------------------------------+
* | [--->] IR_HEADER                      TYPE REF TO ZCL_OSCDO_HEADER
* | [--->] I_CHANGE                       TYPE        CDCHNGIND(optional)
* | [--->] I_OLD_STATE                    TYPE        ABAP_BOOL (default =ABAP_FALSE)
* | [--->] I_RECORD_CONTENTS              TYPE        ABAP_BOOL (default =ABAP_TRUE)
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD record_header_state.

    FIELD-SYMBOLS: <ls_header> TYPE t_header_data.

    DATA: ls_header TYPE t_header_data.

*   try to locate the corresponding record in the global table
    READ TABLE gt_change_data ASSIGNING <ls_header>
      WITH TABLE KEY company_code = ir_header->get_company_code( )
                     header_id    = ir_header->get_header_id( ).
    IF sy-subrc <> 0.
*     no record found - create a new one
      ls_header-company_code = ir_header->get_company_code( ).
      ls_header-header_id    = ir_header->get_header_id( ).
      INSERT ls_header INTO TABLE gt_change_data ASSIGNING <ls_header>.
    ENDIF.

*   When recording the 'old' state, we don't want to set the change indicator yet.
*   Therefore only set the change indicator if it is supplied.
    IF i_change IS SUPPLIED.
      <ls_header>-change = i_change.
    ENDIF.

*   If required, record the current state, either as 'new' or as 'old'.
    IF i_record_contents = abap_true.
      IF i_old_state = abap_true.
        ir_header->record_cdo_data( CHANGING cs_data = <ls_header>-old_data ).
      ELSE.
        ir_header->record_cdo_data( CHANGING cs_data = <ls_header>-new_data ).
      ENDIF.
    ENDIF.

  ENDMETHOD.                    "record_header_state

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->RECORD_ITEM_STATE
* +-------------------------------------------------------------------------------------------------+
* | [--->] IR_ITEM                        TYPE REF TO ZCL_OSCDO_ITEM
* | [--->] I_CHANGE                       TYPE        CDCHNGIND(optional)
* | [--->] I_OLD_STATE                    TYPE        ABAP_BOOL (default =ABAP_FALSE)
* | [--->] I_RECORD_CONTENTS              TYPE        ABAP_BOOL (default =ABAP_TRUE)
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD record_item_state.

    FIELD-SYMBOLS: <ls_header> TYPE t_header_data,
                   <ls_item>   TYPE t_item_data.

    DATA: ls_header TYPE t_header_data,
          ls_item   TYPE t_item_data.

*   try to locate the corresponding record in the global header table
    READ TABLE gt_change_data ASSIGNING <ls_header>
      WITH TABLE KEY company_code = ir_item->get_company_code( )
                     header_id    = ir_item->get_header_id( ).
    IF sy-subrc <> 0.
*     no header record found - create a new one
      ls_header-company_code = ir_item->get_company_code( ).
      ls_header-header_id    = ir_item->get_header_id( ).
      INSERT ls_header INTO TABLE gt_change_data ASSIGNING <ls_header>.
    ENDIF.

*   try to locate the corresponding record in the subordinate item table
    READ TABLE <ls_header>-items ASSIGNING <ls_item>
      WITH TABLE KEY item_number = ir_item->get_item_number( ).
    IF sy-subrc <> 0.
*     no item record found - create a new one
      ls_item-item_number = ir_item->get_item_number( ).
      INSERT ls_item INTO TABLE <ls_header>-items ASSIGNING <ls_item>.
    ENDIF.

*   When recording the 'old' state, we don't want to set the change indicator yet.
*   Therefore only set the change indicator if it is supplied.
    IF i_change IS SUPPLIED.
      <ls_item>-change = i_change.
    ENDIF.

*   If required, record the current state, either as 'new' or as 'old'.
    IF i_record_contents = abap_true.
      IF i_old_state = abap_true.
        ir_item->record_cdo_data( CHANGING cs_data = <ls_item>-old_data ).
      ELSE.
        ir_item->record_cdo_data( CHANGING cs_data = <ls_item>-new_data ).
      ENDIF.
    ENDIF.

  ENDMETHOD.                    "record_item_state

The methods RECORD_CDO_DATA are manual additions to the persistent classes that just fill a structure with the values of the attributes. These methods are rather trivial and therefore not displayed here. If you don’t want to add methods to the persistent classes, you could also add this logic to the change document writer.

In order to fill the data structure at runtime, we need to handle a few events, namely IF_OS_FACTORY~TO_BE_DELETED and IF_OS_FACTORY~LOADED_WITH_STATE. The latter is used to store the ‘old’ state of the object before any changes were made. Since the event is the same for both the header and the items, the methods simply cast to the corresponding reference variable and defer to one of the methods introduced above.

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->ON_LOADED_WITH_STATE
* +-------------------------------------------------------------------------------------------------+
* | [--->] OBJECT                         LIKE
* | [--->] WRITE_ACCESS                   LIKE
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD on_loaded_with_state.

    DATA: lr_header TYPE REF TO zcl_oscdo_header,
          lr_item   TYPE REF TO zcl_oscdo_item.

    CASE cl_abap_classdescr=>get_class_name( object ).
      WHEN co_class_name_header. " TYPE abap_abstypename VALUE '\CLASS=ZCL_OSCDO_HEADER'
        lr_header ?= object.
        record_header_state( ir_header   = lr_header
                             i_old_state = abap_true ).
      WHEN co_class_name_item. " TYPE abap_abstypename VALUE '\CLASS=ZCL_OSCDO_ITEM'
        lr_item ?= object.
        record_item_state( ir_item     = lr_item
                           i_old_state = abap_true ).
    ENDCASE.

  ENDMETHOD.                    "on_loaded_with_state

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->ON_TO_BE_DELETED
* +-------------------------------------------------------------------------------------------------+
* | [--->] OBJECT                         LIKE
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD on_to_be_deleted.

    DATA: lr_header TYPE REF TO zcl_oscdo_header,
          lr_item   TYPE REF TO zcl_oscdo_item.

    CASE cl_abap_classdescr=>get_class_name( object ).
      WHEN co_class_name_header. " TYPE abap_abstypename VALUE '\CLASS=ZCL_OSCDO_HEADER'
        lr_header ?= object.
        record_header_state( ir_header         = lr_header
                             i_change          = 'D'
                             i_record_contents = abap_false ).
      WHEN co_class_name_item. " TYPE abap_abstypename VALUE '\CLASS=ZCL_OSCDO_ITEM'
        lr_item ?= object.
        record_item_state( ir_item           = lr_item
                           i_change          = 'D'
                           i_record_contents = abap_false ).
    ENDCASE.

  ENDMETHOD.                    "on_to_be_deleted

Remember the prefix \CLASS= when defining the constants for the class name!

With these methods, the writer can record the unchanged state of an object once it is loaded and flag it as deleted. We still need a way to track newly created objects, identify the changed objects and store the corresponding new/old structures for future use. This can be done using the basic agent methods:

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->COMPLETE_CHANGE_LIST
* +-------------------------------------------------------------------------------------------------+
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD complete_change_list.

    FIELD-SYMBOLS: <lr_object> TYPE REF TO object.

    DATA: lt_objects TYPE ostyp_ref_tab,
          lr_header  TYPE REF TO zcl_oscdo_header,
          lr_item    TYPE REF TO zcl_oscdo_item.

*   add the newly created headers to the change list
    lt_objects = zca_oscdo_header=>agent->if_os_ca_instance~get_created( ).
    LOOP AT lt_objects ASSIGNING <lr_object>.
      lr_header ?= <lr_object>.
      record_header_state( ir_header   = lr_header
                           i_change    = 'I' ).
    ENDLOOP.

*   add the changed headers to the change list
    lt_objects = zca_oscdo_header=>agent->if_os_ca_instance~get_changed( ).
    LOOP AT lt_objects ASSIGNING <lr_object>.
      lr_header ?= <lr_object>.
      record_header_state( ir_header   = lr_header
                           i_change    = 'U' ).
    ENDLOOP.

*   add the newly created items to the change list
    lt_objects = zca_oscdo_item=>agent->if_os_ca_instance~get_created( ).
    LOOP AT lt_objects ASSIGNING <lr_object>.
      lr_item ?= <lr_object>.
      record_item_state( ir_item     = lr_item
                         i_change    = 'I' ).
    ENDLOOP.

*   add the changed items to the change list
    lt_objects = zca_oscdo_item=>agent->if_os_ca_instance~get_changed( ).
    LOOP AT lt_objects ASSIGNING <lr_object>.
      lr_item ?= <lr_object>.
      record_item_state( ir_item     = lr_item
                         i_change    = 'U' ).
    ENDLOOP.

  ENDMETHOD.                    "complete_change_list

Assuming that the recorded state is complete, it is now only a matter of simple data manipulation to write the change documents:

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->SAVE_CHANGE_DOCUMENTS
* +-------------------------------------------------------------------------------------------------+
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD save_change_documents.

    DATA: lr_persistency_manager TYPE REF TO if_os_persistency_manager,
          l_user                 TYPE syuname,
          l_tcode                TYPE sytcode,
          l_object_id            TYPE cdobjectv,
          l_item_change          TYPE cdchngind,
          l_object_change        TYPE cdchngind,
          lt_new_items           TYPE TABLE OF yzoscdo_item,
          lt_old_items           TYPE TABLE OF yzoscdo_item.

    FIELD-SYMBOLS: <ls_change>      TYPE t_header_data,
                   <ls_item>        TYPE t_item_data,
                   <ls_item_change> TYPE yzoscdo_item.

    lr_persistency_manager = cl_os_persistency_manager=>get_persistency_manager( ).
    l_user  = cl_abap_syst=>get_user_name( ).
    l_tcode = cl_abap_syst=>get_transaction_code( ).

    LOOP AT gt_change_data ASSIGNING <ls_change>.

      FREE: l_object_id, l_item_change, lt_new_items, lt_old_items.

*     determine the object ID - this refers to the header, so it's MANDT BUKRS HEADER_ID
      l_object_id(3)    = cl_abap_syst=>get_client( ).
      l_object_id+3(4)  = <ls_change>-company_code.
      l_object_id+7(32) = <ls_change>-header_id.

*     assemble the item change data
      IF <ls_change>-items IS NOT INITIAL.
        l_item_change = 'U'.
        LOOP AT <ls_change>-items ASSIGNING <ls_item>.
          CASE <ls_item>-change.
            WHEN 'I'.
              APPEND INITIAL LINE TO lt_new_items ASSIGNING <ls_item_change>.
              MOVE-CORRESPONDING <ls_item>-new_data TO <ls_item_change>.
              <ls_item_change>-kz = 'I'.

            WHEN 'U'.
              APPEND INITIAL LINE TO lt_old_items ASSIGNING <ls_item_change>.
              MOVE-CORRESPONDING <ls_item>-old_data TO <ls_item_change>.
              <ls_item_change>-kz = 'U'.
              APPEND INITIAL LINE TO lt_new_items ASSIGNING <ls_item_change>.
              MOVE-CORRESPONDING <ls_item>-new_data TO <ls_item_change>.
              <ls_item_change>-kz = 'U'.

            WHEN 'D'.
              APPEND INITIAL LINE TO lt_old_items ASSIGNING <ls_item_change>.
              MOVE-CORRESPONDING <ls_item>-old_data TO <ls_item_change>.
              <ls_item_change>-kz = 'D'.

          ENDCASE.
        ENDLOOP.
      ENDIF.

*     determine the overall change indicator
      IF <ls_change>-change IS NOT INITIAL.
        l_object_change = <ls_change>-change.
      ELSEIF l_item_change IS NOT INITIAL.
        l_object_id = 'U'.
      ENDIF.

*     update mode or direct call?
      IF lr_persistency_manager->get_update_mode( ) = oscon_dmode_direct.
        CALL FUNCTION 'ZOSCDO_WRITE_DOCUMENT'
          EXPORTING
            objectid                = l_object_id
            tcode                   = l_tcode
            utime                   = sy-uzeit
            udate                   = sy-datum
            username                = l_user
            object_change_indicator = l_object_change
            n_zoscdo_header         = <ls_change>-new_data
            o_zoscdo_header         = <ls_change>-old_data
            upd_zoscdo_header       = <ls_change>-change
            upd_zoscdo_item         = l_item_change
          TABLES
            xzoscdo_item            = lt_new_items
            yzoscdo_item            = lt_old_items.
      ELSE.
        CALL FUNCTION 'ZOSCDO_WRITE_DOCUMENT' IN UPDATE TASK
          EXPORTING
            objectid                = l_object_id
            tcode                   = l_tcode
            utime                   = sy-uzeit
            udate                   = sy-datum
            username                = l_user
            object_change_indicator = l_object_change
            n_zoscdo_header         = <ls_change>-new_data
            o_zoscdo_header         = <ls_change>-old_data
            upd_zoscdo_header       = <ls_change>-change
            upd_zoscdo_item         = l_item_change
          TABLES
            xzoscdo_item            = lt_new_items
            yzoscdo_item            = lt_old_items.
      ENDIF.

    ENDLOOP.

  ENDMETHOD.                    "save_change_documents

The hard part of the implementation was to find a way to grab the changed state right before it is saved and trigger the generated function module at the right time. For this, I decided to register the change document writer as a so-called save handler. The class needs to implement the interface IF_OS_CA_SERVICE for this, but with two exceptions, none of the methods are required. To safeguard against accidental invocation, I simply placed the statement

RAISE EXCEPTION TYPE cx_os_no_implementation.

in each method introduced by the interface, except for the following two methods.

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Public Method ZCL_OSCDO_CHANGE_DOC_WRITER->IF_OS_CA_SERVICE~PREPARE_FOR_TOP_TRANSACTION
* +-------------------------------------------------------------------------------------------------+
* | [--->] I_INVALIDATE                   TYPE        OS_BOOLEAN
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD if_os_ca_service~prepare_for_top_transaction.

    FIELD-SYMBOLS: <lr_agent> TYPE REF TO if_os_ca_service .

*   a new transaction is started - forget all previously stored change data
    FREE gt_change_data.

*   defer the actual method call to the wrapped agents
    LOOP AT gt_agents ASSIGNING <lr_agent>.
      <lr_agent>->prepare_for_top_transaction( i_invalidate ).
    ENDLOOP.

  ENDMETHOD.                    "if_os_ca_service~prepare_for_top_transaction

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Public Method ZCL_OSCDO_CHANGE_DOC_WRITER->IF_OS_CA_SERVICE~SAVE
* +-------------------------------------------------------------------------------------------------+
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD if_os_ca_service~save.

    FIELD-SYMBOLS: <lr_agent> TYPE REF TO if_os_ca_service .

*   defer the actual method call to the wrapped agents
    LOOP AT gt_agents ASSIGNING <lr_agent>.
      <lr_agent>->save( ).
    ENDLOOP.

*   then complete the list of changed objects and write the change documents
    complete_change_list( ).
    save_change_documents( ).

  ENDMETHOD.                    "if_os_ca_service~save

As you can see, the implementation is rather straightforward: leave the complex stuff to the generated agents (that we will store in a table named GT_AGENTS in the not too distant future) and just add our processing logic.

Now that we have the basic data recoding structures in place, we can start to wire the change document writer to the object services. The writer will need to be registered as an event handler, and it would be unwise to register multiple instances. For this reason I designed it as a simple singleton: flag the class as CREATE PRIVATE, add a static attribute SR_INSTANCE TYPE REF TO  ZCL_OSCDO_CHANGE_DOC_WRITER and a static initialization method like this:

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Static Public Method ZCL_OSCDO_CHANGE_DOC_WRITER=>INITIALIZE
* +-------------------------------------------------------------------------------------------------+
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD initialize.

    IF sr_instance IS NOT INITIAL.
      CREATE OBJECT sr_instance.
      sr_instance->register_handlers_for_agent( zca_oscdo_header=>agent ).
      sr_instance->register_handlers_for_agent( zca_oscdo_item=>agent ).
    ENDIF.

  ENDMETHOD.                    "initialize

During the initialization, the following method is used to register both the event handling methods and the save handler:

* <SIGNATURE>---------------------------------------------------------------------------------------+
* | Instance Private Method ZCL_OSCDO_CHANGE_DOC_WRITER->REGISTER_HANDLERS_FOR_AGENT
* +-------------------------------------------------------------------------------------------------+
* | [--->] IR_AGENT                       TYPE REF TO CL_OS_CA_COMMON
* +--------------------------------------------------------------------------------------</SIGNATURE>
  METHOD register_handlers_for_agent.

    DATA: lr_persistency_manager TYPE REF TO if_os_persistency_manager.

    APPEND ir_agent TO gt_agents. " TYPE TABLE OF REF TO if_os_ca_service .

    lr_persistency_manager = cl_os_persistency_manager=>get_persistency_manager( ).
    lr_persistency_manager->register_save_manager_for_ca( i_save_manager = me
                                                          i_class_agent  = ir_agent ).

    SET HANDLER on_loaded_with_state FOR ir_agent.
    SET HANDLER on_to_be_deleted FOR ir_agent.

  ENDMETHOD.                    "register_handlers_for_agent

With the initialization methods completed, a simple call to

  zcl_oscdo_change_doc_writer=>initialize( ).

during the initialization of the application is sufficient to start recording change documents. This article only outlines the basic procedure, but from here on it should be a lot easier to add functionality to the implementation.

Theme by Danetsoft and Danang Probo Sayekti inspired by Maksimer