Salesforce 参照関係向け汎用積上げ集計クラス

Salesforce で親子関係のオブジェクトを積み上げていく時、構造上どうしても主従関係ではなく参照関係で連結せざるを得ない時があります。
そうなった場合、子の数値や金額を親に積上げ集計するためには、トリガーをベースに独自の集計処理を作成しなければなりません。

※無論、Apexトリガを使わずに標準機能を駆使して類似の実装は可能(らしい)ですが、他環境への展開などの汎用性、構築工数、デバッグの難易度などの点で、Apexトリガが圧倒的に優位だと考えます。
時間を贅沢に使って勉強がてら自社環境へ実装してみる等というシチュエーションであれば、フローなどによる複雑な積み上げ集計の実装に挑戦するのもアリだと思いますが。

毎回似たような処理を書くのも飽きてきたので、汎用の積上げ集計ロジックを組んでみました。
片手間の突貫作業なので、特に細かな引数チェックや例外処理などは積んでません。あくまで「使い方をわかった人が必要に応じて調整しながら使う」事を想定した設計です。

コピーライトは入れてますが、あくまで自己責任な素材の提供なので、必要に応じて改造したりして使ってください。ただしこのクラスのバグや記事の情報などが元で何らかの損失を被ってもこちらは関知しません。

改善案や感想などのコメントを貰えると中の人が喜びます。

 /*
    General-purpose Cumulative Engine

    Copyright(c) 2019 SoRaMiMi
    2019/11/06 First Edition
*/

public class CumulativeEngine {
    // Internal parameters
    private String pObjectAPI;
    private String pSumAPI;
    private String pNumAPI;
    private String pMinAPI;
    private String pMaxAPI;
    private String cObjectAPI;
    private String cItemAPI;
    private String cRelationAPI;
    private String customFilter;
    private List<Id> pNeedCalculateRecords;

    // Property
    public String ParentObjectAPIName {
        get { return pObjectAPI; }
        set { pObjectAPI = (value != '') ? value : null; }
    }
    public String ParentSumItemAPIName {
        get { return pSumAPI; }
        set { pSumAPI = (value != '') ? value : null; }
    }
    public String ParentCountItemAPIName {
        get { return pNumAPI; }
        set { pNumAPI = (value != '') ? value : null; }
    }
    public String ParentMinItemAPIName {
        get { return pMinAPI; }
        set { pMinAPI = (value != '') ? value : null; }
    }
    public String ParentMaxItemAPIName {
        get { return pMaxAPI; }
        set { pMaxAPI = (value != '') ? value : null; }
    }
    public String ChildObjectAPIName {
        get { return cObjectAPI; }
        set { cObjectAPI = (value != '') ? value : null; }
    }
    public String ChildItemAPIName {
        get { return cItemAPI; }
        set { cItemAPI = (value != '') ? value : null; }
    }
    public String ChildRelationItemAPIName {
        get { return cRelationAPI; }
        set { cRelationAPI = (value != '') ? value : null; }
    }
    public String CustomFilterCondition {
        get { return customFilter; }
        set { customFilter = (value != '') ? value : null; }
    }

