【Apex】Database.〇〇Resultクラスによるエラーハンドリング

# 導入
この記事ではDatabaseクラスをふんだんに使い、適切なエラーハンドリングを行う方法を解説していきます！

一括でDML操作を行った際、複数レコードにエラーがあった場合でも`Exception.getMessage()`で取得できるのは最初のレコードのみです。
例えばユーザがCSVによるレコードの一括登録を行う機能を使う場面を想定すると、

1. CSVをアップロード → エラーが表示された行を特定して修正
1. 再度CSVをアップロード → 別のエラーが表示されたので再度特定して修正
1. 再度CSVをアップロード → また別のエラーが、、

という悲惨なループに陥ります。

こんな状況を解消するときに役立つのがDatabaseクラスです。
手動で全レコードのバリデーションを行う、なんてことをする必要は無く、比較的容易に原因の特定が行なえます。
以下はそれぞれ「必須の2項目が未入力」「2つの入力規則に抵触」のレコードを一括作成した際のエラー表示です。

※画面イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/3204a7ed-9d3c-8f6c-d750-91740a7fae51.png)


この記事ではDatabaseクラスの使い方と実際の活用例を紹介します。
処理の流れは以下の通りです。

1. Databaseクラスによる部分完了を許可するDML操作
1. Database.〇〇Resultからレコードの情報を取得
1. 部分完了したDML操作をロールバック

少し長めの記事になりますが、もしよければお付き合いください。

# Databaseクラスについて
## 1. DatabaseクラスによるDML操作
https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_database.htm

まず最初に、通常のDML操作とDatabaseクラスを用いたDML操作結果の違いについて説明します。

以下のように通常のDML操作を行った場合は、対象のレコードに1件でもエラーがあれば残りのレコードもデータベースに登録されません。
```java
Account acc1 = new Account(Name = 'レコード1');
Account acc2 = new Account(); // 必須項目無し
List<Account> accList = new List<Account>{acc1, acc2};
insert accList;
// System.DmlException → 2件とも作成されない
```

しかし、DatabaseクラスによるDML操作メソッドの第二引数にfalseを指定することで部分的に登録することが可能です。
```java
Account acc1 = new Account(Name = 'レコード1');
Account acc2 = new Account(); // 必須項目無し
List<Account> accList = new List<Account>{acc1, acc2};
Database.insert(accList, false);
// エラーは発生せず、acc1のみデータベースに登録される
```

第二引数は`allOrNone`の意味で、trueに指定するか省略した場合は通常のDML操作と同じく「全レコードへの操作が成功 / 全レコードへの操作が失敗」の2択、falseに指定したときのみ「部分的な成功を許可する」という意味合いになります。
つまり、以下3つはDMLの操作結果においては同義です。
- `insert 〇〇`
- `Database.insert(〇〇, true)`
- `Database.insert(〇〇)`

また、もちろん`insert`だけでなく`update / upsert / delete`も用意されており、同様に使うことができます。
この章で説明した部分的な完了を許可するDML操作により、次の章で説明するDatabase.〇〇Resultクラスのメソッドを使ったエラー内容の取得が可能になります。

## 2. Database.〇〇Resultクラス
https://developer.salesforce.com/docs/atlas.ja-jp.234.0.apexcode.meta/apexcode/apex_methods_system_database_saveresult.htm#apex_methods_system_database_saveresult

次に、Databaseクラスを用いたDML操作による返り値について説明します。

| メソッド | 返り値 |
|:-----------|:------------|
| Database.insert / Database.update| Database.SaveResult|
| Database.upsert| Database.UpsertResult|
| Database.delete| Database.DeleteResult|

Databaseクラスを使うと上記の通り、返り値が得られます。
`Database.insert(record)`だと返り値は`Database.SaveResult`、`Database.insert(recordList)`だと返り値は`List<Database.SaveResult>`です。

```java
// 単一レコード
Account acc1 = new Account(Name = 'レコード1', Branch__c = 'a015h000013RdqrAAC', Age__c = 30);
Database.SaveResult result = Database.insert(acc1, false);

// 複数レコード
Account acc1 = new Account(Name = 'レコード1', Branch__c = 'a015h000013RdqrAAC', Age__c = 30);
Account acc2 = new Account();
List<Account> accList = new List<Account>{acc1, acc2};
List<Database.SaveResult> results = Database.insert(accList, false);
```

これらのクラスは以下メソッドを使用できます。
- `getErrors()` → エラー詳細が格納されている`List<Database.Error>`を返す
- `getId()` → レコードIDを返す
- `isSuccess()` → DML操作が成功していればtrue、失敗ならfalseを返す
- `isCreated() ※Database.UpsertResultのみ` → レコードが作成された場合はtrue、更新された場合はfalseを返す



