RAP Custom Queries with Aggregation: GROUP BY and Reporting

Category
RAP
Published
Author
Johannes

Custom Entities with Aggregation enable powerful reporting functions in RAP. While CDS Views are suitable for simple aggregations, Custom Queries with ABAP code provide full control over complex GROUP BY operations, dynamic filters, and calculated KPIs.

When to Use Custom Queries for Aggregation?

Custom Queries are the right choice when:

RequirementCDS AggregationCustom Query
Simple GROUP BY✅ RecommendedPossible
Multiple aggregation levels⚠️ Limited✅ Recommended
Dynamic grouping❌ Not possible✅ Recommended
Complex calculations (YoY, %)⚠️ Complex✅ Recommended
External data sources❌ Not possible✅ Recommended
Combining multiple sources❌ Not possible✅ Recommended

Basic Structure of an Aggregation Custom Entity

The Custom Entity defines the aggregated fields:

@EndUserText.label: 'Sales Statistics'
@ObjectModel.query.implementedBy: 'ABAP:ZCL_SALES_STAT_QUERY'
define custom entity ZI_SalesStatistics
{
key CustomerId : abap.char(10);
key SalesMonth : abap.char(7); " YYYY-MM Format
CustomerName : abap.char(80);
TotalRevenue : abap.dec(15,2);
OrderCount : abap.int4;
AvgOrderValue : abap.dec(15,2);
MinOrderValue : abap.dec(15,2);
MaxOrderValue : abap.dec(15,2);
Currency : abap.cuky;
}

Query Implementation with Aggregation

The query class performs aggregation using ABAP SQL:

CLASS zcl_sales_stat_query DEFINITION
PUBLIC
FINAL
CREATE PUBLIC.
PUBLIC SECTION.
INTERFACES if_rap_query_provider.
PRIVATE SECTION.
TYPES: BEGIN OF ty_statistics,
CustomerId TYPE kunnr,
SalesMonth TYPE char7,
CustomerName TYPE name1,
TotalRevenue TYPE wertv8,
OrderCount TYPE int4,
AvgOrderValue TYPE wertv8,
MinOrderValue TYPE wertv8,
MaxOrderValue TYPE wertv8,
Currency TYPE waers,
END OF ty_statistics,
tt_statistics TYPE STANDARD TABLE OF ty_statistics WITH EMPTY KEY.
METHODS aggregate_sales_data
IMPORTING it_filter TYPE if_rap_query_filter=>tt_name_range_pairs
RETURNING VALUE(rt_data) TYPE tt_statistics.
METHODS apply_sorting
IMPORTING it_sort TYPE abp_behv_sort_tab
CHANGING ct_data TYPE tt_statistics.
METHODS apply_paging
IMPORTING io_paging TYPE REF TO if_rap_query_paging
CHANGING ct_data TYPE tt_statistics.
ENDCLASS.
CLASS zcl_sales_stat_query IMPLEMENTATION.
METHOD if_rap_query_provider~select.
IF io_request->is_data_requested( ).
" Retrieve filters
DATA(lt_filter) = VALUE if_rap_query_filter=>tt_name_range_pairs( ).
TRY.
lt_filter = io_request->get_filter( )->get_as_ranges( ).
CATCH cx_rap_query_filter_no_range.
ENDTRY.
" Perform aggregation
DATA(lt_statistics) = aggregate_sales_data( lt_filter ).
" Apply sorting
apply_sorting(
EXPORTING it_sort = io_request->get_sort_elements( )
CHANGING ct_data = lt_statistics
).
" Store total count before paging
DATA(lv_total_count) = lines( lt_statistics ).
" Apply paging
apply_paging(
EXPORTING io_paging = io_request->get_paging( )
CHANGING ct_data = lt_statistics
).
io_response->set_data( lt_statistics ).
ENDIF.
IF io_request->is_total_numb_of_rec_requested( ).
io_response->set_total_number_of_records( lv_total_count ).
ENDIF.
ENDMETHOD.
METHOD aggregate_sales_data.
" Aggregation with GROUP BY
SELECT
vbak~kunnr AS CustomerId,
LEFT( CAST( vbak~erdat AS char ), 7 ) AS SalesMonth,
kna1~name1 AS CustomerName,
SUM( vbap~netwr ) AS TotalRevenue,
COUNT( DISTINCT vbak~vbeln ) AS OrderCount,
AVG( vbap~netwr ) AS AvgOrderValue,
MIN( vbap~netwr ) AS MinOrderValue,
MAX( vbap~netwr ) AS MaxOrderValue,
vbak~waerk AS Currency
FROM i_salesorder AS vbak
INNER JOIN i_salesorderitem AS vbap ON vbap~salesorder = vbak~salesorder
INNER JOIN i_customer AS kna1 ON kna1~customer = vbak~soldtoparty
WHERE vbak~salesordertype = 'TA'
GROUP BY vbak~kunnr, LEFT( CAST( vbak~erdat AS char ), 7 ),
kna1~name1, vbak~waerk
INTO TABLE @rt_data.
" Apply filters afterwards
LOOP AT it_filter INTO DATA(ls_filter).
CASE ls_filter-name.
WHEN 'CUSTOMERID'.
DELETE rt_data WHERE CustomerId NOT IN ls_filter-range.
WHEN 'SALESMONTH'.
DELETE rt_data WHERE SalesMonth NOT IN ls_filter-range.
ENDCASE.
ENDLOOP.
ENDMETHOD.
METHOD apply_sorting.
" Build dynamic sorting
DATA(lv_sort_string) = ``.
LOOP AT it_sort INTO DATA(ls_sort).
IF lv_sort_string IS NOT INITIAL.
lv_sort_string = lv_sort_string && ` `.
ENDIF.
lv_sort_string = lv_sort_string && ls_sort-element_name.
IF ls_sort-descending = abap_true.
lv_sort_string = lv_sort_string && ` DESCENDING`.
ELSE.
lv_sort_string = lv_sort_string && ` ASCENDING`.
ENDIF.
ENDLOOP.
IF lv_sort_string IS NOT INITIAL.
SORT ct_data BY (lv_sort_string).
ELSE.
" Default sorting: Revenue descending
SORT ct_data BY TotalRevenue DESCENDING.
ENDIF.
ENDMETHOD.
METHOD apply_paging.
DATA(lv_offset) = io_paging->get_offset( ).
DATA(lv_page_size) = io_paging->get_page_size( ).
IF lv_page_size <= 0.
RETURN. " No paging requested
ENDIF.
" Limit maximum page size
IF lv_page_size > 500.
lv_page_size = 500.
ENDIF.
DATA(lv_from) = lv_offset + 1.
DATA(lv_to) = lv_offset + lv_page_size.
" Keep only desired range
DATA lt_paged LIKE ct_data.
LOOP AT ct_data INTO DATA(ls_row) FROM lv_from TO lv_to.
APPEND ls_row TO lt_paged.
ENDLOOP.
ct_data = lt_paged.
ENDMETHOD.
ENDCLASS.

