Salesforce ApexでSOQLの結果をCSVとして保存する

# はじめに

今回は、Salesforceのオブジェクトの項目・データをCSVでサクッと取得するためにApexを書きます。

自分は、普段からSalesforceからレコードや、スキーマ情報をApexで取らずに

- API
- CLI
    - SalesforceDX

等でサクッととっているのですが、今回学習のためにApexでやってみました。


:::note warn
今回動かすApexは、
とりあえず全項目を検索し、CSVに出力する作りにしているので
大量データのバックアップ等には向いていません
:::

# この記事でやること
- 以下のApexを作成
    - オブジェクト名を指定することで全部の項目をSELECTする
    - CSVとしてSalesforceのファイル>ライブラリに保存する
        - 保存するもの
            - 検索結果のレコード
            - 検索した項目のAPI参照名とラベルのセット
- Apex実行後に保存されるファイル>ライブラリの作成


# 実行環境

- trailhead ハンズオン組織
- 利用するデータ　ハンズオン組織のサンプルデータ


https://trailhead.salesforce.com/ja/content/learn/modules/trailhead_playground_management



# 書いたコード

今回書いたコードはこちらです。

https://github.com/takaHAL/sfdx/blob/main/force-app/main/default/classes/CsvBatch.cls

```apex: CsvBatch.cls
public with sharing class CsvBatch implements Database.Batchable<SObject>, Database.Stateful {
  private String objectName;
  private Map<String, String> fields;

  public CsvBatch(String objectName) {
    this.objectName = objectName;
  }

  private String objectCsvColumnHeader;
  private String schemaCsvColumnHeader;
  private List<String> objectCsvRowValues = new List<String>();
  private List<String> schemaCsvRowValues = new List<String>();

  public Database.QueryLocator start(Database.BatchableContext bc) {
    fields = getFields(objectName);
    String columns = String.join(new List<String>(this.fields.keySet()), ', ');

    String query = 'SELECT ' + columns + ' FROM ' + objectName;

    return Database.getQueryLocator(query);
  }

  public void execute(Database.BatchableContext bc, List<SObject> records) {
    // 取得したレコードでCSVの行を作成
    // 例） '0015i00000KcklhAAB',false,'Sample Account for Entitlements'
    for (SObject record : records) {
      List<String> row = new List<String>();
      for (String filedName : fields.keySet()) {
        if (record.get(filedName) != null) {
          row.add(String.valueOf(record.get(filedName)).escapeCsv());
        } else {
          row.add('');
        }
      }
      objectCsvRowValues.add(String.join(row, ','));
    }

    // 項目のAPI参照名、ラベルでCSVの行を作成
    schemaCsvColumnHeader = String.join(
      new List<String>{ 'DeveloperName', 'Name' },
      ', '
    );
    List<String> fieldNames = new List<String>();
    for (String filedName : fields.keySet()) {
      List<String> row = new List<String>();
      row.add(filedName.escapeCsv());
      row.add(fields.get(filedName).escapeCsv());

      schemaCsvRowValues.add(String.join(row, ','));
      fieldNames.add(filedName.escapeCsv());
    }

    // オブジェクトの項目名でCSVのヘッダーを作成
    objectCsvColumnHeader = String.join(fieldNames, ', ');
  }

  public void finish(Database.BatchableContext bc) {
    // 保存先ライブラリの情報取得
    List<ContentWorkspace> libraries = [
      SELECT Id, Name
      FROM ContentWorkspace
      WHERE Name = 'ApexResults'
      LIMIT 1
    ];

    if (libraries.isEmpty()) {
      System.debug('保存先ライブラリーが見つかりません。');
    } else {
      List<ContentVersion> documents = new List<ContentVersion>();
      // 項目のAPI参照名、ラベルのCSVを作成
      String schemaCsvFile =
        schemaCsvColumnHeader +
        '\n' +
        String.join(schemaCsvRowValues, '\n');
      ContentVersion schemaDoc = new ContentVersion(
        Title = objectName + '-Schema',
        PathOnClient = objectName + '-Schema' + '.csv',
        VersionData = Blob.valueOf(schemaCsvFile),
        FirstPublishLocationId = libraries[0].Id
      );
      documents.add(schemaDoc);

      // レコード情報をCSVに
      String objectCsvFile =
        objectCsvColumnHeader +
        '\n' +
        String.join(objectCsvRowValues, '\n');
      ContentVersion objectDoc = new ContentVersion(
        Title = objectName,
        PathOnClient = objectName + '.csv',
        VersionData = Blob.valueOf(objectCsvFile),
        FirstPublishLocationId = libraries[0].Id
      );
      documents.add(objectDoc);

      // ContentVersionにCSVのデータを保存
      insert documents;
    }
  }

  // 指定したオブジェクトの項目のAPI参照名を返す
  // API参照名をキー、ラベルを値
  private Map<String, String> getFields(String objectName) {
    Map<String, String> fields = new Map<String, String>();

    Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectName);
    Schema.DescribeSObjectResult describeObject = objectType.getDescribe();

    Map<String, Schema.SObjectField> schemaFields = describeObject.fields.getMap();

    for (Schema.SObjectField schemaField : schemaFields.values()) {
      fields.put(
        schemaField.getDescribe().getName(),
        schemaField.getDescribe().getLabel()
      );
    }

    return fields;
  }
}
```