次章で`getErrors()`を使用し、エラーの詳細を取得していきます！

## 3. Database.Errorクラス
https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_database_error.htm

このクラスは以下メソッドが使用可能です。
- `getFields()` → エラーに関連する項目API名を返す
- `getMessage()` → エラーメッセージを返す（必須項目なら「値を入力してください」/ 入力規則なら設定したメッセージ、など）
- `getStatusCode()` → エラーコードを返す（必須項目なら「REQUIRED_FIELD_MISSING」/ 入力規則なら「FIELD_CUSTOM_VALIDATION_EXCEPTION」、など）

ここまでの内容を踏まえると、以下のような結果が得られます。

```java
// 複数の入力規則によるエラー
Account acc1 = new Account(Name = 'レコード1レコード1レコード1レコード1', Branch__c = 'a015h000013RdqrAAC', Age__c = 1);
// 必須項目不足によるエラー
Account acc2 = new Account();
List<Account> accList = new List<Account>{acc1, acc2};
List<Database.SaveResult> results = Database.insert(accList, false);

for(Database.SaveResult result : results){
    if(result.isSuccess()){
        continue;
    }

    System.debug('-----------------------');
    List<Database.Error> errors = result.getErrors();
    for(Database.Error e : errors){
        System.debug(e.getFields());
        System.debug(e.getMessage());
        System.debug(e.getStatusCode());
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/ee97386e-b380-3b8d-77e3-4b67a377ba28.png)

エラー情報の取得についての説明は以上です。
あとは上手くあれこれ加工して画面に表示すればOKです！

## 4. System.Savepointクラス
https://developer.salesforce.com/docs/atlas.ja-jp.234.0.apexcode.meta/apexcode/langCon_apex_transaction_control.htm

最後に、冒頭手順に記載した「部分完了したDML操作をロールバック」について説明します。

今までの手順を行うと、エラー情報の取得は問題なく行えますが、エラーが発生しなかったレコードはデータベースに登録されてしまいます。
部分的に処理を完了させる場合は問題ありませんが、エラー情報を取得した上で「全レコードへの操作が成功 / 全レコードへの操作が失敗」とする場合はもうひと工夫必要です。

以下の処理では正常に登録できるレコードをinsertした後、`Database.rollback()`によってセーブポイントを生成した箇所に復元しています。
そのため復元後の行ではinsert前の状態に戻っているため、例えばSOQLでデータベースにアクセスしてもacc1は登録されていません。

```java
Account acc1 = new Account(Name = 'レコード1', Branch__c = 'a015h000013RdqrAAC', Age__c = 30);

// セーブポイントを生成
Savepoint sp = Database.setSavepoint();

Database.SaveResult results = Database.insert(acc1, false);

// データベースをセーブポイントを生成した時点に復元する
Database.rollback(sp);
```

この章は本記事の主題とは少し逸れますが、実装する機能によっては必要な処理なのでぜひ覚えておいてください。

# 使ってみる
汎用的に使えるApexクラスと、フローからの呼び出しを想定したコンポーネントを置いておきます。

## Apex
```Java:RollbackResult.cls
// ロールバック有無、エラー情報をまとめて格納するクラス
public without sharing class RollbackResult {
    @AuraEnabled
    public Boolean hasRollbacked;
    @AuraEnabled
    public String stackTrace;
    @AuraEnabled
    public List<Record> records;

    public RollbackResult(){
        this.hasRollbacked = false;
        this.stackTrace = '';
        this.records = new List<Record>();
    }

    public class Record {
        @AuraEnabled
        public String id;
        @AuraEnabled
        public String name;
        @AuraEnabled
        public List<ErrorDetail> errorDetails;

        public Record(String id, String recordName, List<Database.Error> errors){
            this.id = id;
            this.name = recordName;
            this.errorDetails = new List<ErrorDetail>();

            for(Database.Error e : errors){
                errorDetails.add(new ErrorDetail(e));
            }
        }
    }

    public class ErrorDetail{
        @AuraEnabled
        public List<String> fieldNames;
        @AuraEnabled
        public String message;
        @AuraEnabled
        public String statusCode;

        public ErrorDetail(Database.Error e){
            this.fieldNames = e.getFields();
            this.message = e.getMessage();
            this.statusCode = String.valueOf(e.getStatusCode());
        }
    }
}