Practical Example: Revenue Statistics by Customer and Month

A complete example for a revenue reporting application:

1. Custom Entity Definition

@EndUserText.label: 'Customer Revenue Statistics'
@ObjectModel.query.implementedBy: 'ABAP:ZCL_REVENUE_REPORT_QUERY'
define custom entity ZI_CustomerRevenue
{
key Customer : kunnr;
key FiscalYear : gjahr;
key FiscalPeriod : monat;
CustomerName : name1;
Region : regio;
Revenue : wertv8;
Quantity : menge_d;
OrderCount : abap.int4;
AvgOrderValue : wertv8;
Currency : waers;
" Calculated KPIs
RevenueShare : abap.dec(5,2); " % of total revenue
GrowthRate : abap.dec(7,2); " % vs. previous year
}

2. Query Implementation with Extended KPIs

CLASS zcl_revenue_report_query DEFINITION
PUBLIC
FINAL
CREATE PUBLIC.
PUBLIC SECTION.
INTERFACES if_rap_query_provider.
PRIVATE SECTION.
TYPES: BEGIN OF ty_revenue,
Customer TYPE kunnr,
FiscalYear TYPE gjahr,
FiscalPeriod TYPE monat,
CustomerName TYPE name1,
Region TYPE regio,
Revenue TYPE wertv8,
Quantity TYPE menge_d,
OrderCount TYPE int4,
AvgOrderValue TYPE wertv8,
Currency TYPE waers,
RevenueShare TYPE decfloat16,
GrowthRate TYPE decfloat16,
END OF ty_revenue,
tt_revenue TYPE STANDARD TABLE OF ty_revenue WITH EMPTY KEY.
METHODS load_aggregated_data
IMPORTING it_filter TYPE if_rap_query_filter=>tt_name_range_pairs
RETURNING VALUE(rt_data) TYPE tt_revenue.
METHODS calculate_kpis
CHANGING ct_data TYPE tt_revenue.
METHODS calculate_growth_rate
IMPORTING iv_customer TYPE kunnr
iv_year TYPE gjahr
iv_period TYPE monat
iv_revenue TYPE wertv8
RETURNING VALUE(rv_rate) TYPE decfloat16.
ENDCLASS.
CLASS zcl_revenue_report_query IMPLEMENTATION.
METHOD if_rap_query_provider~select.
IF io_request->is_data_requested( ).
" Retrieve filters
DATA(lt_filter) = VALUE if_rap_query_filter=>tt_name_range_pairs( ).
TRY.
lt_filter = io_request->get_filter( )->get_as_ranges( ).
CATCH cx_rap_query_filter_no_range.
ENDTRY.
" Load aggregated data
DATA(lt_data) = load_aggregated_data( lt_filter ).
" Calculate KPIs (RevenueShare, GrowthRate)
calculate_kpis( CHANGING ct_data = lt_data ).
" Sorting
DATA(lt_sort) = io_request->get_sort_elements( ).
IF lt_sort IS NOT INITIAL.
DATA(lv_sort_str) = ``.
LOOP AT lt_sort INTO DATA(ls_sort).
IF lv_sort_str IS NOT INITIAL.
lv_sort_str = lv_sort_str && ` `.
ENDIF.
lv_sort_str = lv_sort_str && ls_sort-element_name.
IF ls_sort-descending = abap_true.
lv_sort_str = lv_sort_str && ` DESCENDING`.
ELSE.
lv_sort_str = lv_sort_str && ` ASCENDING`.
ENDIF.
ENDLOOP.
SORT lt_data BY (lv_sort_str).
ELSE.
SORT lt_data BY Revenue DESCENDING.
ENDIF.
" Total count before paging
DATA(lv_count) = lines( lt_data ).
" Paging
DATA(lv_offset) = io_request->get_paging( )->get_offset( ).
DATA(lv_size) = io_request->get_paging( )->get_page_size( ).
IF lv_size > 0.
DATA lt_paged LIKE lt_data.
LOOP AT lt_data INTO DATA(ls_row)
FROM lv_offset + 1 TO lv_offset + lv_size.
APPEND ls_row TO lt_paged.
ENDLOOP.
io_response->set_data( lt_paged ).
ELSE.
io_response->set_data( lt_data ).
ENDIF.
ENDIF.
IF io_request->is_total_numb_of_rec_requested( ).
io_response->set_total_number_of_records( lv_count ).
ENDIF.
ENDMETHOD.
METHOD load_aggregated_data.
" Main aggregation with GROUP BY
SELECT
vbrk~kunag AS Customer,
vbrk~gjahr AS FiscalYear,
vbrk~fkdat+4(2) AS FiscalPeriod,
kna1~name1 AS CustomerName,
kna1~regio AS Region,
SUM( vbrp~netwr ) AS Revenue,
SUM( vbrp~fkimg ) AS Quantity,
COUNT( DISTINCT vbrk~vbeln ) AS OrderCount,
AVG( vbrp~netwr ) AS AvgOrderValue,
vbrk~waerk AS Currency
FROM i_billingdocument AS vbrk
INNER JOIN i_billingdocumentitem AS vbrp
ON vbrp~billingdocument = vbrk~billingdocument
INNER JOIN i_customer AS kna1
ON kna1~customer = vbrk~soldtoparty
WHERE vbrk~fkart IN ( 'F2', 'L2' ) " Invoices and credit memos
GROUP BY vbrk~kunag, vbrk~gjahr, vbrk~fkdat+4(2),
kna1~name1, kna1~regio, vbrk~waerk
INTO TABLE @rt_data.
" Apply filters
LOOP AT it_filter INTO DATA(ls_filter).
CASE ls_filter-name.
WHEN 'CUSTOMER'.
DELETE rt_data WHERE Customer NOT IN ls_filter-range.
WHEN 'FISCALYEAR'.
DELETE rt_data WHERE FiscalYear NOT IN ls_filter-range.
WHEN 'FISCALPERIOD'.
DELETE rt_data WHERE FiscalPeriod NOT IN ls_filter-range.
WHEN 'REGION'.
DELETE rt_data WHERE Region NOT IN ls_filter-range.
ENDCASE.
ENDLOOP.
ENDMETHOD.
METHOD calculate_kpis.
" 1. Calculate total revenue for RevenueShare
DATA(lv_total_revenue) = REDUCE wertv8(
INIT sum = CONV wertv8( 0 )
FOR ls_row IN ct_data
NEXT sum = sum + ls_row-Revenue
).
" 2. Calculate KPIs per row
LOOP AT ct_data ASSIGNING FIELD-SYMBOL(<ls_row>).
" RevenueShare
IF lv_total_revenue > 0.
<ls_row>-RevenueShare = ( <ls_row>-Revenue / lv_total_revenue ) * 100.
ENDIF.
" GrowthRate vs. previous year month
<ls_row>-GrowthRate = calculate_growth_rate(
iv_customer = <ls_row>-Customer
iv_year = <ls_row>-FiscalYear
iv_period = <ls_row>-FiscalPeriod
iv_revenue = <ls_row>-Revenue
).
ENDLOOP.
ENDMETHOD.
METHOD calculate_growth_rate.
" Retrieve previous year revenue
DATA(lv_prev_year) = iv_year - 1.
SELECT SINGLE SUM( vbrp~netwr ) AS Revenue
FROM i_billingdocument AS vbrk
INNER JOIN i_billingdocumentitem AS vbrp
ON vbrp~billingdocument = vbrk~billingdocument
WHERE vbrk~kunag = @iv_customer
AND vbrk~gjahr = @lv_prev_year
AND vbrk~fkdat+4(2) = @iv_period
AND vbrk~fkart IN ( 'F2', 'L2' )
INTO @DATA(lv_prev_revenue).
IF lv_prev_revenue > 0.
rv_rate = ( ( iv_revenue - lv_prev_revenue ) / lv_prev_revenue ) * 100.
ELSEIF iv_revenue > 0.
rv_rate = 100. " 100% growth if previous year = 0
ENDIF.
ENDMETHOD.
ENDCLASS.