以下はテストコードです。
CSVの中身までは検証していませんが、ファイルが生成されたかどうかだけチェックしています。

```apex: CsvBatch_Test.cls
@isTest
public with sharing class CsvBatch_Test {
  @testSetup
  static void setup() {
    // テスト用のレコードを生成
    Contact contact = new Contact(LastName = 'Test Contact');
    insert contact;
  }

  @isTest
  static void CsvBatch_Test() {
    Test.startTest();
    CsvBatch batch = new CsvBatch('Contact');
    Database.executeBatch(batch);
    Test.stopTest();

    List<ContentWorkspace> libraries = [
      SELECT Id, Name
      FROM ContentWorkspace
      WHERE Name = 'ApexResults'
      LIMIT 1
    ];
    System.assert(!libraries.isEmpty(), 'library not found');

    List<ContentVersion> documents = [SELECT Title FROM ContentVersion WHERE Title LIKE '%Contact%'];
    System.assertEquals(2, documents.size(), 'csv not found');
  }
}

```

# 実行方法

## 保存先の作成

ファイルから新規ライブラリの作成をしましょう。

ファイルのタブが出ていない場合は、公式の[ヘルプ](https://help.salesforce.com/s/articleView?id=000328077&type=1)にもあるように
プロファイルで表示や、アプリケーションのタブに追加しましょう。


- 新規ライブラリの作成

![スクリーンショット 2022-09-20 3.42.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/6abe421a-c191-7645-ddef-b83dd297696e.png)

作成する名前は以下のコードの`Name`と同じ名前で作成してください
※なんでもいいです。

```apex
    List<ContentWorkspace> libraries = [
      SELECT Id, Name
      FROM ContentWorkspace
      WHERE Name = 'ApexResults'
      LIMIT 1
    ];
```



![スクリーンショット 2022-09-20 3.42.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/0453cd0d-ada1-4e16-d08f-43e0021a79b5.png)


作成が完了したら、SOQLで目的のフォルダができているか確認しましょう。

- 設定>開発者コンソールから確認

![スクリーンショット 2022-09-20 3.52.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/9fd089fd-98b2-8769-4d1e-36df675e7755.png)

QueryEditorの欄に以下のSOQLを添付し、Executeボタンで実行して結果が返ってこればOKです。

```sql
SELECT Id, Name
      FROM ContentWorkspace
      WHERE Name = 'ApexResults'
      LIMIT 1
```

![スクリーンショット 2022-09-20 3.57.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/1f0bc59f-2efb-c930-54e6-ed1a9a5b71cf.png)

## コードの実行

以下のコードを開発者コンソールの

- [実行匿名ウィンドウ](https://developer.salesforce.com/docs/atlas.ja-jp.visualforce_workbook.meta/visualforce_workbook/apex1_2.htm)
- VSCode でApexの匿名実行

のどちらかで実行

```apex: 実行する際のコード
// オブジェクト名に取得したいオブジェクトのAPI参照名を入れてください
// 例) Account
CsvBatch batch = new CsvBatch('オブジェクト名');
Database.executeBatch(batch);
```

![スクリーンショット 2022-09-20 4.18.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/62ba719b-b9b8-4259-1625-9cdb3c783994.png)

### 実行結果

実行すると作成したライブラリにファイルが、二つ生成されていますね。

それぞれのファイルの内容がどんなものか簡単に紹介します。

![スクリーンショット 2022-09-20 4.19.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445600/cc59bac9-d5e7-1795-abd3-2298d03ffa72.png)


#### 保存されるCSVの例

Account で実行した場合

#### SELECT結果のCSV

**※保存されているデータはSalesforceのハンズオン組織のものです。**

指定したオブジェクトの項目名をヘッダーにした、
全データのCSVが保存されます。

- Account.csv

|Id|IsDeleted|MasterRecordId|Name|Type|...|
|---|---|---|---|---|---|
|0015i00000KcklhAAB|false||Sample Account for Entitlements||...|
|︙|︙|︙|︙|︙|︙|


### オブジェクトの項目情報のCSV

指定したオブジェクトの項目名のAPI参照名、ラベルの情報が入ったCSVが出力されます。

- Account-Schema.csv

|DeveloperName| Name|
|---|---|
|Id|取引先 ID|
|IsDeleted|	削除|
|MasterRecordId|	マスタレコード ID|
|Name|	取引先名|
|Type| 取引先 種別|
|︙|︙|


# コードの解説

先に実行方法と、Salesforce上でのライブラリの作成の説明をしたのでここからはコードの簡単な流れの説明をしていきます。


## 指定したオブジェクトの項目のAPI参照名を取得

https://github.com/takaHAL/sfdx/blob/main/force-app/main/default/classes/CsvBatch.cls#L103

[Schema.SObjectType](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Schema_SObjectType.htm)
[Schema.DescribeSObjectResult](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_sobject_describe.htm)を利用してオブジェクトの定義情報を取得して

API参照名をキー、ラベルを値にしたMapを作成し返します。

```apex
  private Map<String, String> getFields(String objectName) {
    Map<String, String> fields = new Map<String, String>();

    Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectName);
    Schema.DescribeSObjectResult describeObject = objectType.getDescribe();

    Map<String, Schema.SObjectField> schemaFields = describeObject.fields.getMap();

    for (Schema.SObjectField schemaField : schemaFields.values()) {
      fields.put(schemaField.getDescribe().getName(), schemaField.getDescribe().getLabel());
    }

    return fields;
  }
```

## 取得したAPI参照名でSOQLを作成し実行する

https://github.com/takaHAL/sfdx/blob/main/force-app/main/default/classes/CsvBatch.cls#L14

オブジェクト名から取得したAPI参照名を

`Id, Name` のようにしてSOQL文を作成しオブジェクトに定義されている全項目でSOQLを実行します。

```apex
  public Database.QueryLocator start(Database.BatchableContext bc) {
    fields = getFields(objectName);
    String columns = String.join(new List<String>(fields.keySet()), ', ');

    String query = 'SELECT ' + columns + ' FROM ' + objectName;

    return Database.getQueryLocator(query);
  }
```

## 検索した結果を処理、CSVのヘッダーと行を作成する

https://github.com/takaHAL/sfdx/blob/main/force-app/main/default/classes/CsvBatch.cls#L23

検索結果のレコードを行単位で、Listに追加
CSVのヘッダーとなる項目名等をListに追加しています。

```apex
  public void execute(Database.BatchableContext bc, List<SObject> records) {
    // 取得したレコードでCSVの行を作成
    // 例） '0015i00000KcklhAAB',false,'Sample Account for Entitlements'
    for (SObject record : records) {
      List<String> row = new List<String>();
      for (String filedName : fields.keySet()) {
        if (record.get(filedName) != null) {
          row.add(String.valueOf(record.get(filedName)).escapeCsv());
        } else {
          row.add('');
        }
      }
      objectCsvRowValues.add(String.join(row, ','));
    }

    // 項目のAPI参照名、ラベルでCSVの行を作成
    schemaCsvColumnHeader = String.join(
      new List<String>{ 'DeveloperName', 'Name' },
      ', '
    );
    List<String> fieldNames = new List<String>();
    for (String filedName : fields.keySet()) {
      List<String> row = new List<String>();
      row.add(filedName.escapeCsv());
      row.add(fields.get(filedName).escapeCsv());

      schemaCsvRowValues.add(String.join(row, ','));
      fieldNames.add(filedName.escapeCsv());
    }

    // オブジェクトの項目名でCSVのヘッダーを作成
    objectCsvColumnHeader = String.join(fieldNames, ', ');
  }
```

## CSVをライブラリに保存

https://github.com/takaHAL/sfdx/blob/main/force-app/main/default/classes/CsvBatch.cls#L57

作成した保存先ライブラリ[ContentWorkspaceオブジェクト](https://developer.salesforce.com/docs/atlas.ja-jp.object_reference.meta/object_reference/sforce_api_objects_contentworkspace.htm)の情報を取得し、
作成したCSVに保存するデータを、[Blob.valueOf](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_methods_system_blob.htm)で文字列からBlobに変換し、CSVとして
[ContentVersionオブジェクト](https://developer.salesforce.com/docs/atlas.ja-jp.object_reference.meta/object_reference/sforce_api_objects_contentversion.htm)に新規データとして保存します。

```apex
public void finish(Database.BatchableContext bc) {
    // 保存先ライブラリの情報取得
    List<ContentWorkspace> libraries = [
      SELECT Id, Name
      FROM ContentWorkspace
      WHERE Name = 'ApexResults'
      LIMIT 1
    ];

    if (libraries.isEmpty()) {
      System.debug('保存先ライブラリーが見つかりません。');
    } else {
      List<ContentVersion> documents = new List<ContentVersion>();
      // 項目のAPI参照名、ラベルのCSVを作成
      String schemaCsvFile =
        schemaCsvColumnHeader +
        '\n' +
        String.join(schemaCsvRowValues, '\n');
      ContentVersion schemaDoc = new ContentVersion(
        Title = objectName + '-Schema',
        PathOnClient = objectName + '-Schema' + '.csv',
        VersionData = Blob.valueOf(schemaCsvFile),
        FirstPublishLocationId = libraries[0].Id
      );
      documents.add(schemaDoc);

      // レコード情報をCSVに
      String objectCsvFile =
        objectCsvColumnHeader +
        '\n' +
        String.join(objectCsvRowValues, '\n');
      ContentVersion objectDoc = new ContentVersion(
        Title = objectName,
        PathOnClient = objectName + '.csv',
        VersionData = Blob.valueOf(objectCsvFile),
        FirstPublishLocationId = libraries[0].Id
      );
      documents.add(objectDoc);

      // ContentVersionにCSVのデータを保存
      insert documents;
    }
  }
```

# 最後に

SOQLでSQLの*のように全項目検索しようと思うと[FIELDS関数](https://developer.salesforce.com/docs/atlas.ja-jp.230.0.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_fields.htm)を利用するのですが、LIMIT 200制限や対応していないオブジェクトも存在していたりするので、オブジェクトで定義されてる項目から動的にSOQLを作成して実行しよう！という感じでApexを書いてみました。
ETL等でBigQueryにガッツリSalesforceの分析基盤を作成する前にCSVで出力して
BigQueryで読み込んでデータを見てみる！とか全項目である程度のデータを手元で見たいとかでSOQL書かなくてもCSVにできるので便利そうな感じします。

Apexに関して、まだまだガバナ制限を考慮して作成できてない部分ありますので是非アドバイス等あればコメントで教えてくださると助かります。

# 参考にさせていただいた記事

https://zenn.dev/miyakon/scraps/a6a148e471283d

https://qiita.com/Keiji_otsubo/items/767ed7fbbf2f72943a14

https://qiita.com/TaigerAndDragon/items/168401a4a9954449d14e

https://esp-labo.com/db_stateful/