├── .forceignore ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── apex-trigger-handler └── main │ └── default │ └── classes │ ├── Triggers.cls │ ├── Triggers.cls-meta.xml │ ├── TriggersTest.cls │ └── TriggersTest.cls-meta.xml ├── apex-trigger-loader └── main │ └── default │ ├── classes │ ├── TriggersLoader.cls │ ├── TriggersLoader.cls-meta.xml │ ├── TriggersLoaderTest.cls │ └── TriggersLoaderTest.cls-meta.xml │ ├── layouts │ └── Apex_Trigger_Handler_Setting__mdt-Apex Trigger Handler Setting Layout.layout-meta.xml │ └── objects │ └── Apex_Trigger_Handler_Setting__mdt │ ├── Apex_Trigger_Handler_Setting__mdt.object-meta.xml │ └── fields │ ├── Active__c.field-meta.xml │ ├── Execution_Order__c.field-meta.xml │ ├── Handler_Class__c.field-meta.xml │ ├── SObject__c.field-meta.xml │ ├── Tag__c.field-meta.xml │ └── Trigger_Event__c.field-meta.xml ├── config └── project-scratch-def.json ├── docs └── images │ └── deploy-button.png ├── package.json ├── script.sh └── sfdx-project.json /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | profiles 6 | customMetadata -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sf/ 7 | .sfdx/ 8 | .localdevserver/ 9 | deploy-options.json 10 | 11 | # LWC VSCode autocomplete 12 | **/lwc/jsconfig.json 13 | 14 | # LWC Jest coverage reports 15 | coverage/ 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Dependency directories 25 | node_modules/ 26 | 27 | # Eslint cache 28 | .eslintcache 29 | 30 | # MacOS system files 31 | .DS_Store 32 | 33 | # Windows system files 34 | Thumbs.db 35 | ehthumbs.db 36 | [Dd]esktop.ini 37 | $RECYCLE.BIN/ 38 | 39 | # Local environment variables 40 | .env 41 | .vscode 42 | .husky 43 | package-lock.json -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running prettier 2 | # More information: https://prettier.io/docs/en/ignore.html 3 | # 4 | 5 | **/staticresources/** 6 | .localdevserver 7 | .sfdx 8 | .vscode 9 | 10 | coverage/ 11 | 12 | TriggersTest.cls -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "overrides": [ 4 | { 5 | "files": "*.cls", 6 | "options": { "tabWidth": 4, "printWidth": 120 } 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Jianfeng Jin 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Trigger Handler 2 | 3 | ![](https://img.shields.io/badge/version-1.2.1-brightgreen.svg) ![](https://img.shields.io/badge/build-passing-brightgreen.svg) ![](https://img.shields.io/badge/coverage-%3E95%25-brightgreen.svg) 4 | 5 | There are already many trigger handler libraries out there, but this one has some different approaches or advantages such as state sharing, built in helper methods etc. 6 | 7 | ### Features 8 | 9 | 1. Built-in helpers to perform common operations on trigger properties, such as detect field changes. 10 | 2. Control flow of handler execution with `context.next()`, `context.stop()`, and `context.skips`. 11 | 3. Optionally register and control handlers with custom metadata type settings. 12 | 13 | ### Package ApexTriggerHandler 14 | 15 | This package is the minimal installation which only includes two classes `Triggers.cls` and `TriggersTest.cls`. 16 | 17 | | Environment | Installation Link | Version | 18 | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | 19 | | Production, Developer | | ver 1.2.1 | 20 | | Sandbox | | ver 1.2.1 | 21 | 22 | ### Package ApexTriggerHandlerExt 23 | 24 | This package can be optionally installed to extend a new feature ([custom metadata type settings](#12-bind-with-handler-settings)) for the above one. It introduces additional but only one SOQL query to a custom metadata type. If your system already reaches some governor limit around SOQL queries, can consider deploy this one later. **Note**: The above package is required to be installed before this one. 25 | 26 | | Environment | Installation Link | Version | 27 | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | 28 | | Production, Developer | | ver 1.2.1 | 29 | | Sandbox | | ver 1.2.1 | 30 | 31 | --- 32 | 33 | ### v1.2 Release Notes 34 | 35 | - Support custom metadata type settings to register trigger handlers. ([jump to section](#12-bind-with-handler-settings)) 36 | - **Improve Consistency** (v1.2.1): Ids returned by `props.filterChangedAny` and `props.filterChangedAll` are now in the same Id orders of `props.newList`. 37 | 38 | --- 39 | 40 | ## Table of Contents 41 | 42 | - [1. Trigger](#1-trigger) 43 | - [1.1 Bind with Handler Instances](#11-bind-with-handler-instances) 44 | - [1.2 Bind with Handler Settings](#12-bind-with-handler-settings) 45 | - [1.3 Bind with DI Framework](#13-bind-with-di-framework) 46 | - [2. Trigger Handler](#2-trigger-handler) 47 | - [2.1 Create Handlers](#21-create-handlers) 48 | - [2.2 Skip Handlers](#22-skip-handlers) 49 | - [3. Tests](#3-tests) 50 | - [3.1 Test with Mockup Data](#31-test-with-mockup-data) 51 | - [3.2 Test with Mockup Library](#32-test-with-mockup-library) 52 | - [4. APIs](#4-apis) 53 | - [4.1 Trigger Handler Interfaces](#41-trigger-handler-interfaces) 54 | - [4.2 Triggers.Context](#42-triggerscontext) 55 | - [4.3 Triggers.Props](#43-triggersprops) 56 | - [5. License](#5-license) 57 | 58 | ## 1. Trigger 59 | 60 | ### 1.1 Bind with Handler Instances 61 | 62 | This is an example about how handlers can be registered in triggers. As you have noticed, we are creating same handlers for different trigger events. This is because handlers may need to execute in different orders for different trigger events, we need to provide developers great controls over the order of executions. 63 | 64 | ```java 65 | trigger AccountTrigger on Account (before update, after update) { 66 | Triggers.prepare() 67 | .beforeUpdate() 68 | .bind(new MyAccountHandler()) 69 | .bind(new AnotherAccountHandler()) 70 | .afterUpdate() 71 | .bind(new AnotherAccountHandler()) 72 | .bind(new MyAccountHandler()) 73 | .execute(); 74 | } 75 | ``` 76 | 77 | ### 1.2 Bind with Handler Settings 78 | 79 | This feature is only available when `ApexTriggerHandlerExt` package is installed, or its metadata is manually deployed. Here are some sample records of `Apex_Trigger_Handler_Setting__mdt`, which can provide fine-grained control of trigger handler behaviors at runtime, such as: 80 | 81 | 1. To register trigger handlers for a particular sObject trigger event (`SObject__c`, `Trigger_Event__c`, `Handler_Class__c`). 82 | 2. To activate or deactivate trigger handlers (`Active__c`). 83 | 3. To reorder trigger handlers (`Execution_Order__c`). 84 | 4. To optionally group trigger handlers with tags (`Tag__c`). 85 | 86 | | SObject\_\_c | Trigger_Event\_\_c | Handler_Class\_\_c | Execution_Order\_\_c | Tag\_\_c | Active\_\_c | 87 | | ------------ | ------------------ | ---------------------- | -------------------- | -------- | ----------- | 88 | | Account | BEFORE_UPDATE | AccountTriggerHandler1 | 1 | tag1 | TRUE | 89 | | Account | BEFORE_UPDATE | AccountTriggerHandler2 | 2 | | TRUE | 90 | | Account | BEFORE_UPDATE | AccountTriggerHandler3 | 3 | | **FALSE** | 91 | | Account | AFTER_UPDATE | AccountTriggerHandler4 | 1 | tag1 | TRUE | 92 | | Account | AFTER_UPDATE | AccountTriggerHandler5 | 2 | tag2 | TRUE | 93 | | Account | AFTER_UPDATE | AccountTriggerHandler6 | 3 | tag2 | TRUE | 94 | 95 | Two additional APIs are provided to load the handlers from the above settings, `load()` and `load(tag)`. Their usages are explained in the following comments. 96 | 97 | ```java 98 | trigger AccountTrigger on Account (before update, after update) { 99 | Triggers.prepare() 100 | .beforeUpdate() 101 | .bind(new MyAccountHandler()) 102 | .load() // load all active handlers under Account BEFORE_UPDATE 103 | // - AccountTriggerHandler1 104 | // - AccountTriggerHandler2 105 | .bind(new AnotherAccountHandler()) 106 | .afterUpdate() 107 | .bind(new AnotherAccountHandler()) 108 | .load('tag1') // load all active handlers with 'tag1' under Account AFTER_UPDATE 109 | // - AccountTriggerHandler4 110 | .bind(new MyAccountHandler()) 111 | .load('tag2') // load all active handlers with 'tag2' under Account AFTER_UPDATE 112 | // - AccountTriggerHandler5 113 | // - AccountTriggerHandler6 114 | .execute(); 115 | } 116 | ``` 117 | 118 | ### 1.3 Bind with DI Framework 119 | 120 | The following demo is using [Apex DI](https://github.com/apexfarm/ApexDI) as a dependency injection (DI) framework. 121 | 122 | ```java 123 | trigger AccountTrigger on Account (before update, after update) { 124 | // reference interfaces and decouple trigger from implementations 125 | DI.Module salesModule = DI.getModule(SalesModule.class); 126 | Triggers.prepare() 127 | .beforeUpdate() 128 | .bind((Triggers.Handler) salesModule.getService(IMyAccountHandler.class)) 129 | .bind((Triggers.Handler) salesModule.getService(IAnotherAccountHandler.class)) 130 | .afterUpdate() 131 | .bind((Triggers.Handler) salesModule.getService(IAnotherAccountHandler.class)) 132 | .bind((Triggers.Handler) salesModule.getService(IMyAccountHandler.class)) 133 | .execute(); 134 | } 135 | 136 | public class SalesModule extends DI.Module { 137 | // register handler implementation against interfaces 138 | protected overried void configure(DI.ServiceCollection services) { 139 | services.addTransient('IMyAccountHandler', 'MyAccountHandler'); 140 | services.addTransient('IAnotherAccountHandler', 'AnotherAccountHandler'); 141 | } 142 | } 143 | 144 | public class IMyAccountHandler extends Triggers.Handler {} 145 | public class MyAccountHandler implements 146 | IMyAccountHandler, Triggers.BeforeUpdate, Triggers.AfterUpdate {} 147 | 148 | public class IAnotherAccountHandler extends Triggers.Handler {} 149 | public class AnotherAccountHandler implements 150 | IAnotherAccountHandler, Triggers.BeforeUpdate, Triggers.AfterUpdate {} 151 | ``` 152 | 153 | ## 2. Trigger Handler 154 | 155 | ### 2.1 Create Handlers 156 | 157 | To create a trigger handler, you will need to create a class that implements the `Triggers.Handler` interface and its `criteria` method. Please check the comments below for detailed explanations and tricks to customize a trigger handler. 158 | 159 | ```java 160 | // 1. Use interfaces instead of a base class to extend a custom handler. With interface 161 | // approach we can declare only the needed interfaces explicitly, which is much cleaner 162 | // and clearer. 163 | public class MyAccountHandler implements Triggers.Handler, 164 | Triggers.BeforeUpdate, 165 | Triggers.AfterUpdate { 166 | 167 | // 2. There is a "criteria" stage before any handler execution. This gives 168 | // developers opportunities to turn on and off the handlers according to 169 | // configurations at run time. 170 | public Boolean criteria(Triggers.Context context) { 171 | return Triggers.WHEN_ALWAYS; 172 | 173 | // 3. There are also helper methods to check if certain fields have changes 174 | // return context.props.isChangedAny(Account.Name, Account.Description); 175 | // return context.props.isChangedAll(Account.Name, Account.Description); 176 | } 177 | 178 | public void beforeUpdate(Triggers.Context context) { 179 | then(context); 180 | } 181 | 182 | public void afterUpdate(Triggers.Context context) { 183 | then(context); 184 | } 185 | 186 | private void then(Triggers.Context context) { 187 | // 4. All properties on Trigger have been exposed to context.props. 188 | // Direct reference of Trigger.old and Trigger.new should be avoided, 189 | // instead use context.props.oldList and context.props.newList. 190 | if (context.props.isUpdate) { 191 | 192 | // 5. Use context.state to pass query or computation results down to all 193 | // following handlers within the current trigger context, i.e. before update. 194 | // Before update and after update are considered as differenet contexts. 195 | Integer counter = (Integer) context.state.get('counter'); 196 | if (counter == null) { 197 | context.state.put('counter', 0); 198 | } else { 199 | context.state.put('counter', counter + 1); 200 | } 201 | 202 | // 6. Use context.skips or Triggers.skips to prevent specific handlers from 203 | // execution. Please do remember restore the handler when appropriate. 204 | context.skips.add(ContactHandler.class); 205 | List contacts = ...; 206 | Database.insert(contacts); 207 | context.skips.remove(ContactHandler.class); 208 | 209 | // 7-1. Call context.next() to execute the next handler. It is optional to use, 210 | // unless some following up logics need to be performed after all following 211 | // handlers finished. 212 | context.next(); 213 | 214 | // 7-2. If context.stop() is called instead of context.next(), any following 215 | // handlers won't be executed, just like the STOP in process builder. 216 | context.stop(); 217 | } 218 | } 219 | } 220 | ``` 221 | 222 | ### 2.2 Skip Handlers 223 | 224 | Global static variable `Triggers.skips` references the same `context.skips`, so you can use it to skip handlers outside of the handler contexts. For example, when you want to skip a trigger handler in a batch class: 225 | 226 | ```java 227 | global class AccountUpdateBatch implements Database.Batchable { 228 | ... 229 | global void execute(Database.BatchableContext BC, List scope){ 230 | Triggers.skips.add(MyAccountHandler.class); 231 | // Update accounts... 232 | Triggers.skips.remove(MyAccountHandler.class); 233 | } 234 | ... 235 | } 236 | ``` 237 | 238 | ## 3. Tests 239 | 240 | ### 3.1 Test with Mockup Data 241 | 242 | The following method is private but `@TestVisible`, it can be used in test methods to supply mockup records for old and new lists. So we don't need to perform DMLs to trigger the handlers. 243 | 244 | ```java 245 | @isTest 246 | static void test_AccountTriggerHandler_BeforeUpdate { 247 | List oldList = new List { 248 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'Old Name 1'), 249 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'Old Name 2'), 250 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'Old Name 3')} 251 | 252 | List newList = new List { 253 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'New Name 1'), 254 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'New Name 2'), 255 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'New Name 3')} 256 | 257 | Triggers.prepare(TriggerOperation.Before_Update, oldList, newList) 258 | .beforeUpdate().bind(new MyAccountHandler()).execute(); 259 | } 260 | ``` 261 | 262 | ### 3.2 Test with Mockup Library 263 | 264 | The following demo is using [Apex Test Kit](https://github.com/apexfarm/ApexTestKit) as a mockup data library. The behavior will be the same as the above example, but a sophisticated mock data library can also generate mockup data with read-only fields, such as formula fields, roll-up summary fields and system fields. 265 | 266 | ```java 267 | @isTest 268 | static void test_AccountTriggerHandler_BeforeUpdate { 269 | // automatically generate fake IDs for oldList 270 | List oldList = ATK.prepare(Account.SObjectType, 3) 271 | .field(Account.Name).index('Old Name {0}') 272 | .mock().get(Account.SObjectType); 273 | 274 | // IDs in oldList will be preserved in the newList 275 | List newList = ATK.prepare(Account.SObjectType, oldList) 276 | .field(Account.Name).index('New Name {0}') 277 | .mock().get(Account.SObjectType); 278 | 279 | Triggers.prepare(TriggerOperation.Before_Update, oldList, newList) 280 | .beforeUpdate().bind(new MyAccountHandler()).execute(); 281 | } 282 | ``` 283 | 284 | ## 4. APIs 285 | 286 | ### 4.1 Trigger Handler Interfaces 287 | 288 | | Interface | Method to Implement | 289 | | ----------------------- | ----------------------------------------------- | 290 | | Triggers.Handler | `Boolean criteria(Triggers.Context context);` | 291 | | Triggers.BeforeInsert | `void beforeInsert(Triggers.Context context);` | 292 | | Triggers.AfterInsert | `void afterInsert(Triggers.Context context);` | 293 | | Triggers.BeforeUpdate | `void beforeUpdate(Triggers.Context context);` | 294 | | Triggers.AfterUpdate | `void afterUpdate(Triggers.Context context);` | 295 | | Triggers.BeforeDelete | `void beforeDelete(Triggers.Context context);` | 296 | | Triggers.AfterDelete | `void afterDelete(Triggers.Context context);` | 297 | | Triggers.BeforeUndelete | `void afterUndelete(Triggers.Context context);` | 298 | 299 | ### 4.2 Triggers.Context 300 | 301 | | Property/Method | Type | Description | 302 | | --------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 303 | | context.props | Triggers.Props | All properties on Trigger are exposed by this class. In addition there are frequently used helper methods and a convinient sObjectType property, in case reflection is needed . | 304 | | context.state | Map | A map provided for developers to pass any value down to other handlers. | 305 | | context.skips | Triggers.Skips | A set to store handlers to be skipped. Call the following methods to manage skips: `context.skips.add()`, `context.skips.remove()`, `context.skips.clear()` `context.skips.contains()` etc. | 306 | | context.next() | void | Call the next handler. | 307 | | context.stop() | void | Stop execute any following handlers. A bit like the the stop in process builders. | 308 | 309 | ### 4.3 Triggers.Props 310 | 311 | #### Properties 312 | 313 | | Property | Type | Description | 314 | | ------------- | ------------------ | ------------------------ | 315 | | sObjectType | SObjectType | The current SObjectType. | 316 | | isExecuting | Boolean | Trigger.isExecuting | 317 | | isBefore | Boolean | Trigger.isBefore | 318 | | isAfter | Boolean | Trigger.isAfter | 319 | | isInsert | Boolean | Trigger.isInsert | 320 | | isUpdate | Boolean | Trigger.isUpdate | 321 | | isDelete | Boolean | Trigger.isDelete | 322 | | isUndelete | Boolean | Trigger.isUndelete | 323 | | oldList | List\ | Trigger.old | 324 | | oldMap | Map\ | Trigger.oldMap | 325 | | newList | List\ | Trigger.new | 326 | | newMap | Map\ | Trigger.newMap | 327 | | operationType | TriggerOperation | Trigger.operationType | 328 | | size | Integer | Trigger.size | 329 | 330 | #### Methods 331 | 332 | **Note**: the following `isChanged` method has the same behavior has the `ISCHANGED` formula: 333 | 334 | > - This function returns `false` when evaluating any field on a newly created record. 335 | > - If a text field was previously blank, this function returns `true` when it contains any value. 336 | > - For number, percent, or currency fields, this function returns `true` when: 337 | > - The field was blank and now contains any value 338 | > - The field was zero and now is blank 339 | > - The field was zero and now contains any other value 340 | 341 | | Method | Type | Description | 342 | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- | 343 | | - `isChanged(SObjectField field1)` | Boolean | Check if any record has a field changed during an update. | 344 | | - `isChangedAny(SObjectField field1, SObjectField field2)`
- `isChangedAny(SObjectField field1, SObjectField field2, SObjectField field3)`
- `isChangedAny(List fields)` | Boolean | Check if any record has multiple fields changed during an update. Return `true` if any specified field is changed. | 345 | | - `isChangedAll(SObjectField field1, SObjectField field2)`
- `isChangedAll(SObjectField field1, SObjectField field2, SObjectField field3)`
- `isChangedAll(List fields)` | Boolean | Check if any record has multiple fields changed during an update. Return `true` only if all specified fields are changed. | 346 | | - `filterChanged(SObjectField field1)` | List\ | Filter IDs of records have a field changed during an update. | 347 | | - `filterChangedAny(SObjectField field1, SObjectField field2)`
- `filterChangedAny(SObjectField field1, SObjectField field2, SObjectField field3)`
- `filterChangedAny(List fields)` | List\ | Filter IDs of records have multiple fields changed during an update. Return IDs if any specified field is changed. | 348 | | - `filterChangedAll(SObjectField field1, SObjectField field2)`
- `filterChangedAll(SObjectField field1, SObjectField field2, SObjectField field3)`
- `filterChangedAll(List fields)` | List\ | Filter IDs of records have multiple fields changed during an update. Return IDs only if all specified fields are changed. | 349 | 350 | ## 5. License 351 | 352 | BSD 3-Clause License 353 | -------------------------------------------------------------------------------- /apex-trigger-handler/main/default/classes/Triggers.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * BSD 3-Clause License 3 | * 4 | * Copyright (c) 2020, Jianfeng Jin 5 | * All rights reserved. 6 | * 7 | * Redistribution and use in source and binary forms, with or without 8 | * modification, are permitted provided that the following conditions are met: 9 | * 10 | * 1. Redistributions of source code must retain the above copyright notice, this 11 | * list of conditions and the following disclaimer. 12 | * 13 | * 2. Redistributions in binary form must reproduce the above copyright notice, 14 | * this list of conditions and the following disclaimer in the documentation 15 | * and/or other materials provided with the distribution. 16 | * 17 | * 3. Neither the name of the copyright holder nor the names of its 18 | * contributors may be used to endorse or promote products derived from 19 | * this software without specific prior written permission. 20 | * 21 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | */ 32 | 33 | public class Triggers { 34 | public static final Boolean WHEN_ALWAYS = true; 35 | public static final Skips skips = new Skips(); 36 | 37 | @TestVisible 38 | private static Loader LOADER { 39 | get { 40 | if (LOADER == null) { 41 | Type LoaderType = Type.forName('TriggersLoader'); 42 | if (LoaderType == null) { 43 | LOADER = new NullLoader(); 44 | } else { 45 | LOADER = (Loader) LoaderType.newInstance(); 46 | } 47 | } 48 | return LOADER; 49 | } 50 | set; 51 | } 52 | 53 | public static Manager prepare() { 54 | return new ManagerImpl(); 55 | } 56 | 57 | @TestVisible 58 | private static Manager prepare(TriggerOperation operationType, List oldList, List newList) { 59 | Props props = new Props(operationType, oldList, newList); 60 | return new ManagerImpl(props); 61 | } 62 | 63 | @TestVisible 64 | private class ManagerImpl implements Manager { 65 | public final Props props { get; private set; } 66 | public final Context context { get; private set; } 67 | 68 | Boolean canBind = false; 69 | 70 | @TestVisible 71 | private ManagerImpl() { 72 | this(new Props()); 73 | } 74 | 75 | @TestVisible 76 | private ManagerImpl(Props props) { 77 | this.props = props; 78 | this.context = new Context(this.props); 79 | } 80 | 81 | public Manager beforeInsert() { 82 | canBind = this.props.operationType == TriggerOperation.BEFORE_INSERT; 83 | return this; 84 | } 85 | 86 | public Manager afterInsert() { 87 | canBind = this.props.operationType == TriggerOperation.AFTER_INSERT; 88 | return this; 89 | } 90 | 91 | public Manager beforeUpdate() { 92 | canBind = this.props.operationType == TriggerOperation.BEFORE_UPDATE; 93 | return this; 94 | } 95 | 96 | public Manager afterUpdate() { 97 | canBind = this.props.operationType == TriggerOperation.AFTER_UPDATE; 98 | return this; 99 | } 100 | 101 | public Manager beforeDelete() { 102 | canBind = this.props.operationType == TriggerOperation.BEFORE_DELETE; 103 | return this; 104 | } 105 | 106 | public Manager afterDelete() { 107 | canBind = this.props.operationType == TriggerOperation.AFTER_DELETE; 108 | return this; 109 | } 110 | 111 | public Manager afterUndelete() { 112 | canBind = this.props.operationType == TriggerOperation.AFTER_UNDELETE; 113 | return this; 114 | } 115 | 116 | public Manager bind(Handler handler) { 117 | if (canBind) { 118 | Boolean isImplemented = false; 119 | switch on this.props.operationType { 120 | when BEFORE_INSERT { 121 | isImplemented = handler instanceof BeforeInsert; 122 | } 123 | when AFTER_INSERT { 124 | isImplemented = handler instanceof AfterInsert; 125 | } 126 | when BEFORE_UPDATE { 127 | isImplemented = handler instanceof BeforeUpdate; 128 | } 129 | when AFTER_UPDATE { 130 | isImplemented = handler instanceof AfterUpdate; 131 | } 132 | when BEFORE_DELETE { 133 | isImplemented = handler instanceof BeforeDelete; 134 | } 135 | when AFTER_DELETE { 136 | isImplemented = handler instanceof AfterDelete; 137 | } 138 | when AFTER_UNDELETE { 139 | isImplemented = handler instanceof AfterUndelete; 140 | } 141 | when else { 142 | } 143 | } 144 | if (isImplemented) { 145 | this.context.handlers.add(handler); 146 | } 147 | } 148 | return this; 149 | } 150 | 151 | public Manager load() { 152 | if (canBind) { 153 | List handlers = LOADER.load(this.props.sObjectType, this.props.operationType); 154 | for (Handler handler : handlers) { 155 | this.bind(handler); 156 | } 157 | } 158 | return this; 159 | } 160 | 161 | public Manager load(String tag) { 162 | if (canBind) { 163 | List handlers = LOADER.load(this.props.sObjectType, this.props.operationType, tag); 164 | for (Handler handler : handlers) { 165 | this.bind(handler); 166 | } 167 | } 168 | return this; 169 | } 170 | 171 | public void execute() { 172 | this.context.execute(); 173 | this.props.isExecuting = false; 174 | } 175 | } 176 | 177 | public class Skips { 178 | @TestVisible 179 | private final Set skippedHandlers = new Set(); 180 | 181 | public void add(type handlerType) { 182 | this.skippedHandlers.add(handlerType); 183 | } 184 | 185 | public void remove(type handlerType) { 186 | this.skippedHandlers.remove(handlerType); 187 | } 188 | 189 | public Boolean contains(type handlerType) { 190 | return this.skippedHandlers.contains(handlerType); 191 | } 192 | 193 | @TestVisible 194 | private Boolean contains(Handler handler) { 195 | return contains(getHandlerType(handler)); 196 | } 197 | 198 | public void clear() { 199 | this.skippedHandlers.clear(); 200 | } 201 | 202 | private Type getHandlerType(Handler handler) { 203 | String printName = String.valueOf(handler); 204 | String typeName = printName.substring(0, printName.indexOf(':')); 205 | return Type.forName(typeName); 206 | } 207 | } 208 | 209 | public class Context { 210 | public final Map state { get; private set; } 211 | public final Skips skips { get; private set; } 212 | public final Props props { get; private set; } 213 | 214 | private final List handlers = new List(); 215 | private Integer currIndex = -1; 216 | private Boolean isExecutingCriteria = false; 217 | 218 | private Context(Props props) { 219 | this.props = props; 220 | this.state = new Map(); 221 | this.skips = Triggers.skips; 222 | } 223 | 224 | private void reset() { 225 | this.currIndex = -1; 226 | } 227 | 228 | public void stop() { 229 | this.currIndex = this.handlers.size(); 230 | } 231 | 232 | private Boolean hasNext() { 233 | return this.currIndex < this.handlers.size() - 1; 234 | } 235 | 236 | public void next() { 237 | // prevent calling context.next() in criteria phase 238 | if (!this.isExecutingCriteria) { 239 | while (hasNext()) { 240 | runNext(); 241 | } 242 | } 243 | } 244 | 245 | private void execute() { 246 | reset(); 247 | while (hasNext()) { 248 | runNext(); 249 | } 250 | } 251 | 252 | private void runNext() { 253 | this.currIndex++; 254 | Handler handler = this.handlers[this.currIndex]; 255 | this.isExecutingCriteria = true; 256 | if (!this.skips.contains(handler) && handler.criteria(this) == true) { 257 | this.isExecutingCriteria = false; 258 | switch on this.props.operationType { 259 | when BEFORE_INSERT { 260 | ((BeforeInsert) handler).beforeInsert(this); 261 | } 262 | when AFTER_INSERT { 263 | ((AfterInsert) handler).afterInsert(this); 264 | } 265 | when BEFORE_UPDATE { 266 | ((BeforeUpdate) handler).beforeUpdate(this); 267 | } 268 | when AFTER_UPDATE { 269 | ((AfterUpdate) handler).afterUpdate(this); 270 | } 271 | when BEFORE_DELETE { 272 | ((BeforeDelete) handler).beforeDelete(this); 273 | } 274 | when AFTER_DELETE { 275 | ((AfterDelete) handler).afterDelete(this); 276 | } 277 | when AFTER_UNDELETE { 278 | ((AfterUndelete) handler).afterUndelete(this); 279 | } 280 | when else { 281 | } 282 | } 283 | } else { 284 | this.isExecutingCriteria = false; 285 | } 286 | } 287 | } 288 | 289 | public class Props { 290 | // Standard Properties 291 | @TestVisible 292 | public Boolean isExecuting { get; private set; } 293 | @TestVisible 294 | public Boolean isBefore { get; private set; } 295 | @TestVisible 296 | public Boolean isAfter { get; private set; } 297 | @TestVisible 298 | public Boolean isInsert { get; private set; } 299 | @TestVisible 300 | public Boolean isUpdate { get; private set; } 301 | @TestVisible 302 | public Boolean isDelete { get; private set; } 303 | @TestVisible 304 | public Boolean isUndelete { get; private set; } 305 | @TestVisible 306 | public List oldList { get; private set; } 307 | @TestVisible 308 | public Map oldMap { get; private set; } 309 | @TestVisible 310 | public List newList { get; private set; } 311 | @TestVisible 312 | public Map newMap { get; private set; } 313 | @TestVisible 314 | public TriggerOperation operationType { get; private set; } 315 | @TestVisible 316 | public Integer size { get; private set; } 317 | 318 | // Custom Properties 319 | @TestVisible 320 | public SObjectType sObjectType { get; private set; } 321 | 322 | @TestVisible 323 | private Props() { 324 | this.isExecuting = true; 325 | this.isBefore = Trigger.isBefore; 326 | this.isAfter = Trigger.isAfter; 327 | this.isInsert = Trigger.isInsert; 328 | this.isUpdate = Trigger.isUpdate; 329 | this.isDelete = Trigger.isDelete; 330 | this.isUndelete = Trigger.isUndelete; 331 | this.oldList = Trigger.old; 332 | this.oldMap = Trigger.oldMap; 333 | this.newList = Trigger.new; 334 | this.newMap = Trigger.newMap; 335 | this.operationType = Trigger.operationType; 336 | this.size = Trigger.size; 337 | this.setSObjectType(); 338 | } 339 | 340 | @TestVisible 341 | private Props(TriggerOperation operationType, List oldList, List newList) { 342 | this.isExecuting = true; 343 | this.operationType = operationType; 344 | this.isBefore = false; 345 | this.isAfter = false; 346 | this.isInsert = false; 347 | this.isUpdate = false; 348 | this.isDelete = false; 349 | this.isUndelete = false; 350 | switch on operationType { 351 | when BEFORE_INSERT { 352 | this.isBefore = true; 353 | this.isInsert = true; 354 | this.oldList = null; 355 | this.oldMap = null; 356 | this.newList = newList; 357 | this.newMap = newList != null ? new Map(newList) : null; 358 | } 359 | when AFTER_INSERT { 360 | this.isAfter = true; 361 | this.isInsert = true; 362 | this.oldList = null; 363 | this.oldMap = null; 364 | this.newList = newList; 365 | this.newMap = newList != null ? new Map(newList) : null; 366 | } 367 | when BEFORE_UPDATE { 368 | this.isBefore = true; 369 | this.isUpdate = true; 370 | this.oldList = oldList; 371 | this.oldMap = oldList != null ? new Map(oldList) : null; 372 | this.newList = newList; 373 | this.newMap = newList != null ? new Map(newList) : null; 374 | } 375 | when AFTER_UPDATE { 376 | this.isAfter = true; 377 | this.isUpdate = true; 378 | this.oldList = oldList; 379 | this.oldMap = oldList != null ? new Map(oldList) : null; 380 | this.newList = newList; 381 | this.newMap = newList != null ? new Map(newList) : null; 382 | } 383 | when BEFORE_DELETE { 384 | this.isBefore = true; 385 | this.isDelete = true; 386 | this.oldList = oldList; 387 | this.oldMap = oldList != null ? new Map(oldList) : null; 388 | this.newList = null; 389 | this.newMap = null; 390 | } 391 | when AFTER_DELETE { 392 | this.isAfter = true; 393 | this.isDelete = true; 394 | this.oldList = oldList; 395 | this.oldMap = oldList != null ? new Map(oldList) : null; 396 | this.newList = null; 397 | this.newMap = null; 398 | } 399 | when AFTER_UNDELETE { 400 | this.isAfter = true; 401 | this.isUndelete = true; 402 | this.oldList = null; 403 | this.oldMap = null; 404 | this.newList = newList; 405 | this.newMap = newList != null ? new Map(newList) : null; 406 | } 407 | when else { 408 | } 409 | } 410 | this.setSize(); 411 | this.setSObjectType(); 412 | } 413 | 414 | private void setSize() { 415 | this.size = 0; 416 | if (this.oldList != null) { 417 | this.size = this.oldList.size(); 418 | } else if (this.newList != null) { 419 | this.size = this.newList.size(); 420 | } 421 | } 422 | 423 | @TestVisible 424 | private void setSObjectType() { 425 | if (this.oldList != null) { 426 | this.sObjectType = this.oldList.getSobjectType(); 427 | } else if (this.newList != null) { 428 | this.sObjectType = this.newList.getSObjectType(); 429 | } 430 | } 431 | 432 | public List getValues(SObjectField field) { 433 | List values = new List(); 434 | List objects = new List(); 435 | 436 | if (this.isInsert || this.isUpdate || this.isUndelete) { 437 | objects = this.newList; 438 | } else if (this.isDelete) { 439 | objects = this.oldList; 440 | } 441 | 442 | for (SObject obj : objects) { 443 | values.add(obj.get(field)); 444 | } 445 | return values; 446 | } 447 | 448 | @TestVisible 449 | private Boolean isChanged(Id objectId, SObjectField field) { 450 | if (this.isUpdate) { 451 | Object oldValue = this.oldMap.get(objectId).get(field); 452 | Object newValue = this.newMap.get(objectId).get(field); 453 | return oldValue != newValue; 454 | } 455 | return false; 456 | } 457 | 458 | public Boolean isChanged(SObjectField field1) { 459 | return isChangedAny(new List{ field1 }); 460 | } 461 | 462 | public Boolean isChangedAny(SObjectField field1, SObjectField field2) { 463 | return isChangedAny(new List{ field1, field2 }); 464 | } 465 | 466 | public Boolean isChangedAny(SObjectField field1, SObjectField field2, SObjectField field3) { 467 | return isChangedAny(new List{ field1, field2, field3 }); 468 | } 469 | 470 | public Boolean isChangedAny(List fields) { 471 | return filterChangedAny(fields).size() > 0; 472 | } 473 | 474 | public Boolean isChangedAll(SObjectField field1, SObjectField field2) { 475 | return isChangedAll(new List{ field1, field2 }); 476 | } 477 | 478 | public Boolean isChangedAll(SObjectField field1, SObjectField field2, SObjectField field3) { 479 | return isChangedAll(new List{ field1, field2, field3 }); 480 | } 481 | 482 | public Boolean isChangedAll(List fields) { 483 | return filterChangedAll(fields).size() > 0; 484 | } 485 | 486 | public List filterChanged(SObjectField field1) { 487 | return filterChangedAny(new List{ field1 }); 488 | } 489 | 490 | public List filterChangedAny(SObjectField field1, SObjectField field2) { 491 | return filterChangedAny(new List{ field1, field2 }); 492 | } 493 | 494 | public List filterChangedAny(SObjectField field1, SObjectField field2, SObjectField field3) { 495 | return filterChangedAny(new List{ field1, field2, field3 }); 496 | } 497 | 498 | public List filterChangedAny(List fields) { 499 | List changedIds = new List(); 500 | if (this.isUpdate) { 501 | for (SObject newObj : this.newList) { 502 | Id objectId = newObj.Id; 503 | for (SObjectField field : fields) { 504 | if (isChanged(objectId, field)) { 505 | changedIds.add(objectId); 506 | break; 507 | } 508 | } 509 | } 510 | } 511 | return changedIds; 512 | } 513 | 514 | public List filterChangedAll(SObjectField field1, SObjectField field2) { 515 | return filterChangedAll(new List{ field1, field2 }); 516 | } 517 | 518 | public List filterChangedAll(SObjectField field1, SObjectField field2, SObjectField field3) { 519 | return filterChangedAll(new List{ field1, field2, field3 }); 520 | } 521 | 522 | public List filterChangedAll(List fields) { 523 | List changedIds = new List(); 524 | if (this.isUpdate) { 525 | for (SObject newObj : this.newList) { 526 | Id objectId = newObj.Id; 527 | changedIds.add(objectId); 528 | for (SObjectField field : fields) { 529 | if (!isChanged(objectId, field)) { 530 | changedIds.remove(changedIds.size() - 1); 531 | break; 532 | } 533 | } 534 | } 535 | } 536 | return changedIds; 537 | } 538 | } 539 | 540 | @TestVisible 541 | private class NullLoader implements Loader { 542 | public List load(SObjectType sObjectType, TriggerOperation triggerEvent) { 543 | return new List(); 544 | } 545 | 546 | public List load(SObjectType sObjectType, TriggerOperation triggerEvent, String tag) { 547 | return new List(); 548 | } 549 | } 550 | 551 | //=================== 552 | // #region interfaces 553 | public interface Loader { 554 | List load(SObjectType sObjectType, TriggerOperation triggerEvent); 555 | List load(SObjectType sObjectType, TriggerOperation triggerEvent, String tag); 556 | } 557 | 558 | public interface Manager { 559 | Manager beforeInsert(); 560 | Manager afterInsert(); 561 | Manager beforeUpdate(); 562 | Manager afterUpdate(); 563 | Manager beforeDelete(); 564 | Manager afterDelete(); 565 | Manager afterUndelete(); 566 | Manager bind(Handler handler); 567 | Manager load(); 568 | Manager load(String tag); 569 | void execute(); 570 | } 571 | 572 | public interface Handler { 573 | Boolean criteria(Context context); 574 | } 575 | 576 | public interface BeforeInsert { 577 | void beforeInsert(Context context); 578 | } 579 | 580 | public interface AfterInsert { 581 | void afterInsert(Context context); 582 | } 583 | 584 | public interface BeforeUpdate { 585 | void beforeUpdate(Context context); 586 | } 587 | 588 | public interface AfterUpdate { 589 | void afterUpdate(Context context); 590 | } 591 | 592 | public interface BeforeDelete { 593 | void beforeDelete(Context context); 594 | } 595 | 596 | public interface AfterDelete { 597 | void afterDelete(Context context); 598 | } 599 | 600 | public interface AfterUndelete { 601 | void afterUndelete(Context context); 602 | } 603 | // #endregion 604 | //=================== 605 | } 606 | -------------------------------------------------------------------------------- /apex-trigger-handler/main/default/classes/Triggers.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57.0 4 | Active 5 | -------------------------------------------------------------------------------- /apex-trigger-handler/main/default/classes/TriggersTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * BSD 3-Clause License 3 | * 4 | * Copyright (c) 2020, Jianfeng Jin 5 | * All rights reserved. 6 | * 7 | * Redistribution and use in source and binary forms, with or without 8 | * modification, are permitted provided that the following conditions are met: 9 | * 10 | * 1. Redistributions of source code must retain the above copyright notice, this 11 | * list of conditions and the following disclaimer. 12 | * 13 | * 2. Redistributions in binary form must reproduce the above copyright notice, 14 | * this list of conditions and the following disclaimer in the documentation 15 | * and/or other materials provided with the distribution. 16 | * 17 | * 3. Neither the name of the copyright holder nor the names of its 18 | * contributors may be used to endorse or promote products derived from 19 | * this software without specific prior written permission. 20 | * 21 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | */ 32 | 33 | @IsTest 34 | public class TriggersTest implements Triggers.Handler, Triggers.BeforeInsert { 35 | public static String getFakeId(Schema.SObjectType objectType, Integer index) { 36 | return objectType.getDescribe().getKeyPrefix() 37 | + '000zzzz' // start from a large Id to avoid confliction during unit test. 38 | + String.valueOf(index).leftPad(5, '0'); 39 | } 40 | 41 | static List createAccounts() { 42 | return new List { 43 | new Account(Id = getFakeId(Account.SObjectType, 1), Name = 'Account 1', Description = 'Account 1', BillingCity = 'New York'), 44 | new Account(Id = getFakeId(Account.SObjectType, 2), Name = 'Account 2', Description = 'Account 2', BillingCity = 'New York'), 45 | new Account(Id = getFakeId(Account.SObjectType, 3), Name = 'Account 3', Description = 'Account 3', BillingCity = 'New York') 46 | }; 47 | } 48 | 49 | @IsTest 50 | static void testSkips_getHandlerName_ByType() { 51 | Triggers.Skips skips = new Triggers.Skips(); 52 | skips.add(String.class); 53 | Assert.areEqual(true, skips.contains(String.class)); 54 | 55 | skips.remove(String.class); 56 | Assert.areEqual(0, skips.skippedHandlers.size()); 57 | 58 | skips.add(TriggersTest.TriggersTest.class); 59 | Assert.areEqual(true, skips.contains(TriggersTest.class)); 60 | 61 | skips.clear(); 62 | Assert.areEqual(0, skips.skippedHandlers.size()); 63 | 64 | skips.add(TriggersTest.class); 65 | Assert.areEqual(true, skips.contains(new TriggersTest())); 66 | } 67 | 68 | // ==================== 69 | // #region Test Binding 70 | @IsTest 71 | static void testBinding_BaseLine() { 72 | Triggers.prepare() 73 | .beforeInsert() 74 | .bind(new MainHandler()) 75 | .beforeUpdate() 76 | .bind(new MainHandler()) 77 | .beforeDelete() 78 | .bind(new MainHandler()) 79 | .execute(); 80 | 81 | Triggers.prepare() 82 | .afterInsert() 83 | .bind(new MainHandler()) 84 | .afterUpdate() 85 | .bind(new MainHandler()) 86 | .afterDelete() 87 | .bind(new MainHandler()) 88 | .afterUndelete() 89 | .bind(new MainHandler()) 90 | .execute(); 91 | } 92 | 93 | @IsTest 94 | static void testBinding_NullLoader() { 95 | Triggers.Loader loader = Triggers.LOADER; 96 | 97 | Triggers.LOADER = new Triggers.NullLoader(); 98 | Triggers.prepare(TriggerOperation.BEFORE_INSERT, null, null) 99 | .beforeInsert() 100 | .load() 101 | .load('tag1') 102 | .execute(); 103 | } 104 | 105 | @IsTest 106 | static void testBinding_Stop_BeforeInsert() { 107 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_INSERT, null, null); 108 | triggerManager.beforeInsert() 109 | .bind(new FirstHandler()) 110 | .bind(new MainHandler()) 111 | .bind(new MainHandler()) 112 | .bind(new StopHandler()) 113 | .bind(new MainHandler()) 114 | .bind(new LastHandler()) 115 | .execute(); 116 | 117 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 118 | } 119 | 120 | @IsTest 121 | static void testBinding_Stop_AfterInsert() { 122 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.AFTER_INSERT, null, null); 123 | triggerManager.afterInsert() 124 | .bind(new FirstHandler()) 125 | .bind(new MainHandler()) 126 | .bind(new MainHandler()) 127 | .bind(new StopHandler()) 128 | .bind(new MainHandler()) 129 | .bind(new LastHandler()) 130 | .execute(); 131 | 132 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 133 | } 134 | 135 | @IsTest 136 | static void testBinding_Inactive_BeforeInsert() { 137 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_INSERT, null, null); 138 | triggerManager.beforeInsert() 139 | .bind(new FirstHandler()) 140 | .bind(new MainHandler()) 141 | .bind(new InactiveHandler()) 142 | .bind(new MainHandler()) 143 | .bind(new LastHandler()) 144 | .execute(); 145 | 146 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 147 | } 148 | 149 | @IsTest 150 | static void testBinding_Skip_Remove() { 151 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_INSERT, null, null); 152 | triggerManager.beforeInsert() 153 | .bind(new FirstHandler()) 154 | .bind(new MainHandler()) 155 | .bind(new AddSkippedHandler()) 156 | .bind(new TriggersTest()) 157 | .bind(new RemoveSkippedHandler()) 158 | .bind(new TriggersTest()) 159 | .bind(new LastHandler()) 160 | .execute(); 161 | 162 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 163 | } 164 | 165 | @IsTest 166 | static void testBinding_Skip_Clear() { 167 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_INSERT, null, null); 168 | triggerManager 169 | .beforeInsert() 170 | .bind(new FirstHandler()) 171 | .bind(new MainHandler()) 172 | .bind(new AddSkippedHandler()) 173 | .bind(new TriggersTest()) 174 | .bind(new ClearSkippedHandler()) 175 | .bind(new TriggersTest()) 176 | .bind(new LastHandler()) 177 | .execute(); 178 | 179 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 180 | } 181 | // #endregion 182 | // ==================== 183 | 184 | // ================== 185 | // #region Test State 186 | @IsTest 187 | static void testBinding_State_BeforeInsert() { 188 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare( 189 | TriggerOperation.BEFORE_INSERT, new List{}, new List()); 190 | triggerManager 191 | .beforeInsert() 192 | .bind(new FirstHandler()) 193 | .bind(new MainHandler()) 194 | .bind(new MainHandler()) 195 | .bind(new LastHandler()) 196 | .execute(); 197 | 198 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 199 | } 200 | 201 | @IsTest 202 | static void testBinding_State_AfterInsert() { 203 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.AFTER_INSERT, null, null); 204 | triggerManager 205 | .afterInsert() 206 | .bind(new FirstHandler()) 207 | .bind(new MainHandler()) 208 | .bind(new MainHandler()) 209 | .bind(new LastHandler()) 210 | .execute(); 211 | 212 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 213 | } 214 | 215 | @IsTest 216 | static void testBinding_State_BeforeUpdate() { 217 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare( 218 | TriggerOperation.BEFORE_UPDATE, new List(), new List()); 219 | triggerManager 220 | .beforeUpdate() 221 | .bind(new FirstHandler()) 222 | .bind(new MainHandler()) 223 | .bind(new MainHandler()) 224 | .bind(new LastHandler()) 225 | .execute(); 226 | 227 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 228 | } 229 | 230 | @IsTest 231 | static void testBinding_State_AfterUpdate() { 232 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare( 233 | TriggerOperation.AFTER_UPDATE, new List (), new List ()); 234 | triggerManager 235 | .afterUpdate() 236 | .bind(new FirstHandler()) 237 | .bind(new MainHandler()) 238 | .bind(new MainHandler()) 239 | .bind(new LastHandler()) 240 | .execute(); 241 | 242 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 243 | } 244 | 245 | @IsTest 246 | static void testBinding_State_BeforeDelete() { 247 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_DELETE, null, null); 248 | triggerManager 249 | .beforeDelete() 250 | .bind(new FirstHandler()) 251 | .bind(new MainHandler()) 252 | .bind(new MainHandler()) 253 | .bind(new LastHandler()) 254 | .execute(); 255 | 256 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 257 | } 258 | 259 | @IsTest 260 | static void testBinding_State_AfterDelete() { 261 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.AFTER_DELETE, null, null); 262 | triggerManager 263 | .afterDelete() 264 | .bind(new FirstHandler()) 265 | .bind(new MainHandler()) 266 | .bind(new MainHandler()) 267 | .bind(new LastHandler()) 268 | .execute(); 269 | 270 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 271 | } 272 | 273 | @IsTest 274 | static void testBinding_State_AfterUndelete() { 275 | Triggers.ManagerImpl triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.AFTER_UNDELETE, null, null); 276 | triggerManager 277 | .afterUndelete() 278 | .bind(new FirstHandler()) 279 | .bind(new MainHandler()) 280 | .bind(new MainHandler()) 281 | .bind(new LastHandler()) 282 | .execute(); 283 | 284 | Assert.areEqual(4, triggerManager.context.state.get('counter')); 285 | } 286 | 287 | @IsTest 288 | static void testBinding_Mock() { 289 | List oldList = new List { 290 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'Old Name 1'), 291 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'Old Name 2'), 292 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'Old Name 3')}; 293 | 294 | List newList = new List { 295 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'New Name 1'), 296 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'New Name 2'), 297 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'New Name 3')}; 298 | 299 | Triggers.prepare(TriggerOperation.BEFORE_INSERT, oldList, newList) 300 | .afterUpdate() 301 | .bind(new AccountNameChangedHandler()) 302 | .execute(); 303 | 304 | Triggers.prepare(TriggerOperation.AFTER_INSERT, oldList, newList) 305 | .afterUpdate() 306 | .bind(new AccountNameChangedHandler()) 307 | .execute(); 308 | 309 | Triggers.prepare(TriggerOperation.BEFORE_UPDATE, oldList, newList) 310 | .afterUpdate() 311 | .bind(new AccountNameChangedHandler()) 312 | .execute(); 313 | 314 | Triggers.prepare(TriggerOperation.AFTER_UPDATE, oldList, newList) 315 | .afterUpdate() 316 | .bind(new AccountNameChangedHandler()) 317 | .execute(); 318 | 319 | Triggers.prepare(TriggerOperation.BEFORE_DELETE, oldList, newList) 320 | .afterUpdate() 321 | .bind(new AccountNameChangedHandler()) 322 | .execute(); 323 | 324 | Triggers.prepare(TriggerOperation.AFTER_DELETE, oldList, newList) 325 | .afterUpdate() 326 | .bind(new AccountNameChangedHandler()) 327 | .execute(); 328 | 329 | Triggers.prepare(TriggerOperation.AFTER_UNDELETE, oldList, newList) 330 | .afterUpdate() 331 | .bind(new AccountNameChangedHandler()) 332 | .execute(); 333 | } 334 | // #endregion 335 | // ================== 336 | 337 | // =================== 338 | // #region Test Helper 339 | @IsTest 340 | static void testProps_IsChanged_Negative() { 341 | Triggers.Props props = new Triggers.Props(); 342 | props.isInsert = true; 343 | props.isUpdate = false; 344 | props.isDelete = false; 345 | props.isUndelete = false; 346 | 347 | List accounts = createAccounts(); 348 | props.newList = accounts; 349 | props.newMap = new Map(accounts); 350 | 351 | Boolean isChanged = props.isChanged(accounts[0].Id, Account.Name); 352 | 353 | Assert.areEqual(false, isChanged); 354 | } 355 | 356 | @IsTest 357 | static void testProps_GetValues_Undelete() { 358 | Triggers.Props props = new Triggers.Props(); 359 | props.isInsert = false; 360 | props.isUpdate = false; 361 | props.isDelete = false; 362 | props.isUndelete = true; 363 | props.newList = new List { 364 | new Account(Name = 'Account 1'), 365 | new Account(Name = 'Account 2'), 366 | new Account(Name = 'Account 3') 367 | }; 368 | 369 | List names = props.getValues(Account.Name); 370 | Assert.areEqual(3, names.size()); 371 | Assert.areEqual('Account 1', (String)names[0]); 372 | Assert.areEqual('Account 2', (String)names[1]); 373 | Assert.areEqual('Account 3', (String)names[2]); 374 | } 375 | 376 | @IsTest 377 | static void testProps_GetValues_Delete() { 378 | Triggers.Props props = new Triggers.Props(); 379 | props.isInsert = false; 380 | props.isUpdate = false; 381 | props.isDelete = true; 382 | props.isUndelete = false; 383 | props.oldList = new List { 384 | new Account(Name = 'Account 1'), 385 | new Account(Name = 'Account 2'), 386 | new Account(Name = 'Account 3') 387 | }; 388 | 389 | List names = props.getValues(Account.Name); 390 | Assert.areEqual(3, names.size()); 391 | Assert.areEqual('Account 1', (String)names[0]); 392 | Assert.areEqual('Account 2', (String)names[1]); 393 | Assert.areEqual('Account 3', (String)names[2]); 394 | } 395 | 396 | @IsTest 397 | static void testProps_FilterChanged_NoChange() { 398 | Triggers.Props props = new Triggers.Props(); 399 | props.isInsert = false; 400 | props.isUpdate = true; 401 | props.isDelete = false; 402 | props.isUndelete = false; 403 | 404 | List accounts = createAccounts(); 405 | props.oldList = accounts; 406 | props.oldMap = new Map(accounts); 407 | props.newList = accounts; 408 | props.newMap = new Map(accounts); 409 | 410 | List changedIds = props.filterChanged(Account.Name); 411 | Boolean isChanged = props.isChanged(Account.Name); 412 | 413 | Assert.areEqual(0, changedIds.size()); 414 | Assert.areEqual(false, isChanged); 415 | } 416 | 417 | @IsTest 418 | static void testProps_FilterChanged() { 419 | Triggers.Props props = new Triggers.Props(); 420 | props.isInsert = false; 421 | props.isUpdate = true; 422 | props.isDelete = false; 423 | props.isUndelete = false; 424 | List accounts = createAccounts(); 425 | props.oldList = accounts; 426 | props.oldMap = new Map(accounts); 427 | List newAccounts = accounts.deepClone(); 428 | newAccounts[0].Id = accounts[0].Id; 429 | newAccounts[1].Id = accounts[1].Id; 430 | newAccounts[1].Name = 'Account 4'; 431 | newAccounts[2].Id = accounts[2].Id; 432 | newAccounts[2].Description = 'Account 5'; 433 | props.newList = newAccounts; 434 | props.newMap = new Map(newAccounts); 435 | 436 | List changedIds = props.filterChanged(Account.Name); 437 | Boolean isChanged = props.isChanged(Account.Name); 438 | 439 | Assert.areEqual(1, changedIds.size()); 440 | Assert.areEqual(true, isChanged); 441 | } 442 | 443 | 444 | @IsTest 445 | static void testProps_FilterChangedAny_X2_NoChange() { 446 | Triggers.Props props = new Triggers.Props(); 447 | props.isInsert = false; 448 | props.isUpdate = true; 449 | props.isDelete = false; 450 | props.isUndelete = false; 451 | 452 | List accounts = createAccounts(); 453 | props.oldList = accounts; 454 | props.oldMap = new Map(accounts); 455 | props.newList = accounts; 456 | props.newMap = new Map(accounts); 457 | 458 | List changedIds = props.filterChangedAny(Account.Name, Account.Description); 459 | Boolean isChanged = props.isChangedAny(Account.Name, Account.Description); 460 | 461 | Assert.areEqual(0, changedIds.size()); 462 | Assert.areEqual(false, isChanged); 463 | } 464 | 465 | @IsTest 466 | static void testProps_FilterChangedAny_X2() { 467 | Triggers.Props props = new Triggers.Props(); 468 | props.isInsert = false; 469 | props.isUpdate = true; 470 | props.isDelete = false; 471 | props.isUndelete = false; 472 | List accounts = createAccounts(); 473 | props.oldList = accounts; 474 | props.oldMap = new Map(accounts); 475 | List newAccounts = accounts.deepClone(); 476 | newAccounts[0].Id = accounts[0].Id; 477 | newAccounts[1].Id = accounts[1].Id; 478 | newAccounts[1].Name = 'Account 4'; 479 | newAccounts[2].Id = accounts[2].Id; 480 | newAccounts[2].Description = 'Account 5'; 481 | props.newList = newAccounts; 482 | props.newMap = new Map(newAccounts); 483 | 484 | List changedIds = props.filterChangedAny(Account.Name, Account.Description); 485 | Boolean isChanged = props.isChangedAny(Account.Name, Account.Description); 486 | 487 | Assert.areEqual(2, changedIds.size()); 488 | Assert.areEqual(true, isChanged); 489 | } 490 | 491 | @IsTest 492 | static void testProps_FilterChangedAny_X3() { 493 | Triggers.Props props = new Triggers.Props(); 494 | props.isInsert = false; 495 | props.isUpdate = true; 496 | props.isDelete = false; 497 | props.isUndelete = false; 498 | List accounts = createAccounts(); 499 | props.oldList = accounts; 500 | props.oldMap = new Map(accounts); 501 | List newAccounts = accounts.deepClone(); 502 | newAccounts[0].Id = accounts[0].Id; 503 | newAccounts[0].BillingCity = 'Account 4'; 504 | newAccounts[1].Id = accounts[1].Id; 505 | newAccounts[1].Name = 'Account 4'; 506 | newAccounts[2].Id = accounts[2].Id; 507 | newAccounts[2].Description = 'Account 5'; 508 | props.newList = newAccounts; 509 | props.newMap = new Map(newAccounts); 510 | 511 | List changedIds = props.filterChangedAny(Account.Name, Account.Description, Account.BillingCity); 512 | Boolean isChanged = props.isChangedAny(Account.Name, Account.Description, Account.BillingCity); 513 | 514 | Assert.areEqual(3, changedIds.size()); 515 | Assert.areEqual(true, isChanged); 516 | } 517 | 518 | @IsTest 519 | static void testProps_FilterChangedAll_X2_NoChange() { 520 | Triggers.Props props = new Triggers.Props(); 521 | props.isInsert = false; 522 | props.isUpdate = true; 523 | props.isDelete = false; 524 | props.isUndelete = false; 525 | 526 | List accounts = createAccounts(); 527 | props.oldList = accounts; 528 | props.oldMap = new Map(accounts); 529 | props.newList = accounts; 530 | props.newMap = new Map(accounts); 531 | 532 | List changedIds = props.filterChangedAll(Account.Name, Account.Description); 533 | Boolean isChanged = props.isChangedAll(Account.Name, Account.Description); 534 | 535 | Assert.areEqual(0, changedIds.size()); 536 | Assert.areEqual(false, isChanged); 537 | } 538 | 539 | @IsTest 540 | static void testProps_FilterChangedAll_X2() { 541 | Triggers.Props props = new Triggers.Props(); 542 | props.isInsert = false; 543 | props.isUpdate = true; 544 | props.isDelete = false; 545 | props.isUndelete = false; 546 | List accounts = createAccounts(); 547 | props.oldList = accounts; 548 | props.oldMap = new Map(accounts); 549 | List newAccounts = accounts.deepClone(); 550 | newAccounts[0].Id = accounts[0].Id; 551 | newAccounts[0].Name = 'Account 6'; 552 | newAccounts[0].Description = 'Account 6'; 553 | newAccounts[1].Id = accounts[1].Id; 554 | newAccounts[1].Name = 'Account 4'; 555 | newAccounts[2].Id = accounts[2].Id; 556 | newAccounts[2].Description = 'Account 5'; 557 | props.newList = newAccounts; 558 | props.newMap = new Map(newAccounts); 559 | 560 | List changedIds = props.filterChangedAll(Account.Name, Account.Description); 561 | Boolean isChanged = props.isChangedAll(Account.Name, Account.Description); 562 | 563 | Assert.areEqual(1, changedIds.size()); 564 | Assert.areEqual(true, isChanged); 565 | } 566 | 567 | @IsTest 568 | static void testProps_sObjectType() { 569 | Triggers.Props props = new Triggers.Props(); 570 | props.isInsert = false; 571 | props.isUpdate = true; 572 | props.isDelete = false; 573 | props.isUndelete = false; 574 | List accounts = createAccounts(); 575 | props.oldList = accounts; 576 | props.oldMap = new Map(accounts); 577 | List newAccounts = accounts.deepClone(); 578 | newAccounts[0].Id = accounts[0].Id; 579 | newAccounts[0].Name = 'Account 6'; 580 | newAccounts[0].Description = 'Account 6'; 581 | newAccounts[1].Id = accounts[1].Id; 582 | newAccounts[1].Name = 'Account 4'; 583 | newAccounts[2].Id = accounts[2].Id; 584 | newAccounts[2].Description = 'Account 5'; 585 | props.newList = newAccounts; 586 | props.newMap = new Map(newAccounts); 587 | 588 | Assert.areEqual(null, props.sObjectType); 589 | props.setSObjectType(); 590 | Assert.areEqual(Account.SObjectType, props.sObjectType); 591 | } 592 | 593 | @IsTest 594 | static void testProps_FilterChangedAll_X3() { 595 | Triggers.Props props = new Triggers.Props(); 596 | props.isInsert = false; 597 | props.isUpdate = true; 598 | props.isDelete = false; 599 | props.isUndelete = false; 600 | List accounts = createAccounts(); 601 | props.oldList = accounts; 602 | props.oldMap = new Map(accounts); 603 | List newAccounts = accounts.deepClone(); 604 | newAccounts[0].Id = accounts[0].Id; 605 | newAccounts[0].Name = 'Account 6'; 606 | newAccounts[0].Description = 'Account 6'; 607 | newAccounts[0].BillingCity = 'Dalian'; 608 | newAccounts[1].Id = accounts[1].Id; 609 | newAccounts[1].Name = 'Account 4'; 610 | newAccounts[1].Description = 'Account 4'; 611 | newAccounts[2].Id = accounts[2].Id; 612 | newAccounts[2].Description = 'Account 5'; 613 | newAccounts[2].BillingCity = 'Dalian'; 614 | props.newList = newAccounts; 615 | props.newMap = new Map(newAccounts); 616 | 617 | List changedIds = props.filterChangedAll(Account.Name, Account.Description, Account.BillingCity); 618 | Boolean isChanged = props.isChangedAll(Account.Name, Account.Description, Account.BillingCity); 619 | 620 | Assert.areEqual(1, changedIds.size()); 621 | Assert.areEqual(true, isChanged); 622 | } 623 | // #endregion 624 | // =================== 625 | 626 | // ===================== 627 | // #region Handler Impls 628 | // TriggersTest is treated as a handler to be skipped, because inner class cannot be reflected 629 | // from an instance back to type properly. 630 | public Boolean criteria(Triggers.Context context) { 631 | return Triggers.WHEN_ALWAYS; 632 | } 633 | 634 | public void beforeInsert(Triggers.Context context) { 635 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 636 | context.next(); 637 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 638 | } 639 | 640 | public class FirstHandler implements Triggers.Handler, Triggers.BeforeInsert, Triggers.AfterInsert, 641 | Triggers.BeforeUpdate, Triggers.AfterUpdate, Triggers.BeforeDelete, Triggers.AfterDelete, 642 | Triggers.AfterUndelete { 643 | public Boolean criteria(Triggers.Context context) { 644 | context.next(); // negative case, shouldn't do this 645 | return Triggers.WHEN_ALWAYS; 646 | } 647 | 648 | private void then(Triggers.Context context) { 649 | if (context.state.get('counter') == null) { 650 | context.state.put('counter', 0); 651 | } 652 | Assert.areEqual(0, context.state.get('counter')); 653 | context.next(); 654 | Assert.areEqual(4, context.state.get('counter')); 655 | } 656 | 657 | public void beforeInsert(Triggers.Context context) { 658 | then(context); 659 | } 660 | 661 | public void afterInsert(Triggers.Context context) { 662 | then(context); 663 | } 664 | 665 | public void beforeUpdate(Triggers.Context context) { 666 | then(context); 667 | } 668 | 669 | public void afterUpdate(Triggers.Context context) { 670 | then(context); 671 | } 672 | 673 | public void beforeDelete(Triggers.Context context) { 674 | then(context); 675 | } 676 | 677 | public void afterDelete(Triggers.Context context) { 678 | then(context); 679 | } 680 | 681 | public void afterUndelete(Triggers.Context context) { 682 | then(context); 683 | } 684 | } 685 | 686 | public class MainHandler implements Triggers.Handler, Triggers.BeforeInsert, Triggers.AfterInsert, 687 | Triggers.BeforeUpdate, Triggers.AfterUpdate, Triggers.BeforeDelete, Triggers.AfterDelete, 688 | Triggers.AfterUndelete { 689 | public Boolean criteria(Triggers.Context context) { 690 | context.next(); // shouldn't work in when method 691 | return Triggers.WHEN_ALWAYS; 692 | } 693 | 694 | private void then(Triggers.Context context) { 695 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 696 | context.next(); 697 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 698 | } 699 | 700 | public void beforeInsert(Triggers.Context context) { 701 | then(context); 702 | } 703 | 704 | public void afterInsert(Triggers.Context context) { 705 | then(context); 706 | } 707 | 708 | public void beforeUpdate(Triggers.Context context) { 709 | then(context); 710 | } 711 | 712 | public void afterUpdate(Triggers.Context context) { 713 | then(context); 714 | } 715 | 716 | public void beforeDelete(Triggers.Context context) { 717 | then(context); 718 | } 719 | 720 | public void afterDelete(Triggers.Context context) { 721 | then(context); 722 | } 723 | 724 | public void afterUndelete(Triggers.Context context) { 725 | then(context); 726 | } 727 | } 728 | 729 | public class StopHandler implements Triggers.Handler, Triggers.BeforeInsert, Triggers.AfterInsert { 730 | public Boolean criteria(Triggers.Context context) { 731 | context.next(); // shouldn't work in when method 732 | return Triggers.WHEN_ALWAYS; 733 | } 734 | 735 | private void then(Triggers.Context context) { 736 | context.stop(); 737 | } 738 | 739 | public void beforeInsert(Triggers.Context context) { 740 | then(context); 741 | } 742 | 743 | public void afterInsert(Triggers.Context context) { 744 | then(context); 745 | } 746 | } 747 | 748 | public class InactiveHandler implements Triggers.Handler, Triggers.BeforeInsert { 749 | public Boolean criteria(Triggers.Context context) { 750 | context.next(); // negative test, shouldn't work in when method 751 | context.next(); 752 | context.next(); 753 | return !Triggers.WHEN_ALWAYS; 754 | } 755 | 756 | private void then(Triggers.Context context) { 757 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 758 | context.next(); 759 | context.state.put('counter', (Integer)context.state.get('counter') + 1); 760 | } 761 | 762 | public void beforeInsert(Triggers.Context context) { 763 | then(context); 764 | } 765 | } 766 | 767 | public class AddSkippedHandler implements Triggers.Handler, Triggers.BeforeInsert { 768 | public Boolean criteria(Triggers.Context context) { 769 | return Triggers.WHEN_ALWAYS; 770 | } 771 | 772 | private void then(Triggers.Context context) { 773 | context.skips.add(TriggersTest.class); 774 | context.next(); 775 | } 776 | 777 | public void beforeInsert(Triggers.Context context) { 778 | then(context); 779 | } 780 | } 781 | 782 | public class RemoveSkippedHandler implements Triggers.Handler, Triggers.BeforeInsert { 783 | public Boolean criteria(Triggers.Context context) { 784 | return Triggers.WHEN_ALWAYS; 785 | } 786 | 787 | private void then(Triggers.Context context) { 788 | if (context.skips.contains(TriggersTest.class)) { 789 | context.skips.remove(TriggersTest.class); 790 | } 791 | context.next(); 792 | } 793 | 794 | public void beforeInsert(Triggers.Context context) { 795 | then(context); 796 | } 797 | } 798 | 799 | public class ClearSkippedHandler implements Triggers.Handler, Triggers.BeforeInsert { 800 | public Boolean criteria(Triggers.Context context) { 801 | return Triggers.WHEN_ALWAYS; 802 | } 803 | 804 | private void then(Triggers.Context context) { 805 | context.skips.clear(); 806 | context.next(); 807 | } 808 | 809 | public void beforeInsert(Triggers.Context context) { 810 | then(context); 811 | } 812 | } 813 | 814 | public class LastHandler implements Triggers.Handler, Triggers.BeforeInsert, Triggers.AfterInsert, 815 | Triggers.BeforeUpdate, Triggers.AfterUpdate, Triggers.BeforeDelete, Triggers.AfterDelete, 816 | Triggers.AfterUndelete { 817 | public Boolean criteria(Triggers.Context context) { 818 | context.next(); // shouldn't work in when method 819 | return Triggers.WHEN_ALWAYS; 820 | } 821 | 822 | private void then(Triggers.Context context) { 823 | Assert.areEqual(2, context.state.get('counter')); 824 | context.next(); 825 | Assert.areEqual(2, context.state.get('counter')); 826 | } 827 | 828 | public void beforeInsert(Triggers.Context context) { 829 | then(context); 830 | } 831 | 832 | public void afterInsert(Triggers.Context context) { 833 | then(context); 834 | } 835 | 836 | public void beforeUpdate(Triggers.Context context) { 837 | then(context); 838 | } 839 | 840 | public void afterUpdate(Triggers.Context context) { 841 | then(context); 842 | } 843 | 844 | public void beforeDelete(Triggers.Context context) { 845 | then(context); 846 | } 847 | 848 | public void afterDelete(Triggers.Context context) { 849 | then(context); 850 | } 851 | 852 | public void afterUndelete(Triggers.Context context) { 853 | then(context); 854 | } 855 | } 856 | 857 | public class AccountNameChangedHandler implements Triggers.Handler, Triggers.BeforeInsert, 858 | Triggers.AfterInsert, Triggers.BeforeUpdate, Triggers.AfterUpdate, Triggers.BeforeDelete, 859 | Triggers.AfterDelete, Triggers.AfterUndelete { 860 | public Boolean criteria(Triggers.Context context) { 861 | return Triggers.WHEN_ALWAYS; 862 | } 863 | 864 | public void beforeInsert(Triggers.Context context) { 865 | Assert.areEqual(null, context.props.oldList); 866 | Assert.areEqual(null, context.props.oldMap); 867 | Assert.areEqual(3, context.props.newList.size()); 868 | Assert.areEqual(3, context.props.newMap.size()); 869 | } 870 | 871 | public void afterInsert(Triggers.Context context) { 872 | Assert.areEqual(null, context.props.oldList); 873 | Assert.areEqual(null, context.props.oldMap); 874 | Assert.areEqual(3, context.props.newList.size()); 875 | Assert.areEqual(3, context.props.newMap.size()); 876 | } 877 | 878 | public void beforeUpdate(Triggers.Context context) { 879 | Assert.areEqual(3, context.props.oldList.size()); 880 | Assert.areEqual(3, context.props.oldMap.size()); 881 | Assert.areEqual(3, context.props.newList.size()); 882 | Assert.areEqual(3, context.props.newMap.size()); 883 | 884 | Assert.areEqual(3, context.props.filterChanged(Account.Name).size()); 885 | Assert.areEqual(true, context.props.isChanged(Account.Name)); 886 | } 887 | 888 | public void afterUpdate(Triggers.Context context) { 889 | Assert.areEqual(3, context.props.oldList.size()); 890 | Assert.areEqual(3, context.props.oldMap.size()); 891 | Assert.areEqual(3, context.props.newList.size()); 892 | Assert.areEqual(3, context.props.newMap.size()); 893 | 894 | Assert.areEqual(3, context.props.filterChanged(Account.Name).size()); 895 | Assert.areEqual(true, context.props.isChanged(Account.Name)); 896 | } 897 | 898 | public void beforeDelete(Triggers.Context context) { 899 | Assert.areEqual(3, context.props.oldList.size()); 900 | Assert.areEqual(3, context.props.oldMap.size()); 901 | Assert.areEqual(null, context.props.newList); 902 | Assert.areEqual(null, context.props.newMap); 903 | 904 | } 905 | 906 | public void afterDelete(Triggers.Context context) { 907 | Assert.areEqual(3, context.props.oldList.size()); 908 | Assert.areEqual(3, context.props.oldMap.size()); 909 | Assert.areEqual(null, context.props.newList); 910 | Assert.areEqual(null, context.props.newMap); 911 | } 912 | 913 | public void afterUndelete(Triggers.Context context) { 914 | Assert.areEqual(null, context.props.oldList); 915 | Assert.areEqual(null, context.props.oldMap); 916 | Assert.areEqual(3, context.props.newList.size()); 917 | Assert.areEqual(3, context.props.newMap.size()); 918 | } 919 | } 920 | // #endregion 921 | // ===================== 922 | } -------------------------------------------------------------------------------- /apex-trigger-handler/main/default/classes/TriggersTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57.0 4 | Active 5 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/classes/TriggersLoader.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * BSD 3-Clause License 3 | * 4 | * Copyright (c) 2020, Jianfeng Jin 5 | * All rights reserved. 6 | * 7 | * Redistribution and use in source and binary forms, with or without 8 | * modification, are permitted provided that the following conditions are met: 9 | * 10 | * 1. Redistributions of source code must retain the above copyright notice, this 11 | * list of conditions and the following disclaimer. 12 | * 13 | * 2. Redistributions in binary form must reproduce the above copyright notice, 14 | * this list of conditions and the following disclaimer in the documentation 15 | * and/or other materials provided with the distribution. 16 | * 17 | * 3. Neither the name of the copyright holder nor the names of its 18 | * contributors may be used to endorse or promote products derived from 19 | * this software without specific prior written permission. 20 | * 21 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | */ 32 | 33 | public class TriggersLoader implements Triggers.Loader { 34 | @TestVisible 35 | private static List settings { 36 | get { 37 | if (settings == null) { 38 | settings = [ 39 | SELECT Id, Label, SObject__c, Trigger_Event__c, Handler_Class__c, Execution_Order__c, Tag__c 40 | FROM Apex_Trigger_Handler_Setting__mdt 41 | WHERE Active__c = TRUE 42 | ORDER BY SObject__c, Trigger_Event__c, Execution_Order__c 43 | ]; 44 | } 45 | return settings; 46 | } 47 | set; 48 | } 49 | 50 | private static Map>> handlerSettings { 51 | get { 52 | if (handlerSettings == null) { 53 | handlerSettings = new Map>>(); 54 | SObjectType preObjectType = null; 55 | TriggerOperation preTriggerEvent = null; 56 | for (Apex_Trigger_Handler_Setting__mdt setting : settings) { 57 | SObjectType objectType = Schema.getGlobalDescribe().get(setting.SObject__c); 58 | if (objectType == null) { 59 | throw new TypeException( 60 | 'Apex Trigger Handler Setting [' + 61 | setting.Label + 62 | '] doesn\'t have a valid SObject__c [' + 63 | setting.SObject__c + 64 | '].' 65 | ); 66 | } else if (preObjectType != objectType) { 67 | preObjectType = objectType; 68 | preTriggerEvent = null; 69 | handlerSettings.put( 70 | objectType, 71 | new Map>() 72 | ); 73 | } 74 | 75 | TriggerOperation triggerEvent = TriggerOperation.valueOf(setting.Trigger_Event__c); 76 | if (preTriggerEvent != triggerEvent) { 77 | preTriggerEvent = triggerEvent; 78 | handlerSettings.get(objectType) 79 | .put(triggerEvent, new List()); 80 | } 81 | 82 | handlerSettings.get(objectType).get(triggerEvent).add(setting); 83 | } 84 | } 85 | 86 | return handlerSettings; 87 | } 88 | set; 89 | } 90 | 91 | public List load(SObjectType sObjectType, TriggerOperation triggerEvent) { 92 | return load(sObjectType, triggerEvent, null, false); 93 | } 94 | 95 | public List load(SObjectType sObjectType, TriggerOperation triggerEvent, String tag) { 96 | return load(sObjectType, triggerEvent, tag, true); 97 | } 98 | 99 | private List load( 100 | SObjectType sObjectType, 101 | TriggerOperation triggerEvent, 102 | String tag, 103 | Boolean needCheckTag 104 | ) { 105 | List handlers = new List(); 106 | if (handlerSettings.containsKey(sObjectType) && handlerSettings.get(sObjectType).containsKey(triggerEvent)) { 107 | for (Apex_Trigger_Handler_Setting__mdt setting : handlerSettings.get(sObjectType).get(triggerEvent)) { 108 | if (!needCheckTag || setting.Tag__c == tag) { 109 | Type handlerType = Type.forName(setting.Handler_Class__c); 110 | if (handlerType == null || !Triggers.Handler.class.isAssignableFrom(handlerType)) { 111 | throw new TypeException( 112 | 'Apex Trigger Handler Setting [' + 113 | setting.Label + 114 | '] doesn\'t have a valid Handler_Class__c [' + 115 | setting.Handler_Class__c + 116 | '].' 117 | ); 118 | } 119 | handlers.add((Triggers.Handler) handlerType.newInstance()); 120 | } 121 | } 122 | } 123 | return handlers; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/classes/TriggersLoader.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 56.0 4 | Active 5 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/classes/TriggersLoaderTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * BSD 3-Clause License 3 | * 4 | * Copyright (c) 2020, Jianfeng Jin 5 | * All rights reserved. 6 | * 7 | * Redistribution and use in source and binary forms, with or without 8 | * modification, are permitted provided that the following conditions are met: 9 | * 10 | * 1. Redistributions of source code must retain the above copyright notice, this 11 | * list of conditions and the following disclaimer. 12 | * 13 | * 2. Redistributions in binary form must reproduce the above copyright notice, 14 | * this list of conditions and the following disclaimer in the documentation 15 | * and/or other materials provided with the distribution. 16 | * 17 | * 3. Neither the name of the copyright holder nor the names of its 18 | * contributors may be used to endorse or promote products derived from 19 | * this software without specific prior written permission. 20 | * 21 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | */ 32 | 33 | @isTest 34 | public class TriggersLoaderTest { 35 | static { 36 | TriggersLoader.settings = new List{ 37 | new Apex_Trigger_Handler_Setting__mdt( 38 | SObject__c = 'Account', 39 | Trigger_Event__c = 'BEFORE_UPDATE', 40 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler1', 41 | Execution_Order__c = 1, 42 | Tag__c = 'tag1' 43 | ), 44 | new Apex_Trigger_Handler_Setting__mdt( 45 | SObject__c = 'Account', 46 | Trigger_Event__c = 'BEFORE_UPDATE', 47 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler2', 48 | Execution_Order__c = 2, 49 | Tag__c = 'tag2' 50 | ), 51 | new Apex_Trigger_Handler_Setting__mdt( 52 | SObject__c = 'Account', 53 | Trigger_Event__c = 'BEFORE_UPDATE', 54 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler3', 55 | Execution_Order__c = 3, 56 | Tag__c = null 57 | ), 58 | new Apex_Trigger_Handler_Setting__mdt( 59 | SObject__c = 'Account', 60 | Trigger_Event__c = 'AFTER_UPDATE', 61 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler3', 62 | Execution_Order__c = 1, 63 | Tag__c = 'tag1' 64 | ), 65 | new Apex_Trigger_Handler_Setting__mdt( 66 | SObject__c = 'Account', 67 | Trigger_Event__c = 'AFTER_UPDATE', 68 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler2', 69 | Execution_Order__c = 2, 70 | Tag__c = 'tag1' 71 | ), 72 | new Apex_Trigger_Handler_Setting__mdt( 73 | SObject__c = 'Account', 74 | Trigger_Event__c = 'AFTER_UPDATE', 75 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler1', 76 | Execution_Order__c = 3, 77 | Tag__c = 'tag2' 78 | ) 79 | }; 80 | } 81 | 82 | @IsTest 83 | static void testBinding_Settings_Exception_ObjectType() { 84 | TriggersLoader.settings = new List{ 85 | new Apex_Trigger_Handler_Setting__mdt( 86 | SObject__c = 'XXX___InvalidAccount', 87 | Trigger_Event__c = 'BEFORE_INSERT', 88 | Handler_Class__c = 'TriggersLoaderTest.AccountTriggerHandler1', 89 | Execution_Order__c = 1, 90 | Tag__c = null 91 | ) 92 | }; 93 | 94 | List oldList = new List(); 95 | List newList = new List(); 96 | 97 | Exception exp; 98 | try { 99 | Triggers.prepare(TriggerOperation.BEFORE_INSERT, oldList, newList).beforeInsert().load().execute(); 100 | } catch (Exception ex) { 101 | exp = ex; 102 | } 103 | System.debug(exp); 104 | Assert.areNotEqual(null, exp); 105 | } 106 | 107 | @IsTest 108 | static void testBinding_Settings_Exception_HandlerClass() { 109 | TriggersLoader.settings = new List{ 110 | new Apex_Trigger_Handler_Setting__mdt( 111 | SObject__c = 'Account', 112 | Trigger_Event__c = 'BEFORE_INSERT', 113 | Handler_Class__c = 'TriggersLoaderTest.InvalidTriggerHandler', 114 | Execution_Order__c = 1, 115 | Tag__c = null 116 | ) 117 | }; 118 | 119 | List oldList = new List(); 120 | List newList = new List(); 121 | 122 | Exception exp; 123 | try { 124 | Triggers.prepare(TriggerOperation.BEFORE_INSERT, oldList, newList).beforeInsert().load().execute(); 125 | } catch (Exception ex) { 126 | exp = ex; 127 | } 128 | System.debug(exp); 129 | Assert.areNotEqual(null, exp); 130 | } 131 | 132 | @IsTest 133 | static void testBinding_settings() { 134 | List oldList = new List{ 135 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'Old Name 1'), 136 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'Old Name 2'), 137 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'Old Name 3') 138 | }; 139 | 140 | List newList = new List{ 141 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 1), Name = 'New Name 1'), 142 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 2), Name = 'New Name 2'), 143 | new Account(Id = TriggersTest.getFakeId(Account.SObjectType, 3), Name = 'New Name 3') 144 | }; 145 | 146 | Triggers.ManagerImpl triggerManager; 147 | triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.BEFORE_UPDATE, oldList, newList); 148 | triggerManager 149 | .beforeUpdate() 150 | .bind(new MiddleTriggerHandler()) 151 | .load() 152 | .bind(new MiddleTriggerHandler()) 153 | .afterUpdate() 154 | .bind(new MiddleTriggerHandler()) 155 | .load('tag1') 156 | .bind(new MiddleTriggerHandler()) 157 | .load('tag2') 158 | .bind(new MiddleTriggerHandler()) 159 | .execute(); 160 | List handlerTypes = (List) triggerManager.context.state.get('handlers'); 161 | Assert.areEqual(5, handlerTypes.size()); 162 | Assert.areEqual(MiddleTriggerHandler.class, handlerTypes[0]); 163 | Assert.areEqual(AccountTriggerHandler1.class, handlerTypes[1]); 164 | Assert.areEqual(AccountTriggerHandler2.class, handlerTypes[2]); 165 | Assert.areEqual(AccountTriggerHandler3.class, handlerTypes[3]); 166 | Assert.areEqual(MiddleTriggerHandler.class, handlerTypes[4]); 167 | 168 | triggerManager = (Triggers.ManagerImpl) Triggers.prepare(TriggerOperation.AFTER_UPDATE, oldList, newList); 169 | triggerManager 170 | .beforeUpdate() 171 | .bind(new MiddleTriggerHandler()) 172 | .load() 173 | .bind(new MiddleTriggerHandler()) 174 | .afterUpdate() 175 | .bind(new MiddleTriggerHandler()) 176 | .load('tag1') 177 | .bind(new MiddleTriggerHandler()) 178 | .load('tag2') 179 | .bind(new MiddleTriggerHandler()) 180 | .execute(); 181 | handlerTypes = (List) triggerManager.context.state.get('handlers'); 182 | Assert.areEqual(6, handlerTypes.size()); 183 | Assert.areEqual(MiddleTriggerHandler.class, handlerTypes[0]); 184 | Assert.areEqual(AccountTriggerHandler3.class, handlerTypes[1]); 185 | Assert.areEqual(AccountTriggerHandler2.class, handlerTypes[2]); 186 | Assert.areEqual(MiddleTriggerHandler.class, handlerTypes[3]); 187 | Assert.areEqual(AccountTriggerHandler1.class, handlerTypes[4]); 188 | Assert.areEqual(MiddleTriggerHandler.class, handlerTypes[5]); 189 | } 190 | 191 | public class MiddleTriggerHandler implements Triggers.Handler, Triggers.BeforeUpdate, Triggers.AfterUpdate { 192 | public Boolean criteria(Triggers.Context context) { 193 | return Triggers.WHEN_ALWAYS; 194 | } 195 | 196 | public void beforeUpdate(Triggers.Context context) { 197 | then(context); 198 | } 199 | 200 | public void afterUpdate(Triggers.Context context) { 201 | then(context); 202 | } 203 | 204 | private void then(Triggers.Context context) { 205 | if (!context.state.containsKey('handlers')) { 206 | context.state.put('handlers', new List()); 207 | } 208 | ((List) context.state.get('handlers')).add(MiddleTriggerHandler.class); 209 | } 210 | } 211 | 212 | public class AccountTriggerHandler1 implements Triggers.Handler, Triggers.BeforeUpdate, Triggers.AfterUpdate { 213 | public Boolean criteria(Triggers.Context context) { 214 | return Triggers.WHEN_ALWAYS; 215 | } 216 | 217 | public void beforeUpdate(Triggers.Context context) { 218 | then(context); 219 | } 220 | 221 | public void afterUpdate(Triggers.Context context) { 222 | then(context); 223 | } 224 | 225 | private void then(Triggers.Context context) { 226 | ((List) context.state.get('handlers')).add(AccountTriggerHandler1.class); 227 | } 228 | } 229 | 230 | public class AccountTriggerHandler2 implements Triggers.Handler, Triggers.BeforeUpdate, Triggers.AfterUpdate { 231 | public Boolean criteria(Triggers.Context context) { 232 | return Triggers.WHEN_ALWAYS; 233 | } 234 | 235 | public void beforeUpdate(Triggers.Context context) { 236 | then(context); 237 | } 238 | 239 | public void afterUpdate(Triggers.Context context) { 240 | then(context); 241 | } 242 | 243 | private void then(Triggers.Context context) { 244 | ((List) context.state.get('handlers')).add(AccountTriggerHandler2.class); 245 | } 246 | } 247 | 248 | public class AccountTriggerHandler3 implements Triggers.Handler, Triggers.BeforeUpdate, Triggers.AfterUpdate { 249 | public Boolean criteria(Triggers.Context context) { 250 | return Triggers.WHEN_ALWAYS; 251 | } 252 | 253 | public void beforeUpdate(Triggers.Context context) { 254 | then(context); 255 | } 256 | 257 | public void afterUpdate(Triggers.Context context) { 258 | then(context); 259 | } 260 | 261 | private void then(Triggers.Context context) { 262 | ((List) context.state.get('handlers')).add(AccountTriggerHandler3.class); 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/classes/TriggersLoaderTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 56.0 4 | Active 5 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/layouts/Apex_Trigger_Handler_Setting__mdt-Apex Trigger Handler Setting Layout.layout-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | true 7 | 8 | 9 | 10 | Required 11 | MasterLabel 12 | 13 | 14 | Required 15 | DeveloperName 16 | 17 | 18 | 19 | 20 | Edit 21 | IsProtected 22 | 23 | 24 | Required 25 | NamespacePrefix 26 | 27 | 28 | 29 | 30 | 31 | true 32 | true 33 | true 34 | 35 | 36 | 37 | Required 38 | SObject__c 39 | 40 | 41 | Required 42 | Trigger_Event__c 43 | 44 | 45 | Required 46 | Handler_Class__c 47 | 48 | 49 | 50 | 51 | Required 52 | Execution_Order__c 53 | 54 | 55 | Edit 56 | Tag__c 57 | 58 | 59 | Edit 60 | Active__c 61 | 62 | 63 | 64 | 65 | 66 | false 67 | true 68 | false 69 | 70 | 71 | 72 | Readonly 73 | CreatedById 74 | 75 | 76 | 77 | 78 | Readonly 79 | LastModifiedById 80 | 81 | 82 | 83 | 84 | 85 | true 86 | false 87 | false 88 | 89 | 90 | 91 | 92 | 93 | 94 | false 95 | false 96 | false 97 | false 98 | false 99 | 100 | 00h9D000001kDS9 101 | 4 102 | 0 103 | Default 104 | 105 | 106 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/Apex_Trigger_Handler_Setting__mdt.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Apex Trigger Handler Settings 5 | Protected 6 | 7 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/Active__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Active__c 4 | true 5 | false 6 | DeveloperControlled 7 | 8 | Checkbox 9 | 10 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/Execution_Order__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Execution_Order__c 4 | false 5 | DeveloperControlled 6 | 7 | 5 8 | true 9 | 0 10 | Number 11 | false 12 | 13 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/Handler_Class__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Handler_Class__c 4 | false 5 | DeveloperControlled 6 | 7 | 255 8 | true 9 | Text 10 | false 11 | 12 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/SObject__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | SObject__c 4 | false 5 | DeveloperControlled 6 | 7 | 255 8 | true 9 | Text 10 | false 11 | 12 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/Tag__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tag__c 4 | false 5 | DeveloperControlled 6 | 7 | 30 8 | false 9 | Text 10 | false 11 | 12 | -------------------------------------------------------------------------------- /apex-trigger-loader/main/default/objects/Apex_Trigger_Handler_Setting__mdt/fields/Trigger_Event__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Trigger_Event__c 4 | false 5 | DeveloperControlled 6 | 7 | true 8 | Picklist 9 | 10 | true 11 | 12 | false 13 | 14 | BEFORE_INSERT 15 | true 16 | 17 | 18 | 19 | AFTER_INSERT 20 | false 21 | 22 | 23 | 24 | BEFORE_UPDATE 25 | false 26 | 27 | 28 | 29 | AFTER_UPDATE 30 | false 31 | 32 | 33 | 34 | BEFORE_DELETE 35 | false 36 | 37 | 38 | 39 | AFTER_DELETE 40 | false 41 | 42 | 43 | 44 | AFTER_UNDELETE 45 | false 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Apex Trigger Handler", 3 | "edition": "Developer", 4 | "language": "en_US", 5 | "features": ["EnableSetPasswordInApi"], 6 | "settings": { 7 | "lightningExperienceSettings": { 8 | "enableS1DesktopEnabled": true 9 | }, 10 | "mobileSettings": { 11 | "enableS1EncryptedStoragePref2": false 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/images/deploy-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apexfarm/ApexTriggerHandler/0ca9278f126fe688f3fe99b3a288058845787a22/docs/images/deploy-button.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "eslint **/{aura,lwc}/**", 8 | "test": "npm run test:unit", 9 | "test:unit": "sfdx-lwc-jest", 10 | "test:unit:watch": "sfdx-lwc-jest --watch", 11 | "test:unit:debug": "sfdx-lwc-jest --debug", 12 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 13 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 14 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 15 | "postinstall": "husky install", 16 | "precommit": "lint-staged" 17 | }, 18 | "devDependencies": { 19 | "@lwc/eslint-plugin-lwc": "^1.1.2", 20 | "@prettier/plugin-xml": "^2.0.1", 21 | "@salesforce/eslint-config-lwc": "^3.2.3", 22 | "@salesforce/eslint-plugin-aura": "^2.0.0", 23 | "@salesforce/eslint-plugin-lightning": "^1.0.0", 24 | "@salesforce/sfdx-lwc-jest": "^1.1.0", 25 | "eslint": "^8.11.0", 26 | "eslint-plugin-import": "^2.25.4", 27 | "eslint-plugin-jest": "^26.1.2", 28 | "husky": "^7.0.4", 29 | "lint-staged": "^12.3.7", 30 | "prettier": "^2.6.0", 31 | "prettier-plugin-apex": "^1.10.0" 32 | }, 33 | "lint-staged": { 34 | "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ 35 | "prettier --write" 36 | ], 37 | "**/{aura,lwc}/**": [ 38 | "eslint" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /script.sh: -------------------------------------------------------------------------------- 1 | # force:package:create only execute for the first time 2 | # sfdx force:package:create -n ApexTriggerHandler -t Unlocked -r apex-trigger-handler 3 | sfdx force:package:version:create -p ApexTriggerHandler -x -c --wait 10 --codecoverage 4 | sfdx force:package:version:list 5 | sfdx force:package:version:promote -p 04t2v000007CfgQAAS 6 | sfdx force:package:version:report -p 04t2v000007CfgQAAS 7 | 8 | 9 | # sfdx force:package:create -n ApexTriggerHandlerExt -t Unlocked -r apex-trigger-loader 10 | sfdx force:package:version:create -p ApexTriggerHandlerExt -x -c --wait 10 --codecoverage 11 | sfdx force:package:version:list 12 | sfdx force:package:version:promote -p 04t2v000007CfgVAAS 13 | sfdx force:package:version:report -p 04t2v000007CfgVAAS -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "apex-trigger-handler", 5 | "package": "ApexTriggerHandler", 6 | "versionName": "ver 1.2.1", 7 | "versionNumber": "1.2.1.NEXT" 8 | }, 9 | { 10 | "path": "apex-trigger-loader", 11 | "package": "ApexTriggerHandlerExt", 12 | "versionName": "ver 1.2.1", 13 | "versionNumber": "1.2.1.NEXT", 14 | "default": true, 15 | "dependencies": [ 16 | { 17 | "package": "ApexTriggerHandler@1.2.1-1" 18 | } 19 | ] 20 | } 21 | ], 22 | "name": "ApexTriggerHandler", 23 | "namespace": "", 24 | "sfdcLoginUrl": "https://login.salesforce.com", 25 | "sourceApiVersion": "57.0", 26 | "packageAliases": { 27 | "ApexTriggerHandler": "0Ho2v000000PBFvCAO", 28 | "ApexTriggerHandler@1.2.0-1": "04t2v000007Cfg6AAC", 29 | "ApexTriggerHandlerExt": "0Ho2v000000PBG0CAO", 30 | "ApexTriggerHandlerExt@1.2.0-1": "04t2v000007CfgBAAS", 31 | "ApexTriggerHandler@1.2.1-1": "04t2v000007CfgQAAS", 32 | "ApexTriggerHandlerExt@1.2.1-1": "04t2v000007CfgVAAS" 33 | } 34 | } --------------------------------------------------------------------------------