Skip to content

Commit

Permalink
Flow support for change data capture events (#130)
Browse files Browse the repository at this point in the history
  • Loading branch information
mitchspano authored Dec 24, 2023
1 parent de90bd1 commit 5d3fb5d
Show file tree
Hide file tree
Showing 12 changed files with 390 additions and 54 deletions.
45 changes: 30 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<img src="https://raw.githubusercontent.com/afawcett/githubsfdeploy/master/src/main/webapp/resources/img/deploy.png" alt="Deploy to Salesforce" />
</a>

#### [Unlocked Package Installation (Production)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t3h000004juLaAAI)
#### [Unlocked Package Installation (Production)](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t3h000004juLuAAI)

#### [Unlocked Package Installation (Sandbox)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t3h000004juLaAAI)
#### [Unlocked Package Installation (Sandbox)](https://test.salesforce.com/packaging/installPackage.apexp?p0=04t3h000004juLuAAI)

---

Expand All @@ -32,15 +32,15 @@ The related lists on the `SObject_Trigger_Setting__mdt` record provide a consoli

The Trigger Actions Framework conforms strongly to the [Open–closed principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle) and the [Single-responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle). To add or modify trigger logic in our Salesforce org, we won't need to keep modifying the body of a TriggerHandler class; we can create a class or a flow with responsibility scoped to the automation we are trying to build and configure these actions to run in a specified order within a given trigger context.

The work is performed in the `MetadataTriggerHandler` class which implements the the [Strategy Pattern](https://en.wikipedia.org/wiki/Strategy_pattern) by fetching all Trigger Action metadata that is configured in the org for the given trigger context and uses [reflection](https://en.wikipedia.org/wiki/Reflective_programming) to dynamically instantiate an object which implements a `TriggerAction` interface, then casts the object to the appropriate interface as specified in the metadata and calls the respective context methods in the order specified.
The work is performed in the `MetadataTriggerHandler` class which implements the [Strategy Pattern](https://en.wikipedia.org/wiki/Strategy_pattern) by fetching all Trigger Action metadata that is configured in the org for the given trigger context and uses [reflection](https://en.wikipedia.org/wiki/Reflective_programming) to dynamically instantiate an object that implements a `TriggerAction`` interface, then casts the object to the appropriate interface as specified in the metadata and calls the respective context methods in the order specified.

Note that if an Apex class is specified in metadata and it does not exist or does not implement the correct interface, a runtime error will occur.

---

### Enabling on an SObject
### Enabling for an SObject

To get started, call the the `MetadataTriggerHandler` class within the body of the trigger of the sObject:
To get started, call the `MetadataTriggerHandler` class within the body of the trigger of the sObject:

```java
trigger OpportunityTrigger on Opportunity (
Expand Down Expand Up @@ -100,13 +100,28 @@ To make your flows usable, they must be auto-launched flows and you need to crea

| Variable Name | Variable Type | Available for Input | Available for Output | Description | Available Contexts |
| ------------- | ------------- | ------------------- | -------------------- | -------------------------------------------------- | ------------------------ |
| record | record | yes | yes | the new version of the record in the DML operation | insert, update, undelete |
| recordPrior | record | yes | no | the old version of the record in the DML operation | update, delete |
| `record` | record | yes | yes | the new version of the record in the DML operation | insert, update, undelete |
| `recordPrior` | record | yes | no | the old version of the record in the DML operation | update, delete |

To enable this flow, simply insert a trigger action record with `Apex_Class_Name__` equal to `TriggerActionFlow` and set the `Flow_Name__c` field with the API name of the flow itself. You can select the `Allow_Flow_Recursion__c` checkbox to allow flows to run recursively (advanced).
To enable this flow, simply insert a trigger action record with `Apex_Class_Name__c` equal to `TriggerActionFlow` and set the `Flow_Name__c` field with the API name of the flow itself. You can select the `Allow_Flow_Recursion__c` checkbox to allow flows to run recursively (advanced).

![Flow Trigger Action](images/flowTriggerAction.png)

### Flow Actions for Change Data Capture Events

Trigger Action Flows can also be used to process Change Data Capture events, but there are two minor modifications necessary:

#### Adjust the Flow Variables

| Variable Name | Variable Type | Available for Input | Available for Output | Description |
| ------------- | -------------------------------------- | ------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record` | record | yes | no | the changeEvent object |
| `header` | `FlowChangeEventHeader` (Apex Defined) | yes | no | a flow-accessible version of the [`ChangeEventHeader` object](https://developer.salesforce.com/docs/atlas.en-us.change_data_capture.meta/change_data_capture/cdc_event_fields_header.htm) |

#### Adjust the `Trigger_Action__mdt` Record

Create a trigger action record with `Apex_Class_Name__c` equal to `TriggerActionFlowChangeEvent` (instead of `TriggerActionFlow`) and set the `Flow_Name__c` field with the API name of the flow itself.

---

## Compatibility with sObjects from Installed Packages
Expand Down Expand Up @@ -220,7 +235,7 @@ Developers can enter the API name of a permission in the `Bypass_Permission__c`

#### Required Permission

Developers can enter the API name of a permission in the `Required_Permission__c` field. If this field has a value, then the trigger/action will only execute if the running user has the custom permission identified. This can be allow for new functionality to be released to a subset of users.
Developers can enter the API name of a permission in the `Required_Permission__c` field. If this field has a value, then the trigger/action will only execute if the running user has the custom permission identified. This can allow for new functionality to be released to a subset of users.

---

Expand Down Expand Up @@ -288,7 +303,7 @@ public class TA_Opportunity_StandardizeName implements TriggerAction.BeforeInser
```

**Note:**
In the example above, the top level class is the implementation of the Singleton pattern, but we also define an inner class called `Service` which is the actual Trigger Action itself. When using this pattern for query management, the `Apex_Class_Name__c` value on the `Trigger_Action__mdt` row would be `TA_Opportunity_Queries.Service`.
In the example above, the top-level class is the implementation of the Singleton pattern, but we also define an inner class called `Service` which is the actual Trigger Action itself. When using this pattern for query management, the `Apex_Class_Name__c` value on the `Trigger_Action__mdt` row would be `TA_Opportunity_Queries.Service`.

![Query Setup](images/queriesService.png)

Expand Down Expand Up @@ -389,11 +404,11 @@ The Apex Trigger Actions Framework now has support for a novel feature not found

A DML finalizer is a piece of code that executes **exactly one time** at the very end of a DML operation.

This is notably different than the final action within a given trigger context. The final configured action can be executed multiple times in case of cascading DML operations within trigger logic or when more than 200 records are included in the original DML operation. This can lead to challenges capturing logs or invoking asynchronous logic.
This is notably different than the final action within a given trigger context. The final configured action can be executed multiple times in case of cascading DML operations within trigger logic or when more than 200 records are included in the original DML operation. This can lead to challenges when capturing logs or invoking asynchronous logic.

DML finalizers can be very helpful for things such as _enqueuing a queuable operation_ or _inserting a collection of gathered logs_.

Finalizers within the Apex Trigger Actions Framework operate using many of the same mechanisms. First define a class which implements the `TriggerAction.DmlFinalizer` interface. Include public static variables/methods so that the trigger actions executing can register objects to be processed during the finalizer's execution.
Finalizers within the Apex Trigger Actions Framework operate using many of the same mechanisms. First, define a class that implements the `TriggerAction.DmlFinalizer` interface. Include public static variables/methods so that the trigger actions executing can register objects to be processed during the finalizer's execution.

```java
public with sharing class OpportunityCategoryCalculator implements Queueable, TriggerAction.DmlFinalizer {
Expand Down Expand Up @@ -471,13 +486,13 @@ The `FinalizerHandler.Context` object specified in the `TriggerAction.DmlFinaliz

#### Universal Adoption

To use a DML Finalizer, the Apex Trigger Actions Framework must be enabled on every SObject which supports triggers which will have a DML operation on it during a transaction, and enabled in all trigger contexts on those sObjects. If DML is performed on an SObject that has a trigger which does not use the framework, the system will not be able to detect when to finalize the DML operation.
To use a DML Finalizer, the Apex Trigger Actions Framework must be enabled on every SObject that supports triggers and will have a DML operation on it during a transaction, and enabled in all trigger contexts on those sObjects. If DML is performed on an SObject that has a trigger that does not use the framework, the system will not be able to detect when to finalize the DML operation.

#### Offsetting the Number of DML Rows

Detecting when to finalize the operation requires knowledge of the total number of records passed to the DML operation. Unfortunately, there is no bulletproof way of how to do this currently in Apex; the best thing we can do is to rely on `Limits.getDmlRows()` to infer the number of records passed to the DML operation.
Detecting when to finalize the operation requires knowledge of the total number of records passed to the DML operation. Unfortunately, there is no bulletproof way to do this currently in Apex; the best thing we can do is to rely on `Limits.getDmlRows()` to infer the number of records passed to the DML operation.

This works in most cases, but certain operations such as setting a `System.Savepoint` consume a DML row, and there are certain sObjects where triggers are not supported like `CaseTeamMember` which can throw off the counts and remove our ability to detect when to finalize. In order to avoid this problem, use the `TriggerBase.offsetExistingDmlRows()` method before calling the first DML operation within your Apex.
This works in most cases, but certain operations (such as setting a `System.Savepoint`) consume a DML row, and there are certain sObjects where triggers are not supported like `CaseTeamMember` which can throw off the counts and remove our ability to detect when to finalize. In order to avoid this problem, use the `TriggerBase.offsetExistingDmlRows()` method before calling the first DML operation within your Apex.

```java
Savepoint sp = Database.setSavepoint(); // adds to Limits.getDmlRows()
Expand Down
65 changes: 33 additions & 32 deletions sfdx-project.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
{
"packageDirectories": [
{
"path": "trigger-actions-framework",
"default": true,
"package": "Trigger Actions Framework",
"versionName": "Version 0.2",
"versionNumber": "0.2.1.NEXT"
}
],
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "56.0",
"packageAliases": {
"Trigger Actions Framework": "0Ho3h0000008Om4CAE",
"Trigger Actions Framework@0.1.0-1": "04t3h000004VaHaAAK",
"Trigger Actions Framework@0.1.2": "04t3h000004VaIdAAK",
"Trigger Actions Framework@0.1.3": "04t3h000004VaInAAK",
"Trigger Actions Framework@0.1.4-0": "04t3h000004VaJWAA0",
"Trigger Actions Framework@0.1.4-1": "04t3h000004VaJbAAK",
"Trigger Actions Framework@0.1.5-0": "04t3h000004VaJqAAK",
"Trigger Actions Framework@0.1.6": "04t3h000004VaLDAA0",
"Trigger Actions Framework@0.1.7": "04t3h000004VaLIAA0",
"Trigger Actions Framework@0.1.8": "04t3h000004VaLNAA0",
"Trigger Actions Framework@0.1.9": "04t3h000004VaLSAA0",
"Trigger Actions Framework@0.2.0": "04t3h000004VaLmAAK",
"Trigger Actions Framework@0.2.1": "04t3h000004VaVFAA0",
"Trigger Actions Framework@0.2.2-1": "04t3h000004OYREAA4",
"Trigger Actions Framework@0.2.3-1": "04t3h000004OYTKAA4",
"Trigger Actions Framework@0.2.5-1": "04t3h000004OYUDAA4",
"Trigger Actions Framework@0.2.6-1": "04t3h000004juLaAAI"
}
}
"packageDirectories": [
{
"path": "trigger-actions-framework",
"default": true,
"package": "Trigger Actions Framework",
"versionName": "Version 0.2",
"versionNumber": "0.2.8.NEXT"
}
],
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "59.0",
"packageAliases": {
"Trigger Actions Framework": "0Ho3h0000008Om4CAE",
"Trigger Actions Framework@0.1.0-1": "04t3h000004VaHaAAK",
"Trigger Actions Framework@0.1.2": "04t3h000004VaIdAAK",
"Trigger Actions Framework@0.1.3": "04t3h000004VaInAAK",
"Trigger Actions Framework@0.1.4-0": "04t3h000004VaJWAA0",
"Trigger Actions Framework@0.1.4-1": "04t3h000004VaJbAAK",
"Trigger Actions Framework@0.1.5-0": "04t3h000004VaJqAAK",
"Trigger Actions Framework@0.1.6": "04t3h000004VaLDAA0",
"Trigger Actions Framework@0.1.7": "04t3h000004VaLIAA0",
"Trigger Actions Framework@0.1.8": "04t3h000004VaLNAA0",
"Trigger Actions Framework@0.1.9": "04t3h000004VaLSAA0",
"Trigger Actions Framework@0.2.0": "04t3h000004VaLmAAK",
"Trigger Actions Framework@0.2.1": "04t3h000004VaVFAA0",
"Trigger Actions Framework@0.2.2-1": "04t3h000004OYREAA4",
"Trigger Actions Framework@0.2.3-1": "04t3h000004OYTKAA4",
"Trigger Actions Framework@0.2.5-1": "04t3h000004OYUDAA4",
"Trigger Actions Framework@0.2.6-1": "04t3h000004juLaAAI",
"Trigger Actions Framework@0.2.8": "04t3h000004juLuAAI"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
Copyright 2023 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
@SuppressWarnings('PMD.ApexDoc')
public with sharing class FlowChangeEventHeader {
@InvocableVariable
@AuraEnabled
public String entityName;
@InvocableVariable
@AuraEnabled
public List<String> recordIds;
@InvocableVariable
@AuraEnabled
public String changeType;
@InvocableVariable
@AuraEnabled
public String changeOrigin;
@InvocableVariable
@AuraEnabled
public String transactionKey;
@InvocableVariable
@AuraEnabled
public Integer sequenceNumber;
@InvocableVariable
@AuraEnabled
public Long commitTimestamp;
@InvocableVariable
@AuraEnabled
public String commitUser;
@InvocableVariable
@AuraEnabled
public Long commitNumber;
@InvocableVariable
@AuraEnabled
public List<String> nulledFields;
@InvocableVariable
@AuraEnabled
public List<String> diffFields;
@InvocableVariable
@AuraEnabled
public List<String> changedFields;

public FlowChangeEventHeader(EventBus.ChangeEventHeader header) {
this.entityName = header.entityName;
this.recordIds = header.recordIds;
this.changeType = header.changeType;
this.changeOrigin = header.changeOrigin;
this.transactionKey = header.transactionKey;
this.sequenceNumber = header.sequenceNumber;
this.commitTimestamp = header.commitTimestamp;
this.commitUser = header.commitUser;
this.commitNumber = header.commitNumber;
this.nulledFields = header.nulledFields;
this.diffFields = header.diffFields;
this.changedFields = header.changedFields;
}

public Boolean equals(Object obj) {
if (obj != null && obj instanceof FlowChangeEventHeader) {
FlowChangeEventHeader other = (FlowChangeEventHeader) obj;
return this.entityName == other.entityName &&
this.recordIds == other.recordIds &&
this.changeType == other.changeType &&
this.changeOrigin == other.changeOrigin &&
this.transactionKey == other.transactionKey &&
this.sequenceNumber == other.sequenceNumber &&
this.commitTimestamp == other.commitTimestamp &&
this.commitUser == other.commitUser &&
this.commitNumber == other.commitNumber &&
this.nulledFields == other.nulledFields &&
this.diffFields == other.diffFields &&
this.changedFields == other.changedFields;
}
return false;
}

public Integer hashCode() {
return JSON.serialize(this).hashCode();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>58.0</apiVersion>
<status>Active</status>
</ApexClass>
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
Copyright 2023 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

@IsTest(isParallel=true)
@SuppressWarnings('PMD.ApexDoc')
private with sharing class FlowChangeEventHeaderTest {
private static FlowChangeEventHeader header = new FlowChangeEventHeader(
new EventBus.ChangeEventHeader()
);

@IsTest
private static void shouldBeAbleToConstruct() {
Assert.isNotNull(header, 'Unable to construct a FlowChangeEventHeader');
}

@IsTest
private static void shouldBeAbleToGenerateHashCode() {
Assert.isNotNull(header.hashCode(), 'Hash code was not generated');
}

@IsTest
private static void shouldBeAbleToCompare() {
FlowChangeEventHeader other = new FlowChangeEventHeader(
new EventBus.ChangeEventHeader()
);
other.changeType = 'CREATE';

Assert.areEqual(
new FlowChangeEventHeader(new EventBus.ChangeEventHeader()),
header,
'Unable to detect identical FlowChangeEventHeader objects'
);
Assert.areNotEqual(
header,
other,
'Unable to detect different FlowChangeEventHeader objects'
);
Assert.areNotEqual(
header,
null,
'Unable to detect difference between FlowChangeEventHeader and null'
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>58.0</apiVersion>
<status>Active</status>
</ApexClass>
Loading

0 comments on commit 5d3fb5d

Please sign in to comment.