Sorting Aggregated Data

Proper sorting is crucial for aggregated data:

METHOD apply_aggregation_sorting.
DATA(lt_sort) = io_request->get_sort_elements( ).
" Sorting over multiple fields
LOOP AT lt_sort INTO DATA(ls_sort).
CASE ls_sort-element_name.
WHEN 'TOTALREVENUE'.
IF ls_sort-descending = abap_true.
SORT ct_data BY TotalRevenue DESCENDING.
ELSE.
SORT ct_data BY TotalRevenue ASCENDING.
ENDIF.
WHEN 'ORDERCOUNT'.
IF ls_sort-descending = abap_true.
SORT ct_data BY OrderCount DESCENDING TotalRevenue DESCENDING.
ELSE.
SORT ct_data BY OrderCount ASCENDING.
ENDIF.
WHEN 'AVGORDERVALUE'.
IF ls_sort-descending = abap_true.
SORT ct_data BY AvgOrderValue DESCENDING.
ELSE.
SORT ct_data BY AvgOrderValue ASCENDING.
ENDIF.
WHEN 'CUSTOMERNAME'.
IF ls_sort-descending = abap_true.
SORT ct_data BY CustomerName DESCENDING.
ELSE.
SORT ct_data BY CustomerName ASCENDING.
ENDIF.
ENDCASE.
ENDLOOP.
" Default sorting when nothing specified
IF lt_sort IS INITIAL.
SORT ct_data BY TotalRevenue DESCENDING.
ENDIF.
ENDMETHOD.

