mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-31 04:01:24 +08:00
parent
768aa1e783
commit
bba4327cd8
@ -1,8 +1,6 @@
|
||||
---
|
||||
name: creating-webapp
|
||||
description: "Use this skill when creating or setting up a new SFDX React web application. Covers first steps, npm install, skills-first protocol, deployment order, and core web app rules."
|
||||
paths:
|
||||
- "**/webapplications/**/*"
|
||||
---
|
||||
|
||||
# First Steps (MUST FOLLOW)
|
||||
|
||||
108
skills/generating-apex-test/SKILL.md
Normal file
108
skills/generating-apex-test/SKILL.md
Normal file
@ -0,0 +1,108 @@
|
||||
---
|
||||
name: generating-apex-test
|
||||
description: Apex test class generation with TestDataFactory patterns, bulk testing (200+ records), mocking strategies, and assertion best practices. Use this skill when the user asks to create, write, or improve Apex test classes, add coverage, build mocks, or implement testing patterns for triggers, services, batch jobs, queueables, and integrations.
|
||||
---
|
||||
|
||||
# Apex Test Class Skill
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Bulkify tests** - Always test with 200+ records to catch governor limit issues
|
||||
2. **Isolate test data** - Use `@TestSetup` and TestDataFactory; never rely on org data
|
||||
3. **Assert meaningfully** - Test behavior, not just coverage; include failure messages
|
||||
4. **Mock external dependencies** - Use `HttpCalloutMock`, `Test.setMock()` for integrations
|
||||
5. **Test negative paths** - Validate error handling, not just happy paths
|
||||
|
||||
## Test Class Structure
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
private class MyServiceTest {
|
||||
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// Create shared test data using TestDataFactory
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldPerformExpectedBehavior_WhenValidInput() {
|
||||
// Given: Setup specific test state
|
||||
List<Account> accounts = [SELECT Id, Name FROM Account];
|
||||
|
||||
// When: Execute the code under test
|
||||
Test.startTest();
|
||||
MyService.processAccounts(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Assert expected outcomes
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
System.assertEquals(200, updated.size(), 'All accounts should be processed');
|
||||
for (Account acc : updated) {
|
||||
System.assertEquals('Processed', acc.Status__c, 'Status should be updated');
|
||||
}
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldThrowException_WhenInvalidInput() {
|
||||
// Given
|
||||
List<Account> emptyList = new List<Account>();
|
||||
|
||||
// When/Then
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.processAccounts(emptyList);
|
||||
System.assert(false, 'Expected MyCustomException to be thrown');
|
||||
} catch (MyCustomException e) {
|
||||
System.assert(e.getMessage().contains('cannot be empty'),
|
||||
'Exception message should indicate empty input');
|
||||
}
|
||||
Test.stopTest();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
Use descriptive method names: `should[ExpectedBehavior]_When[Condition]`
|
||||
|
||||
Examples:
|
||||
- `shouldCreateContact_WhenAccountIsActive`
|
||||
- `shouldThrowException_WhenEmailIsInvalid`
|
||||
- `shouldSendNotification_WhenOpportunityClosedWon`
|
||||
- `shouldBypassTrigger_WhenRunningAsBatch`
|
||||
|
||||
## Test.startTest() / Test.stopTest()
|
||||
|
||||
Always wrap the code under test:
|
||||
- Resets governor limits for accurate limit testing
|
||||
- Executes async operations synchronously (queueables, batch, future)
|
||||
- Fires scheduled jobs immediately
|
||||
|
||||
## Asset Templates
|
||||
|
||||
Ready-to-use scaffolds for common test patterns:
|
||||
|
||||
- **[assets/test-class-template.cls](assets/test-class-template.cls)** - Starter test class with positive, negative, bulk, and governor limit test stubs
|
||||
- **[assets/test-data-factory-template.cls](assets/test-data-factory-template.cls)** - TestDataFactory with Account, Contact, Opportunity, User factories and field override support
|
||||
|
||||
## Reference Files
|
||||
|
||||
Detailed patterns for specific scenarios:
|
||||
|
||||
- **[references/test-data-factory.md](references/test-data-factory.md)** - TestDataFactory class patterns and field defaults
|
||||
- **[references/assertion-patterns.md](references/assertion-patterns.md)** - Assertion best practices and common pitfalls
|
||||
- **[references/mocking-patterns.md](references/mocking-patterns.md)** - HttpCalloutMock, Test.setMock(), stubbing
|
||||
- **[references/async-testing.md](references/async-testing.md)** - Batch, Queueable, Future, Scheduled job testing
|
||||
|
||||
## Quick Reference: What to Test
|
||||
|
||||
| Component | Key Test Scenarios |
|
||||
|-----------|-------------------|
|
||||
| Trigger | Bulk insert/update/delete, recursion, field changes |
|
||||
| Service | Valid/invalid inputs, bulk operations, exceptions |
|
||||
| Controller | Page load, action methods, view state |
|
||||
| Batch | Start/execute/finish, chunking, error records |
|
||||
| Queueable | Chaining, bulkification, error handling |
|
||||
| Callout | Success response, error response, timeout |
|
||||
| Scheduled | Execution, CRON validation |
|
||||
124
skills/generating-apex-test/assets/test-class-template.cls
Normal file
124
skills/generating-apex-test/assets/test-class-template.cls
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @description Test class for {ClassUnderTest}.
|
||||
* Tests bulk operations (200+ records), positive/negative paths,
|
||||
* and exception handling.
|
||||
* @author Generated by Apex Test Writer Skill
|
||||
*/
|
||||
@isTest
|
||||
private class {ClassUnderTest}Test {
|
||||
|
||||
// ─── Test Setup ───────────────────────────────────────────────────────
|
||||
|
||||
@TestSetup
|
||||
static void setupTestData() {
|
||||
// Create shared test data using TestDataFactory
|
||||
// List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
}
|
||||
|
||||
// ─── Positive Tests ───────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldPerformExpectedBehavior_WhenValidInput() {
|
||||
// Given: Setup specific test state
|
||||
// List<Account> accounts = [SELECT Id, Name FROM Account];
|
||||
|
||||
// When: Execute the code under test
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.methodUnderTest(params);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Assert expected outcomes
|
||||
// System.assertEquals(expected, actual, 'Descriptive failure message');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldHandleBulkRecords_WhenProcessing200() {
|
||||
// Given: 200+ records to verify bulkification
|
||||
// List<Account> accounts = [SELECT Id FROM Account];
|
||||
// System.assertEquals(200, accounts.size(), 'Should have 200 test records');
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.bulkMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Verify all records processed
|
||||
// List<Account> results = [SELECT Id, Status__c FROM Account];
|
||||
// for (Account acc : results) {
|
||||
// System.assertEquals('Processed', acc.Status__c, 'All records should be processed');
|
||||
// }
|
||||
}
|
||||
|
||||
// ─── Negative Tests ───────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldThrowException_WhenNullInput() {
|
||||
Boolean exceptionThrown = false;
|
||||
String exceptionMessage = '';
|
||||
|
||||
Test.startTest();
|
||||
try {
|
||||
// {ClassUnderTest}.methodUnderTest(null);
|
||||
} catch (Exception e) {
|
||||
exceptionThrown = true;
|
||||
exceptionMessage = e.getMessage();
|
||||
}
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(exceptionThrown, 'Exception should be thrown for null input');
|
||||
System.assert(exceptionMessage.contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldReturnEmpty_WhenEmptyInput() {
|
||||
Test.startTest();
|
||||
// List<SObject> results = {ClassUnderTest}.methodUnderTest(new List<Id>());
|
||||
Test.stopTest();
|
||||
|
||||
// System.assert(results.isEmpty(), 'Should return empty list for empty input');
|
||||
}
|
||||
|
||||
// ─── Edge Case Tests ──────────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldHandleMixedRecords_WhenSomeQualify() {
|
||||
// Given: Mix of qualifying and non-qualifying records
|
||||
// List<Account> accounts = [SELECT Id, Status__c FROM Account];
|
||||
// Integer half = accounts.size() / 2;
|
||||
// for (Integer i = 0; i < half; i++) {
|
||||
// accounts[i].Status__c = 'Qualifying';
|
||||
// }
|
||||
// update accounts;
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.conditionalMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
// Then: Only qualifying records should be affected
|
||||
// List<Account> qualifying = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
// System.assertEquals(half, qualifying.size(), 'Only qualifying records should be processed');
|
||||
}
|
||||
|
||||
// ─── Governor Limit Tests ─────────────────────────────────────────────
|
||||
|
||||
@isTest
|
||||
static void shouldNotExceedGovernorLimits_WhenBulkProcessing() {
|
||||
// Given
|
||||
// List<Account> accounts = [SELECT Id FROM Account];
|
||||
|
||||
Test.startTest();
|
||||
// {ClassUnderTest}.heavyMethod(accounts);
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(Limits.getDmlStatements() < Limits.getLimitDmlStatements(),
|
||||
'Should not exceed DML statement limit');
|
||||
System.assert(Limits.getQueries() < Limits.getLimitQueries(),
|
||||
'Should not exceed SOQL query limit');
|
||||
}
|
||||
|
||||
// ─── Helper Methods ───────────────────────────────────────────────────
|
||||
|
||||
// Add test-specific helper methods here
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @description Centralized factory for creating test data with sensible defaults.
|
||||
* All methods accept a doInsert flag for flexibility.
|
||||
* Bulk methods create multiple records; single-record methods delegate to bulk.
|
||||
* @author Generated by Apex Test Writer Skill
|
||||
*/
|
||||
@isTest
|
||||
public class TestDataFactory {
|
||||
|
||||
// ─── Accounts ─────────────────────────────────────────────────────────
|
||||
|
||||
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
||||
List<Account> accounts = new List<Account>();
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
accounts.add(new Account(
|
||||
Name = 'Test Account ' + i,
|
||||
BillingCity = 'San Francisco',
|
||||
BillingState = 'CA',
|
||||
BillingCountry = 'USA',
|
||||
Industry = 'Technology',
|
||||
Type = 'Customer'
|
||||
));
|
||||
}
|
||||
if (doInsert) insert accounts;
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public static Account createAccount(Boolean doInsert) {
|
||||
return createAccounts(1, doInsert)[0];
|
||||
}
|
||||
|
||||
// ─── Contacts ─────────────────────────────────────────────────────────
|
||||
|
||||
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Contact> contacts = new List<Contact>();
|
||||
Integer idx = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
contacts.add(new Contact(
|
||||
FirstName = 'Test',
|
||||
LastName = 'Contact ' + idx,
|
||||
Email = 'test.contact' + idx + '@example.com',
|
||||
AccountId = acc.Id
|
||||
));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
// ─── Opportunities ────────────────────────────────────────────────────
|
||||
|
||||
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Opportunity> opps = new List<Opportunity>();
|
||||
Integer idx = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
opps.add(new Opportunity(
|
||||
Name = 'Test Opportunity ' + idx,
|
||||
AccountId = acc.Id,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000 + (idx * 1000)
|
||||
));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert opps;
|
||||
return opps;
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────
|
||||
|
||||
public static User createUser(String profileName, Boolean doInsert) {
|
||||
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
||||
String uniqueKey = String.valueOf(DateTime.now().getTime());
|
||||
|
||||
User u = new User(
|
||||
FirstName = 'Test',
|
||||
LastName = 'User ' + uniqueKey,
|
||||
Email = 'testuser' + uniqueKey + '@example.com',
|
||||
Username = 'testuser' + uniqueKey + '@example.com.test',
|
||||
Alias = 'tuser',
|
||||
TimeZoneSidKey = 'America/Los_Angeles',
|
||||
LocaleSidKey = 'en_US',
|
||||
EmailEncodingKey = 'UTF-8',
|
||||
LanguageLocaleKey = 'en_US',
|
||||
ProfileId = p.Id
|
||||
);
|
||||
if (doInsert) insert u;
|
||||
return u;
|
||||
}
|
||||
|
||||
// ─── Field Override Pattern ────────────────────────────────────────────
|
||||
|
||||
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
Industry = 'Technology'
|
||||
);
|
||||
for (String fieldName : fieldOverrides.keySet()) {
|
||||
acc.put(fieldName, fieldOverrides.get(fieldName));
|
||||
}
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ─── Custom Objects ───────────────────────────────────────────────────
|
||||
// Add methods for your custom objects following the same pattern:
|
||||
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
|
||||
}
|
||||
165
skills/generating-apex-test/references/assertion-patterns.md
Normal file
165
skills/generating-apex-test/references/assertion-patterns.md
Normal file
@ -0,0 +1,165 @@
|
||||
# Assertion Patterns
|
||||
|
||||
## Assertion Methods
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `System.assertEquals(expected, actual, msg)` | Exact equality |
|
||||
| `System.assertNotEquals(expected, actual, msg)` | Value should differ |
|
||||
| `System.assert(condition, msg)` | Boolean condition |
|
||||
|
||||
**Always include the third parameter (message)** - Makes test failures meaningful.
|
||||
|
||||
## Good vs Bad Assertions
|
||||
|
||||
### ❌ Bad: No message, tests coverage not behavior
|
||||
|
||||
```apex
|
||||
System.assertEquals(true, result);
|
||||
System.assert(accounts.size() > 0);
|
||||
```
|
||||
|
||||
### ✅ Good: Descriptive message, tests specific behavior
|
||||
|
||||
```apex
|
||||
System.assertEquals(true, result, 'Service should return true for valid input');
|
||||
System.assertEquals(200, accounts.size(), 'All 200 accounts should be processed');
|
||||
```
|
||||
|
||||
## Common Assertion Patterns
|
||||
|
||||
### Collection Size
|
||||
|
||||
```apex
|
||||
// Exact count
|
||||
System.assertEquals(200, results.size(), 'Should process all 200 records');
|
||||
|
||||
// Not empty
|
||||
System.assert(!results.isEmpty(), 'Results should not be empty');
|
||||
|
||||
// Empty
|
||||
System.assert(results.isEmpty(), 'No results expected for invalid input');
|
||||
```
|
||||
|
||||
### Field Values
|
||||
|
||||
```apex
|
||||
// Single record
|
||||
System.assertEquals('Processed', acc.Status__c, 'Account status should be updated to Processed');
|
||||
|
||||
// All records in collection
|
||||
for (Account acc : updatedAccounts) {
|
||||
System.assertEquals('Active', acc.Status__c,
|
||||
'Account ' + acc.Name + ' should have Active status');
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldThrowException_WhenInputInvalid() {
|
||||
Boolean exceptionThrown = false;
|
||||
String exceptionMessage = '';
|
||||
|
||||
Test.startTest();
|
||||
try {
|
||||
MyService.process(null);
|
||||
} catch (MyCustomException e) {
|
||||
exceptionThrown = true;
|
||||
exceptionMessage = e.getMessage();
|
||||
}
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(exceptionThrown, 'MyCustomException should be thrown for null input');
|
||||
System.assert(exceptionMessage.contains('cannot be null'),
|
||||
'Exception message should mention null input');
|
||||
}
|
||||
```
|
||||
|
||||
### DML Results
|
||||
|
||||
```apex
|
||||
// Insert success
|
||||
Database.SaveResult[] results = Database.insert(accounts, false);
|
||||
for (Database.SaveResult sr : results) {
|
||||
System.assert(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors());
|
||||
}
|
||||
|
||||
// Expected failures
|
||||
Database.SaveResult sr = Database.insert(invalidAccount, false);
|
||||
System.assert(!sr.isSuccess(), 'Insert should fail for invalid data');
|
||||
System.assert(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'),
|
||||
'Error should indicate missing required field');
|
||||
```
|
||||
|
||||
### Comparing Objects
|
||||
|
||||
```apex
|
||||
// Compare specific fields, not entire objects
|
||||
System.assertEquals(expected.Name, actual.Name, 'Names should match');
|
||||
System.assertEquals(expected.Status__c, actual.Status__c, 'Status should match');
|
||||
|
||||
// Or use JSON for deep comparison (use sparingly)
|
||||
System.assertEquals(
|
||||
JSON.serialize(expected),
|
||||
JSON.serialize(actual),
|
||||
'Objects should be identical'
|
||||
);
|
||||
```
|
||||
|
||||
### Date/DateTime Assertions
|
||||
|
||||
```apex
|
||||
// Exact date
|
||||
System.assertEquals(Date.today(), record.CreatedDate__c, 'Should be created today');
|
||||
|
||||
// Date within range
|
||||
System.assert(record.DueDate__c >= Date.today(), 'Due date should be in the future');
|
||||
System.assert(record.DueDate__c <= Date.today().addDays(30),
|
||||
'Due date should be within 30 days');
|
||||
```
|
||||
|
||||
### Null Checks
|
||||
|
||||
```apex
|
||||
// Should be null
|
||||
System.assertEquals(null, result.ErrorMessage__c, 'No error expected for valid input');
|
||||
|
||||
// Should not be null
|
||||
System.assertNotEquals(null, result.Id, 'Record should have been inserted');
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### ❌ Testing implementation, not behavior
|
||||
|
||||
```apex
|
||||
// Bad: Testing that a specific method was called
|
||||
System.assert(MyClass.methodWasCalled, 'Method should be called');
|
||||
|
||||
// Good: Testing the observable outcome
|
||||
System.assertEquals('Expected Value', record.Field__c, 'Field should be updated');
|
||||
```
|
||||
|
||||
### ❌ Overly generic assertions
|
||||
|
||||
```apex
|
||||
// Bad: Passes for any non-empty result
|
||||
System.assert(results.size() > 0);
|
||||
|
||||
// Good: Verifies exact expected count
|
||||
System.assertEquals(200, results.size(), 'All 200 records should be returned');
|
||||
```
|
||||
|
||||
### ❌ Missing negative test assertions
|
||||
|
||||
```apex
|
||||
// Bad: Only tests that no exception occurred
|
||||
MyService.process(data); // Test passes if no exception
|
||||
|
||||
// Good: Verifies the actual outcome
|
||||
Result r = MyService.process(data);
|
||||
System.assertEquals('Success', r.status, 'Processing should succeed');
|
||||
System.assertEquals(0, r.errorCount, 'No errors should occur');
|
||||
```
|
||||
276
skills/generating-apex-test/references/async-testing.md
Normal file
276
skills/generating-apex-test/references/async-testing.md
Normal file
@ -0,0 +1,276 @@
|
||||
# Async Testing Patterns
|
||||
|
||||
## Key Principle
|
||||
|
||||
`Test.stopTest()` forces all async operations to execute synchronously, allowing assertions on their results.
|
||||
|
||||
## Batch Apex Testing
|
||||
|
||||
### Basic Batch Test
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldProcessAllRecords_WhenBatchExecutes() {
|
||||
// Given: Create test data
|
||||
List<Account> accounts = TestDataFactory.createAccounts(200, true);
|
||||
|
||||
// When: Execute batch
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Id batchId = Database.executeBatch(batch, 200);
|
||||
Test.stopTest(); // Forces batch to complete
|
||||
|
||||
// Then: Verify results
|
||||
List<Account> updated = [SELECT Id, Status__c FROM Account];
|
||||
for (Account acc : updated) {
|
||||
System.assertEquals('Processed', acc.Status__c,
|
||||
'Batch should update all account statuses');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch with Failures
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldLogErrors_WhenRecordsFail() {
|
||||
// Given: Create mix of valid and invalid records
|
||||
List<Account> accounts = TestDataFactory.createAccounts(198, true);
|
||||
|
||||
// Create 2 accounts that will fail processing
|
||||
List<Account> invalidAccounts = new List<Account>();
|
||||
for (Integer i = 0; i < 2; i++) {
|
||||
invalidAccounts.add(new Account(
|
||||
Name = 'Invalid Account ' + i,
|
||||
Invalid_Field__c = 'triggers_validation_error'
|
||||
));
|
||||
}
|
||||
insert invalidAccounts;
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
|
||||
System.assertEquals(2, errors.size(), 'Should log 2 failed records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Batch Scope
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldRespectBatchSize() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(250, true);
|
||||
|
||||
Test.startTest();
|
||||
MyBatchClass batch = new MyBatchClass();
|
||||
Database.executeBatch(batch, 50); // 5 batches of 50
|
||||
Test.stopTest();
|
||||
|
||||
// Note: In tests, all batches execute but you can verify total processing
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(250, processed.size(), 'All records should be processed');
|
||||
}
|
||||
```
|
||||
|
||||
## Queueable Testing
|
||||
|
||||
### Basic Queueable Test
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldCompleteProcessing_WhenQueueableEnqueued() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableClass queueable = new MyQueueableClass(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest(); // Forces queueable to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals('Processed', updated.Status__c,
|
||||
'Queueable should update account status');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable Chaining
|
||||
|
||||
Chained queueables only execute the first job in tests:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldChainNextJob_WhenMoreRecordsExist() {
|
||||
// Given: More records than one queueable can process
|
||||
List<Account> accounts = TestDataFactory.createAccounts(500, true);
|
||||
|
||||
Test.startTest();
|
||||
// First queueable processes batch 1 and chains next
|
||||
MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify first batch processed
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(100, processed.size(), 'First batch should process 100 records');
|
||||
|
||||
// Verify chain was enqueued (check AsyncApexJob)
|
||||
List<AsyncApexJob> jobs = [
|
||||
SELECT Id, Status, JobType
|
||||
FROM AsyncApexJob
|
||||
WHERE ApexClass.Name = 'MyChainedQueueable'
|
||||
];
|
||||
System.assert(jobs.size() >= 1, 'Chained job should be enqueued');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Queueable with Callouts
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldMakeCallout_WhenQueueableWithCallout() {
|
||||
// Given
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
|
||||
System.enqueueJob(queueable);
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals('Synced', updated.External_Status__c,
|
||||
'Should update status after successful callout');
|
||||
}
|
||||
```
|
||||
|
||||
## Future Method Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldExecuteFutureMethod() {
|
||||
// Given
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
MyClass.processFuture(acc.Id); // @future method
|
||||
Test.stopTest(); // Forces future to complete
|
||||
|
||||
// Then
|
||||
Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
|
||||
System.assertEquals(true, updated.Processed__c, 'Future should process record');
|
||||
}
|
||||
```
|
||||
|
||||
## Scheduled Apex Testing
|
||||
|
||||
### Testing Scheduled Execution
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldExecuteScheduledJob() {
|
||||
// Given
|
||||
List<Account> accounts = TestDataFactory.createAccounts(50, true);
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 0 1 1 ? 2099'; // Arbitrary future time
|
||||
String jobId = System.schedule('Test Job', cronExp, new MyScheduledClass());
|
||||
|
||||
// Execute the scheduled job immediately
|
||||
MyScheduledClass scheduled = new MyScheduledClass();
|
||||
scheduled.execute(null); // Pass null SchedulableContext in tests
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(50, processed.size(), 'Scheduled job should process records');
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Schedule Registration
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldScheduleJob() {
|
||||
Test.startTest();
|
||||
String cronExp = '0 0 6 * * ?'; // Daily at 6 AM
|
||||
String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
|
||||
Test.stopTest();
|
||||
|
||||
// Verify job is scheduled
|
||||
CronTrigger ct = [
|
||||
SELECT Id, CronExpression, State
|
||||
FROM CronTrigger
|
||||
WHERE Id = :jobId
|
||||
];
|
||||
System.assertEquals('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
|
||||
System.assertEquals('WAITING', ct.State, 'Job should be waiting');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Async Limits
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldNotExceedQueueableLimits() {
|
||||
// Given: Setup that might enqueue multiple jobs
|
||||
List<Account> accounts = TestDataFactory.createAccounts(100, true);
|
||||
|
||||
Test.startTest();
|
||||
Integer queueablesBefore = Limits.getQueueableJobs();
|
||||
|
||||
MyService.processWithQueueables(accounts);
|
||||
|
||||
Integer queueablesUsed = Limits.getQueueableJobs() - queueablesBefore;
|
||||
Test.stopTest();
|
||||
|
||||
// Verify limit not exceeded (50 in synchronous context, 1 in queueable)
|
||||
System.assert(queueablesUsed <= 50,
|
||||
'Should not exceed queueable limit. Used: ' + queueablesUsed);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### ❌ Forgetting Test.stopTest()
|
||||
|
||||
```apex
|
||||
// Bad: Async never executes
|
||||
Test.startTest();
|
||||
System.enqueueJob(new MyQueueable());
|
||||
// Missing Test.stopTest()!
|
||||
|
||||
List<Account> results = [SELECT Id FROM Account WHERE Processed__c = true];
|
||||
System.assertEquals(100, results.size()); // FAILS - queueable didn't run
|
||||
```
|
||||
|
||||
### ❌ Testing chained jobs without understanding limits
|
||||
|
||||
```apex
|
||||
// Only the FIRST chained queueable runs in tests
|
||||
// Design tests to verify:
|
||||
// 1. First job completes correctly
|
||||
// 2. Chain is properly enqueued (check AsyncApexJob)
|
||||
// 3. Each job works independently
|
||||
```
|
||||
|
||||
### ❌ Not mocking callouts in async
|
||||
|
||||
```apex
|
||||
// Async with callouts MUST have mock set BEFORE Test.startTest()
|
||||
Test.setMock(HttpCalloutMock.class, new MockResponse()); // Before startTest!
|
||||
Test.startTest();
|
||||
System.enqueueJob(new QueueableWithCallout());
|
||||
Test.stopTest();
|
||||
```
|
||||
219
skills/generating-apex-test/references/mocking-patterns.md
Normal file
219
skills/generating-apex-test/references/mocking-patterns.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Mocking Patterns
|
||||
|
||||
## HTTP Callout Mocking
|
||||
|
||||
Apex doesn't allow real HTTP callouts in tests. Use `HttpCalloutMock` interface.
|
||||
|
||||
### Basic Mock Implementation
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MockHttpResponse implements HttpCalloutMock {
|
||||
|
||||
private Integer statusCode;
|
||||
private String body;
|
||||
|
||||
public MockHttpResponse(Integer statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(statusCode);
|
||||
res.setBody(body);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Mock
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldProcessApiResponse_WhenCalloutSucceeds() {
|
||||
// Given
|
||||
String mockResponse = '{"status": "success", "data": [{"id": "123"}]}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
List<ExternalRecord> results = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.assertEquals(1, results.size(), 'Should parse one record from response');
|
||||
System.assertEquals('123', results[0].externalId, 'Should extract correct ID');
|
||||
}
|
||||
|
||||
@isTest
|
||||
static void shouldHandleError_WhenCalloutFails() {
|
||||
// Given
|
||||
String errorResponse = '{"error": "Unauthorized"}';
|
||||
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(401, errorResponse));
|
||||
|
||||
// When
|
||||
Test.startTest();
|
||||
CalloutResult result = MyIntegrationService.fetchRecords();
|
||||
Test.stopTest();
|
||||
|
||||
// Then
|
||||
System.assertEquals(false, result.isSuccess, 'Should indicate failure');
|
||||
System.assert(result.errorMessage.contains('Unauthorized'), 'Should capture error');
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Request Mock
|
||||
|
||||
For services making multiple callouts:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MultiRequestMock implements HttpCalloutMock {
|
||||
|
||||
private Map<String, HttpResponse> endpointResponses;
|
||||
|
||||
public MultiRequestMock(Map<String, HttpResponse> responses) {
|
||||
this.endpointResponses = responses;
|
||||
}
|
||||
|
||||
public HTTPResponse respond(HTTPRequest req) {
|
||||
String endpoint = req.getEndpoint();
|
||||
|
||||
for (String key : endpointResponses.keySet()) {
|
||||
if (endpoint.contains(key)) {
|
||||
return endpointResponses.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Default 404 if no match
|
||||
HttpResponse res = new HttpResponse();
|
||||
res.setStatusCode(404);
|
||||
res.setBody('{"error": "Not found"}');
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Map<String, HttpResponse> mocks = new Map<String, HttpResponse>();
|
||||
|
||||
HttpResponse authResponse = new HttpResponse();
|
||||
authResponse.setStatusCode(200);
|
||||
authResponse.setBody('{"token": "abc123"}');
|
||||
mocks.put('/oauth/token', authResponse);
|
||||
|
||||
HttpResponse dataResponse = new HttpResponse();
|
||||
dataResponse.setStatusCode(200);
|
||||
dataResponse.setBody('{"records": []}');
|
||||
mocks.put('/api/records', dataResponse);
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, new MultiRequestMock(mocks));
|
||||
```
|
||||
|
||||
## StaticResourceCalloutMock
|
||||
|
||||
For complex response bodies, store JSON in Static Resources:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldParseComplexResponse() {
|
||||
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
|
||||
mock.setStaticResource('TestApiResponse'); // Static Resource name
|
||||
mock.setStatusCode(200);
|
||||
mock.setHeader('Content-Type', 'application/json');
|
||||
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
|
||||
Test.startTest();
|
||||
Result r = MyService.callExternalApi();
|
||||
Test.stopTest();
|
||||
|
||||
System.assertNotEquals(null, r, 'Should parse response');
|
||||
}
|
||||
```
|
||||
|
||||
## Stub API (Enterprise Pattern)
|
||||
|
||||
For mocking Apex class dependencies using `System.StubProvider`:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class MyServiceMock implements System.StubProvider {
|
||||
|
||||
public Object handleMethodCall(
|
||||
Object stubbedObject,
|
||||
String stubbedMethodName,
|
||||
Type returnType,
|
||||
List<Type> paramTypes,
|
||||
List<String> paramNames,
|
||||
List<Object> args
|
||||
) {
|
||||
if (stubbedMethodName == 'getAccountData') {
|
||||
return new AccountData('Mock Account', 'Active');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in test:
|
||||
@isTest
|
||||
static void shouldUseAccountData() {
|
||||
MyServiceMock mockProvider = new MyServiceMock();
|
||||
IMyService mockService = (IMyService)Test.createStub(IMyService.class, mockProvider);
|
||||
|
||||
// Inject mock into class under test
|
||||
MyController controller = new MyController(mockService);
|
||||
|
||||
Test.startTest();
|
||||
String result = controller.displayAccountInfo();
|
||||
Test.stopTest();
|
||||
|
||||
System.assert(result.contains('Mock Account'), 'Should use mocked data');
|
||||
}
|
||||
```
|
||||
|
||||
## Email Mocking
|
||||
|
||||
Apex sends real emails by default. Use limits to verify:
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldSendEmail_WhenTriggered() {
|
||||
Integer emailsBefore = Limits.getEmailInvocations();
|
||||
|
||||
Test.startTest();
|
||||
MyService.sendNotification(testContact);
|
||||
Test.stopTest();
|
||||
|
||||
// Verify email was queued (not actually sent in tests)
|
||||
System.assertEquals(
|
||||
emailsBefore + 1,
|
||||
Limits.getEmailInvocations(),
|
||||
'One email should be sent'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Event Testing
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
static void shouldPublishEvent_WhenRecordCreated() {
|
||||
Test.startTest();
|
||||
|
||||
// Enable event delivery in test context
|
||||
Test.enableChangeDataCapture();
|
||||
|
||||
Account acc = TestDataFactory.createAccount(true);
|
||||
|
||||
// Deliver events
|
||||
Test.getEventBus().deliver();
|
||||
|
||||
Test.stopTest();
|
||||
|
||||
// Query platform event trigger results
|
||||
List<EventLog__c> logs = [SELECT Id FROM EventLog__c WHERE AccountId__c = :acc.Id];
|
||||
System.assertEquals(1, logs.size(), 'Event handler should create log record');
|
||||
}
|
||||
```
|
||||
176
skills/generating-apex-test/references/test-data-factory.md
Normal file
176
skills/generating-apex-test/references/test-data-factory.md
Normal file
@ -0,0 +1,176 @@
|
||||
# TestDataFactory Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
TestDataFactory is a centralized utility class for creating test records with sensible defaults. It ensures consistent test data across all test classes and reduces duplication.
|
||||
|
||||
## Base Template
|
||||
|
||||
```apex
|
||||
@isTest
|
||||
public class TestDataFactory {
|
||||
|
||||
// ============ ACCOUNTS ============
|
||||
|
||||
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
|
||||
List<Account> accounts = new List<Account>();
|
||||
for (Integer i = 0; i < count; i++) {
|
||||
accounts.add(new Account(
|
||||
Name = 'Test Account ' + i,
|
||||
BillingStreet = '123 Test St',
|
||||
BillingCity = 'San Francisco',
|
||||
BillingState = 'CA',
|
||||
BillingPostalCode = '94105',
|
||||
BillingCountry = 'USA',
|
||||
Industry = 'Technology',
|
||||
Type = 'Customer'
|
||||
));
|
||||
}
|
||||
if (doInsert) insert accounts;
|
||||
return accounts;
|
||||
}
|
||||
|
||||
public static Account createAccount(Boolean doInsert) {
|
||||
return createAccounts(1, doInsert)[0];
|
||||
}
|
||||
|
||||
// ============ CONTACTS ============
|
||||
|
||||
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Contact> contacts = new List<Contact>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
contacts.add(new Contact(
|
||||
FirstName = 'Test',
|
||||
LastName = 'Contact ' + index,
|
||||
Email = 'test.contact' + index + '@example.com',
|
||||
Phone = '555-000-' + String.valueOf(index).leftPad(4, '0'),
|
||||
AccountId = acc.Id
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert contacts;
|
||||
return contacts;
|
||||
}
|
||||
|
||||
// ============ OPPORTUNITIES ============
|
||||
|
||||
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
|
||||
List<Opportunity> opps = new List<Opportunity>();
|
||||
Integer index = 0;
|
||||
for (Account acc : accounts) {
|
||||
for (Integer i = 0; i < countPerAccount; i++) {
|
||||
opps.add(new Opportunity(
|
||||
Name = 'Test Opportunity ' + index,
|
||||
AccountId = acc.Id,
|
||||
StageName = 'Prospecting',
|
||||
CloseDate = Date.today().addDays(30),
|
||||
Amount = 10000 + (index * 1000)
|
||||
));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
if (doInsert) insert opps;
|
||||
return opps;
|
||||
}
|
||||
|
||||
// ============ USERS ============
|
||||
|
||||
public static User createUser(String profileName, Boolean doInsert) {
|
||||
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
|
||||
String uniqueKey = String.valueOf(DateTime.now().getTime());
|
||||
|
||||
User u = new User(
|
||||
FirstName = 'Test',
|
||||
LastName = 'User ' + uniqueKey,
|
||||
Email = 'testuser' + uniqueKey + '@example.com',
|
||||
Username = 'testuser' + uniqueKey + '@example.com.test',
|
||||
Alias = 'tuser',
|
||||
TimeZoneSidKey = 'America/Los_Angeles',
|
||||
LocaleSidKey = 'en_US',
|
||||
EmailEncodingKey = 'UTF-8',
|
||||
LanguageLocaleKey = 'en_US',
|
||||
ProfileId = p.Id
|
||||
);
|
||||
if (doInsert) insert u;
|
||||
return u;
|
||||
}
|
||||
|
||||
// ============ CUSTOM OBJECTS ============
|
||||
|
||||
// Add methods for your custom objects following the same pattern:
|
||||
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Field Override Pattern
|
||||
|
||||
Allow callers to override default values:
|
||||
|
||||
```apex
|
||||
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
Industry = 'Technology'
|
||||
);
|
||||
|
||||
// Apply overrides
|
||||
for (String fieldName : fieldOverrides.keySet()) {
|
||||
acc.put(fieldName, fieldOverrides.get(fieldName));
|
||||
}
|
||||
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Usage:
|
||||
Account acc = TestDataFactory.createAccount(new Map<String, Object>{
|
||||
'Name' => 'Custom Name',
|
||||
'Industry' => 'Healthcare'
|
||||
}, true);
|
||||
```
|
||||
|
||||
## Handling Required Fields and Validation Rules
|
||||
|
||||
```apex
|
||||
public static Account createAccountWithRequiredFields(Boolean doInsert) {
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
// Required custom fields
|
||||
External_Id__c = 'EXT-' + String.valueOf(DateTime.now().getTime()),
|
||||
// Fields required by validation rules
|
||||
Phone = '555-123-4567',
|
||||
Website = 'https://example.com'
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Record Type Support
|
||||
|
||||
```apex
|
||||
public static Account createAccountByRecordType(String recordTypeName, Boolean doInsert) {
|
||||
Id recordTypeId = Schema.SObjectType.Account
|
||||
.getRecordTypeInfosByDeveloperName()
|
||||
.get(recordTypeName)
|
||||
.getRecordTypeId();
|
||||
|
||||
Account acc = new Account(
|
||||
Name = 'Test Account',
|
||||
RecordTypeId = recordTypeId
|
||||
);
|
||||
if (doInsert) insert acc;
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always include doInsert parameter** - Allows flexibility for tests that need to modify records before insert
|
||||
2. **Use unique identifiers** - Include index or timestamp in Name/Email fields to avoid duplicates
|
||||
3. **Set all required fields** - Include all fields required by validation rules
|
||||
4. **Return the created records** - Enables chaining and further manipulation
|
||||
5. **Create bulk methods first** - Single record methods should call bulk methods with count=1
|
||||
253
skills/generating-apex/SKILL.md
Normal file
253
skills/generating-apex/SKILL.md
Normal file
@ -0,0 +1,253 @@
|
||||
---
|
||||
name: generating-apex
|
||||
description: Generate production-ready Apex classes for Salesforce following enterprise best practices. Covers service, selector, domain, batch, queueable, schedulable, DTO, utility, interface, abstract, and custom exception classes. Use this skill when the user asks to create, build, generate, scaffold, or refactor an Apex class. SCOPE Apex classes only — does not apply to triggers or unit tests.
|
||||
---
|
||||
|
||||
# Apex Class
|
||||
|
||||
Generate well-structured, production-ready Apex classes for Salesforce development.
|
||||
|
||||
## Scope
|
||||
|
||||
**Generates:**
|
||||
- Service classes (business logic layer)
|
||||
- Selector / query classes (SOQL encapsulation)
|
||||
- Domain classes (SObject-specific logic)
|
||||
- Batch Apex classes (`Database.Batchable`)
|
||||
- Queueable Apex classes (`Queueable`, optionally `Finalizer`)
|
||||
- Schedulable Apex classes (`Schedulable`)
|
||||
- DTO / wrapper / request-response classes
|
||||
- Utility / helper classes
|
||||
- Custom exception classes
|
||||
- Interfaces and abstract classes
|
||||
|
||||
**Does NOT generate:**
|
||||
- Triggers (use a trigger framework skill)
|
||||
- Unit tests (use a test writer skill)
|
||||
- Aura controllers
|
||||
- LWC JavaScript controllers
|
||||
|
||||
---
|
||||
|
||||
## Gathering Requirements
|
||||
|
||||
Before generating code, gather these inputs from the user (ask if not provided):
|
||||
|
||||
1. **Class type** — Service, Selector, Batch, Queueable, Schedulable, Domain, DTO, Utility, Interface, Abstract, Exception
|
||||
2. **Class name** — or enough context to derive a meaningful name
|
||||
3. **SObject(s) involved** — if applicable
|
||||
4. **Business requirements** — plain-language description of what the class should do
|
||||
5. **Optional preferences:**
|
||||
- Sharing model (`with sharing`, `without sharing`, `inherited sharing`)
|
||||
- Access modifier (`public` or `global`)
|
||||
- API version (default: `62.0`)
|
||||
- Whether to include ApexDoc comments (default: yes)
|
||||
|
||||
If the user provides a clear, complete request, generate immediately without unnecessary back-and-forth.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards — ALWAYS Follow These
|
||||
|
||||
### Separation of Concerns
|
||||
- **Service classes** contain business logic. They call Selectors for data and may call Domain classes for SObject-specific behavior.
|
||||
- **Selector classes** encapsulate all SOQL queries. Services never contain inline SOQL.
|
||||
- **Domain classes** encapsulate SObject-specific logic (field defaults, validation, transformation).
|
||||
- Keep classes focused on a single responsibility.
|
||||
|
||||
### Bulkification
|
||||
- All methods must operate on collections (`List`, `Set`, `Map`) by default.
|
||||
- Never accept a single SObject when a `List<SObject>` is appropriate.
|
||||
- Provide convenience overloads for single-record callers only when it makes the API cleaner, and have them delegate to the bulk method.
|
||||
|
||||
### Governor Limit Safety
|
||||
- **No SOQL or DML inside loops.** Ever.
|
||||
- Collect IDs/records first, query/DML once outside the loop.
|
||||
- Use `Limits` class checks in batch/bulk operations where appropriate.
|
||||
- Prefer `Database.insert(records, false)` with error handling in batch contexts.
|
||||
|
||||
### Sharing Model
|
||||
- Default to `with sharing` unless the user specifies otherwise.
|
||||
- If `without sharing` or `inherited sharing` is used, add an ApexDoc `@description` comment explaining WHY.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Class Type | Pattern | Example |
|
||||
|-------------|-------------------------------|-------------------------------|
|
||||
| Service | `{SObject}Service` | `AccountService` |
|
||||
| Selector | `{SObject}Selector` | `AccountSelector` |
|
||||
| Domain | `{SObject}Domain` | `OpportunityDomain` |
|
||||
| Batch | `{Descriptive}Batch` | `AccountDeduplicationBatch` |
|
||||
| Queueable | `{Descriptive}Queueable` | `ExternalSyncQueueable` |
|
||||
| Schedulable | `{Descriptive}Schedulable` | `DailyCleanupSchedulable` |
|
||||
| DTO | `{Descriptive}DTO` | `AccountMergeRequestDTO` |
|
||||
| Wrapper | `{Descriptive}Wrapper` | `OpportunityLineWrapper` |
|
||||
| Utility | `{Descriptive}Util` | `StringUtil`, `DateUtil` |
|
||||
| Interface | `I{Descriptive}` | `INotificationService` |
|
||||
| Abstract | `Abstract{Descriptive}` | `AbstractIntegrationService` |
|
||||
| Exception | `{Descriptive}Exception` | `AccountServiceException` |
|
||||
|
||||
### ApexDoc Comments
|
||||
Include ApexDoc on every `public` and `global` method and on the class itself:
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the class does
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
```
|
||||
|
||||
```apex
|
||||
/**
|
||||
* @description Brief description of what the method does
|
||||
* @param paramName Description of the parameter
|
||||
* @return Description of the return value
|
||||
* @example
|
||||
* List<Account> results = AccountService.deduplicateAccounts(accountIds);
|
||||
*/
|
||||
```
|
||||
|
||||
### Null Safety
|
||||
- Use guard clauses at the top of methods for null/empty inputs.
|
||||
- Use safe navigation (`?.`) where appropriate.
|
||||
- Return empty collections rather than `null`.
|
||||
|
||||
### Constants
|
||||
- No magic strings or numbers in logic.
|
||||
- Use `private static final` constants or a dedicated constants class.
|
||||
- Use `Label.` custom labels for user-facing strings when appropriate.
|
||||
|
||||
### Custom Exceptions
|
||||
- Each service class should define or reference a corresponding custom exception.
|
||||
- Inner exception classes are preferred for simple cases: `public class AccountServiceException extends Exception {}`
|
||||
- Include meaningful error messages with context.
|
||||
|
||||
### Error Handling
|
||||
- Catch specific exceptions, not generic `Exception` unless re-throwing.
|
||||
- Log errors meaningfully (assume a logging utility exists or stub one).
|
||||
- In batch contexts, use `Database.SaveResult` / `Database.UpsertResult` with partial success.
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
For every class, produce TWO files:
|
||||
|
||||
1. **`{ClassName}.cls`** — The Apex class source code
|
||||
2. **`{ClassName}.cls-meta.xml`** — The metadata file
|
||||
|
||||
### Meta XML Template
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>{API_VERSION}</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
```
|
||||
|
||||
Default `apiVersion` is `62.0` unless the user specifies otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Class Type–Specific Instructions
|
||||
|
||||
### Service Classes
|
||||
- Read and follow: `assets/service.cls`
|
||||
- Stateless — no instance variables holding mutable state
|
||||
- All public methods should be `static` unless there's a compelling reason for instance methods
|
||||
- Delegate queries to a Selector class
|
||||
- Wrap business logic errors in a custom exception
|
||||
|
||||
### Selector Classes
|
||||
- Read and follow: `assets/selector.cls`
|
||||
- One Selector per SObject (or per logical query domain)
|
||||
- Return `List<SObject>` or `Map<Id, SObject>`
|
||||
- Accept filter criteria as method parameters, not hardcoded
|
||||
- Include a private method that returns the base field list to keep DRY
|
||||
|
||||
### Domain Classes
|
||||
- Read and follow: `assets/domain.cls`
|
||||
- Encapsulate field-level defaults, derivations, and validations
|
||||
- Operate on `List<SObject>` — designed to be called from triggers or services
|
||||
- No SOQL or DML — only in-memory SObject manipulation
|
||||
|
||||
### Batch Classes
|
||||
- Read and follow: `assets/batch.cls`
|
||||
- Implement `Database.Batchable<SObject>` and optionally `Database.Stateful`
|
||||
- Use `Database.QueryLocator` in `start()` for large datasets
|
||||
- Handle partial failures in `execute()` using `Database.SaveResult`
|
||||
- Implement meaningful `finish()` — at minimum, log completion
|
||||
|
||||
### Queueable Classes
|
||||
- Read and follow: `assets/queueable.cls`
|
||||
- Implement `Queueable` and optionally `Database.AllowsCallouts`
|
||||
- Accept data through the constructor — queueables are stateful
|
||||
- For chaining, include guard logic to prevent infinite chains
|
||||
- Optionally implement `Finalizer` for error recovery
|
||||
|
||||
### Schedulable Classes
|
||||
- Read and follow: `assets/schedulable.cls`
|
||||
- Implement `Schedulable`
|
||||
- Keep `execute()` lightweight — delegate to a Batch or Queueable
|
||||
- Include a static method that returns a CRON expression for convenience
|
||||
- Document the expected schedule in ApexDoc
|
||||
|
||||
### DTO / Wrapper Classes
|
||||
- Read and follow: `assets/dto.cls`
|
||||
- Use `public` properties — no getters/setters unless validation is needed
|
||||
- Include a no-arg constructor and optionally a parameterized constructor
|
||||
- Implement `Comparable` if sorting is needed
|
||||
- Keep them serialization-friendly (no transient state unless intentional)
|
||||
|
||||
### Utility Classes
|
||||
- Read and follow: `assets/utility.cls`
|
||||
- All methods `public static`
|
||||
- Class should be `public with sharing` with a `private` constructor to prevent instantiation
|
||||
- Group related utilities (e.g., `StringUtil`, `DateUtil`, `CollectionUtil`)
|
||||
- Every method must be side-effect-free (no DML, no SOQL)
|
||||
|
||||
### Interfaces
|
||||
- Read and follow: `assets/interface.cls`
|
||||
- Define the contract clearly with ApexDoc on every method signature
|
||||
- Use meaningful names that describe the capability: `INotificationService`, `IRetryable`
|
||||
|
||||
### Abstract Classes
|
||||
- Read and follow: `assets/abstract.cls`
|
||||
- Provide default implementations for common behavior
|
||||
- Mark extension points as `protected virtual` or `protected abstract`
|
||||
- Include a concrete example in the ApexDoc showing how to extend
|
||||
|
||||
### Custom Exceptions
|
||||
- Read and follow: `assets/exception.cls`
|
||||
- Extend `Exception`
|
||||
- Keep them simple — Apex exceptions don't support custom constructors well
|
||||
- Name them descriptively: `AccountServiceException`, `IntegrationTimeoutException`
|
||||
|
||||
---
|
||||
|
||||
## Generation Workflow
|
||||
|
||||
1. Determine the class type from the user's request
|
||||
2. Read the corresponding template from `assets/`
|
||||
3. Read relevant examples from `references/` if the class type has one
|
||||
4. Apply the user's requirements to the template pattern
|
||||
5. Generate the `.cls` file with full ApexDoc
|
||||
6. Generate the `.cls-meta.xml` file
|
||||
7. Present both files to the user
|
||||
8. Include a brief note on design decisions if any non-obvious choices were made
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ SOQL or DML inside loops
|
||||
- ❌ Hardcoded IDs or record type names (use `Schema.SObjectType` or Custom Metadata)
|
||||
- ❌ God classes that mix query + logic + DML
|
||||
- ❌ `public` fields on service classes
|
||||
- ❌ Returning `null` from methods that should return collections
|
||||
- ❌ Generic `catch (Exception e)` without re-throwing or meaningful handling
|
||||
- ❌ Business logic in Batch `start()` methods
|
||||
- ❌ Tight coupling between classes — use interfaces for extensibility
|
||||
- ❌ Magic strings or numbers
|
||||
- ❌ Methods longer than ~40 lines — break them into private helpers
|
||||
128
skills/generating-apex/assets/abstract.cls
Normal file
128
skills/generating-apex/assets/abstract.cls
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @description Abstract base class for {describe the family of classes this serves}.
|
||||
* Provides common behavior and defines extension points for subclasses.
|
||||
* Subclasses must implement the abstract methods to provide specific behavior.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Extending this abstract class:
|
||||
* public class SalesforceIntegrationService extends {ClassName} {
|
||||
* protected override String getEndpoint() {
|
||||
* return 'callout:Salesforce_API/services/data/v62.0';
|
||||
* }
|
||||
*
|
||||
* protected override Map<String, String> getHeaders() {
|
||||
* return new Map<String, String>{
|
||||
* 'Content-Type' => 'application/json'
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public abstract with sharing class {ClassName} {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
// ─── Protected State ─────────────────────────────────────────────────
|
||||
protected Integer timeoutMs;
|
||||
|
||||
// ─── Constructor ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Initializes the base class with default configuration
|
||||
*/
|
||||
protected {ClassName}() {
|
||||
this.timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ─── Abstract Methods (must be implemented by subclasses) ────────────
|
||||
|
||||
/**
|
||||
* @description Returns the endpoint URL for this integration.
|
||||
* Subclasses must provide their specific endpoint.
|
||||
* @return The endpoint URL as a String
|
||||
*/
|
||||
protected abstract String getEndpoint();
|
||||
|
||||
/**
|
||||
* @description Returns the HTTP headers for this integration.
|
||||
* Subclasses define their own required headers.
|
||||
* @return Map of header name to header value
|
||||
*/
|
||||
protected abstract Map<String, String> getHeaders();
|
||||
|
||||
// ─── Virtual Methods (can be overridden by subclasses) ───────────────
|
||||
|
||||
/**
|
||||
* @description Hook called before the main operation executes.
|
||||
* Override to add pre-processing logic.
|
||||
* Default implementation does nothing.
|
||||
* @param context Map of contextual data
|
||||
*/
|
||||
protected virtual void beforeExecute(Map<String, Object> context) {
|
||||
// Default: no-op — override in subclass if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Hook called after the main operation completes.
|
||||
* Override to add post-processing logic.
|
||||
* Default implementation does nothing.
|
||||
* @param context Map of contextual data
|
||||
* @param result The result from the operation
|
||||
*/
|
||||
protected virtual void afterExecute(Map<String, Object> context, Object result) {
|
||||
// Default: no-op — override in subclass if needed
|
||||
}
|
||||
|
||||
// ─── Template Method (common workflow) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Executes the operation using the template method pattern.
|
||||
* Calls beforeExecute → doExecute → afterExecute in sequence.
|
||||
* @param context Map of data needed for the operation
|
||||
* @return The result of the operation
|
||||
*/
|
||||
public Object execute(Map<String, Object> context) {
|
||||
beforeExecute(context);
|
||||
|
||||
Object result;
|
||||
try {
|
||||
result = doExecute(context);
|
||||
} catch (Exception e) {
|
||||
handleError(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
afterExecute(context, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Protected Helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Core execution logic — override this for the main operation.
|
||||
* Default implementation throws — subclass must provide implementation.
|
||||
* @param context Map of data needed for the operation
|
||||
* @return The result of the operation
|
||||
*/
|
||||
protected virtual Object doExecute(Map<String, Object> context) {
|
||||
throw new UnsupportedOperationException(
|
||||
'Subclass must override doExecute()'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Error handler called when doExecute throws.
|
||||
* Override to customize error handling (e.g., logging, retry).
|
||||
* @param e The exception that was thrown
|
||||
*/
|
||||
protected virtual void handleError(Exception e) {
|
||||
System.debug(LoggingLevel.ERROR,
|
||||
this.toString() + ' error: ' + e.getMessage() + '\n' + e.getStackTraceString()
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
public class UnsupportedOperationException extends Exception {}
|
||||
}
|
||||
125
skills/generating-apex/assets/batch.cls
Normal file
125
skills/generating-apex/assets/batch.cls
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @description Batch Apex class for {describe the batch operation}.
|
||||
* Processes {SObject} records in configurable batch sizes.
|
||||
* Implements Database.Stateful to track cumulative results across chunks.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Execute with default batch size
|
||||
* Database.executeBatch(new {ClassName}());
|
||||
*
|
||||
* // Execute with custom batch size
|
||||
* Database.executeBatch(new {ClassName}(), 100);
|
||||
*/
|
||||
public with sharing class {ClassName} implements Database.Batchable<SObject>, Database.Stateful {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_BATCH_SIZE = 200;
|
||||
|
||||
// ─── Stateful Tracking ───────────────────────────────────────────────
|
||||
private Integer totalProcessed = 0;
|
||||
private Integer totalErrors = 0;
|
||||
private List<String> errorMessages = new List<String>();
|
||||
|
||||
// ─── Constructor ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Default constructor
|
||||
*/
|
||||
public {ClassName}() {
|
||||
// Default configuration
|
||||
}
|
||||
|
||||
// ─── Batchable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Defines the scope of records to process.
|
||||
* Uses Database.QueryLocator for efficient large-dataset processing.
|
||||
* @param bc The batch context
|
||||
* @return QueryLocator for the records to process
|
||||
*/
|
||||
public Database.QueryLocator start(Database.BatchableContext bc) {
|
||||
return Database.getQueryLocator([
|
||||
SELECT Id, Name
|
||||
// TODO: Add fields needed for processing
|
||||
FROM {SObject}
|
||||
// TODO: Add WHERE clause to scope the records
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes each batch of records.
|
||||
* Uses Database.update with allOrNone=false for partial success handling.
|
||||
* @param bc The batch context
|
||||
* @param scope List of {SObject} records in the current batch
|
||||
*/
|
||||
public void execute(Database.BatchableContext bc, List<{SObject}> scope) {
|
||||
List<{SObject}> recordsToUpdate = new List<{SObject}>();
|
||||
|
||||
for ({SObject} record : scope) {
|
||||
// TODO: Apply business logic to each record
|
||||
recordsToUpdate.add(record);
|
||||
}
|
||||
|
||||
if (!recordsToUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(recordsToUpdate, false);
|
||||
processResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Performs post-processing after all batches complete.
|
||||
* Logs a summary of the batch execution.
|
||||
* @param bc The batch context
|
||||
*/
|
||||
public void finish(Database.BatchableContext bc) {
|
||||
String summary = String.format(
|
||||
'{0} completed. Processed: {1}, Errors: {2}',
|
||||
new List<Object>{
|
||||
{ClassName}.class.getName(),
|
||||
totalProcessed,
|
||||
totalErrors
|
||||
}
|
||||
);
|
||||
|
||||
System.debug(LoggingLevel.INFO, summary);
|
||||
|
||||
if (!errorMessages.isEmpty()) {
|
||||
System.debug(LoggingLevel.ERROR, 'Error details: ' + String.join(errorMessages, '\n'));
|
||||
}
|
||||
|
||||
// TODO: Send completion notification email or post to chatter if needed
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Processes Database.SaveResult list, tracking successes and failures
|
||||
* @param results List of SaveResult from a DML operation
|
||||
*/
|
||||
private void processResults(List<Database.SaveResult> results) {
|
||||
for (Database.SaveResult result : results) {
|
||||
if (result.isSuccess()) {
|
||||
totalProcessed++;
|
||||
} else {
|
||||
totalErrors++;
|
||||
for (Database.Error err : result.getErrors()) {
|
||||
errorMessages.add(
|
||||
'Record ' + result.getId() + ': ' +
|
||||
err.getStatusCode() + ' - ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Static Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Convenience method to execute with default batch size
|
||||
* @return The batch job Id
|
||||
*/
|
||||
public static Id run() {
|
||||
return Database.executeBatch(new {ClassName}(), DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
102
skills/generating-apex/assets/domain.cls
Normal file
102
skills/generating-apex/assets/domain.cls
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @description Domain class for {SObject}.
|
||||
* Encapsulates field-level defaults, derivations, and validations.
|
||||
* Operates only on in-memory SObject data — no SOQL or DML.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Domain {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
// TODO: Add constants for default values, statuses, etc.
|
||||
|
||||
// ─── Field Defaults ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Applies default field values to new {SObject} records.
|
||||
* Call this before insert to ensure consistent defaults.
|
||||
* @param records List of {SObject} records to apply defaults to
|
||||
*/
|
||||
public static void applyDefaults(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Set default field values
|
||||
// Example:
|
||||
// if (record.Status__c == null) {
|
||||
// record.Status__c = DEFAULT_STATUS;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Derivations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Derives calculated field values based on other fields.
|
||||
* Call this before insert and before update.
|
||||
* @param records List of {SObject} records to derive values for
|
||||
*/
|
||||
public static void deriveFields(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Derive calculated field values
|
||||
// Example:
|
||||
// record.FullAddress__c = buildFullAddress(record);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Validations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Validates {SObject} records and adds errors for any violations.
|
||||
* Call this before insert and before update.
|
||||
* @param records List of {SObject} records to validate
|
||||
*/
|
||||
public static void validate(List<{SObject}> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for ({SObject} record : records) {
|
||||
// TODO: Add validation rules
|
||||
// Example:
|
||||
// if (String.isBlank(record.Name)) {
|
||||
// record.addError('Name is required.');
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comparisons ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Determines which fields have changed between old and new record versions.
|
||||
* Useful in before update context.
|
||||
* @param oldRecord The previous version of the record
|
||||
* @param newRecord The current version of the record
|
||||
* @return Set of field API names that have changed
|
||||
*/
|
||||
public static Set<String> getChangedFields({SObject} oldRecord, {SObject} newRecord) {
|
||||
Set<String> changedFields = new Set<String>();
|
||||
|
||||
if (oldRecord == null || newRecord == null) {
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.{SObject}.fields.getMap();
|
||||
for (String fieldName : fieldMap.keySet()) {
|
||||
if (oldRecord.get(fieldName) != newRecord.get(fieldName)) {
|
||||
changedFields.add(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add private helper methods as needed
|
||||
}
|
||||
108
skills/generating-apex/assets/dto.cls
Normal file
108
skills/generating-apex/assets/dto.cls
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @description Data Transfer Object for {describe the data this DTO represents}.
|
||||
* Used to pass structured data between layers without exposing SObjects.
|
||||
* Serialization-friendly for use with JSON.serialize/deserialize and API responses.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Create from constructor
|
||||
* {ClassName} dto = new {ClassName}('value1', 42);
|
||||
*
|
||||
* // Deserialize from JSON
|
||||
* {ClassName} dto = ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
|
||||
*/
|
||||
public with sharing class {ClassName} {
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public String name { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Id recordId { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public Boolean isActive { get; set; }
|
||||
|
||||
/** @description {Describe this property} */
|
||||
public List<String> tags { get; set; }
|
||||
|
||||
// TODO: Add additional properties as needed
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description No-arg constructor for deserialization compatibility
|
||||
*/
|
||||
public {ClassName}() {
|
||||
this.tags = new List<String>();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parameterized constructor for convenience
|
||||
* @param name The name value
|
||||
* @param recordId The associated record Id
|
||||
*/
|
||||
public {ClassName}(String name, Id recordId) {
|
||||
this();
|
||||
this.name = name;
|
||||
this.recordId = recordId;
|
||||
}
|
||||
|
||||
// ─── Factory Methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Creates a DTO instance from an SObject record
|
||||
* @param record The source {SObject} record
|
||||
* @return A populated {ClassName} instance
|
||||
*/
|
||||
public static {ClassName} fromSObject(SObject record) {
|
||||
if (record == null) {
|
||||
return new {ClassName}();
|
||||
}
|
||||
|
||||
{ClassName} dto = new {ClassName}();
|
||||
dto.recordId = record.Id;
|
||||
dto.name = (String) record.get('Name');
|
||||
// TODO: Map additional fields
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a list of DTOs from a list of SObject records
|
||||
* @param records The source records
|
||||
* @return List of populated {ClassName} instances
|
||||
*/
|
||||
public static List<{ClassName}> fromSObjects(List<SObject> records) {
|
||||
List<{ClassName}> dtos = new List<{ClassName}>();
|
||||
if (records == null) {
|
||||
return dtos;
|
||||
}
|
||||
|
||||
for (SObject record : records) {
|
||||
dtos.add(fromSObject(record));
|
||||
}
|
||||
return dtos;
|
||||
}
|
||||
|
||||
// ─── Utility Methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Serializes this DTO to a JSON string
|
||||
* @return JSON representation of this DTO
|
||||
*/
|
||||
public String toJson() {
|
||||
return JSON.serialize(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deserializes a JSON string into a {ClassName} instance
|
||||
* @param jsonString The JSON string to deserialize
|
||||
* @return A {ClassName} instance
|
||||
*/
|
||||
public static {ClassName} fromJson(String jsonString) {
|
||||
return ({ClassName}) JSON.deserialize(jsonString, {ClassName}.class);
|
||||
}
|
||||
}
|
||||
51
skills/generating-apex/assets/exception.cls
Normal file
51
skills/generating-apex/assets/exception.cls
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @description Custom exception for {describe when this exception is thrown}.
|
||||
* Use this exception to signal domain-specific errors that callers
|
||||
* can catch and handle distinctly from system exceptions.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* throw new {ClassName}('Account merge failed: duplicate detected.');
|
||||
*
|
||||
* // Wrap a caught exception
|
||||
* try {
|
||||
* // ... risky operation
|
||||
* } catch (DmlException e) {
|
||||
* throw new {ClassName}('DML failed during account merge: ' + e.getMessage());
|
||||
* }
|
||||
*/
|
||||
public with sharing class {ClassName} extends Exception {
|
||||
// Apex custom exceptions automatically inherit:
|
||||
// - getMessage()
|
||||
// - getCause()
|
||||
// - getStackTraceString()
|
||||
// - setMessage(String)
|
||||
// - initCause(Exception)
|
||||
//
|
||||
// And support these constructor patterns:
|
||||
// - new {ClassName}()
|
||||
// - new {ClassName}('message')
|
||||
// - new {ClassName}(causeException)
|
||||
// - new {ClassName}('message', causeException)
|
||||
//
|
||||
// Note: Apex does NOT support custom constructors on Exception subclasses.
|
||||
// To add context, use the message string or create a wrapper pattern:
|
||||
//
|
||||
// Example wrapper pattern (if you need structured error data):
|
||||
//
|
||||
// public class {ClassName}Detail {
|
||||
// public String errorCode;
|
||||
// public List<Id> failedRecordIds;
|
||||
// public String detail;
|
||||
//
|
||||
// public {ClassName}Detail(String errorCode, List<Id> failedRecordIds, String detail) {
|
||||
// this.errorCode = errorCode;
|
||||
// this.failedRecordIds = failedRecordIds;
|
||||
// this.detail = detail;
|
||||
// }
|
||||
//
|
||||
// public override String toString() {
|
||||
// return '[' + errorCode + '] ' + detail + ' (Records: ' + failedRecordIds + ')';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
25
skills/generating-apex/assets/interface.cls
Normal file
25
skills/generating-apex/assets/interface.cls
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @description Interface for {describe the capability or contract this interface defines}.
|
||||
* Implement this interface to provide {describe what implementations do}.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* public class EmailNotificationService implements {InterfaceName} {
|
||||
* public void execute(Map<String, Object> params) {
|
||||
* // Implementation
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public interface {InterfaceName} {
|
||||
|
||||
/**
|
||||
* @description {Describe what this method should do}
|
||||
* @param params {Describe the parameter}
|
||||
* @return {Describe the return value}
|
||||
*/
|
||||
// TODO: Define interface methods
|
||||
// Example:
|
||||
// void execute(Map<String, Object> params);
|
||||
// Boolean isEligible(SObject record);
|
||||
// List<SObject> process(List<SObject> records);
|
||||
}
|
||||
92
skills/generating-apex/assets/queueable.cls
Normal file
92
skills/generating-apex/assets/queueable.cls
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @description Queueable Apex class for {describe the async operation}.
|
||||
* Accepts data through the constructor for stateful processing.
|
||||
* Optionally implements Database.AllowsCallouts for external integrations.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Enqueue the job
|
||||
* Id jobId = System.enqueueJob(new {ClassName}(recordIds));
|
||||
*/
|
||||
public with sharing class {ClassName} implements Queueable /*, Database.AllowsCallouts */ {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer MAX_CHAIN_DEPTH = 5;
|
||||
|
||||
// ─── Instance Variables (Stateful) ───────────────────────────────────
|
||||
private Set<Id> recordIds;
|
||||
private Integer chainDepth;
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Creates a new queueable job to process the specified records
|
||||
* @param recordIds Set of record Ids to process
|
||||
*/
|
||||
public {ClassName}(Set<Id> recordIds) {
|
||||
this(recordIds, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Creates a new queueable job with chain depth tracking
|
||||
* @param recordIds Set of record Ids to process
|
||||
* @param chainDepth Current depth in the queueable chain
|
||||
*/
|
||||
public {ClassName}(Set<Id> recordIds, Integer chainDepth) {
|
||||
this.recordIds = recordIds ?? new Set<Id>();
|
||||
this.chainDepth = chainDepth;
|
||||
}
|
||||
|
||||
// ─── Queueable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Executes the asynchronous work
|
||||
* @param context The queueable context
|
||||
*/
|
||||
public void execute(QueueableContext context) {
|
||||
if (this.recordIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Implement the async processing logic
|
||||
// List<{SObject}> records = {SObject}Selector.selectByIds(this.recordIds);
|
||||
// ... process records ...
|
||||
|
||||
// Chain to next job if there's more work and we haven't hit the depth limit
|
||||
chainIfNeeded();
|
||||
|
||||
} catch (Exception e) {
|
||||
handleError(context.getJobId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Chains to the next queueable job if needed, with depth guard
|
||||
*/
|
||||
private void chainIfNeeded() {
|
||||
// TODO: Determine if chaining is needed (e.g., remaining records to process)
|
||||
Set<Id> remainingIds = new Set<Id>();
|
||||
|
||||
if (!remainingIds.isEmpty() && this.chainDepth < MAX_CHAIN_DEPTH) {
|
||||
if (!Test.isRunningTest()) {
|
||||
System.enqueueJob(new {ClassName}(remainingIds, this.chainDepth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handles errors during execution
|
||||
* @param jobId The async job Id
|
||||
* @param e The exception that occurred
|
||||
*/
|
||||
private void handleError(Id jobId, Exception e) {
|
||||
System.debug(LoggingLevel.ERROR,
|
||||
'{ClassName} failed (Job: ' + jobId + '): ' +
|
||||
e.getMessage() + '\n' + e.getStackTraceString()
|
||||
);
|
||||
// TODO: Persist error to a log object or send notification
|
||||
}
|
||||
}
|
||||
75
skills/generating-apex/assets/schedulable.cls
Normal file
75
skills/generating-apex/assets/schedulable.cls
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @description Schedulable Apex class for {describe the scheduled operation}.
|
||||
* Delegates heavy processing to a Batch or Queueable job.
|
||||
* Keep execute() lightweight — it should only launch other jobs.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Schedule to run daily at 2 AM
|
||||
* String jobId = System.schedule(
|
||||
* '{ClassName} - Daily',
|
||||
* {ClassName}.CRON_DAILY_2AM,
|
||||
* new {ClassName}()
|
||||
* );
|
||||
*
|
||||
* // Or use the convenience method
|
||||
* String jobId = {ClassName}.scheduleDaily();
|
||||
*/
|
||||
public with sharing class {ClassName} implements Schedulable {
|
||||
|
||||
// ─── CRON Expressions ────────────────────────────────────────────────
|
||||
// Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year
|
||||
|
||||
/** @description Runs daily at 2:00 AM */
|
||||
public static final String CRON_DAILY_2AM = '0 0 2 * * ?';
|
||||
|
||||
/** @description Runs every weekday at 6:00 AM */
|
||||
public static final String CRON_WEEKDAYS_6AM = '0 0 6 ? * MON-FRI';
|
||||
|
||||
/** @description Runs hourly at the top of the hour */
|
||||
public static final String CRON_HOURLY = '0 0 * * * ?';
|
||||
|
||||
// ─── Schedulable Interface ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Entry point for the scheduled execution.
|
||||
* Delegates to a Batch or Queueable for the actual work.
|
||||
* @param sc The schedulable context
|
||||
*/
|
||||
public void execute(SchedulableContext sc) {
|
||||
// Option A: Launch a Batch job
|
||||
// Database.executeBatch(new {BatchClassName}(), 200);
|
||||
|
||||
// Option B: Launch a Queueable job
|
||||
// System.enqueueJob(new {QueueableClassName}(params));
|
||||
|
||||
// TODO: Implement delegation to appropriate async job
|
||||
}
|
||||
|
||||
// ─── Convenience Scheduling Methods ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Schedules this job to run daily at 2 AM
|
||||
* @return The scheduled job Id
|
||||
*/
|
||||
public static String scheduleDaily() {
|
||||
return System.schedule(
|
||||
'{ClassName} - Daily 2AM',
|
||||
CRON_DAILY_2AM,
|
||||
new {ClassName}()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Aborts this scheduled job by name
|
||||
* @param jobName The name used when scheduling
|
||||
*/
|
||||
public static void abort(String jobName) {
|
||||
for (CronTrigger ct : [
|
||||
SELECT Id FROM CronTrigger
|
||||
WHERE CronJobDetail.Name = :jobName
|
||||
]) {
|
||||
System.abortJob(ct.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
skills/generating-apex/assets/selector.cls
Normal file
92
skills/generating-apex/assets/selector.cls
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @description Selector class for {SObject} queries.
|
||||
* Encapsulates all SOQL for {SObject} records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Selector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns the default set of fields to query for {SObject}.
|
||||
* Centralizes field references to keep queries DRY.
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getDefaultFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'CreatedDate',
|
||||
'LastModifiedDate'
|
||||
// TODO: Add additional fields here
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Query Methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records by their Ids
|
||||
* @param recordIds Set of {SObject} Ids to query
|
||||
* @return List of {SObject} records matching the provided Ids
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<{SObject}> results = {SObject}Selector.selectByIds(ids);
|
||||
*/
|
||||
public static List<{SObject}> selectByIds(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<{SObject}>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM {SObject}' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records as a Map keyed by Id
|
||||
* @param recordIds Set of {SObject} Ids to query
|
||||
* @return Map of Id to {SObject}
|
||||
*/
|
||||
public static Map<Id, {SObject}> selectMapByIds(Set<Id> recordIds) {
|
||||
return new Map<Id, {SObject}>(selectByIds(recordIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects {SObject} records by a specific field value
|
||||
* @param fieldName API name of the field to filter on
|
||||
* @param values Set of values to match
|
||||
* @return List of matching {SObject} records
|
||||
* @example
|
||||
* List<{SObject}> results = {SObject}Selector.selectByField('Status__c', new Set<String>{ 'Active' });
|
||||
*/
|
||||
public static List<{SObject}> selectByField(String fieldName, Set<String> values) {
|
||||
if (String.isBlank(fieldName) || values == null || values.isEmpty()) {
|
||||
return new List<{SObject}>();
|
||||
}
|
||||
|
||||
// Validate field name to prevent SOQL injection
|
||||
Schema.SObjectField field = Schema.SObjectType.{SObject}.fields.getMap().get(fieldName);
|
||||
if (field == null) {
|
||||
throw new QueryException('Invalid field name: ' + fieldName);
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM {SObject}' +
|
||||
' WHERE ' + String.escapeSingleQuotes(fieldName) + ' IN :values'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for query errors
|
||||
*/
|
||||
public class QueryException extends Exception {}
|
||||
}
|
||||
69
skills/generating-apex/assets/service.cls
Normal file
69
skills/generating-apex/assets/service.cls
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @description Service class for {SObject} business logic.
|
||||
* Follows separation of concerns: delegates queries to {SObject}Selector
|
||||
* and SObject manipulation to {SObject}Domain where applicable.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {SObject}Service {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description {Describe the primary operation}
|
||||
* @param recordIds Set of {SObject} Ids to process
|
||||
* @return List of processed {SObject} records
|
||||
* @throws {SObject}ServiceException if processing fails
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<{SObject}> results = {SObject}Service.process{SObject}s(ids);
|
||||
*/
|
||||
public static List<{SObject}> process{SObject}s(Set<Id> recordIds) {
|
||||
// Guard clause
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
throw new {SObject}ServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
// Query via Selector
|
||||
List<{SObject}> records = {SObject}Selector.selectByIds(recordIds);
|
||||
|
||||
// Apply business logic
|
||||
// TODO: Implement business logic here
|
||||
|
||||
// DML
|
||||
try {
|
||||
update records;
|
||||
} catch (DmlException e) {
|
||||
throw new {SObject}ServiceException(
|
||||
'Failed to update {SObject} records: ' + e.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ─── Convenience Overloads ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Single-record convenience overload
|
||||
* @param recordId The {SObject} Id to process
|
||||
* @return The processed {SObject} record
|
||||
*/
|
||||
public static {SObject} process{SObject}(Id recordId) {
|
||||
List<{SObject}> results = process{SObject}s(new Set<Id>{ recordId });
|
||||
return results.isEmpty() ? null : results[0];
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add private helper methods as needed
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for {SObject}Service errors
|
||||
*/
|
||||
public class {SObject}ServiceException extends Exception {}
|
||||
}
|
||||
97
skills/generating-apex/assets/utility.cls
Normal file
97
skills/generating-apex/assets/utility.cls
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @description Utility class for {describe the category of utilities: String, Date, Collection, etc.}.
|
||||
* All methods are static and side-effect-free (no SOQL, no DML).
|
||||
* Private constructor prevents instantiation.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class {ClassName} {
|
||||
|
||||
// ─── Private Constructor ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Prevents instantiation — use static methods only
|
||||
*/
|
||||
@TestVisible
|
||||
private {ClassName}() {
|
||||
// Utility class — do not instantiate
|
||||
}
|
||||
|
||||
// ─── Public Methods ──────────────────────────────────────────────────
|
||||
|
||||
// TODO: Add utility methods below. Examples:
|
||||
|
||||
/**
|
||||
* @description Safely converts a String to an Integer, returning a default if parsing fails
|
||||
* @param value The String to parse
|
||||
* @param defaultValue The fallback value if parsing fails
|
||||
* @return The parsed Integer or the default value
|
||||
* @example
|
||||
* Integer result = {ClassName}.safeParseInteger('42', 0); // returns 42
|
||||
* Integer result = {ClassName}.safeParseInteger('abc', 0); // returns 0
|
||||
*/
|
||||
public static Integer safeParseInteger(String value, Integer defaultValue) {
|
||||
if (String.isBlank(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.trim());
|
||||
} catch (TypeException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Chunks a list into smaller sublists of the specified size.
|
||||
* Useful for processing records in governor-limit-safe batches.
|
||||
* @param items The list to chunk
|
||||
* @param chunkSize The maximum size of each chunk
|
||||
* @return A list of sublists, each containing up to chunkSize elements
|
||||
* @example
|
||||
* List<List<String>> chunks = {ClassName}.chunkList(myList, 200);
|
||||
*/
|
||||
public static List<List<Object>> chunkList(List<Object> items, Integer chunkSize) {
|
||||
List<List<Object>> chunks = new List<List<Object>>();
|
||||
if (items == null || items.isEmpty() || chunkSize <= 0) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
List<Object> currentChunk = new List<Object>();
|
||||
for (Object item : items) {
|
||||
currentChunk.add(item);
|
||||
if (currentChunk.size() == chunkSize) {
|
||||
chunks.add(currentChunk);
|
||||
currentChunk = new List<Object>();
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentChunk.isEmpty()) {
|
||||
chunks.add(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extracts a Set of non-null field values from a list of SObjects
|
||||
* @param records The SObject records to extract from
|
||||
* @param fieldName The API name of the field to extract
|
||||
* @return A Set of non-null String values
|
||||
* @example
|
||||
* Set<String> emails = {ClassName}.pluckStrings(contacts, 'Email');
|
||||
*/
|
||||
public static Set<String> pluckStrings(List<SObject> records, String fieldName) {
|
||||
Set<String> values = new Set<String>();
|
||||
if (records == null || String.isBlank(fieldName)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (SObject record : records) {
|
||||
Object val = record.get(fieldName);
|
||||
if (val != null) {
|
||||
values.add(String.valueOf(val));
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
148
skills/generating-apex/references/AccountDeduplicationBatch.cls
Normal file
148
skills/generating-apex/references/AccountDeduplicationBatch.cls
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @description Batch Apex class for identifying and flagging duplicate Account records.
|
||||
* Compares Accounts by Name and BillingPostalCode to find potential duplicates.
|
||||
* Flags duplicates by setting the Is_Potential_Duplicate__c checkbox.
|
||||
* Implements Database.Stateful to track results across batch chunks.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*
|
||||
* @example
|
||||
* // Run with default batch size (200)
|
||||
* Id jobId = AccountDeduplicationBatch.run();
|
||||
*
|
||||
* // Run with smaller batch size for complex processing
|
||||
* Database.executeBatch(new AccountDeduplicationBatch(), 50);
|
||||
*/
|
||||
public with sharing class AccountDeduplicationBatch implements Database.Batchable<SObject>, Database.Stateful {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final Integer DEFAULT_BATCH_SIZE = 200;
|
||||
|
||||
// ─── Stateful Tracking ───────────────────────────────────────────────
|
||||
private Integer totalScanned = 0;
|
||||
private Integer duplicatesFound = 0;
|
||||
private Integer totalErrors = 0;
|
||||
private List<String> errorMessages = new List<String>();
|
||||
|
||||
// ─── Batchable Interface ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Queries all active Accounts that haven't already been flagged
|
||||
* @param bc The batch context
|
||||
* @return QueryLocator scoped to unflagged active Accounts
|
||||
*/
|
||||
public Database.QueryLocator start(Database.BatchableContext bc) {
|
||||
return Database.getQueryLocator([
|
||||
SELECT Id, Name, BillingPostalCode, Is_Potential_Duplicate__c
|
||||
FROM Account
|
||||
WHERE IsDeleted = FALSE
|
||||
AND Is_Potential_Duplicate__c = FALSE
|
||||
ORDER BY Name ASC
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes each batch by building a duplicate key and checking for matches.
|
||||
* Uses a composite key of normalized Name + BillingPostalCode.
|
||||
* @param bc The batch context
|
||||
* @param scope List of Account records in the current batch
|
||||
*/
|
||||
public void execute(Database.BatchableContext bc, List<Account> scope) {
|
||||
totalScanned += scope.size();
|
||||
|
||||
// Build duplicate keys for this batch
|
||||
Map<String, List<Account>> dupeKeyMap = new Map<String, List<Account>>();
|
||||
for (Account acct : scope) {
|
||||
String key = buildDuplicateKey(acct);
|
||||
if (String.isNotBlank(key)) {
|
||||
if (!dupeKeyMap.containsKey(key)) {
|
||||
dupeKeyMap.put(key, new List<Account>());
|
||||
}
|
||||
dupeKeyMap.get(key).add(acct);
|
||||
}
|
||||
}
|
||||
|
||||
// Flag records that share a key with another record
|
||||
List<Account> toUpdate = new List<Account>();
|
||||
for (String key : dupeKeyMap.keySet()) {
|
||||
List<Account> group = dupeKeyMap.get(key);
|
||||
if (group.size() > 1) {
|
||||
for (Account acct : group) {
|
||||
toUpdate.add(new Account(
|
||||
Id = acct.Id,
|
||||
Is_Potential_Duplicate__c = true
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(toUpdate, false);
|
||||
processResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Logs a summary of the deduplication batch run
|
||||
* @param bc The batch context
|
||||
*/
|
||||
public void finish(Database.BatchableContext bc) {
|
||||
String summary = String.format(
|
||||
'AccountDeduplicationBatch completed. Scanned: {0}, Duplicates flagged: {1}, Errors: {2}',
|
||||
new List<Object>{ totalScanned, duplicatesFound, totalErrors }
|
||||
);
|
||||
|
||||
System.debug(LoggingLevel.INFO, summary);
|
||||
|
||||
if (!errorMessages.isEmpty()) {
|
||||
System.debug(LoggingLevel.ERROR, 'Error details:\n' + String.join(errorMessages, '\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Builds a normalized composite key for duplicate detection
|
||||
* @param acct The Account record
|
||||
* @return Normalized key string, or null if insufficient data
|
||||
*/
|
||||
private String buildDuplicateKey(Account acct) {
|
||||
if (String.isBlank(acct.Name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedName = acct.Name.trim().toUpperCase().replaceAll('\\s+', ' ');
|
||||
String postalCode = (acct.BillingPostalCode ?? '').trim().toUpperCase();
|
||||
|
||||
return normalizedName + '|' + postalCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Processes DML results, tracking successes and failures
|
||||
* @param results List of Database.SaveResult from update operation
|
||||
*/
|
||||
private void processResults(List<Database.SaveResult> results) {
|
||||
for (Database.SaveResult sr : results) {
|
||||
if (sr.isSuccess()) {
|
||||
duplicatesFound++;
|
||||
} else {
|
||||
totalErrors++;
|
||||
for (Database.Error err : sr.getErrors()) {
|
||||
errorMessages.add(
|
||||
'Record ' + sr.getId() + ': ' +
|
||||
err.getStatusCode() + ' - ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Static Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Convenience method to execute with default batch size
|
||||
* @return The batch job Id
|
||||
*/
|
||||
public static Id run() {
|
||||
return Database.executeBatch(new AccountDeduplicationBatch(), DEFAULT_BATCH_SIZE);
|
||||
}
|
||||
}
|
||||
193
skills/generating-apex/references/AccountSelector.cls
Normal file
193
skills/generating-apex/references/AccountSelector.cls
Normal file
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @description Selector class for Account queries.
|
||||
* Encapsulates all SOQL for Account records.
|
||||
* All methods return bulkified results (Lists or Maps).
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class AccountSelector {
|
||||
|
||||
// ─── Field Lists ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns the default set of fields to query for Account.
|
||||
* Centralizes field references to keep queries DRY.
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getDefaultFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'AccountNumber',
|
||||
'Type',
|
||||
'Industry',
|
||||
'AnnualRevenue',
|
||||
'NumberOfEmployees',
|
||||
'Phone',
|
||||
'Website',
|
||||
'OwnerId',
|
||||
'CreatedDate',
|
||||
'LastModifiedDate'
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Returns fields needed for billing/territory operations
|
||||
* @return Comma-separated field list as a String
|
||||
*/
|
||||
private static String getBillingFields() {
|
||||
return String.join(
|
||||
new List<String>{
|
||||
'Id',
|
||||
'Name',
|
||||
'BillingStreet',
|
||||
'BillingCity',
|
||||
'BillingState',
|
||||
'BillingPostalCode',
|
||||
'BillingCountry',
|
||||
'Territory__c'
|
||||
},
|
||||
', '
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Query Methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Selects Account records by their Ids
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records matching the provided Ids
|
||||
* @example
|
||||
* Set<Id> ids = new Set<Id>{ '001xx000003DGbY' };
|
||||
* List<Account> results = AccountSelector.selectByIds(ids);
|
||||
*/
|
||||
public static List<Account> selectByIds(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Account records as a Map keyed by Id
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return Map of Id to Account
|
||||
*/
|
||||
public static Map<Id, Account> selectMapByIds(Set<Id> recordIds) {
|
||||
return new Map<Id, Account>(selectByIds(recordIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts with billing address fields for territory assignment
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records with billing address fields populated
|
||||
*/
|
||||
public static List<Account> selectWithBillingAddress(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getBillingFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Id IN :recordIds'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts by Account Type
|
||||
* @param accountTypes Set of Account Type values to filter by
|
||||
* @return List of matching Account records
|
||||
* @example
|
||||
* List<Account> prospects = AccountSelector.selectByType(
|
||||
* new Set<String>{ 'Prospect', 'Customer - Direct' }
|
||||
* );
|
||||
*/
|
||||
public static List<Account> selectByType(Set<String> accountTypes) {
|
||||
if (accountTypes == null || accountTypes.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Type IN :accountTypes' +
|
||||
' ORDER BY Name ASC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts by Industry with a minimum annual revenue
|
||||
* @param industries Set of Industry values to filter by
|
||||
* @param minRevenue Minimum AnnualRevenue threshold
|
||||
* @return List of matching Account records ordered by revenue descending
|
||||
* @example
|
||||
* List<Account> techAccounts = AccountSelector.selectByIndustryAndRevenue(
|
||||
* new Set<String>{ 'Technology' },
|
||||
* 1000000
|
||||
* );
|
||||
*/
|
||||
public static List<Account> selectByIndustryAndRevenue(Set<String> industries, Decimal minRevenue) {
|
||||
if (industries == null || industries.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
Decimal revenueThreshold = minRevenue ?? 0;
|
||||
|
||||
return Database.query(
|
||||
'SELECT ' + getDefaultFields() +
|
||||
' FROM Account' +
|
||||
' WHERE Industry IN :industries' +
|
||||
' AND AnnualRevenue >= :revenueThreshold' +
|
||||
' ORDER BY AnnualRevenue DESC'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Selects Accounts with their related Contacts (subquery)
|
||||
* @param recordIds Set of Account Ids to query
|
||||
* @return List of Account records with nested Contacts
|
||||
*/
|
||||
public static List<Account> selectWithContacts(Set<Id> recordIds) {
|
||||
if (recordIds == null || recordIds.isEmpty()) {
|
||||
return new List<Account>();
|
||||
}
|
||||
|
||||
return [
|
||||
SELECT Id, Name, Type, Industry,
|
||||
(SELECT Id, FirstName, LastName, Email, Title
|
||||
FROM Contacts
|
||||
ORDER BY LastName ASC)
|
||||
FROM Account
|
||||
WHERE Id IN :recordIds
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Aggregate Queries ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Returns a count of Accounts grouped by Industry
|
||||
* @return List of AggregateResult with Industry and record count
|
||||
* @example
|
||||
* List<AggregateResult> results = AccountSelector.countByIndustry();
|
||||
* for (AggregateResult ar : results) {
|
||||
* System.debug(ar.get('Industry') + ': ' + ar.get('cnt'));
|
||||
* }
|
||||
*/
|
||||
public static List<AggregateResult> countByIndustry() {
|
||||
return [
|
||||
SELECT Industry, COUNT(Id) cnt
|
||||
FROM Account
|
||||
WHERE Industry != NULL
|
||||
GROUP BY Industry
|
||||
ORDER BY COUNT(Id) DESC
|
||||
];
|
||||
}
|
||||
}
|
||||
201
skills/generating-apex/references/AccountService.cls
Normal file
201
skills/generating-apex/references/AccountService.cls
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @description Service class for Account business logic.
|
||||
* Provides account deduplication, enrichment, and territory assignment.
|
||||
* Delegates queries to AccountSelector and SObject manipulation to AccountDomain.
|
||||
* @author Generated by Apex Class Writer Skill
|
||||
*/
|
||||
public with sharing class AccountService {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
private static final String ERROR_NULL_INPUT = 'Input cannot be null or empty.';
|
||||
private static final String ERROR_MERGE_FAILED = 'Account merge failed for master Id: ';
|
||||
private static final Integer MAX_MERGE_BATCH = 3;
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Merges duplicate Account records into a master record.
|
||||
* The master record retains its field values; child records are reparented.
|
||||
* @param masterIds Map of master Account Id to Set of duplicate Account Ids to merge
|
||||
* @return List of master Account Ids that were successfully merged
|
||||
* @throws AccountServiceException if merge processing fails
|
||||
* @example
|
||||
* Map<Id, Set<Id>> mergeMap = new Map<Id, Set<Id>>{
|
||||
* masterAcctId => new Set<Id>{ dupeId1, dupeId2 }
|
||||
* };
|
||||
* List<Id> mergedIds = AccountService.mergeAccounts(mergeMap);
|
||||
*/
|
||||
public static List<Id> mergeAccounts(Map<Id, Set<Id>> mergeMap) {
|
||||
if (mergeMap == null || mergeMap.isEmpty()) {
|
||||
throw new AccountServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
// Collect all Ids for a single query
|
||||
Set<Id> allIds = new Set<Id>();
|
||||
allIds.addAll(mergeMap.keySet());
|
||||
for (Set<Id> dupeIds : mergeMap.values()) {
|
||||
allIds.addAll(dupeIds);
|
||||
}
|
||||
|
||||
// Query all records at once via Selector
|
||||
Map<Id, Account> accountMap = AccountSelector.selectMapByIds(allIds);
|
||||
|
||||
List<Id> successfulMerges = new List<Id>();
|
||||
List<String> errors = new List<String>();
|
||||
|
||||
for (Id masterId : mergeMap.keySet()) {
|
||||
Account master = accountMap.get(masterId);
|
||||
if (master == null) {
|
||||
errors.add('Master account not found: ' + masterId);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<Account> duplicates = new List<Account>();
|
||||
for (Id dupeId : mergeMap.get(masterId)) {
|
||||
Account dupe = accountMap.get(dupeId);
|
||||
if (dupe != null) {
|
||||
duplicates.add(dupe);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Apex merge supports up to 3 records at a time
|
||||
for (List<Account> chunk : chunkAccounts(duplicates, MAX_MERGE_BATCH)) {
|
||||
Database.merge(master, chunk);
|
||||
}
|
||||
successfulMerges.add(masterId);
|
||||
} catch (DmlException e) {
|
||||
errors.add(ERROR_MERGE_FAILED + masterId + ' - ' + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
System.debug(LoggingLevel.WARN, 'Merge errors: ' + String.join(errors, '\n'));
|
||||
}
|
||||
|
||||
return successfulMerges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Assigns accounts to territories based on Billing State/Country.
|
||||
* Uses Custom Metadata Type (Territory_Mapping__mdt) for mappings.
|
||||
* @param accountIds Set of Account Ids to assign territories for
|
||||
* @return Number of accounts successfully updated
|
||||
* @throws AccountServiceException if territory assignment fails
|
||||
*/
|
||||
public static Integer assignTerritories(Set<Id> accountIds) {
|
||||
if (accountIds == null || accountIds.isEmpty()) {
|
||||
throw new AccountServiceException(ERROR_NULL_INPUT);
|
||||
}
|
||||
|
||||
List<Account> accounts = AccountSelector.selectWithBillingAddress(accountIds);
|
||||
Map<String, String> territoryMap = loadTerritoryMappings();
|
||||
|
||||
List<Account> toUpdate = new List<Account>();
|
||||
for (Account acct : accounts) {
|
||||
String key = buildTerritoryKey(acct.BillingState, acct.BillingCountry);
|
||||
String territory = territoryMap.get(key);
|
||||
|
||||
if (territory != null && territory != acct.Territory__c) {
|
||||
toUpdate.add(new Account(
|
||||
Id = acct.Id,
|
||||
Territory__c = territory
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!toUpdate.isEmpty()) {
|
||||
List<Database.SaveResult> results = Database.update(toUpdate, false);
|
||||
return countSuccesses(results);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── Convenience Overloads ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Single-account territory assignment convenience method
|
||||
* @param accountId The Account Id to assign a territory for
|
||||
* @return 1 if updated, 0 if no change needed
|
||||
*/
|
||||
public static Integer assignTerritory(Id accountId) {
|
||||
return assignTerritories(new Set<Id>{ accountId });
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Loads territory mappings from Custom Metadata
|
||||
* @return Map of territory key (State:Country) to territory name
|
||||
*/
|
||||
private static Map<String, String> loadTerritoryMappings() {
|
||||
Map<String, String> mappings = new Map<String, String>();
|
||||
for (Territory_Mapping__mdt mapping : Territory_Mapping__mdt.getAll().values()) {
|
||||
String key = buildTerritoryKey(mapping.State__c, mapping.Country__c);
|
||||
mappings.put(key, mapping.Territory_Name__c);
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Builds a consistent territory lookup key
|
||||
* @param state The billing state
|
||||
* @param country The billing country
|
||||
* @return A normalized key string
|
||||
*/
|
||||
private static String buildTerritoryKey(String state, String country) {
|
||||
return (state ?? '').toUpperCase() + ':' + (country ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Chunks a list of Accounts into sublists of the given size
|
||||
* @param accounts The accounts to chunk
|
||||
* @param chunkSize Maximum chunk size
|
||||
* @return List of account sublists
|
||||
*/
|
||||
private static List<List<Account>> chunkAccounts(List<Account> accounts, Integer chunkSize) {
|
||||
List<List<Account>> chunks = new List<List<Account>>();
|
||||
List<Account> current = new List<Account>();
|
||||
|
||||
for (Account acct : accounts) {
|
||||
current.add(acct);
|
||||
if (current.size() == chunkSize) {
|
||||
chunks.add(current);
|
||||
current = new List<Account>();
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
chunks.add(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Counts successful results from a DML operation
|
||||
* @param results List of Database.SaveResult
|
||||
* @return Count of successful operations
|
||||
*/
|
||||
private static Integer countSuccesses(List<Database.SaveResult> results) {
|
||||
Integer count = 0;
|
||||
for (Database.SaveResult sr : results) {
|
||||
if (sr.isSuccess()) {
|
||||
count++;
|
||||
} else {
|
||||
for (Database.Error err : sr.getErrors()) {
|
||||
System.debug(LoggingLevel.WARN,
|
||||
'Update failed for ' + sr.getId() + ': ' + err.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── Exception ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @description Custom exception for AccountService errors
|
||||
*/
|
||||
public class AccountServiceException extends Exception {}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user