Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ _An Extensible Rule Engine capable of conducting static analysis on the metadata
| **Unsafe Running Context** ([`UnsafeRunningContext`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/UnsafeRunningContext.ts)) | This flow is configured to run in System Mode without Sharing. This system context grants all running users the permission to view and edit all data in your org. Running a flow in System Mode without Sharing can lead to unsafe data access. |
| **Same Record Field Updates** ([`SameRecordFieldUpdates`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/SameRecordFieldUpdates.ts)) | Much like triggers, before contexts can update the same record by accessing the trigger variables `$Record` without needing to invoke a DML. |
| **Trigger Order** ([`TriggerOrder`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/TriggerOrder.ts)) | Guarantee your flow execution order with the Trigger Order property introduced in Spring '22 |
| **Cyclomatic Complexity** ([`CyclomaticComplexity`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/TriggerOrder.ts)) | The number of loops and decision rules, plus the number of decisions. Use a combination of 1) subflows and 2) breaking flows into multiple concise trigger ordered flows, to reduce the cyclomatic complexity within a single flow, ensuring maintainability and simplicity. |
| **Cyclomatic Complexity** ([`CyclomaticComplexity`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/CyclomaticComplexity.ts)) | The number of loops and decision rules, plus the number of decisions. Use a combination of 1) subflows and 2) breaking flows into multiple concise trigger ordered flows, to reduce the cyclomatic complexity within a single flow, ensuring maintainability and simplicity. |
| **Recursive After Update** ([`RecursiveAfterUpdate`](https://github.com/Lightning-Flow-Scanner/lightning-flow-scanner-core/tree/master/src/main/rules/RecursiveAfterUpdate.ts)) | After updates are meant to be used for record modifications that are not the same record that triggered the flow. Using after updates on the same record can lead to recursion and unexpected behavior. Consider using before save flows for same record updates. |

## Core Functions

Expand Down
2 changes: 1 addition & 1 deletion src/main/models/ResultDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class ResultDetails {
this.details = {
locationX: element.locationX,
locationY: element.locationY,
connectsTo: element.connectors.map((connector) => connector.reference),
connectsTo: element.connectors?.map((connector) => connector.reference),
};
}
if (violation.metaType === "attribute") {
Expand Down
96 changes: 96 additions & 0 deletions src/main/rules/RecursiveAfterUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as core from "../internals/internals";
import { RuleCommon } from "../models/RuleCommon";

export class RecursiveAfterUpdate extends RuleCommon implements core.IRuleDefinition {
protected qualifiedRecordTriggerTypes: Set<string> = new Set<string>([
"Create",
"Update",
"CreateAndUpdate",
]);

constructor() {
super(
{
name: "RecursiveAfterUpdate",
label: "Recursive After Update",
description:
"After updates are meant to be used for record modifications that are not the same record that triggered the flow. Using after updates on the same record can lead to recursion and unexpected behavior. Consider using before save flows for same record updates.",
supportedTypes: [...core.FlowType.backEndTypes],
docRefs: [
{
label: "Learn about same record field updates",
path: "https://architect.salesforce.com/decision-guides/trigger-automation#Same_Record_Field_Updates",
},
],
isConfigurable: false,
autoFixable: false,
},
{ severity: "warning" }
);
}

public execute(flow: core.Flow): core.RuleResult {
const results: core.ResultDetails[] = [];

const isAfterSave = flow.start?.triggerType === "RecordAfterSave";
const isQualifiedTriggerTypes = this.qualifiedRecordTriggerTypes.has(
flow.start?.recordTriggerType
);

if (!isAfterSave || !isQualifiedTriggerTypes) {
return new core.RuleResult(this, results);
}

const potentialElements = flow.elements?.filter(
(node) => node.subtype === "recordUpdates"
) as core.FlowNode[];

if (potentialElements == null || typeof potentialElements[Symbol.iterator] !== "function") {
return new core.RuleResult(this, results);
}

for (const node of potentialElements) {
if (
typeof node.element === "object" &&
"inputReference" in node.element &&
node.element.inputReference === "$Record"
) {
results.push(new core.ResultDetails(node));
}
}

// do another search for record updates from lookups with the same object type
// and check whether there is references on record updates

const lookupElementsWithTheSameObjectType = flow.elements
?.filter(
(node) =>
node.subtype === "recordLookups" &&
typeof node.element === "object" &&
"object" in node.element &&
flow.start.object === node.element["object"]
)
?.map((node) => {
return node.name;
});

if (
lookupElementsWithTheSameObjectType == null ||
typeof lookupElementsWithTheSameObjectType[Symbol.iterator] !== "function"
) {
return new core.RuleResult(this, results);
}

for (const node of potentialElements) {
if (
typeof node.element === "object" &&
"inputReference" in node.element &&
lookupElementsWithTheSameObjectType.includes(node.element.inputReference as string)
) {
results.push(new core.ResultDetails(node));
}
}

return new core.RuleResult(this, results);
}
}
6 changes: 5 additions & 1 deletion src/main/rules/SameRecordFieldUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import * as core from "../internals/internals";
import { RuleCommon } from "../models/RuleCommon";

export class SameRecordFieldUpdates extends RuleCommon implements core.IRuleDefinition {
protected qualifiedRecordTriggerTypes: Set<string> = new Set<string>(["Create", "Update"]);
protected qualifiedRecordTriggerTypes: Set<string> = new Set<string>([
"Create",
"Update",
"CreateAndUpdate",
]);

constructor() {
super(
Expand Down
184 changes: 184 additions & 0 deletions tests/RecursiveAfterUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { ParsedFlow } from "../src/main/models/ParsedFlow";
import { RuleResult, Flow, scan } from "../src";

import { describe, it, expect } from "@jest/globals";
import { RecursiveAfterUpdate } from "../src/main/rules/RecursiveAfterUpdate";

describe("RecursiveAfterUpdate", () => {
const rule = new RecursiveAfterUpdate();

describe("e2e", () => {
it("should not trigger from default configuration on store", () => {
const testData: ParsedFlow = {
flow: {
start: { recordTriggerType: "CreateAndUpdate", triggerType: "RecordAfterSave" },
elements: [
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "$Record",
},
},
],
type: "AutoLaunchedFlow",
},
} as Partial<ParsedFlow> as ParsedFlow;
const ruleConfig = {
rules: {},
exceptions: {},
};
const results = scan([testData], ruleConfig);
const scanResults = results.pop();

expect(
scanResults?.ruleResults.some((rule) => rule.ruleName === "RecursiveAfterUpdate")
).toBeFalsy();
});

it("should trigger from when opt-in", () => {
const testData: ParsedFlow = {
flow: {
start: { recordTriggerType: "CreateAndUpdate", triggerType: "RecordAfterSave" },
elements: [
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "$Record",
},
},
],
type: "AutoLaunchedFlow",
},
} as Partial<ParsedFlow> as ParsedFlow;
const ruleConfig = {
rules: {
RecursiveAfterUpdate: {
severity: "warning",
},
},
exceptions: {},
};
const results = scan([testData], ruleConfig);
const scanResults = results.pop();

expect(
scanResults?.ruleResults.some((rule) => rule.ruleName === "RecursiveAfterUpdate")
).toBeTruthy();
expect(
scanResults?.ruleResults?.find((rule) => rule.ruleName === "RecursiveAfterUpdate")?.occurs
).toBeTruthy();
});
});

describe("units", () => {
describe("when using inputReference as $Record", () => {
it("should trigger when matching record input reference", () => {
const testData: ParsedFlow = {
flow: {
start: { recordTriggerType: "CreateAndUpdate", triggerType: "RecordAfterSave" },
elements: [
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "$Record",
},
},
],
},
} as Partial<ParsedFlow> as ParsedFlow;

const ruleResult: RuleResult = rule.execute(testData.flow as Flow);
expect(ruleResult.occurs).toBe(true);
});

it("should not trigger when not matching record input reference", () => {
const testData: ParsedFlow = {
flow: {
start: { recordTriggerType: "CreateAndUpdate", triggerType: "RecordAfterSave" },
elements: [
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "SomethingElse",
},
},
],
},
} as Partial<ParsedFlow> as ParsedFlow;

const ruleResult: RuleResult = rule.execute(testData.flow as Flow);
expect(ruleResult.occurs).toBe(false);
});
});

describe("when using inputReference as lookup element", () => {
it("should trigger when inferred type matches inputReference", () => {
const testData: ParsedFlow = {
flow: {
start: {
recordTriggerType: "CreateAndUpdate",
triggerType: "RecordAfterSave",
object: "Case",
},
elements: [
{
subtype: "recordLookups",
metaType: "node",
element: {
object: "Case",
},
name: "lookupElement",
},
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "lookupElement",
},
},
],
},
} as Partial<ParsedFlow> as ParsedFlow;

const ruleResult: RuleResult = rule.execute(testData.flow as Flow);
expect(ruleResult.occurs).toBe(true);
});

it("should not trigger when no matching inferred reference", () => {
const testData: ParsedFlow = {
flow: {
start: {
recordTriggerType: "CreateAndUpdate",
triggerType: "RecordAfterSave",
object: "Case",
},
elements: [
{
subtype: "recordLookups",
metaType: "node",
element: {
object: "Case",
},
name: "lookupElement",
},
{
subtype: "recordUpdates",
metaType: "node",
element: {
inputReference: "SomethingElse",
},
},
],
},
} as Partial<ParsedFlow> as ParsedFlow;

const ruleResult: RuleResult = rule.execute(testData.flow as Flow);
expect(ruleResult.occurs).toBe(false);
});
});
});
});