Paging Aggregated Results

Paging must occur after aggregation but before output:

┌─────────────────────────────────────────────────┐
│ 1. SELECT with GROUP BY │
│ → Aggregated raw data (e.g., 500 rows) │
├─────────────────────────────────────────────────┤
│ 2. KPI calculation │
│ → RevenueShare, GrowthRate, etc. │
├─────────────────────────────────────────────────┤
│ 3. Filter on aggregated fields │
│ → e.g., TotalRevenue > 10000 │
├─────────────────────────────────────────────────┤
│ 4. Store total count │
│ → For pagination info (e.g., 487 rows) │
├─────────────────────────────────────────────────┤
│ 5. Sorting │
│ → According to request parameters │
├─────────────────────────────────────────────────┤
│ 6. Apply paging │
│ → Return only page X of Y │
└─────────────────────────────────────────────────┘
METHOD if_rap_query_provider~select.
" Steps 1-3: Aggregation and filters
DATA(lt_data) = aggregate_and_filter( io_request ).
" Step 4: Total count BEFORE paging
DATA(lv_total_count) = lines( lt_data ).
" Step 5: Sorting
apply_sorting(
EXPORTING it_sort = io_request->get_sort_elements( )
CHANGING ct_data = lt_data
).
" Step 6: Paging
DATA(lv_offset) = io_request->get_paging( )->get_offset( ).
DATA(lv_page_size) = io_request->get_paging( )->get_page_size( ).
IF lv_page_size > 0.
DATA lt_paged LIKE lt_data.
DATA(lv_from) = lv_offset + 1.
DATA(lv_to) = lv_offset + lv_page_size.
LOOP AT lt_data INTO DATA(ls_row) FROM lv_from TO lv_to.
APPEND ls_row TO lt_paged.
ENDLOOP.
io_response->set_data( lt_paged ).
ELSE.
io_response->set_data( lt_data ).
ENDIF.
" Total count for pagination controls
IF io_request->is_total_numb_of_rec_requested( ).
io_response->set_total_number_of_records( lv_total_count ).
ENDIF.
ENDMETHOD.