```

```java:DatabaseRollbackHandler.cls
// DML操作結果のエラー有無に応じてロールバックを行い、RollbackResultクラスを返却する
public without sharing class DatabaseRollbackHandler {
    private Savepoint sp;
    private RollbackResult result;

    public DatabaseRollbackHandler(Savepoint sp) {
        this.sp = sp;
        this.result = new RollbackResult();
    }

    public DatabaseRollbackHandler() {
        this(Database.setSavepoint());
    }

    // ロジック統一用の汎用Resultクラス
    public without sharing class GenericResult {
        private Id recordId;
        private List<Database.Error> errors;
        public Id getId() { return recordId; }
        public List<Database.Error> getErrors() { return errors; }

        public GenericResult(Id recordId, List<Database.Error> errors) {
            this.recordId = recordId;
            this.errors = (errors != null) ? errors : new List<Database.Error>();
        }
        public GenericResult(Database.SaveResult result) {
            this(result.getId(), result.getErrors());
        }
        public GenericResult(Database.UpsertResult result) {
            this(result.getId(), result.getErrors());
        }
        public GenericResult(Database.DeleteResult result) {
            this(result.getId(), result.getErrors());
        }
        public GenericResult(Database.UndeleteResult result) {
            this(result.getId(), result.getErrors());
        }
    }

    // コールスタック取得用例外クラス
    private class RollbackHandleException extends Exception {}

    public RollbackResult tryInsert(List<SObject> records){
        List<Database.SaveResult> insertResults = Database.insert(records, false);

        for (Integer i = 0; i < insertResults.size(); i++) {
            if(insertResults[i].isSuccess()){
                continue;
            }

            result.records.add(generateRollbackResultRecord(new GenericResult(insertResults[i]), records[i]));
        }

        return mightRollback(result);
    }

    public RollbackResult tryUpdate(List<SObject> records){
        List<Database.SaveResult> updateResults = Database.update(records, false);

        for (Integer i = 0; i < updateResults.size(); i++) {
            if(updateResults[i].isSuccess()){
                continue;
            }

            result.records.add(generateRollbackResultRecord(new GenericResult(updateResults[i]), records[i]));
        }

        return mightRollback(result);
    }

    public RollbackResult tryUpsert(List<SObject> records){
        List<Database.UpsertResult> upsertResults = Database.upsert(records, false);

        for (Integer i = 0; i < upsertResults.size(); i++) {
            if(upsertResults[i].isSuccess()){
                continue;
            }

            result.records.add(generateRollbackResultRecord(new GenericResult(upsertResults[i]), records[i]));
        }

        return mightRollback(result);
    }

    public RollbackResult tryDelete(List<SObject> records){
        List<Database.DeleteResult> deleteResults = Database.delete(records, false);

        for (Integer i = 0; i < deleteResults.size(); i++) {
            if(deleteResults[i].isSuccess()){
                continue;
            }

            result.records.add(generateRollbackResultRecord(new GenericResult(deleteResults[i]), records[i]));
        }

        return mightRollback(result);
    }

    // RollbackResult.Recordのインスタンスを生成
    private RollbackResult.Record generateRollbackResultRecord(GenericResult genericResult, SObject record){
        String recordId = genericResult.getId();
        String recordName = getNameFieldValue(record);
        List<Database.Error> errors = genericResult.getErrors();
        return new RollbackResult.Record(recordId, recordName, errors);
    }

    // レコードの名前項目（Name, Subject等）を取得する
    private String getNameFieldValue(SObject record){
        Map<String, SObjectField> fieldMap = record.getSObjectType().getDescribe().fields.getMap();
        for(SObjectField field : fieldMap.values()){
            DescribeFieldResult result = field.getDescribe();
            if(result.isNameField()){
                return String.valueOf(record.get(result.getSobjectField()));
            }
        }
        return '';
    }

    // DML操作時にエラーが発生した場合は、コールスタックの取得とロールバックを行う
    private RollbackResult mightRollback(RollbackResult result){
        result.hasRollbacked = !result.records.isEmpty();

        if(!result.hasRollbacked){
            return result;
        }

        result.stackTrace = getStackTrace();
        Database.rollback(sp);
        return result;
    }

    // コールスタックを取得する
    private String getStackTrace(){
        RollbackHandleException e = new RollbackHandleException();
        String rawStack = e.getStackTraceString();
        return rawStack.substringAfter('\n').substringAfter('\n').substringAfter('\n'); // 改行コード後の文字列切り出しを3回行い、try〇〇メソッド呼び出し直前のスタックを返却する
    }
}
```

## 使用例1
```java
DatabaseRollbackHandler rollbackHandler = new DatabaseRollbackHandler();

