SAP RAP Custom Entity: A Complete Guide

SAP RAP Custom Entity: A Complete Guide

SAP RAP

In a traditional SAP RAP application, we typically work with CDS views that directly read from database tables. But what happens when you need to display data that doesn't come from a single source, or when you need to call external APIs, BAPIs, or perform complex calculations?

That's where Custom Entities come to the rescue.

A Custom Entity is essentially a "wrapper" around ABAP code that provides data instead of reading directly from database tables. Think of it as a bridge between your complex data retrieval logic and the RAP framework.

In this blog, we'll build a practical Customer app using Custom Entity to understand how it all works together.

What Makes Custom Entity Special?

Unlike regular CDS views that have a SELECT statement, Custom Entities have no SELECT clause. Instead, they delegate the data retrieval to an ABAP class that implements the IF_RAP_QUERY_PROVIDER interface. This gives you complete control over how and where your data comes from.

The beauty of Custom Entity lies in its flexibility:

  • Call external web services or APIs

  • Execute BAPIs or function modules

  • Combine data from multiple sources

  • Perform complex calculations

  • Apply custom business logic during data retrieval

The Four Essential Objects

Every Custom Entity RAP app requires at least four objects to create a read only application:

  1. Custom Entity CDS - Defines the structure and UI annotations

  2. Query Implementation Class - Contains the data retrieval logic

  3. Service Definition - Exposes the entity as a service

  4. Service Binding - Publishes the service for consumption

Let's build them step by step using our Customer example.

Step 1: Create the Query Implementation Class First

We always start with the class because the Custom Entity CDS validates that the class exists when you try to activate it.

CLASS zsac_cl_ce_customer DEFINITION
  PUBLIC
  FINAL
  CREATE PUBLIC .
  
  PUBLIC SECTION.
    INTERFACES if_rap_query_provider.
  PROTECTED SECTION.
  PRIVATE SECTION.
ENDCLASS.

CLASS zsac_cl_ce_customer IMPLEMENTATION.
  METHOD if_rap_query_provider~select.
    " Implementation will come here
  ENDMETHOD.
ENDCLASS.

The IF_RAP_QUERY_PROVIDER interface is the heart of Custom Entity functionality. It contains one method SELECT that gets called whenever the framework needs data.

Step 2: Create the Custom Entity CDS

Now we can create our Custom Entity structure with UI annotations:

@EndUserText.label: 'Custom Entity for Customer'
@ObjectModel.query.implementedBy: 'ABAP:ZSAC_CL_CE_CUSTOMER'
@UI: {
  headerInfo: { typeName: 'Customer',
                typeNamePlural: 'Customers',
                title: { type: #STANDARD, label: 'Customer ID', value: 'CustomerID' },
                description: { type: #STANDARD, label: 'Customer Name', value: 'FullName' }
            }
              }
define root custom entity ZSAC_CE_CUSTOMER
{

      @UI.facet    : [{
           id      : 'Customer',
           purpose : #STANDARD,
           position: 10,
           label   : 'Customer',
           type    :  #IDENTIFICATION_REFERENCE
       }]

      @UI          : { lineItem: [{ position: 10 }],
             identification: [{ position: 10 }],
             selectionField: [{ position: 10 }] }
  key CustomerID  : /dmo/customer_id;
      @UI          : { lineItem: [{ position: 20 }],
             identification: [{ position: 20 }],
             selectionField: [{ position: 20 }] }
      FirstName    : /dmo/first_name;

      @UI          : { lineItem: [{ position: 30 }],
             identification: [{ position: 30 }],
             selectionField: [{ position: 30 }] }
      LastName     : /dmo/last_name;

      @UI          : { lineItem: [{ position: 40 }],
             identification: [{ position: 40 }],
             selectionField: [{ position: 40 }] }
      Street       : /dmo/street;

      @UI          : { lineItem: [{ position: 50 }],
             identification: [{ position: 50 }],
             selectionField: [{ position: 50 }] }
      PostalCode   : /dmo/postal_code;

      @UI          : { lineItem: [{ position: 60 }],
             identification: [{ position: 60 }],
             selectionField: [{ position: 60 }] }
      City         : /dmo/city;

      @UI          : { lineItem: [{ position: 70 }],
             identification: [{ position: 70 }] }
      @EndUserText.label: 'Country'
      Country  : abap.char(50);

      @UI          : { lineItem: [{ position: 80 }],
             identification: [{ position: 80 }] }
      PhoneNumber  : /dmo/phone_number;

      @UI          : { lineItem: [{ position: 90 }],
             identification: [{ position: 90 }] }
      EmailAddress : /dmo/email_address;
      
      @UI          : { lineItem: [{ position: 100 }],
             identification: [{ position: 100 }] }
      @EndUserText.label: 'Customer Name'
      FullName     : abap.char(255);

}

The key annotation here is @ObjectModel.query.implementedBy: 'ABAP:ZSAC_CL_CE_CUSTOMER' which tells RAP which class to call for data retrieval.

Step 3: Understanding io_request and io_response

Now comes the crucial part - implementing the SELECT method. The interface provides two important objects:

  • io_request - Contains information about what the client is asking for

  • io_response - Used to send data back to the clien

What io_request Gives You

The io_request object is packed with useful information:

" Check if data is requested (vs just count)
IF io_request->is_data_requested( ).

" Get paging information
DATA(lv_top) = io_request->get_paging( )->get_page_size( ).
DATA(lv_skip) = io_request->get_paging( )->get_offset( ).

" Get requested fields (for optimization)
DATA(lt_requested_fields) = io_request->get_requested_elements( ).

" Get sorting requirements
DATA(lt_sort) = io_request->get_sort_elements( ).

" Get filter conditions
DATA(lv_conditions) = io_request->get_filter( )->get_as_sql_string( ).

What io_response Expects

The io_response object is your way to send data back:

" Set the actual data
io_response->set_data( lt_customer_data ).

" Set total count for paging
io_response->set_total_number_of_records( lv_records ).

Critical Note: You must implement both set_data() and set_total_number_of_records() even if you think you don't need the count. The framework requires it and will throw a dump if missing.

Step 4: Complete Query Implementation

Here's our complete implementation with all the logic:

CLASS zsac_cl_ce_customer DEFINITION
  PUBLIC
  FINAL
  CREATE PUBLIC .

  PUBLIC SECTION.

    INTERFACES if_rap_query_provider.

  PROTECTED SECTION.
  PRIVATE SECTION.
ENDCLASS.



CLASS zsac_cl_ce_customer IMPLEMENTATION.

  METHOD if_rap_query_provider~select.
    DATA: lt_customer_data TYPE STANDARD TABLE OF zsac_ce_customer.

    IF io_request->is_data_requested( ).

      " Get the requested fields
      DATA(lt_requested_fields) = io_request->get_requested_elements( ).

      DATA(lv_top)     = io_request->get_paging( )->get_page_size( ).
      IF lv_top <= 0. lv_top = 1. ENDIF.

      DATA(lv_skip)    = io_request->get_paging( )->get_offset( ).

      DATA(lt_sort)    = io_request->get_sort_elements( ).

      DATA : lv_orderby TYPE string.
      LOOP AT lt_sort INTO DATA(ls_sort).
        IF ls_sort-descending = abap_true.
          lv_orderby = |{ lv_orderby } { ls_sort-element_name } DESCENDING |.
        ELSE.
          lv_orderby = |{ lv_orderby } { ls_sort-element_name } ASCENDING |.
        ENDIF.
      ENDLOOP.
      IF lv_orderby IS INITIAL.
        lv_orderby = 'CUSTOMERID'.
      ENDIF.

      DATA(lv_conditions) =  io_request->get_filter( )->get_as_sql_string( ).

*    Total number of records
      SELECT COUNT( customer_id ) FROM /dmo/customer  INTO @DATA(lv_records).

      " Select: no alias for the main view (to keep filter simple), aliases only for joined texts
      IF lv_conditions IS INITIAL.
        SELECT FROM /DMO/I_Customer
               INNER JOIN I_Country     AS Country ON /DMO/I_Customer~CountryCode = Country~Country
               INNER JOIN I_CountryText AS CText   ON Country~Country = CText~Country
                                                  AND CText~Language = @sy-langu
               FIELDS
                 /DMO/I_Customer~CustomerID,
                 /DMO/I_Customer~FirstName,
                 /DMO/I_Customer~LastName,
                 concat_with_space( /DMO/I_Customer~FirstName, /DMO/I_Customer~LastName, 1 ) AS FullName,
                 /DMO/I_Customer~Street,
                 /DMO/I_Customer~PostalCode,
                 /DMO/I_Customer~City,
                 CText~CountryName AS Country,
                 /DMO/I_Customer~PhoneNumber,
                 /DMO/I_Customer~EmailAddress
          ORDER BY (lv_orderby)
          INTO CORRESPONDING FIELDS OF TABLE @lt_customer_data
          UP TO @lv_top ROWS OFFSET @lv_skip.
      ELSE.
        SELECT FROM /DMO/I_Customer
               INNER JOIN I_Country     AS Country ON /DMO/I_Customer~CountryCode = Country~Country
               INNER JOIN I_CountryText AS CText   ON Country~Country = CText~Country
                                                  AND CText~Language = @sy-langu
               FIELDS
                 /DMO/I_Customer~CustomerID,
                 /DMO/I_Customer~FirstName,
                 /DMO/I_Customer~LastName,
                 concat_with_space( /DMO/I_Customer~FirstName, /DMO/I_Customer~LastName, 1 ) AS FullName,
                 /DMO/I_Customer~Street,
                 /DMO/I_Customer~PostalCode,
                 /DMO/I_Customer~City,
                 CText~CountryName AS Country,
                 /DMO/I_Customer~PhoneNumber,
                 /DMO/I_Customer~EmailAddress
          WHERE (lv_conditions)               " only when not initial
          ORDER BY (lv_orderby)
          INTO CORRESPONDING FIELDS OF TABLE @lt_customer_data
          UP TO @lv_top ROWS OFFSET @lv_skip.
      ENDIF.

      io_response->set_total_number_of_records( lv_records ).
      io_response->set_data( lt_customer_data ).

    ENDIF.

  ENDMETHOD.

ENDCLASS.

Why the Complex JOIN Logic?

You might wonder why we're doing all these JOINs when we could simply read from the CDS view using path expressions. Here's the key insight:

When you need language-specific text (like Country names), you have three options:

  1. Create a CDS view that exposes the text

  2. Use path expressions in yourSQL query.

  3. Join in Open SQL like we do here and filter ctext~language = sy-langu

We are joining I_Country and I_CountryText to get the country name in the user's login language.

Common Pitfalls and How to Fix Them

1. Boolean Expression Expected

Problem: Using WHERE (lv_conditions) when lv_conditions is initial
Solution: Always check if conditions exist before using them:

IF lv_conditions IS INITIAL.
  " SELECT without WHERE clause
ELSE.
  " SELECT with WHERE (lv_conditions)
ENDIF.

2. Ambiguous Field Names

Problem: Filter fails because field names are ambiguous in JOINs
Solution: Keep both technical field (for filters) and display field (for UI) when needed. Use proper aliases in your SELECT.

3. Duplicate Resource Error

Problem: RAP expects unique keys, but your JOINs cause duplicates
Solution: Ensure CustomerID is unique in returned rows or use DISTINCT/grouping when your joins cause duplication.

4. Dynamic ORDER BY Issues

Problem: lv_orderby contains invalid column names
Solution: Make sure lv_orderby contains valid column names that match your SELECT list, without stray quotes or braces.

5. Requested Fields Optimization

Problem: Always selecting all fields impacts performance
Solution: Use io_request->get_requested_elements( ) to select only needed columns and optimize your JOINs accordingly.

Step 5: Create Service Definition

Right-click on your Custom Entity and create a Service Definition:

@EndUserText.label: 'Customer Service Definition'
define service ZUI_SAC_CUSTOMER {
  expose ZSAC_CE_CUSTOMER as Customer;
}

The Service Definition controls what gets exposed to the outside world.

Step 6: Create Service Binding

Right-click on the Service Definition and create a Service Binding:

  • Name: ZUI_SAC_CUSTOMER_O4

  • Binding Type: OData V4 - UI (for Fiori Elements apps)

  • Service Definition: Your service definition name

After activation, click Publish to make the service available. Then you can click Preview to test your Custom Entity app.

 Custom Entity list report

 Custom Entity object page

Performance Considerations

Custom Entities require careful performance planning since you're implementing the query logic manually:

  1. Implement paging properly - Always respect get_page_size() and get_offset()

  2. Handle filtering efficiently - Use indexed fields where possible

  3. Consider caching - For relatively static data, implement caching mechanisms

  4. Optimize JOINs - Only join tables you actually need for the requested fields

Testing Your Custom Entity

Unlike regular CDS views, you cannot test Custom Entities using ADT Data Preview since there's no underlying database artifact. Instead:

  1. Use the Service Binding preview - This is your primary testing method

  2. Test in Fiori Elements - The most realistic test environment

When to Use Custom Entity vs Regular CDS

Use Custom Entity when:

  • Data comes from external sources (APIs, BAPIs)

  • Complex calculations are needed

  • Multiple data sources must be combined

  • You need full control over data retrieval logic

Use Regular CDS when:

  • Data comes from database tables

  • Standard joins and calculations suffice

  • You want automatic optimization by the database

Here's a challenge for you:

Try filtering by the Country field in your app. You might encounter a short dump error that prevents the filter from working properly.

Your Task:

  • Identify and analyze why the Country filter fails while other filters work fine
  • Implement a solution that makes Country filtering work seamlessly

Wrapping Up

Custom Entities in SAP RAP provide incredible flexibility for scenarios where standard CDS views fall short.

By understanding how io_request and io_response work together, properly taking care of data retrieval logic, and avoiding common pitfalls, you can build robust apps that handle complex data scenarios.

The key takeaways:

  • Always implement both data and count methods in io_response

  • Handle filter conditions carefully to avoid Boolean expression errors

  • Use proper aliases to prevent ambiguous column names

  • Consider performance implications of your query logic

  • Test through Service Binding preview since ADT Data Preview isn't available

One suggestion by my side - Debug the service and check how io_request and io_response are populated.

Thanks for reading :)

Tags: #custom#rap