    // Constructor
    public CumulativeEngine() {
        pNeedCalculateRecords = new List<Id>();
    }
    // Trigger event pre-summary processes
    public void sumOnInsert(Map<IdsObject> newMap) {
        for (Id recId : newMap.keySet()) {
            sObject newRec = newMap.get(recId);
            Id parentRecordId = (Id)newRec.getPopulatedFieldsAsMap().get(cRelationAPI);
            addNCR(parentRecordId);
        }
    }
    public void sumOnUpdate(Map<IdsObject> newMap, Map<IdSObject> oldMap) {
        for (Id recId : newMap.keySet()) {
            sObject newRec = newMap.get(recId);
            sObject oldRec = oldMap.get(recId);
            Id newParentRecordId = (Id)newRec.getPopulatedFieldsAsMap().get(cRelationAPI);
            Id oldParentRecordId = (Id)oldRec.getPopulatedFieldsAsMap().get(cRelationAPI);
            if (newParentRecordId != oldParentRecordId) {
                // Relation Changed
                addNCR(newParentRecordId);
                addNCR(oldParentRecordId);
            } else {
                // Only Update
                addNCR(newParentRecordId);
            }
        }
    }
    public void sumOnDelete(Map<IdSObject> oldMap) {
        SumOnInsert(oldMap);
    }
    public void sumOnUndelete(Map<IdSObject> newMap) {
        SumOnInsert(newMap);
    }
    // Need Update?
    public boolean isNeedUpdate() {
        return (!pNeedCalculateRecords.isEmpty());
    }
    // Summary processing and Update
    public void doSummary() {
        if (isNeedUpdate()) {
            List<sObject> dmlBuffer = new List<sObject>();
            List<Id> procRecords = new List<Id>();
            string sumQuery =
                'SELECT ' + cRelationAPI + ' ';
            if (pSumAPI != null) sumQuery += ', SUM(' + cItemAPI + ') SumValue ';
            if (pNumAPI != null) sumQuery += ', COUNT(' + cItemAPI + ') NumValue ';
            if (pMinAPI != null) sumQuery += ', MIN(' + cItemAPI + ') MinValue ';
            if (pMaxAPI != null) sumQuery += ', MAX(' + cItemAPI + ') MaxValue ';
            sumQuery +=
                'FROM ' + cObjectAPI + ' ' +
                'WHERE ' + cRelationAPI + ' IN :pNeedCalculateRecords ';
            if (customFilter != null) {
                sumQuery += 'AND ' + customFilter + ' ';
            }
            sumQuery += 'GROUP BY ' + cRelationAPI;
            AggregateResult[] groupedResults = Database.query(sumQuery);
            for (AggregateResult Res : groupedResults) {
                Id recId = (Id)Res.get(cRelationAPI);
                sObject updateRec = createObject(pObjectAPI);
                updateRec.put('Id', recId);
                if (pSumAPI != null) updateRec.put(pSumAPI, Res.get('SumValue'));
                if (pNumAPI != null) updateRec.put(pNumAPI, Res.get('NumValue'));
                if (pMinAPI != null) updateRec.put(pMinAPI, Res.get('MinValue'));
                if (pMaxAPI != null) updateRec.put(pMaxAPI, Res.get('MaxValue'));
                dmlBuffer.add(updateRec);
                procRecords.add(recId);
            }
            for (id targetId : pNeedCalculateRecords) {
                if (!procRecords.contains(targetId)) {
                    sObject updateRec = createObject(pObjectAPI);
                    updateRec.put('Id', targetId);
                    if (pSumAPI != null) updateRec.put(pSumAPI, 0);
                    if (pNumAPI != null) updateRec.put(pNumAPI, 0);
                    if (pMinAPI != null) updateRec.put(pMinAPI, 0);
                    if (pMaxAPI != null) updateRec.put(pMaxAPI, 0);
                    dmlBuffer.add(updateRec);
                }
            }
            if (!dmlBuffer.isEmpty()) {
                update dmlBuffer;
            }
        }
    }
    // Summary job stacking
    private void addNCR(Id addId) {
        if (addId != null) {
            if (!pNeedCalculateRecords.contains(addId)) {
                pNeedCalculateRecords.add(addId);
            }
        }
    }
    // Dynamic object creation
    private static sObject createObject(String typeName) {
        Schema.SObjectType targetType = Schema.getGlobalDescribe().get(typeName);
        if (targetType != null) {
            return targetType.newSObject();
        } else {
            return null;
        }
    }
} 

