RAP: cancellazione logica con managed e unmanaged save
RAP managed, di default, esegue una DELETE fisica in fase di salvataggio: il record sparisce dal database, punto. Ma in molti scenari reali — anagrafiche, customizing, entità referenziate da log — la riga non può sparire: serve tracciabilità, serve poter ripristinare, servono i riferimenti intatti. La risposta è la cancellazione logica (soft delete): la riga resta a DB, marcata con un flag is_deleted, e sparisce solo dalle letture.
In questo articolo applico il pattern completo su un'anagrafica di esempio: dalla struttura della tabella al filtro nella interface view, fino al saver save_modified che intercetta la DELETE del framework e la trasforma in un UPDATE. Il tutto restando in managed: il framework continua a gestire lock, ETag, draft e validazioni — solo la persistenza passa a noi, con with unmanaged save.
Quando usare la cancellazione logica
Non è un pattern da applicare ovunque. La regola pratica che seguo:
- Anagrafiche e customizing dove serve tracciabilità e non si può perdere lo storico.
- Entità referenziate da tabelle di log o da altre tabelle applicative: una
DELETEfisica romperebbe i riferimenti. - Casi in cui l'eliminazione deve poter essere bloccata da precondizioni (record già eliminato, dipendenze ancora aperte).
Se invece la DELETE fisica va bene — dati transitori, staging, entità senza riferimenti — restate su managed puro con delete; nel BDEF, senza saver custom. Meno codice, meno manutenzione.
Step 1 — La tabella: flag + timestamp di eliminazione
L'unico campo davvero necessario è il flag is_deleted: è lui a guidare il filtro di lettura e le validazioni. Registrare anche il momento dell'eliminazione non è obbligatorio, ma è fortemente consigliato.
Per riusare gli stessi campi su tutte le tabelle che vogliono la soft delete, li definisco una volta sola in un include DDIC:
ZRAP_SG_DELETED_LOG — eliminazione logica:
@EndUserText.label : 'Audit: eliminazione logica'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
define structure zrap_sg_deleted_log {
is_deleted : zrap_sg_deleted; // flag: l'unico campo davvero necessario
deleted_at : timestampl; // timestamp UTC: consigliato
}
La tabella dell'anagrafica dichiara chiave e campi di business, poi include la struttura:
@EndUserText.label : 'Anagrafica gruppi'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #ALLOWED
define table zrap_sg_group {
key mandt : mandt not null;
key group_id : zrap_sg_group_id not null;
group_name : zrap_sg_group_name;
include zrap_sg_deleted_log; // is_deleted + deleted_at
}
Ogni nuova entità che vuole la soft delete riusa esattamente gli stessi campi e tipi, senza duplicazioni.
Step 2 — Interface view: il filtro che nasconde gli eliminati
Il cuore della cancellazione logica lato lettura è una sola riga: where is_deleted <> 'X'. I record marcati non compaiono più in nessuna query dell'applicazione, pur restando a DB.
@AbapCatalog.viewEnhancementCategory: [#NONE]
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Gruppo - Interface View'
@Metadata.ignorePropagatedAnnotations: true
@ObjectModel.usageType: { serviceQuality: #A, sizeCategory: #S, dataClass: #CUSTOMIZING }
define root view entity ZRAP_SG_I_Group
as select from zrap_sg_group as GRP
{
// ── Chiave ──────────────────────────────────────────────
key GRP.group_id as GroupId,
// ── Campi di business ───────────────────────────────────
GRP.group_name as GroupName,
// ── Eliminazione logica ─────────────────────────────────
@Semantics.booleanIndicator: true
GRP.is_deleted as IsDeleted,
GRP.deleted_at as DeletedAt
}
where
GRP.is_deleted <> 'X' // gli eliminati logicamente spariscono dalle letture
Direzione del filtro:
<> 'X'è il caso standard — mostra solo i record attivi. Se serve una view "cestino" per il ripristino, si crea una view separata con la condizione invertita (= 'X'). Si sconsiglia di mettere le due logiche nella stessa view di consumo.
Step 3 — Behavior Definition: with unmanaged save
Tre elementi rendono la delete "logica":
with unmanaged save— il framework continua a orchestrare la transazione, ma il DML fisico lo scriviamo noi nel saver.delete ( precheck );— abilita un precheck per rifiutare delete non ammesse prima che arrivino al salvataggio.field ( readonly )sui campi di sistema — il client non può valorizzarli; li scrive solo lasave_modified.
managed implementation in class ZBP_RAP_SG_GROUP unique;
strict ( 2 );
define behavior for ZRAP_SG_I_Group alias Group
with unmanaged save
lock master
authorization master ( global )
{
// ── Operazioni standard ─────────────────────────────────────
create;
update;
delete ( precheck );
// ── Validazioni ─────────────────────────────────────────────
validation validateIsDeleted on save { create; update; }
// ── Field control ───────────────────────────────────────────
field ( readonly : update ) GroupId;
// Campi di sistema: valorizzati SOLO dalla save_modified
field ( readonly ) IsDeleted, DeletedAt;
// ── Mapping ─────────────────────────────────────────────────
mapping for zrap_sg_group corresponding
{
GroupId = group_id;
GroupName = group_name;
}
}
Con with unmanaged save il mapping serve solo alla materializzazione dei dati in ingresso: il DML fisico lo scrive comunque la save_modified. I campi readonly non entrano nel mapping perché non arrivano mai dal client.
Step 4 — precheckDelete: bloccare le delete non ammesse
Il precheck rifiuta la delete quando non ha senso o non è permessa — qui, record già eliminato logicamente; nello stesso punto si aggiungono le altre precondizioni di business (dipendenze aperte, stato non compatibile). Il controllo è bulk: una sola SELECT per tutte le chiavi, poi BINARY SEARCH in loop.
METHOD precheckdelete.
IF keys IS INITIAL.
RETURN.
ENDIF.
" Bulk check: record già eliminati logicamente
SELECT group_id
FROM zrap_sg_group
FOR ALL ENTRIES IN @keys
WHERE group_id = @keys-GroupId
AND is_deleted = @abap_true
INTO TABLE @DATA(lt_deleted).
SORT lt_deleted BY group_id.
" Errore applicativo per ogni record già eliminato
LOOP AT keys ASSIGNING FIELD-SYMBOL(<ls_key>).
READ TABLE lt_deleted TRANSPORTING NO FIELDS
WITH KEY group_id = <ls_key>-GroupId
BINARY SEARCH.
IF sy-subrc = 0.
APPEND VALUE #(
%key = <ls_key>-%key
%fail-cause = if_abap_behv=>cause-unspecific
) TO failed-group.
APPEND VALUE #(
%key = <ls_key>-%key
%msg = new_message(
id = 'ZRAP_SG'
number = '001'
severity = if_abap_behv_message=>severity-error
v1 = <ls_key>-GroupId )
) TO reported-group.
ENDIF.
ENDLOOP.
ENDMETHOD.
Se il precheck fallisce, il framework scarta l'istanza prima del salvataggio: nessuna scrittura, errore applicativo pulito verso il client.
Step 5 — save_modified: la DELETE che diventa UPDATE
È qui che avviene la trasformazione. La richiesta di DELETE arriva al saver nella struttura delete-group, ma non viene mai eseguita come DELETE FROM: rileggo la riga completa (il framework passa le sole chiavi), valorizzo flag e timestamp, ed eseguo un UPDATE.
CLASS lsc_group DEFINITION INHERITING FROM cl_abap_behavior_saver.
PROTECTED SECTION.
METHODS save_modified REDEFINITION.
ENDCLASS.
CLASS lsc_group IMPLEMENTATION.
METHOD save_modified.
"================================================================
" DELETE: eliminazione logica — is_deleted = 'X' + timestamp
"================================================================
IF delete-group IS NOT INITIAL.
" Rileggo la riga completa a DB: il framework passa le sole chiavi
SELECT *
FROM zrap_sg_group
FOR ALL ENTRIES IN @delete-group
WHERE group_id = @delete-group-GroupId
INTO TABLE @DATA(lt_delete).
LOOP AT lt_delete ASSIGNING FIELD-SYMBOL(<ls_del>).
<ls_del>-is_deleted = abap_true.
GET TIME STAMP FIELD <ls_del>-deleted_at.
ENDLOOP.
UPDATE zrap_sg_group FROM TABLE lt_delete.
ENDIF.
ENDMETHOD.
ENDCLASS.
Nel behavior pool reale la stessa save_modified gestisce anche create- (INSERT) e update- (merge dei campi da %control + UPDATE): stesso schema "leggi → valorizza i campi di sistema → DML".
Il tassello finale: bloccare le modifiche dopo l'eliminazione
Un record eliminato logicamente non deve più essere modificabile — altrimenti resterebbe scrivibile pur essendo "cancellato". Lo garantisce la validation dichiarata nel BDEF:
validation validateIsDeleted on save { create; update; }
L'implementazione legge lo stato a DB e rifiuta con errore applicativo ogni UPDATE su record con is_deleted = 'X'. Insieme al precheck sulla delete, chiude il cerchio: un record eliminato non si ri-elimina e non si modifica.
Il flusso end-to-end, ricapitolando:
- DELETE OData sull'entità → il framework managed raccoglie la chiave.
precheckDeleteverifica le precondizioni. Se fallisce → errore, nessuna scrittura.save_modifiedintercettadelete-group, rilegge la riga, valorizzais_deleted+deleted_at, esegue l'UPDATE.- La interface view (
where is_deleted <> 'X') esclude il record: sparito dall'app, presente a DB. - Ogni UPDATE successivo è bloccato da
validateIsDeleted.
In sintesi
| Oggetto | Elemento chiave |
|---|---|
| Tabella | include zrap_sg_deleted_log; — flag obbligatorio + timestamp UTC consigliato |
| Interface view | where is_deleted <> 'X' |
| Behavior Definition | with unmanaged save, delete ( precheck ), field ( readonly ) sui campi di sistema |
| Behavior pool | saver lsc_ con save_modified REDEFINITION; DELETE → UPDATE del flag |
| Blocco post-delete | validation validateIsDeleted on save { create; update; } |
Il punto di forza del pattern è che resta dentro il modello managed: lock, ETag, draft, validazioni e messaggistica continuano a funzionare come sempre — l'unica cosa che cambia è il significato della DELETE. Il client fa una DELETE OData standard, l'applicazione si comporta come se il record fosse sparito, e il database conserva tutto: audit, ripristino e integrità referenziale inclusi.