Filtering on Aggregated Fields

Sometimes you want to filter by aggregated values (e.g., “only customers with revenue > 10000”):

METHOD apply_aggregation_filter.
" Standard filters (before aggregation)
LOOP AT it_filter INTO DATA(ls_filter).
CASE ls_filter-name.
" Filters on base data - in WHERE clause
WHEN 'CUSTOMER' OR 'FISCALYEAR' OR 'REGION'.
" These filters already in SQL query
" Filters on aggregated values - afterwards
WHEN 'TOTALREVENUE'.
DELETE ct_data WHERE TotalRevenue NOT IN ls_filter-range.
WHEN 'ORDERCOUNT'.
DELETE ct_data WHERE OrderCount NOT IN ls_filter-range.
WHEN 'AVGORDERVALUE'.
DELETE ct_data WHERE AvgOrderValue NOT IN ls_filter-range.
ENDCASE.
ENDLOOP.
ENDMETHOD.

HAVING-Equivalent Logic

For complex aggregation filters, use HAVING-equivalent logic:

METHOD aggregate_with_having.
" Perform aggregation
SELECT
kunag AS Customer,
gjahr AS FiscalYear,
SUM( netwr ) AS TotalRevenue,
COUNT( * ) AS OrderCount,
AVG( netwr ) AS AvgOrderValue
FROM i_billingdocument
GROUP BY kunag, gjahr
HAVING SUM( netwr ) > 10000 " Minimum revenue
AND COUNT( * ) >= 5 " Minimum orders
INTO TABLE @rt_data.
ENDMETHOD.

Service Exposure

The Custom Entity is exposed as an OData service as usual:

@EndUserText.label: 'Revenue Reporting Service'
define service ZUI_RevenueReport {
expose ZI_CustomerRevenue as CustomerRevenue;
expose ZI_SalesStatistics as SalesStatistics;
}

Performance Tips for Aggregation Queries

TipDescription
Use indexesGROUP BY fields should be indexed
Apply filters earlyWHERE before GROUP BY reduces data volume
Limit pagingMax 500 rows per page
CachingCache frequently accessed aggregations
AsynchronousLong reports as background jobs
" Example: Caching for frequent aggregations
CLASS-DATA: gt_cache TYPE tt_statistics,
gv_cache_key TYPE string,
gv_cache_time TYPE timestamp.
METHOD get_cached_or_fresh.
" Build cache key from filter
DATA(lv_key) = build_cache_key( it_filter ).
" Cache valid? (5 minutes)
DATA(lv_now) = utclong_current( ).
DATA(lv_age) = cl_abap_tstmp=>subtract(
tstmp1 = lv_now
tstmp2 = gv_cache_time
).
IF lv_key = gv_cache_key AND lv_age < 300.
rt_data = gt_cache.
ELSE.
rt_data = aggregate_fresh( it_filter ).
gt_cache = rt_data.
gv_cache_key = lv_key.
gv_cache_time = lv_now.
ENDIF.
ENDMETHOD.

Best Practices

  1. Aggregation in Database - SELECT with GROUP BY instead of LOOP AT with COLLECT
  2. Apply Filters Early - WHERE clause reduces data volume before grouping
  3. Use HAVING - For filters on aggregated values
  4. Separate Total Count - Store before paging for correct pagination
  5. Flexible Sorting - Enable dynamic sorting over all fields
  6. Calculate KPIs - RevenueShare, GrowthRate after aggregation
  7. Use Caching - Cache frequently accessed reports
  8. Error Handling - TRY-CATCH for filter conversion

Summary

Custom Queries with aggregation enable powerful reporting in RAP:

  • GROUP BY in ABAP SQL for efficient aggregation
  • SUM, COUNT, AVG, MIN, MAX as aggregation functions
  • Paging after aggregation for large result sets
  • Dynamic sorting over all fields
  • KPI calculation after basic aggregation
  • Filters on aggregate values for flexible evaluations