上記の CumulativeEngine クラスを設置した上で、以下のようなトリガーで呼び出します。

 trigger TestChild_Trigger on TestChild__c (after insert, after update, after delete, after undelete) {
    CumulativeEngine CE = new CumulativeEngine();
    CE.ParentObjectAPIName    = 'TestParent__c';
    CE.ParentSumItemAPIName   = 'Sum__c';
    CE.ParentCountItemAPIName = 'Num__c';
    CE.ParentMinItemAPIName   = 'Min__c';
    CE.ParentMaxItemAPIName   = 'Max__c';
    CE.ChildObjectAPIName       = 'TestChild__c';
    CE.ChildItemAPIName         = 'Amount__c';
    CE.ChildRelationItemAPIName = 'toTestparent__c';
    CE.CustomFilterCondition = 'Active__c=true';
    if (Trigger.isAfter) {
        if (Trigger.isInsert) CE.sumOnInsert(Trigger.newMap);
        if (Trigger.isUpdate) CE.sumOnUpdate(Trigger.newMapTrigger.oldMap);
        if (Trigger.isDelete) CE.sumOnDelete(Trigger.oldMap);
        if (Trigger.isUndelete) CE.sumOnUndelete(Trigger.newMap);
        if (CE.isNeedUpdate()) CE.doSummary();
    }
}

上記のトリガーを見れば、だいたい何をやっていて何が必要かは分かると思います。

  • ParentObjectAPIName
    親(集計先)オブジェクトのAPI名をセットします(必須)
  • ParentSumItemAPIName/ParentCountItemAPIName/ParentMinItemAPIName/ParentMaxItemAPIName
    親オブジェクトに集計値のセット先として準備した項目のAPI名です。使わない集計はnullか未指定とします(いずれか一つは指定)
  • ChildObjectAPIName
    子(集計元)オブジェクトのAPI名をセットします(必須)
  • ChildItemAPIName
    子オブジェクトの集計対象となる項目(金額とか数量とか)のAPI名をセットします(必須)
  • ChildRelationItemAPIName
    子オブジェクトの参照関係項目(親オブジェクトへ接続している項目)のAPI名をセットします(必須)
  • CustomFilterCondition
    集計の際に、子オブジェクトの項目で絞り込みを行う場合は SOQL の WHERE 条件の書き方で指定します。使わない場合はnullか未指定とします。
    単純に SOQL クエリ生成時に差し込んでるだけなので AND や () なども使えます。ただしバインド変数は使用できませんので、ここで渡す段階で文字列化してください。

これらのパラメータを指定した上で、トリガーの中でそのオペレーションタイプに応じて必要なsumOn****を呼び出します。その際にトリガーの newMap や oldMap などを引き渡し、実際の集計処理の予備処理を行わせます。集計タイミングの都合上、全て After のトリガである必要があります(集計をSOQLで行うので、DBへ書き込み後でないと拾えない)。

予備処理後に doSummary() を呼び出し、子オブジェクトからの集計処理(集計関数SOQL)と親オブジェクトへの値書き込み(Update DML)を実行します。

積上げ集計には、SOQL1回実行、DML1回実行、のリソースを消費します。集計対象が多い(子レコードが膨大)な場合や他に重い処理を行っていなければ、まずガバナ制限に引っかかることはないと思います。

毎回ゼロからトリガー作って~クラス作って~をやるよりは楽かと思います。

ん?テストコード?そんなん各自サクッと書いてwwww
仕組み上クラス専用のテストが書きにくいので、実装先のオブジェクトの insert / update / delete / undelete を単純に舐めるテストコードでカバレッジ上げてくださいw

※2019/12/05 修正
SOQLクエリの集計関数を動的生成に変更し、特定の集計関数に非対応の項目でも使用できるよう修正(例:Date型項目を集計時、Sum項目をnull指定して無効化することで使用可能に)

※2020/05/14 修正
一部手直し

 

この記事が気に入ったら
いいね!しよう

最新情報をお届けします

Twitter でそらみみをフォローしよう!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です