@salesforce/afv-skills 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +330 -0
- package/README.md +466 -0
- package/package.json +23 -0
- package/skills/apex-class/SKILL.md +253 -0
- package/skills/apex-class/examples/AccountDeduplicationBatch.cls +148 -0
- package/skills/apex-class/examples/AccountSelector.cls +193 -0
- package/skills/apex-class/examples/AccountService.cls +201 -0
- package/skills/apex-class/templates/abstract.cls +128 -0
- package/skills/apex-class/templates/batch.cls +125 -0
- package/skills/apex-class/templates/domain.cls +102 -0
- package/skills/apex-class/templates/dto.cls +108 -0
- package/skills/apex-class/templates/exception.cls +51 -0
- package/skills/apex-class/templates/interface.cls +25 -0
- package/skills/apex-class/templates/queueable.cls +92 -0
- package/skills/apex-class/templates/schedulable.cls +75 -0
- package/skills/apex-class/templates/selector.cls +92 -0
- package/skills/apex-class/templates/service.cls +69 -0
- package/skills/apex-class/templates/utility.cls +97 -0
- package/skills/apex-test-class/SKILL.md +101 -0
- package/skills/apex-test-class/references/assertion-patterns.md +209 -0
- package/skills/apex-test-class/references/async-testing.md +276 -0
- package/skills/apex-test-class/references/mocking-patterns.md +219 -0
- package/skills/apex-test-class/references/test-data-factory.md +176 -0
- package/skills/deployment-readiness-check/SKILL.md +257 -0
- package/skills/deployment-readiness-check/assets/deployment_checklist.md +286 -0
- package/skills/deployment-readiness-check/references/rollback_procedures.md +308 -0
- package/skills/deployment-readiness-check/scripts/check_metadata.sh +207 -0
- package/skills/salesforce-custom-application/SKILL.md +211 -0
- package/skills/salesforce-custom-field/SKILL.md +505 -0
- package/skills/salesforce-custom-lightning-type/SKILL.md +157 -0
- package/skills/salesforce-custom-object/SKILL.md +238 -0
- package/skills/salesforce-custom-tab/SKILL.md +78 -0
- package/skills/salesforce-experience-site/SKILL.md +178 -0
- package/skills/salesforce-flexipage/SKILL.md +445 -0
- package/skills/salesforce-flow/SKILL.md +368 -0
- package/skills/salesforce-fragment/SKILL.md +42 -0
- package/skills/salesforce-lightning-app-build/SKILL.md +254 -0
- package/skills/salesforce-list-view/SKILL.md +216 -0
- package/skills/salesforce-validation-rule/SKILL.md +72 -0
- package/skills/salesforce-web-app-creating-records/SKILL.md +84 -0
- package/skills/salesforce-web-app-feature/SKILL.md +70 -0
- package/skills/salesforce-web-app-list-and-create-records/SKILL.md +36 -0
- package/skills/salesforce-web-application/SKILL.md +34 -0
- package/skills/trigger-refactor-pipeline/SKILL.md +191 -0
- package/skills/trigger-refactor-pipeline/assets/test_template.apex +321 -0
- package/skills/trigger-refactor-pipeline/references/handler_patterns.md +442 -0
- package/skills/trigger-refactor-pipeline/scripts/analyze_trigger.py +258 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Trigger Handler Patterns Reference
|
|
2
|
+
|
|
3
|
+
This guide covers common patterns for refactoring Salesforce triggers into handler classes with bulk-safe operations.
|
|
4
|
+
|
|
5
|
+
## Pattern 1: Simple Handler Class
|
|
6
|
+
|
|
7
|
+
**Best for**: Triggers with 1-3 contexts and straightforward logic.
|
|
8
|
+
|
|
9
|
+
### Structure
|
|
10
|
+
|
|
11
|
+
```apex
|
|
12
|
+
public class OpportunityTriggerHandler {
|
|
13
|
+
|
|
14
|
+
public void beforeInsert(List<Opportunity> newRecords) {
|
|
15
|
+
validateClosedWonAmount(newRecords);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
|
19
|
+
updateDescriptionOnStageChange(newRecords, oldMap);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
|
23
|
+
createTasksForClosedWon(newRecords, oldMap);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Private helper methods below
|
|
27
|
+
private void validateClosedWonAmount(List<Opportunity> opportunities) {
|
|
28
|
+
for (Opportunity opp : opportunities) {
|
|
29
|
+
if (opp.StageName == 'Closed Won' &&
|
|
30
|
+
(opp.Amount == null || opp.Amount < 1000)) {
|
|
31
|
+
opp.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private void updateDescriptionOnStageChange(
|
|
37
|
+
List<Opportunity> newRecords,
|
|
38
|
+
Map<Id, Opportunity> oldMap
|
|
39
|
+
) {
|
|
40
|
+
for (Opportunity opp : newRecords) {
|
|
41
|
+
Opportunity oldOpp = oldMap.get(opp.Id);
|
|
42
|
+
if (opp.StageName != oldOpp.StageName) {
|
|
43
|
+
opp.Description = 'Stage changed from ' +
|
|
44
|
+
oldOpp.StageName + ' to ' + opp.StageName;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private void createTasksForClosedWon(
|
|
50
|
+
List<Opportunity> newRecords,
|
|
51
|
+
Map<Id, Opportunity> oldMap
|
|
52
|
+
) {
|
|
53
|
+
List<Task> tasksToInsert = new List<Task>();
|
|
54
|
+
|
|
55
|
+
for (Opportunity opp : newRecords) {
|
|
56
|
+
Opportunity oldOpp = oldMap.get(opp.Id);
|
|
57
|
+
|
|
58
|
+
// Check if stage changed to Closed Won
|
|
59
|
+
if (opp.StageName == 'Closed Won' &&
|
|
60
|
+
oldOpp.StageName != 'Closed Won') {
|
|
61
|
+
|
|
62
|
+
tasksToInsert.add(new Task(
|
|
63
|
+
WhatId = opp.Id,
|
|
64
|
+
OwnerId = opp.OwnerId,
|
|
65
|
+
Subject = 'Send thank-you',
|
|
66
|
+
Status = 'Not Started',
|
|
67
|
+
Priority = 'Normal',
|
|
68
|
+
ActivityDate = Date.today()
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Bulk DML outside loop
|
|
74
|
+
if (!tasksToInsert.isEmpty()) {
|
|
75
|
+
insert tasksToInsert;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Trigger Delegation
|
|
82
|
+
|
|
83
|
+
```apex
|
|
84
|
+
trigger OpportunityTrigger on Opportunity (
|
|
85
|
+
before insert, before update, after update
|
|
86
|
+
) {
|
|
87
|
+
OpportunityTriggerHandler handler = new OpportunityTriggerHandler();
|
|
88
|
+
|
|
89
|
+
if (Trigger.isBefore) {
|
|
90
|
+
if (Trigger.isInsert) {
|
|
91
|
+
handler.beforeInsert(Trigger.new);
|
|
92
|
+
} else if (Trigger.isUpdate) {
|
|
93
|
+
handler.beforeUpdate(Trigger.new, Trigger.oldMap);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Trigger.isAfter && Trigger.isUpdate) {
|
|
98
|
+
handler.afterUpdate(Trigger.new, Trigger.oldMap);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Pattern 2: Handler with Database Methods
|
|
104
|
+
|
|
105
|
+
**Best for**: When you need granular error handling and partial success.
|
|
106
|
+
|
|
107
|
+
### Key Features
|
|
108
|
+
|
|
109
|
+
- Uses `Database.insert()` instead of `insert` for partial saves
|
|
110
|
+
- Returns `Database.SaveResult` for error handling
|
|
111
|
+
- Logs errors without stopping execution
|
|
112
|
+
|
|
113
|
+
### Example
|
|
114
|
+
|
|
115
|
+
```apex
|
|
116
|
+
private void createTasksForClosedWon(
|
|
117
|
+
List<Opportunity> newRecords,
|
|
118
|
+
Map<Id, Opportunity> oldMap
|
|
119
|
+
) {
|
|
120
|
+
List<Task> tasksToInsert = new List<Task>();
|
|
121
|
+
|
|
122
|
+
for (Opportunity opp : newRecords) {
|
|
123
|
+
Opportunity oldOpp = oldMap.get(opp.Id);
|
|
124
|
+
|
|
125
|
+
if (opp.StageName == 'Closed Won' &&
|
|
126
|
+
oldOpp.StageName != 'Closed Won') {
|
|
127
|
+
|
|
128
|
+
tasksToInsert.add(new Task(
|
|
129
|
+
WhatId = opp.Id,
|
|
130
|
+
OwnerId = opp.OwnerId,
|
|
131
|
+
Subject = 'Send thank-you',
|
|
132
|
+
Status = 'Not Started',
|
|
133
|
+
Priority = 'Normal',
|
|
134
|
+
ActivityDate = Date.today()
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!tasksToInsert.isEmpty()) {
|
|
140
|
+
Database.SaveResult[] results = Database.insert(tasksToInsert, false);
|
|
141
|
+
|
|
142
|
+
// Log errors without stopping execution
|
|
143
|
+
for (Integer i = 0; i < results.size(); i++) {
|
|
144
|
+
if (!results[i].isSuccess()) {
|
|
145
|
+
System.debug('Failed to create task: ' + results[i].getErrors());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Pattern 3: Handler with Maps for Lookups
|
|
153
|
+
|
|
154
|
+
**Best for**: When you need to query related records for processing.
|
|
155
|
+
|
|
156
|
+
### Key Features
|
|
157
|
+
|
|
158
|
+
- Pre-queries related records using Sets
|
|
159
|
+
- Uses Maps for O(1) lookups instead of nested loops
|
|
160
|
+
- Avoids SOQL in loops
|
|
161
|
+
|
|
162
|
+
### Example
|
|
163
|
+
|
|
164
|
+
```apex
|
|
165
|
+
private void enrichOpportunitiesWithAccountData(List<Opportunity> opportunities) {
|
|
166
|
+
// Collect Account IDs
|
|
167
|
+
Set<Id> accountIds = new Set<Id>();
|
|
168
|
+
for (Opportunity opp : opportunities) {
|
|
169
|
+
if (opp.AccountId != null) {
|
|
170
|
+
accountIds.add(opp.AccountId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Single SOQL query outside loop
|
|
175
|
+
Map<Id, Account> accountMap = new Map<Id, Account>([
|
|
176
|
+
SELECT Id, Name, Industry, AnnualRevenue
|
|
177
|
+
FROM Account
|
|
178
|
+
WHERE Id IN :accountIds
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
// Use Map for O(1) lookup
|
|
182
|
+
for (Opportunity opp : opportunities) {
|
|
183
|
+
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
|
|
184
|
+
Account acc = accountMap.get(opp.AccountId);
|
|
185
|
+
// Process with account data
|
|
186
|
+
opp.Description = 'Account Industry: ' + acc.Industry;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Pattern 4: Unified Handler Framework
|
|
193
|
+
|
|
194
|
+
**Best for**: Complex triggers with many contexts and cross-cutting concerns.
|
|
195
|
+
|
|
196
|
+
### Structure
|
|
197
|
+
|
|
198
|
+
```apex
|
|
199
|
+
public abstract class TriggerHandler {
|
|
200
|
+
|
|
201
|
+
protected Boolean isBefore;
|
|
202
|
+
protected Boolean isAfter;
|
|
203
|
+
protected Boolean isInsert;
|
|
204
|
+
protected Boolean isUpdate;
|
|
205
|
+
protected Boolean isDelete;
|
|
206
|
+
protected Boolean isUndelete;
|
|
207
|
+
|
|
208
|
+
public void run() {
|
|
209
|
+
isBefore = Trigger.isBefore;
|
|
210
|
+
isAfter = Trigger.isAfter;
|
|
211
|
+
isInsert = Trigger.isInsert;
|
|
212
|
+
isUpdate = Trigger.isUpdate;
|
|
213
|
+
isDelete = Trigger.isDelete;
|
|
214
|
+
isUndelete = Trigger.isUndelete;
|
|
215
|
+
|
|
216
|
+
if (isBefore) {
|
|
217
|
+
if (isInsert) beforeInsert();
|
|
218
|
+
if (isUpdate) beforeUpdate();
|
|
219
|
+
if (isDelete) beforeDelete();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (isAfter) {
|
|
223
|
+
if (isInsert) afterInsert();
|
|
224
|
+
if (isUpdate) afterUpdate();
|
|
225
|
+
if (isDelete) afterDelete();
|
|
226
|
+
if (isUndelete) afterUndelete();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
protected virtual void beforeInsert() {}
|
|
231
|
+
protected virtual void beforeUpdate() {}
|
|
232
|
+
protected virtual void beforeDelete() {}
|
|
233
|
+
protected virtual void afterInsert() {}
|
|
234
|
+
protected virtual void afterUpdate() {}
|
|
235
|
+
protected virtual void afterDelete() {}
|
|
236
|
+
protected virtual void afterUndelete() {}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Concrete Handler
|
|
241
|
+
|
|
242
|
+
```apex
|
|
243
|
+
public class OpportunityTriggerHandler extends TriggerHandler {
|
|
244
|
+
|
|
245
|
+
private List<Opportunity> newRecords;
|
|
246
|
+
private List<Opportunity> oldRecords;
|
|
247
|
+
private Map<Id, Opportunity> newMap;
|
|
248
|
+
private Map<Id, Opportunity> oldMap;
|
|
249
|
+
|
|
250
|
+
public OpportunityTriggerHandler() {
|
|
251
|
+
this.newRecords = (List<Opportunity>) Trigger.new;
|
|
252
|
+
this.oldRecords = (List<Opportunity>) Trigger.old;
|
|
253
|
+
this.newMap = (Map<Id, Opportunity>) Trigger.newMap;
|
|
254
|
+
this.oldMap = (Map<Id, Opportunity>) Trigger.oldMap;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
protected override void beforeInsert() {
|
|
258
|
+
validateClosedWonAmount();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
protected override void beforeUpdate() {
|
|
262
|
+
updateDescriptionOnStageChange();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
protected override void afterUpdate() {
|
|
266
|
+
createTasksForClosedWon();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Private helper methods omitted for brevity
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Trigger Delegation
|
|
274
|
+
|
|
275
|
+
```apex
|
|
276
|
+
trigger OpportunityTrigger on Opportunity (
|
|
277
|
+
before insert, before update, after update
|
|
278
|
+
) {
|
|
279
|
+
new OpportunityTriggerHandler().run();
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Best Practices
|
|
284
|
+
|
|
285
|
+
### 1. Bulkification
|
|
286
|
+
|
|
287
|
+
Always process records in collections:
|
|
288
|
+
|
|
289
|
+
```apex
|
|
290
|
+
// ✓ Good: Collect DML outside loop
|
|
291
|
+
List<Task> tasksToInsert = new List<Task>();
|
|
292
|
+
for (Opportunity opp : opportunities) {
|
|
293
|
+
tasksToInsert.add(new Task(...));
|
|
294
|
+
}
|
|
295
|
+
if (!tasksToInsert.isEmpty()) {
|
|
296
|
+
insert tasksToInsert;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ✗ Bad: DML inside loop
|
|
300
|
+
for (Opportunity opp : opportunities) {
|
|
301
|
+
insert new Task(...); // SOQL/DML in loop!
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 2. Defensive Null Checks
|
|
306
|
+
|
|
307
|
+
```apex
|
|
308
|
+
// ✓ Good: Check for null before accessing
|
|
309
|
+
if (opp.AccountId != null && accountMap.containsKey(opp.AccountId)) {
|
|
310
|
+
Account acc = accountMap.get(opp.AccountId);
|
|
311
|
+
// Safe to use acc
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ✗ Bad: Assumes data exists
|
|
315
|
+
Account acc = accountMap.get(opp.AccountId);
|
|
316
|
+
String industry = acc.Industry; // NullPointerException risk
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### 3. Clear Method Names
|
|
320
|
+
|
|
321
|
+
```apex
|
|
322
|
+
// ✓ Good: Descriptive, verb-noun pattern
|
|
323
|
+
private void validateClosedWonAmount(List<Opportunity> opportunities)
|
|
324
|
+
private void createTasksForClosedWon(List<Opportunity> opportunities)
|
|
325
|
+
|
|
326
|
+
// ✗ Bad: Vague or unclear
|
|
327
|
+
private void validate(List<Opportunity> opportunities)
|
|
328
|
+
private void doStuff(List<Opportunity> opportunities)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### 4. Single Responsibility
|
|
332
|
+
|
|
333
|
+
Each handler method should do one thing:
|
|
334
|
+
|
|
335
|
+
```apex
|
|
336
|
+
// ✓ Good: Separate concerns
|
|
337
|
+
private void validateClosedWonAmount(List<Opportunity> opportunities)
|
|
338
|
+
private void validateRequiredFields(List<Opportunity> opportunities)
|
|
339
|
+
private void calculateDiscounts(List<Opportunity> opportunities)
|
|
340
|
+
|
|
341
|
+
// ✗ Bad: One method does everything
|
|
342
|
+
private void processOpportunities(List<Opportunity> opportunities)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### 5. Test Boundaries
|
|
346
|
+
|
|
347
|
+
Structure code to make testing easier:
|
|
348
|
+
|
|
349
|
+
```apex
|
|
350
|
+
// ✓ Good: Public method for testing, private for implementation
|
|
351
|
+
@TestVisible
|
|
352
|
+
private void createTasksForClosedWon(
|
|
353
|
+
List<Opportunity> newRecords,
|
|
354
|
+
Map<Id, Opportunity> oldMap
|
|
355
|
+
) {
|
|
356
|
+
// Implementation
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Deployment Order
|
|
361
|
+
|
|
362
|
+
When deploying refactored triggers:
|
|
363
|
+
|
|
364
|
+
1. Deploy handler class(es) first
|
|
365
|
+
2. Update trigger to use handler
|
|
366
|
+
3. Deploy test class
|
|
367
|
+
4. Run all tests before production deployment
|
|
368
|
+
5. Monitor debug logs for 24-48 hours after production deployment
|
|
369
|
+
|
|
370
|
+
## Rollback Strategy
|
|
371
|
+
|
|
372
|
+
Keep the old trigger code commented out or in version control:
|
|
373
|
+
|
|
374
|
+
```apex
|
|
375
|
+
trigger OpportunityTrigger on Opportunity (...) {
|
|
376
|
+
// New handler approach
|
|
377
|
+
new OpportunityTriggerHandler().run();
|
|
378
|
+
|
|
379
|
+
/* OLD CODE - REMOVE AFTER 1 WEEK IF NO ISSUES
|
|
380
|
+
if (Trigger.isBefore && Trigger.isInsert) {
|
|
381
|
+
for (Opportunity o : Trigger.new) {
|
|
382
|
+
// old logic
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
*/
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Common Pitfalls
|
|
390
|
+
|
|
391
|
+
### Pitfall 1: Recursive Triggers
|
|
392
|
+
|
|
393
|
+
**Problem**: Handler calls DML which triggers the same trigger again.
|
|
394
|
+
|
|
395
|
+
**Solution**: Use static flag to prevent recursion:
|
|
396
|
+
|
|
397
|
+
```apex
|
|
398
|
+
public class OpportunityTriggerHandler {
|
|
399
|
+
private static Boolean isExecuting = false;
|
|
400
|
+
|
|
401
|
+
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
|
|
402
|
+
if (isExecuting) return;
|
|
403
|
+
|
|
404
|
+
isExecuting = true;
|
|
405
|
+
try {
|
|
406
|
+
// Your logic here
|
|
407
|
+
} finally {
|
|
408
|
+
isExecuting = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Pitfall 2: Mixed Context Logic
|
|
415
|
+
|
|
416
|
+
**Problem**: Before-context logic mixed with after-context logic.
|
|
417
|
+
|
|
418
|
+
**Solution**: Keep context methods separate and focused:
|
|
419
|
+
|
|
420
|
+
```apex
|
|
421
|
+
// ✓ Good: Separate methods per context
|
|
422
|
+
public void beforeUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
|
423
|
+
public void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
|
424
|
+
|
|
425
|
+
// ✗ Bad: Mixed logic in one method
|
|
426
|
+
public void handleUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Pitfall 3: Over-Engineering
|
|
430
|
+
|
|
431
|
+
**Problem**: Using complex framework for simple triggers.
|
|
432
|
+
|
|
433
|
+
**Solution**: Choose the right pattern for your complexity:
|
|
434
|
+
- 1-3 contexts with simple logic → Simple Handler (Pattern 1)
|
|
435
|
+
- 3-5 contexts with moderate complexity → Handler with Database Methods (Pattern 2)
|
|
436
|
+
- 5+ contexts with cross-cutting concerns → Unified Framework (Pattern 4)
|
|
437
|
+
|
|
438
|
+
## Additional Resources
|
|
439
|
+
|
|
440
|
+
- [Apex Developer Guide: Trigger and Bulk Request Best Practices](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_bulk_requests.htm)
|
|
441
|
+
- [Apex Enterprise Patterns](https://github.com/apex-enterprise-patterns)
|
|
442
|
+
- [Trigger Framework Comparison](https://github.com/kevinohara80/sfdc-trigger-framework)
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Analyze Salesforce Apex triggers for common anti-patterns.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python analyze_trigger.py <TriggerName>
|
|
7
|
+
|
|
8
|
+
Requirements:
|
|
9
|
+
- Salesforce CLI authenticated to target org
|
|
10
|
+
- Python 3.9+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import subprocess
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import List, Dict, Tuple
|
|
18
|
+
|
|
19
|
+
class TriggerAnalyzer:
|
|
20
|
+
def __init__(self, trigger_name: str):
|
|
21
|
+
self.trigger_name = trigger_name
|
|
22
|
+
self.trigger_body = ""
|
|
23
|
+
self.issues = {
|
|
24
|
+
'dml_in_loops': [],
|
|
25
|
+
'soql_in_loops': [],
|
|
26
|
+
'missing_bulk': [],
|
|
27
|
+
}
|
|
28
|
+
self.complexity_score = 0
|
|
29
|
+
|
|
30
|
+
def retrieve_trigger(self) -> bool:
|
|
31
|
+
"""Retrieve trigger source code from Salesforce org."""
|
|
32
|
+
try:
|
|
33
|
+
cmd = [
|
|
34
|
+
'sf', 'apex', 'get', 'trigger',
|
|
35
|
+
'--trigger-name', self.trigger_name,
|
|
36
|
+
'--json'
|
|
37
|
+
]
|
|
38
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
39
|
+
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
print(f"Error retrieving trigger: {result.stderr}")
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
data = json.loads(result.stdout)
|
|
45
|
+
self.trigger_body = data.get('result', {}).get('body', '')
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"Failed to retrieve trigger: {e}")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def analyze_dml_in_loops(self) -> None:
|
|
53
|
+
"""Detect DML operations inside for loops."""
|
|
54
|
+
lines = self.trigger_body.split('\n')
|
|
55
|
+
in_loop = False
|
|
56
|
+
loop_start = 0
|
|
57
|
+
|
|
58
|
+
dml_patterns = [
|
|
59
|
+
r'\binsert\s+', r'\bupdate\s+', r'\bdelete\s+',
|
|
60
|
+
r'\bundelete\s+', r'\bupsert\s+'
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for i, line in enumerate(lines, 1):
|
|
64
|
+
# Track loop entry
|
|
65
|
+
if re.search(r'\bfor\s*\(', line):
|
|
66
|
+
in_loop = True
|
|
67
|
+
loop_start = i
|
|
68
|
+
|
|
69
|
+
# Track loop exit
|
|
70
|
+
if in_loop and line.strip() == '}':
|
|
71
|
+
# Check if this closes the loop (simplified heuristic)
|
|
72
|
+
in_loop = False
|
|
73
|
+
|
|
74
|
+
# Check for DML in loop
|
|
75
|
+
if in_loop:
|
|
76
|
+
for pattern in dml_patterns:
|
|
77
|
+
if re.search(pattern, line):
|
|
78
|
+
self.issues['dml_in_loops'].append({
|
|
79
|
+
'line': i,
|
|
80
|
+
'code': line.strip(),
|
|
81
|
+
'loop_start': loop_start
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
def analyze_soql_in_loops(self) -> None:
|
|
85
|
+
"""Detect SOQL queries inside for loops."""
|
|
86
|
+
lines = self.trigger_body.split('\n')
|
|
87
|
+
in_loop = False
|
|
88
|
+
loop_start = 0
|
|
89
|
+
|
|
90
|
+
soql_pattern = r'\[SELECT\s+'
|
|
91
|
+
|
|
92
|
+
for i, line in enumerate(lines, 1):
|
|
93
|
+
if re.search(r'\bfor\s*\(', line):
|
|
94
|
+
in_loop = True
|
|
95
|
+
loop_start = i
|
|
96
|
+
|
|
97
|
+
if in_loop and line.strip() == '}':
|
|
98
|
+
in_loop = False
|
|
99
|
+
|
|
100
|
+
if in_loop and re.search(soql_pattern, line, re.IGNORECASE):
|
|
101
|
+
self.issues['soql_in_loops'].append({
|
|
102
|
+
'line': i,
|
|
103
|
+
'code': line.strip(),
|
|
104
|
+
'loop_start': loop_start
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
def analyze_bulkification(self) -> None:
|
|
108
|
+
"""Check for proper bulkification patterns."""
|
|
109
|
+
# Simple heuristic: if we have DML in loops, bulkification is missing
|
|
110
|
+
if self.issues['dml_in_loops']:
|
|
111
|
+
self.issues['missing_bulk'].append({
|
|
112
|
+
'message': 'DML operations should be collected and executed outside loops',
|
|
113
|
+
'affected_lines': [issue['line'] for issue in self.issues['dml_in_loops']]
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if self.issues['soql_in_loops']:
|
|
117
|
+
self.issues['missing_bulk'].append({
|
|
118
|
+
'message': 'SOQL queries should be moved outside loops or use Maps for lookups',
|
|
119
|
+
'affected_lines': [issue['line'] for issue in self.issues['soql_in_loops']]
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
def calculate_complexity(self) -> None:
|
|
123
|
+
"""Calculate overall complexity score (1-10)."""
|
|
124
|
+
score = 1
|
|
125
|
+
|
|
126
|
+
# Add points for each issue type
|
|
127
|
+
score += len(self.issues['dml_in_loops']) * 2
|
|
128
|
+
score += len(self.issues['soql_in_loops']) * 2
|
|
129
|
+
score += len(self.issues['missing_bulk']) * 1
|
|
130
|
+
|
|
131
|
+
# Count trigger contexts
|
|
132
|
+
contexts = len(re.findall(r'Trigger\.(isBefore|isAfter)', self.trigger_body))
|
|
133
|
+
score += contexts
|
|
134
|
+
|
|
135
|
+
# Count lines of code (normalized)
|
|
136
|
+
loc = len([l for l in self.trigger_body.split('\n') if l.strip()])
|
|
137
|
+
score += min(loc // 10, 3)
|
|
138
|
+
|
|
139
|
+
self.complexity_score = min(score, 10)
|
|
140
|
+
|
|
141
|
+
def recommend_approach(self) -> str:
|
|
142
|
+
"""Recommend refactoring approach based on analysis."""
|
|
143
|
+
if self.complexity_score <= 3:
|
|
144
|
+
return "Simple handler class with separate methods for each trigger context"
|
|
145
|
+
elif self.complexity_score <= 6:
|
|
146
|
+
return "Handler class with bulkified collections and helper methods"
|
|
147
|
+
else:
|
|
148
|
+
return "Unified handler framework with separate concern classes (validation, DML, etc.)"
|
|
149
|
+
|
|
150
|
+
def generate_report(self) -> None:
|
|
151
|
+
"""Print analysis report."""
|
|
152
|
+
print("\n" + "="*70)
|
|
153
|
+
print(f"TRIGGER ANALYSIS REPORT: {self.trigger_name}")
|
|
154
|
+
print("="*70 + "\n")
|
|
155
|
+
|
|
156
|
+
print(f"Complexity Score: {self.complexity_score}/10")
|
|
157
|
+
print(f"Recommended Approach: {self.recommend_approach()}\n")
|
|
158
|
+
|
|
159
|
+
# DML in loops
|
|
160
|
+
if self.issues['dml_in_loops']:
|
|
161
|
+
print("⚠️ DML OPERATIONS IN LOOPS:")
|
|
162
|
+
for issue in self.issues['dml_in_loops']:
|
|
163
|
+
print(f" Line {issue['line']}: {issue['code']}")
|
|
164
|
+
print(f" └─ Loop started at line {issue['loop_start']}")
|
|
165
|
+
print()
|
|
166
|
+
else:
|
|
167
|
+
print("✓ No DML operations found in loops\n")
|
|
168
|
+
|
|
169
|
+
# SOQL in loops
|
|
170
|
+
if self.issues['soql_in_loops']:
|
|
171
|
+
print("⚠️ SOQL QUERIES IN LOOPS:")
|
|
172
|
+
for issue in self.issues['soql_in_loops']:
|
|
173
|
+
print(f" Line {issue['line']}: {issue['code']}")
|
|
174
|
+
print(f" └─ Loop started at line {issue['loop_start']}")
|
|
175
|
+
print()
|
|
176
|
+
else:
|
|
177
|
+
print("✓ No SOQL queries found in loops\n")
|
|
178
|
+
|
|
179
|
+
# Bulkification
|
|
180
|
+
if self.issues['missing_bulk']:
|
|
181
|
+
print("⚠️ BULKIFICATION RECOMMENDATIONS:")
|
|
182
|
+
for issue in self.issues['missing_bulk']:
|
|
183
|
+
print(f" • {issue['message']}")
|
|
184
|
+
print(f" Affected lines: {', '.join(map(str, issue['affected_lines']))}")
|
|
185
|
+
print()
|
|
186
|
+
else:
|
|
187
|
+
print("✓ Bulkification patterns look good\n")
|
|
188
|
+
|
|
189
|
+
print("="*70)
|
|
190
|
+
print("NEXT STEPS:")
|
|
191
|
+
print("1. Review the handler patterns reference guide")
|
|
192
|
+
print("2. Create handler class with bulk-safe collections")
|
|
193
|
+
print("3. Extract trigger logic into handler methods")
|
|
194
|
+
print("4. Generate comprehensive tests using the template")
|
|
195
|
+
print("="*70 + "\n")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main():
|
|
199
|
+
if len(sys.argv) < 2:
|
|
200
|
+
print("Usage: python analyze_trigger.py <TriggerName>")
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
trigger_name = sys.argv[1]
|
|
204
|
+
|
|
205
|
+
print(f"Analyzing trigger: {trigger_name}...")
|
|
206
|
+
|
|
207
|
+
analyzer = TriggerAnalyzer(trigger_name)
|
|
208
|
+
|
|
209
|
+
# For demo purposes, use inline example if retrieval fails
|
|
210
|
+
if not analyzer.retrieve_trigger():
|
|
211
|
+
print("⚠️ Could not retrieve from org. Using example trigger for demonstration.\n")
|
|
212
|
+
# Use the example from the SKILL.md
|
|
213
|
+
analyzer.trigger_body = """trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
|
|
214
|
+
if (Trigger.isBefore && Trigger.isInsert) {
|
|
215
|
+
for (Opportunity o : Trigger.new) {
|
|
216
|
+
if (o.StageName == 'Closed Won' && (o.Amount == null || o.Amount < 1000)) {
|
|
217
|
+
o.addError('Closed Won opportunities must have Amount ≥ 1000.');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (Trigger.isBefore && Trigger.isUpdate) {
|
|
222
|
+
for (Opportunity o : Trigger.new) {
|
|
223
|
+
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
|
224
|
+
if (o.StageName != oldO.StageName) {
|
|
225
|
+
o.Description = 'Stage changed from ' + oldO.StageName + ' to ' + o.StageName;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (Trigger.isAfter && Trigger.isUpdate) {
|
|
230
|
+
for (Opportunity o : Trigger.new) {
|
|
231
|
+
Opportunity oldO = Trigger.oldMap.get(o.Id);
|
|
232
|
+
if (o.StageName == 'Closed Won' && oldO.StageName != 'Closed Won') {
|
|
233
|
+
Task t = new Task(
|
|
234
|
+
WhatId = o.Id,
|
|
235
|
+
OwnerId = o.OwnerId,
|
|
236
|
+
Subject = 'Send thank-you',
|
|
237
|
+
Status = 'Not Started',
|
|
238
|
+
Priority = 'Normal',
|
|
239
|
+
ActivityDate = Date.today()
|
|
240
|
+
);
|
|
241
|
+
insert t;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}"""
|
|
246
|
+
|
|
247
|
+
# Run analysis
|
|
248
|
+
analyzer.analyze_dml_in_loops()
|
|
249
|
+
analyzer.analyze_soql_in_loops()
|
|
250
|
+
analyzer.analyze_bulkification()
|
|
251
|
+
analyzer.calculate_complexity()
|
|
252
|
+
|
|
253
|
+
# Generate report
|
|
254
|
+
analyzer.generate_report()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == '__main__':
|
|
258
|
+
main()
|