├── .forceignore ├── .gitignore ├── LICENSE ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ ├── default │ ├── classes │ │ ├── Chainable.cls │ │ ├── Chainable.cls-meta.xml │ │ ├── ChainableBatch.cls │ │ ├── ChainableBatch.cls-meta.xml │ │ ├── ChainableQueueable.cls │ │ ├── ChainableQueueable.cls-meta.xml │ │ ├── ChainableSchedulable.cls │ │ └── ChainableSchedulable.cls-meta.xml │ ├── flows │ │ └── Chainable.flow-meta.xml │ └── objects │ │ └── Chainable__e │ │ ├── Chainable__e.object-meta.xml │ │ └── fields │ │ ├── Arguments__c.field-meta.xml │ │ ├── InstanceName__c.field-meta.xml │ │ └── Shared__c.field-meta.xml │ └── test │ └── classes │ ├── Chainable_Test.cls │ ├── Chainable_Test.cls-meta.xml │ ├── SampleBatch.cls │ ├── SampleBatch.cls-meta.xml │ ├── SampleDeferArgQueueable.cls │ ├── SampleDeferArgQueueable.cls-meta.xml │ ├── SampleFailRebuild.cls │ ├── SampleFailRebuild.cls-meta.xml │ ├── SampleQueueable.cls │ ├── SampleQueueable.cls-meta.xml │ ├── SampleSchedulable.cls │ └── SampleSchedulable.cls-meta.xml ├── ruleset.xml ├── scripts ├── config.sh └── createScratchOrg.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 | package.xml 5 | **appMenu 6 | **appSwitcher 7 | **profiles 8 | **settings 9 | **authproviders 10 | **namedCredential 11 | 12 | # LWC configuration files 13 | **/jsconfig.json 14 | **/.eslintrc.json 15 | 16 | # LWC Jest 17 | **/__tests__/** 18 | 19 | AppSwitcher.appMenu -------------------------------------------------------------------------------- /.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 | .sfdx/ 7 | .localdevserver/ 8 | .sf/ 9 | 10 | # IDEs 11 | .idea/ 12 | .vscode/ 13 | IlluminatedCloud/ 14 | projectFilesBackup/ 15 | # LWC VSCode autocomplete 16 | **/lwc/jsconfig.json 17 | 18 | # Logs 19 | logs 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Dependency directories 26 | node_modules/ 27 | 28 | # Eslint cache 29 | .eslintcache 30 | 31 | # MacOS system files 32 | .DS_Store 33 | 34 | # Windows system files 35 | Thumbs.db 36 | ehthumbs.db 37 | [Dd]esktop.ini 38 | $RECYCLE.BIN/ 39 | .prettierignore 40 | .prettierrc 41 | package-lock.json 42 | /sfdxproject.iml 43 | 44 | # Temporary folder created by scripts 45 | metadata/ 46 | /sfcleverreach.iml 47 | /out 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Robert Sösemann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Chainable [![Codacy Badge](https://app.codacy.com/project/badge/Grade/7024ec2e01c24c03a323e565e029a5a6)](https://www.codacy.com/gh/rsoesemann/apex-chainable/dashboard?utm_source=github.com&utm_medium=referral&utm_content=rsoesemann/apex-chainable&utm_campaign=Badge_Grade) 2 | 3 | 4 | Deploy to Salesforce 6 | 7 | 8 | Apex Batches can be chained by calling the successor batch from the `finish()` method of the previous batch. 9 | But such hardcoding makes this model inflexible. It's hard to build the chain from outside, neighter from a central class 10 | nor on runtime dependant on business logic. 11 | 12 | The same applies when the `execute()` method of `Schedulable` or `Queueable` classes call other classes. 13 | 14 | ## With `Chainable` 15 | 16 | The `Chainable` wrapper class of this repository overcomes those drawbacks. 17 | 18 | - No need to hardcode successor batch in `finish()` method 19 | - Created batch chains of arbitrary length without changing existing Batch classes 20 | - Support `Batchable`, `Queueable` and `Schedulable` classes as chain members 21 | - Allows sharing and passing of variables between chain members 22 | 23 | ```java 24 | new FirstBatch().setShared('result', new Money(0)) 25 | .then(AnotherBatch()) 26 | .then(QueueableJob()) 27 | .then(ScheduledJob()) 28 | ... 29 | .execute(); 30 | ``` 31 | 32 | ## Without `Chainable` 33 | 34 | ```java 35 | class FirstBatch implements Batchable { 36 | Iterator start(BatchableContext ctx) { ... } 37 | 38 | void execute(BatchableContext ctx, List scope) { ... } 39 | 40 | void finish(BatchableContext ctx) { 41 | Database.enqueueBatch(new SecondBatch()); 42 | } 43 | } 44 | ``` 45 | 46 | ```java 47 | class AnotherBatch implements Batchable { 48 | Iterator start(BatchableContext ctx) { ... } 49 | 50 | void execute(BatchableContext ctx, List scope) { ... } 51 | 52 | void finish(BatchableContext ctx) { 53 | System.schedule('name', cron, new ScheduledJob()); 54 | } 55 | } 56 | ``` 57 | 58 | ## Deferring 59 | 60 | When all the "links" in the chain cannot be completely identified beforehand in order to assemble them in a single chain and trigger its execution, the chain execution can be *deferred* until the end of the transaction. All chainable processes that have been *deferred* will be automatically chained together in a **single** chain and executed sequentually. 61 | 62 | ```java 63 | 64 | // automation 1 65 | new FirstBatch() 66 | .then(AnotherBatch()) 67 | .setShared('result', new Money(0)) // shared variables will be available across other following deferred chainables 68 | .executeDeferred(); 69 | 70 | // automation 2 71 | new QueueableJob() 72 | .then(ScheduledJob()) 73 | ... 74 | .executeDeferred(); 75 | 76 | // the framework would internally build the chain in a separate transaction with a definition like this 77 | new FirstBatch() 78 | .then(AnotherBatch()) 79 | .setShared('result', new Money(0)) 80 | .then(QueueableJob()) 81 | .then(ScheduledJob()) 82 | .execute(); 83 | 84 | ``` 85 | 86 | ### Considerations 87 | 88 | In order to leverage the deferring of the Chainable instances there are some nuances compared to its direct execution. 89 | 90 | * Override the `getDeferArgs` and `setDeferredArgs` if the class receives external input before its execution via constructor parameters or setters to serialize and deserialize them (See the `SampleDeferArgQueueable`). 91 | * Must have a no-arg constructor (if no explicit constructor exists, the default implicit one will be used) 92 | * Must not be an inner class (due to difficulty on dynamic name inferring) -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "apex-chainable_DEV", 3 | "country": "DE", 4 | "edition": "Developer", 5 | "language": "de", 6 | "hasSampleData": true, 7 | "features": [], 8 | "settings": { 9 | } 10 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/Chainable.cls: -------------------------------------------------------------------------------- 1 | public abstract class Chainable { 2 | 3 | @TestVisible 4 | private static final String INVALID_CLASS_MSG = 'Invalid class '; 5 | 6 | private Chainable previous; 7 | private Chainable next; 8 | 9 | private Map sharedVariables = new Map(); 10 | 11 | 12 | // ABSTRACT 13 | 14 | protected abstract void executeAsynchronously(); 15 | protected abstract void executeSynchronously(Context ctx); 16 | 17 | 18 | // VIRTUAL 19 | 20 | protected virtual void setDeferredArgs(String serializedArgs) {} 21 | 22 | 23 | protected virtual void setDeferredShared(String serializedShared) { 24 | Map hydratedShared = (Map)JSON.deserializeUntyped(serializedShared); 25 | for(String key : hydratedShared.keySet()) { 26 | setShared(key, hydratedShared.get(key)); 27 | } 28 | } 29 | 30 | 31 | protected virtual String getDeferArgs() { 32 | return null; 33 | } 34 | 35 | 36 | protected virtual String getDeferShared() { 37 | return JSON.serialize(sharedVariables); 38 | } 39 | 40 | 41 | private virtual void handleDeferException(Exception e) { 42 | throw e; 43 | } 44 | 45 | // PUBLIC 46 | 47 | 48 | @InvocableMethod(label='Rebuild chain from deferred chainables and execute') 49 | public static List rebuildAndExecuteChain(List deferredLinks) { 50 | Chainable chain; 51 | Chainable iteratingInstance; 52 | 53 | List results = new List(); 54 | 55 | for(DeferredChainLink deferredLink : deferredLinks) { 56 | 57 | try { 58 | iteratingInstance = deferredLink.getLinkableInstance(); 59 | 60 | if(chain == null) { 61 | chain = iteratingInstance; 62 | } 63 | else { 64 | chain.then(iteratingInstance); 65 | } 66 | results.add(new DeferRebuildResult()); 67 | } 68 | catch(Exception e) { 69 | results.add(new DeferRebuildResult(e.getMessage())); 70 | } 71 | } 72 | 73 | chain?.execute(); 74 | 75 | return results; 76 | } 77 | 78 | 79 | public Chainable then(Chainable successor) { 80 | if(next != null) { 81 | next.then(successor); 82 | } 83 | else { 84 | next = successor; 85 | next.previous = this; 86 | 87 | next.sharedVariables = sharedVariables; 88 | } 89 | 90 | return this; 91 | } 92 | 93 | 94 | public Chainable execute() { 95 | if(Test.isRunningTest()) { 96 | executeSynchronously(new Context()); 97 | executeNext(); 98 | } 99 | else { 100 | executeAsynchronously(); 101 | } 102 | 103 | return this; 104 | } 105 | 106 | 107 | public Chainable setShared(String key, Object value) { 108 | sharedVariables.put(key, value); 109 | 110 | return this; 111 | } 112 | 113 | 114 | public Object getShared(String key) { 115 | return sharedVariables.get(key); 116 | } 117 | 118 | 119 | public Chainable executeDeferred() { 120 | 121 | List deferEvents = unlink(); 122 | 123 | if(Limits.getLimitDMLStatements() < deferEvents.size()) { 124 | handleDeferException(new DeferLimitException()); 125 | } 126 | 127 | for(Database.SaveResult publishResult : EventBus.publish(deferEvents)) { 128 | if(!publishResult.isSuccess()) { 129 | handleDeferException(new DeferPublishException(publishResult.getErrors())); 130 | } 131 | } 132 | 133 | return this; 134 | } 135 | 136 | @TestVisible 137 | private List unlink() { 138 | 139 | List deferEvents = new List(); 140 | 141 | try { 142 | deferEvents.add(new Chainable__e( 143 | InstanceName__c = String.valueOf(this).substringBefore(':'), 144 | Arguments__c = getDeferArgs(), 145 | Shared__c = getDeferShared() 146 | )); 147 | } 148 | catch(Exception e) { 149 | handleDeferException(new DeferUnlinkException(e)); 150 | } 151 | finally { 152 | if(next != null) { 153 | deferEvents.addAll(next.unlink()); 154 | } 155 | } 156 | 157 | return deferEvents; 158 | } 159 | 160 | 161 | // PROTECTED 162 | 163 | protected void executeNext() { 164 | if(next != null) { 165 | next.execute(); 166 | } 167 | } 168 | 169 | 170 | // INNER 171 | 172 | public class Context { 173 | 174 | private Object originalContext; 175 | 176 | public Context() {} 177 | 178 | public Context(Database.BatchableContext ctx) { 179 | originalContext = ctx; 180 | } 181 | 182 | public Context(QueueableContext ctx) { 183 | originalContext = ctx; 184 | } 185 | 186 | public Context(SchedulableContext ctx) { 187 | originalContext = ctx; 188 | } 189 | 190 | public Object get() { 191 | return originalContext; 192 | } 193 | } 194 | 195 | public class DeferredChainLink { 196 | 197 | @InvocableVariable(required=true) 198 | public Chainable__e deferEvent; 199 | 200 | public Chainable getLinkableInstance() { 201 | Chainable linkableInstance; 202 | 203 | Type jobType = Type.forName(deferEvent.InstanceName__c); 204 | 205 | if(jobType != null && Chainable.class.isAssignableFrom(jobType)) { 206 | 207 | linkableInstance = (Chainable)jobType.newInstance(); 208 | linkableInstance.setDeferredShared(deferEvent.Shared__c); 209 | if(String.isNotBlank(deferEvent.Arguments__c)) { 210 | linkableInstance.setDeferredArgs(deferEvent.Arguments__c); 211 | } 212 | } 213 | else { 214 | throw new DeferredIsNotChainableException(INVALID_CLASS_MSG + deferEvent.InstanceName__c); 215 | } 216 | 217 | return linkableInstance; 218 | } 219 | } 220 | 221 | 222 | public class DeferRebuildResult { 223 | 224 | @InvocableVariable 225 | public Boolean success; 226 | 227 | @InvocableVariable 228 | public String error; 229 | 230 | public DeferRebuildResult() { 231 | success = true; 232 | } 233 | 234 | public DeferRebuildResult(String errorDetail) { 235 | success = false; 236 | error = errorDetail; 237 | } 238 | } 239 | 240 | 241 | public class DeferLimitException extends Exception {} 242 | public class DeferUnlinkException extends Exception {} 243 | public class DeferredIsNotChainableException extends Exception {} 244 | public class DeferPublishException extends Exception { 245 | public DeferPublishException(List publishErrors) { 246 | List messages = new List(); 247 | for(Database.Error error : publishErrors) { 248 | messages.add(error.getMessage()); 249 | } 250 | 251 | setMessage(String.join(messages, '\n')); 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Chainable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableBatch.cls: -------------------------------------------------------------------------------- 1 | public abstract class ChainableBatch extends Chainable 2 | implements Database.Batchable, Database.Stateful, Database.AllowsCallouts { 3 | // ABSTRACT 4 | 5 | protected abstract Iterable start(Context ctx); 6 | protected abstract void execute(Context ctx, Iterable scope); 7 | protected abstract void finish(Context ctx); 8 | 9 | 10 | // PUBLIC 11 | 12 | public Iterable start(Database.BatchableContext ctx) { 13 | return start( new Context(ctx) ); 14 | } 15 | 16 | 17 | public void execute(Database.BatchableContext ctx, Iterable scope) { 18 | execute( new Context(ctx), scope ); 19 | } 20 | 21 | 22 | public void finish(Database.BatchableContext ctx) { 23 | finish( new Context(ctx) ); 24 | 25 | executeNext(); 26 | } 27 | 28 | 29 | protected virtual Integer batchSize() { 30 | return 200; 31 | } 32 | 33 | 34 | public override void executeAsynchronously() { 35 | Database.executeBatch(this, batchSize()); 36 | } 37 | 38 | 39 | public override void executeSynchronously(Context ctx) { 40 | Iterable fullScope = start(ctx); 41 | 42 | if(fullScope.iterator().hasNext()) { 43 | execute(ctx, fullScope); 44 | } 45 | 46 | finish(ctx); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableBatch.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableQueueable.cls: -------------------------------------------------------------------------------- 1 | public abstract class ChainableQueueable extends Chainable 2 | implements Queueable, Database.AllowsCallouts { 3 | // ABSTRACT 4 | 5 | protected abstract void execute(Context ctx); 6 | 7 | 8 | // PUBLIC 9 | 10 | public void execute(QueueableContext ctx) { 11 | execute(new Context(ctx)); 12 | 13 | executeNext(); 14 | } 15 | 16 | 17 | // OVERRIDE 18 | 19 | public override void executeAsynchronously() { 20 | System.enqueueJob(this); 21 | } 22 | 23 | 24 | public override void executeSynchronously(Context ctx) { 25 | execute(ctx); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableQueueable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableSchedulable.cls: -------------------------------------------------------------------------------- 1 | public abstract class ChainableSchedulable extends Chainable 2 | implements Schedulable, Database.AllowsCallouts { 3 | // ABSTRACT 4 | 5 | protected abstract void execute(Context ctx); 6 | 7 | 8 | // PUBLIC 9 | 10 | public void execute(SchedulableContext ctx) { 11 | execute(new Context(ctx)); 12 | 13 | executeNext(); 14 | } 15 | 16 | 17 | public override void executeAsynchronously() { 18 | System.schedule(name(), cronExpression(), this); 19 | } 20 | 21 | 22 | public override void executeSynchronously(Context ctx) { 23 | execute(ctx); 24 | } 25 | 26 | 27 | public virtual String cronExpression() { 28 | Datetime dt = Datetime.now().addMinutes(1); 29 | return dt.second() + ' ' + dt.minute() + ' ' + dt.hour() + ' * * ?'; 30 | } 31 | 32 | 33 | public virtual String name() { 34 | // Note: This class name 35 | return String.valueOf(this).substring(0, String.valueOf(this).indexOf(':')); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /force-app/main/default/classes/ChainableSchedulable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/flows/Chainable.flow-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ExecuteDeferred 5 | 6 | 176 7 | 170 8 | Chainable 9 | apex 10 | 11 | deferEvent 12 | 13 | $Record 14 | 15 | 16 | 17 | 60.0 18 | Chainable {!$Flow.CurrentDateTime} 19 | 20 | 21 | BuilderType 22 | 23 | LightningFlowBuilder 24 | 25 | 26 | 27 | CanvasMode 28 | 29 | AUTO_LAYOUT_CANVAS 30 | 31 | 32 | 33 | OriginBuilderType 34 | 35 | LightningFlowBuilder 36 | 37 | 38 | AutoLaunchedFlow 39 | 40 | 50 41 | 0 42 | 43 | ExecuteDeferred 44 | 45 | Chainable__e 46 | PlatformEvent 47 | 48 | Active 49 | 50 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Chainable__e/Chainable__e.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Deployed 4 | HighVolume 5 | 6 | Chainables 7 | PublishAfterCommit 8 | 9 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Chainable__e/fields/Arguments__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Arguments__c 4 | false 5 | false 6 | false 7 | false 8 | 9 | 131072 10 | LongTextArea 11 | 5 12 | 13 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Chainable__e/fields/InstanceName__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | InstanceName__c 4 | false 5 | false 6 | false 7 | false 8 | 9 | 255 10 | true 11 | Text 12 | false 13 | 14 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Chainable__e/fields/Shared__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Shared__c 4 | false 5 | false 6 | false 7 | false 8 | 9 | 131072 10 | LongTextArea 11 | 5 12 | 13 | -------------------------------------------------------------------------------- /force-app/main/test/classes/Chainable_Test.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public class Chainable_Test { 3 | 4 | private static final String CALL_LOG = 'calls'; 5 | private static List deferredCalls; 6 | 7 | @TestSetup 8 | private static void prepareData() { 9 | insert new Account(Name = 'Acme'); // Note: SampleBatch iterates over Accounts 10 | } 11 | 12 | @IsTest 13 | private static void fullChain() { 14 | 15 | // Execute 16 | Chainable chain = new SampleSchedulable() 17 | .setShared(CALL_LOG, new List()) 18 | 19 | .then( new SampleBatch() ) 20 | .then( new SampleQueueable() ) 21 | 22 | .execute(); 23 | 24 | // Verify 25 | Iterator calls = ((List) chain.getShared(CALL_LOG)).iterator(); 26 | System.assertEquals('SampleSchedulable.execute', (String)calls.next()); 27 | System.assertEquals('SampleBatch.start', (String)calls.next()); 28 | System.assertEquals('SampleBatch.execute', (String)calls.next()); 29 | System.assertEquals('SampleBatch.finish', (String)calls.next()); 30 | System.assertEquals('SampleQueueable.execute', (String)calls.next()); 31 | } 32 | 33 | 34 | @isTest 35 | private static void deferredChains() { 36 | 37 | Test.startTest(); 38 | 39 | // Execute 40 | Chainable chain = new SampleSchedulable() 41 | .setShared(CALL_LOG, new List()) 42 | .executeDeferred(); 43 | 44 | Chainable chain2 = new SampleBatch() 45 | 46 | .then( new SampleQueueable() ) 47 | .then( new SampleDeferArgQueueable(true) ) 48 | 49 | .executeDeferred(); 50 | 51 | Test.stopTest(); 52 | 53 | // Verify 54 | Iterator calls = deferredCalls.iterator(); 55 | System.assertEquals('SampleSchedulable.execute', (String)calls.next()); 56 | System.assertEquals('SampleBatch.start', (String)calls.next()); 57 | System.assertEquals('SampleBatch.execute', (String)calls.next()); 58 | System.assertEquals('SampleBatch.finish', (String)calls.next()); 59 | System.assertEquals('SampleQueueable.execute', (String)calls.next()); 60 | } 61 | 62 | 63 | @isTest 64 | private static void failDeferChainableFault() { 65 | 66 | Test.startTest(); 67 | 68 | // Execute 69 | try { 70 | Chainable chain = new SampleFailDefer() 71 | .executeDeferred(); 72 | 73 | System.assert(false); 74 | } 75 | catch(Exception e) { 76 | System.assert(e instanceof Chainable.DeferUnlinkException); 77 | } 78 | 79 | Test.stopTest(); 80 | } 81 | 82 | 83 | @isTest static void failRebuild() { 84 | 85 | String errorMsg = 'fail to rebuild'; 86 | 87 | Test.startTest(); 88 | 89 | List unlinkEvents = new SampleFailRebuild(errorMsg) 90 | .unlink(); 91 | 92 | System.assertEquals(1, unlinkEvents.size()); 93 | 94 | Chainable.DeferredChainLink param = new Chainable.DeferredChainLink(); 95 | param.deferEvent = unlinkEvents[0]; 96 | 97 | List results = Chainable.rebuildAndExecuteChain(new Chainable.DeferredChainLink[]{param}); 98 | 99 | Test.stopTest(); 100 | 101 | System.assert(!results[0].success); 102 | System.assertEquals(errorMsg, results[0].error); 103 | } 104 | 105 | @isTest static void failRebuildNoFound() { 106 | 107 | Test.startTest(); 108 | 109 | List unlinkEvents = new SampleFailRebuildInner() 110 | .unlink(); 111 | 112 | System.assertEquals(1, unlinkEvents.size()); 113 | 114 | Chainable.DeferredChainLink param = new Chainable.DeferredChainLink(); 115 | param.deferEvent = unlinkEvents[0]; 116 | 117 | List results = Chainable.rebuildAndExecuteChain(new Chainable.DeferredChainLink[]{param}); 118 | 119 | Test.stopTest(); 120 | 121 | System.assert(!results[0].success); 122 | System.assert(results[0].error.startsWith(Chainable.INVALID_CLASS_MSG)); 123 | } 124 | 125 | // HELPER 126 | 127 | public static void log(Chainable chainable) { 128 | List calls = (List) chainable.getShared(CALL_LOG); 129 | calls.add(callLocation()); 130 | deferredCalls = calls; 131 | } 132 | 133 | 134 | // Note: Idea taken from https://salesforce.stackexchange.com/questions/153835 135 | private static String callLocation() { 136 | Pattern STACK_LINE = Pattern.compile('^(?:Class\\.)?([^.]+)\\.?([^\\.\\:]+)?[\\.\\:]?([^\\.\\:]*): line (\\d+), column (\\d+)$'); 137 | 138 | for(String line : new DmlException().getStackTraceString().split('\n')) { 139 | Matcher matcher = STACK_LINE.matcher(line); 140 | 141 | if(matcher.find() && !line.startsWith('Class.' + Chainable_Test.class.getName() + '.')) { 142 | return matcher.group(1) + '.' + matcher.group(2); 143 | } 144 | } 145 | 146 | return null; 147 | } 148 | 149 | private class SampleFailDefer extends ChainableQueueable { 150 | protected override String getDeferArgs() { 151 | throw new Chainable.DeferUnlinkException(); 152 | } 153 | 154 | protected override void execute(Context ctx) {} 155 | } 156 | 157 | private class SampleFailRebuildInner extends ChainableQueueable { 158 | protected override void execute(Context ctx) {} 159 | } 160 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/Chainable_Test.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleBatch.cls: -------------------------------------------------------------------------------- 1 | public class SampleBatch extends ChainableBatch { 2 | 3 | protected override Iterable start(Context ctx) { 4 | Chainable_Test.log(this); 5 | 6 | return (Iterable) [SELECT Phone FROM Account]; 7 | } 8 | 9 | 10 | protected override void execute(Context ctx, Iterable scope) { 11 | Chainable_Test.log(this); 12 | } 13 | 14 | 15 | protected override void finish(Context ctx) { 16 | Chainable_Test.log(this); 17 | } 18 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleBatch.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleDeferArgQueueable.cls: -------------------------------------------------------------------------------- 1 | public with sharing class SampleDeferArgQueueable extends ChainableQueueable { 2 | 3 | private Boolean deferArg = false; 4 | 5 | public override void setDeferredArgs(String serializedArgs) { 6 | deferArg = Boolean.valueOf(serializedArgs); 7 | } 8 | 9 | protected override String getDeferArgs() { 10 | return String.valueOf(deferArg); 11 | } 12 | 13 | public SampleDeferArgQueueable() {} 14 | public SampleDeferArgQueueable(Boolean syncArg) { 15 | deferArg = syncArg; 16 | } 17 | 18 | protected override void execute(Context ctx) { 19 | if(deferArg) { 20 | Chainable_Test.log(this); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleDeferArgQueueable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleFailRebuild.cls: -------------------------------------------------------------------------------- 1 | public class SampleFailRebuild extends ChainableQueueable { 2 | 3 | private String errorToThrow; 4 | 5 | public SampleFailRebuild() {} 6 | public SampleFailRebuild(String msg) { 7 | errorToThrow = msg; 8 | } 9 | 10 | protected override String getDeferArgs() { 11 | return errorToThrow; 12 | } 13 | protected override void setDeferredArgs(String serializedArgs) { 14 | throw new Chainable.DeferUnlinkException(serializedArgs); 15 | } 16 | 17 | protected override void execute(Context ctx) {} 18 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleFailRebuild.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleQueueable.cls: -------------------------------------------------------------------------------- 1 | public class SampleQueueable extends ChainableQueueable { 2 | 3 | protected override void execute(Context ctx) { 4 | Chainable_Test.log(this); 5 | } 6 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleQueueable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 44.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleSchedulable.cls: -------------------------------------------------------------------------------- 1 | public class SampleSchedulable extends ChainableSchedulable { 2 | 3 | protected override void execute(Context ctx) { 4 | for(CronTrigger cron : [SELECT Id FROM CronTrigger WHERE CronJobDetail.Name LIKE :name()]) { 5 | System.abortJob(cron.Id); 6 | } 7 | 8 | Chainable_Test.log(this); 9 | } 10 | } -------------------------------------------------------------------------------- /force-app/main/test/classes/SampleSchedulable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default ruleset for PMD/Codacy 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /scripts/config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DEV_HUB_ALIAS="DevHubPrivate" 3 | SCRATCH_ORG_ALIAS="apex-chainable_DEV" -------------------------------------------------------------------------------- /scripts/createScratchOrg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | source `dirname $0`/config.sh 3 | 4 | execute() { 5 | $@ || exit 6 | } 7 | 8 | if [ -z "$DEV_HUB_URL" ]; then 9 | echo "set default devhub user" 10 | execute sfdx force:config:set defaultdevhubusername=$DEV_HUB_ALIAS 11 | 12 | echo "deleting old scratch org" 13 | sfdx force:org:delete -p -u $SCRATCH_ORG_ALIAS 14 | fi 15 | 16 | 17 | echo "Creating scratch ORG" 18 | execute sfdx force:org:create -a $SCRATCH_ORG_ALIAS -s -f ./config/project-scratch-def.json -d 7 19 | 20 | echo "Make sure Org user is english" 21 | sfdx force:data:record:update -s User -w "Name='User User'" -v "Languagelocalekey=en_US" 22 | 23 | echo "Pushing changes to scratch org" 24 | execute sfdx force:source:push 25 | 26 | echo "Running apex tests" 27 | execute sfdx force:apex:test:run -l RunLocalTests -w 30 28 | 29 | 30 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://login.salesforce.com", 10 | "sourceApiVersion": "50.0" 11 | } --------------------------------------------------------------------------------