Account acc1 = new Account(Name = 'レコード1', Branch__c = 'a015h000013RdqrAAC', Age__c = 30);
Account acc2 = new Account();
List<Account> accList = new List<Account>{acc1, acc2};

RollbackResult rollbackResult = rollbackHandler.tryInsert(accList);

if(rollbackResult.hasRollbacked){
    // ロールバックされた（エラーが1件以上発生した）場合の処理
}
```

## Lightning Web コンポーネント
```html:handleFlowErrorScreen.html
<template>
  <div class="inline-error-container">

    <div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
      <span class="slds-assistive-text">error</span>
      <span class="slds-icon_container slds-icon-utility-error slds-m-right_x-small"
        title="Description of icon when needed">
        <lightning-icon icon-name="utility:error" variant="inverse"></lightning-icon>
      </span>
      <h1>{message}</h1>
    </div>

    <div class="error-content slds-p-around_small slds-scrollable--x">
      <lightning-accordion allow-multiple-sections-open active-section-name={activeSections}>
        <template for:each={result.records} for:item="record" for:index="index">
          <lightning-accordion-section name={index} label={record.name} key={record.index}>
            <template for:each={record.errorDetails} for:item="error" for:index="index">
              <li key={error.index}>
                <p>{error.displyaMessage}</p>
              </li>
            </template>
          </lightning-accordion-section>
        </template>
      </lightning-accordion>

      <div class="slds-p-vertical_x-small slds-m-top_small">
        <lightning-input label="詳細を表示" type="checkbox" onchange={handleCheckboxChange}></lightning-input>
      </div>
      <template if:true={viewDetails}>
        <pre class="error-details slds-box slds-box_x-small slds-theme_default">{errorDetailsJson}</pre>
      </template>
    </div>

  </div>
</template>
```

```javascript:handleFlowErrorScreen.js
import { LightningElement, api } from "lwc";

const ERROR_MESSAGE = "以下のレコードでエラーが発生したため処理が中断しました。";

export default class HandleFlowErrorScreen extends LightningElement {
  @api rollbackResult;
  message = ERROR_MESSAGE;
  viewDetails = false;
  errorDetailsJson;
  result;
  activeSections = [];

  connectedCallback() {
    this.errorDetailsJson = JSON.stringify(this.rollbackResult, null, 2);

    this.result = JSON.parse(JSON.stringify(this.rollbackResult));
    this.result.records.forEach((record) => {
      record.errorDetails.forEach((error) => {
        error.displyaMessage = this.formatDisplayMessage(error);
      });
    });

    this.activeSections = [...Array(this.result.records.length)].map(
      (_, i) => i
    );
  }

  formatDisplayMessage(error) {
    return `・${error.message}`;
  }

  handleCheckboxChange(event) {
    this.viewDetails = event.target.checked;
  }
}
```

```css:handleFlowErrorScreen.css
.inline-error-container {
    display: flex;
    flex-direction: column;
    align-items: center;
}
.error-content {
    width: 100%;
}
.error-title {
    text-align: center;
}
.error-details {
    max-height: 300px;
}
```

```xml:handleFlowErrorScreen.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>55.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>エラー詳細画面</masterLabel>
    <targets>
        <target>lightning__FlowScreen</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__FlowScreen">
            <property name="rollbackResult" type="apex://RollbackResult" label="Apex:RollbackResultクラス型変数" />
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>
```
## 使用例2
フローから呼び出してみる。

### RollbackResult型の変数を作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/cc3729ec-c275-7251-d4f0-feddfd11a739.png)

### Apexアクション内でDatabaseRollbackHandlerによるDML操作を行い、戻り値をRollbackResult型にし変数に割り当て
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/a85040e8-1cad-01dd-9485-df7b4063dcfe.png)

### コンポーネントに変数を渡す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/997b1d86-8587-715e-58b2-7e93c0457e24.png)

### 結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/573865/e04a468d-8b85-3f99-51e9-9156e908f7d9.png)

※JSON表示部分は、勝手ながら[こちらの記事](https://qiita.com/atskimura/items/aac457ffa8d0736fa522)を参考にさせていただきました。ありがとうございます！

# おわりに
使用例ではフローから呼び出す場面を想定しましたが、コンポーネントによるDML処理であればモーダルとして呼び出してももちろんOKです！
Visualforceをお使いの場合は、、無理矢理コンポーネントを埋め込むか、Apexだけ使用してエラー情報は上手く加工して頑張ってくださいすみません

# 参考
・[LWCのエラーハンドリングのベストプラクティスを考える](https://qiita.com/atskimura/items/aac457ffa8d0736fa522)
・[GitHub：apexlarson/DML](https://github.com/apexlarson/DML)