diff --git a/force-app/main/default/classes/ULID/README.md b/force-app/main/default/classes/ULID/README.md new file mode 100644 index 0000000..0096bee --- /dev/null +++ b/force-app/main/default/classes/ULID/README.md @@ -0,0 +1,459 @@ +# ULID - Universally Unique Lexicographically Sortable Identifier + +The ULID class provides an Apex implementation of the [ULID specification](https://github.com/ulid/spec), generating universally unique identifiers that are lexicographically sortable. Think of ULIDs as UUIDs that can be sorted chronologically. + +## Table of Contents + +- [What is a ULID?](#what-is-a-ulid) +- [Why Use ULIDs?](#why-use-ulids) +- [When to Use ULIDs](#when-to-use-ulids) +- [Quick Start](#quick-start) +- [ULID Format](#ulid-format) +- [Comparison with Other Identifiers](#comparison-with-other-identifiers) +- [Usage Examples](#usage-examples) +- [Best Practices](#best-practices) +- [Performance Considerations](#performance-considerations) +- [API Reference](#api-reference) + +## What is a ULID? + +ULID stands for **Universally Unique Lexicographically Sortable Identifier**. It's a 128-bit identifier that combines: + +- **48-bit timestamp** (millisecond precision) +- **80-bit randomness** + +The result is a 26-character string that is: +- ✅ Universally unique (like UUIDs) +- ✅ Lexicographically sortable (chronologically ordered) +- ✅ Case insensitive +- ✅ URL safe +- ✅ Human readable (no ambiguous characters) + +## Why Use ULIDs? + +### Advantages over UUIDs + +| Feature | ULID | UUID v4 | UUID v7 | +|---------|------|---------|---------| +| Sortable | ✅ Yes | ❌ No | ✅ Yes | +| Timestamp | ✅ Embedded | ❌ No | ✅ Embedded | +| Length | 26 chars | 36 chars | 36 chars | +| Case Sensitive | ❌ No | ✅ Yes | ✅ Yes | +| Ambiguous Chars | ❌ Excluded | ✅ Present | ✅ Present | +| URL Safe | ✅ Yes | ⚠️ Needs encoding | ⚠️ Needs encoding | + +### Key Benefits + +1. **Natural Sorting**: ULIDs sort chronologically without additional timestamp fields +2. **Compact**: 26 characters vs 36 for UUIDs +3. **Readable**: No ambiguous characters (0/O, 1/I/l, etc.) +4. **Database Friendly**: Better for indexing and clustering +5. **URL Safe**: No special characters requiring encoding + +## When to Use ULIDs + +### Perfect For: +- **Record IDs** where creation order matters +- **Log entries** and audit trails +- **Message queues** and event sourcing +- **File naming** with chronological ordering +- **API tokens** and session identifiers +- **Database primary keys** (better clustering than UUIDs) + +### Consider Alternatives When: +- You need **shorter identifiers** (use incremental integers) +- **Security** is paramount and timestamp leakage is a concern +- **Legacy systems** require specific UUID formats +- **Deterministic** generation is needed (ULIDs are always random) + +## Quick Start + +The ULID class has a single public method that generates a new ULID: + +```apex +// Generate a new ULID +String ulid = ULID.generate(); +System.debug(ulid); // Output: 01H2YCGVJ8GQWXKR7F5ZQN6R2M +``` + +That's it! Each call to `generate()` produces a unique, sortable identifier. + +## ULID Format + +A ULID consists of two parts: + +``` + 01AN4Z07BY 79KA1307SR9X4MV3 +|----------| |----------------| + Timestamp Randomness + 48bits 80bits +``` + +### Timestamp Component (10 characters) +- Encodes Unix timestamp in milliseconds +- Provides natural chronological sorting +- Readable as base32 from the allowed character set + +### Random Component (16 characters) +- Cryptographically random +- Ensures uniqueness even when generated at the same millisecond +- Uses Salesforce's `Crypto.getRandomLong()` for quality randomness + +### Character Set +ULIDs use Crockford's Base32 encoding with ambiguous characters removed: +``` +0123456789ABCDEFGHJKMNPQRSTVWXYZ +``` + +**Excluded characters**: `I`, `L`, `O`, `U` (to avoid confusion with 1, 1, 0, V) + +## Comparison with Other Identifiers + +### vs Salesforce IDs +```apex +// Salesforce ID (15/18 characters) +Account acc = new Account(Name = 'Test'); +insert acc; +System.debug(acc.Id); // 001XX000004DGb2 + +// ULID (26 characters, sortable) +String ulid = ULID.generate(); +System.debug(ulid); // 01H2YCH9K5QMXR8F3ZVN9J2P4T +``` + +### vs UUIDs +```apex +// Traditional approach with UUID and timestamp +String uuid = 'a1b2c3d4-e5f6-7890-1234-567890abcdef'; +DateTime timestamp = DateTime.now(); + +// ULID approach - timestamp embedded, naturally sortable +String ulid = ULID.generate(); +``` + +## Usage Examples + +### Example 1: Log Entry Identifiers + +```apex +public class LogEntry { + public String id; + public String message; + public DateTime createdAt; + + public LogEntry(String message) { + this.id = ULID.generate(); // Sortable by creation time + this.message = message; + this.createdAt = DateTime.now(); + } +} + +// Generate log entries +List logs = new List(); +logs.add(new LogEntry('User login')); +logs.add(new LogEntry('Data processed')); +logs.add(new LogEntry('User logout')); + +// IDs are naturally sorted chronologically +for (LogEntry log : logs) { + System.debug(log.id + ': ' + log.message); +} +// Output: +// 01H2YCJ4K8QMXR8F3ZVN9J2P4T: User login +// 01H2YCJ4K9RMYS9G4AWO0K3Q5U: Data processed +// 01H2YCJ4KAQNZT0H5BXP1L4R6V: User logout +``` + +### Example 2: Custom Object External IDs + +```apex +public class OrderProcessor { + public void createOrder(Account customer, List products) { + Order__c order = new Order__c(); + order.External_ID__c = ULID.generate(); // Unique, sortable identifier + order.Customer__c = customer.Id; + order.Status__c = 'Draft'; + + insert order; + + // Create order items with ULIDs + List items = new List(); + for (Product2 product : products) { + OrderItem__c item = new OrderItem__c(); + item.External_ID__c = ULID.generate(); + item.Order__c = order.Id; + item.Product__c = product.Id; + items.add(item); + } + insert items; + } +} +``` + +### Example 3: Session and Token Management + +```apex +public class SessionManager { + public static String createSession(Id userId) { + String sessionId = ULID.generate(); + + Session__c session = new Session__c(); + session.Session_ID__c = sessionId; + session.User__c = userId; + session.Created_Date__c = DateTime.now(); + session.Expires_Date__c = DateTime.now().addHours(24); + + insert session; + return sessionId; + } + + public static Boolean isValidSession(String sessionId) { + List sessions = [ + SELECT Id, Expires_Date__c + FROM Session__c + WHERE Session_ID__c = :sessionId + AND Expires_Date__c > :DateTime.now() + LIMIT 1 + ]; + return !sessions.isEmpty(); + } +} +``` + +### Example 4: File and Document Naming + +```apex +public class DocumentManager { + public static String generateFileName(String originalName) { + String extension = ''; + if (originalName.contains('.')) { + Integer lastDot = originalName.lastIndexOf('.'); + extension = originalName.substring(lastDot); + originalName = originalName.substring(0, lastDot); + } + + // Create sortable filename with ULID + String ulid = ULID.generate(); + return ulid + '_' + originalName + extension; + // Example: 01H2YCK3J7PMXQ8E2YVM8I1N3S_document.pdf + } + + public static void uploadDocument(String content, String originalName) { + ContentVersion cv = new ContentVersion(); + cv.Title = originalName; + cv.PathOnClient = generateFileName(originalName); + cv.VersionData = Blob.valueOf(content); + cv.IsMajorVersion = true; + + insert cv; + } +} +``` + +### Example 5: Batch Processing with Tracking + +```apex +public class BatchProcessor implements Database.Batchable { + private String batchId; + + public BatchProcessor() { + this.batchId = ULID.generate(); // Track this batch execution + } + + public Database.QueryLocator start(Database.BatchableContext context) { + // Log batch start + BatchLog__c log = new BatchLog__c(); + log.Batch_ID__c = this.batchId; + log.Status__c = 'Started'; + log.Started_At__c = DateTime.now(); + insert log; + + return Database.getQueryLocator('SELECT Id, Name FROM Account'); + } + + public void execute(Database.BatchableContext context, List scope) { + // Process records and log progress + for (SObject record : scope) { + ProcessingLog__c procLog = new ProcessingLog__c(); + procLog.Processing_ID__c = ULID.generate(); // Unique processing event + procLog.Batch_ID__c = this.batchId; // Link to batch + procLog.Record_ID__c = record.Id; + procLog.Processed_At__c = DateTime.now(); + // ... additional processing + } + } + + public void finish(Database.BatchableContext context) { + // Update batch completion + BatchLog__c log = [SELECT Id FROM BatchLog__c WHERE Batch_ID__c = :this.batchId]; + log.Status__c = 'Completed'; + log.Completed_At__c = DateTime.now(); + update log; + } +} +``` + +## Best Practices + +### 1. Use ULIDs for External Identifiers + +```apex +// Good: ULID for external ID that needs to be sortable +Custom_Object__c obj = new Custom_Object__c(); +obj.External_ID__c = ULID.generate(); + +// Avoid: Using ULIDs for internal relationships (use Salesforce IDs) +// obj.Parent_ULID__c = ULID.generate(); // Unnecessary +``` + +### 2. Index ULID Fields Appropriately + +```apex +// When creating custom fields that will store ULIDs: +// - Set field type to Text(26) +// - Mark as External ID if used for lookups +// - Add database indexes for query performance +``` + +### 3. Store ULIDs in Consistent Format + +```apex +public class ULIDHelper { + // Always store ULIDs in uppercase for consistency + public static String generateStandardized() { + return ULID.generate().toUpperCase(); + } + + // Validate ULID format + public static Boolean isValidULID(String ulid) { + if (String.isBlank(ulid) || ulid.length() != 26) { + return false; + } + + // Check character set (basic validation) + String allowedChars = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + for (Integer i = 0; i < ulid.length(); i++) { + if (!allowedChars.contains(ulid.substring(i, i + 1))) { + return false; + } + } + return true; + } +} +``` + +### 4. Use ULIDs in Integration Scenarios + +```apex +public class IntegrationService { + public static void sendToExternalSystem(List accounts) { + for (Account acc : accounts) { + // Generate ULID for external system tracking + String integrationId = ULID.generate(); + + // Store for correlation + acc.Integration_ID__c = integrationId; + + // Send to external system with ULID + callExternalAPI(acc, integrationId); + } + update accounts; + } +} +``` + +## Performance Considerations + +### Generation Performance +- **Cost**: Very fast generation (single method call) +- **Randomness**: Uses cryptographically secure random number generation +- **Memory**: Minimal memory footprint +- **CPU**: Lightweight encoding operations + +### Database Performance +- **Indexing**: ULIDs perform well in database indexes +- **Clustering**: Natural time-based clustering improves query performance +- **Storage**: 26 characters vs 36 for UUIDs (28% more efficient) + +### Governor Limits +```apex +// ULIDs are safe to generate in bulk operations +List ulids = new List(); +for (Integer i = 0; i < 10000; i++) { + ulids.add(ULID.generate()); // No governor limit concerns +} +``` + +## API Reference + +### ULID Class + +The ULID class provides a single static method for generating ULIDs. + +#### Methods + +**`generate()`** +- **Description**: Generates a new ULID according to the ULID specification +- **Parameters**: None +- **Returns**: `String` - A 26-character ULID +- **Example**: `String ulid = ULID.generate();` + +#### Properties + +The ULID class contains several private constants used in generation: + +- **`CHARACTER_SET`**: List of allowed characters (Crockford's Base32) +- **`TIME_LENGTH`**: Number of characters for timestamp encoding (10) +- **`RANDOM_LENGTH`**: Number of characters for random component (16) + +### ULID Specification Compliance + +This implementation follows the [official ULID specification](https://github.com/ulid/spec): + +- ✅ 128-bit identifier +- ✅ 48-bit timestamp + 80-bit randomness +- ✅ Crockford's Base32 encoding +- ✅ Case insensitive +- ✅ Lexicographically sortable +- ✅ URL safe + +### Thread Safety + +The ULID class is thread-safe and can be called from any context: +- Static methods only +- No shared state +- Uses Salesforce's thread-safe `Crypto.getRandomLong()` + +### Character Set Reference + +``` +Value Symbol Value Symbol Value Symbol Value Symbol + 0 0 8 8 16 G 24 S + 1 1 9 9 17 H 25 T + 2 2 10 A 18 J 26 V + 3 3 11 B 19 K 27 W + 4 4 12 C 20 M 28 X + 5 5 13 D 21 N 29 Y + 6 6 14 E 22 P 30 Z + 7 7 15 F 23 Q 31 Z +``` + +**Note**: Characters I, L, O, U are excluded to avoid ambiguity. + +## Troubleshooting + +### Common Issues + +1. **Case Sensitivity**: ULIDs are case-insensitive but this implementation generates uppercase +2. **Length Validation**: Always exactly 26 characters +3. **Character Validation**: Only uses the specified character set +4. **Sorting**: String comparison works for chronological sorting + +### Integration Tips + +- Store ULIDs in Text(26) fields +- Use External ID type for lookups +- Consider indexing for frequently queried ULID fields +- Always validate ULID format when receiving from external systems + +The ULID class provides a powerful alternative to traditional UUIDs when you need identifiers that are both unique and naturally sortable by creation time. \ No newline at end of file diff --git a/force-app/main/default/classes/ULID/ULID.cls b/force-app/main/default/classes/ULID/ULID.cls index 25e1ac3..69d5f6b 100644 --- a/force-app/main/default/classes/ULID/ULID.cls +++ b/force-app/main/default/classes/ULID/ULID.cls @@ -11,11 +11,11 @@ */ public with sharing class ULID { /** - * This character set is the complete list of allowed characters in + * @description This character set is the complete list of allowed characters in * a ULID string. It intentionally does not include characters that * may be ambiguously read, such as i, l, o, and u characters. */ - private static final List CHARACTERSET = new List{ + private static final List CHARACTER_SET = new List{ '0', '1', '2', @@ -50,14 +50,13 @@ public with sharing class ULID { 'Z' }; // Calculate this once per transaction to avoid unnecessary math - private static final Long CHARACTERSETSIZE = CHARACTERSET.size(); - // This is equal to 2^48-1 and represents the max timestamp - // allowed in a ULID string + private static final Long CHARACTER_SET_SIZE = CHARACTER_SET.size(); + // This is equal to 2^48-1 and represents the max timestamp allowed in a ULID string // private static final Long MAXTIME = 281474976710655L; // This is the number of digits to encode the timestamp into. - private static final Long TIMELENGTH = 10; + private static final Long TIME_LENGTH = 10; // This is the number of digits of random character to generate - private static final Integer RANDOMLENGTH = 16; + private static final Integer RANDOM_LENGTH = 16; /** * @description Generates a ULID string according to spec. @@ -65,8 +64,8 @@ public with sharing class ULID { * @return `String` */ public static String generate() { - return encodeTimestamp(DateTime.now().getTime(), TIMELENGTH) + - generateRandomString(RANDOMLENGTH); + return encodeTimestamp(Datetime.now().getTime(), TIME_LENGTH) + + generateRandomString(RANDOM_LENGTH); } /** @@ -76,13 +75,14 @@ public with sharing class ULID { * @param timeLength how many characters of the timestamp to encode * @return `String` */ + @SuppressWarnings('PMD.UnusedLocalVariable') private static String encodeTimestamp(Long dtSeed, Long timeLength) { Long modulo; String retString = ''; for (Long l = timeLength; timeLength > 0; timeLength--) { - modulo = Math.mod(dtSeed, CHARACTERSETSIZE); - retString = CHARACTERSET[modulo.intValue()] + retString; - dtSeed = (dtSeed - modulo) / CHARACTERSETSIZE; + modulo = Math.mod(dtSeed, CHARACTER_SET_SIZE); + retString = CHARACTER_SET[modulo.intValue()] + retString; + dtSeed = (dtSeed - modulo) / CHARACTER_SET_SIZE; } return retString; } @@ -108,7 +108,7 @@ public with sharing class ULID { * @return `String` */ private static String fetchRandomCharacterFromCharacterSet() { - Long rand = Math.mod(Math.abs(Crypto.getRandomLong()), CHARACTERSETSIZE); - return CHARACTERSET[rand.intValue()]; + Long rand = Math.mod(Math.abs(Crypto.getRandomLong()), CHARACTER_SET_SIZE); + return CHARACTER_SET[rand.intValue()]; } } diff --git a/force-app/main/default/classes/polyfills/tests/PolyfillsTests.cls b/force-app/main/default/classes/polyfills/tests/PolyfillsTests.cls index 8e7796c..87bcc01 100644 --- a/force-app/main/default/classes/polyfills/tests/PolyfillsTests.cls +++ b/force-app/main/default/classes/polyfills/tests/PolyfillsTests.cls @@ -162,7 +162,9 @@ private class PolyfillsTests { (List) SObjectFactory.createSObjects( new Contact(AccountId = acct.Id), 5, - false + null, + false, + true ) ); } diff --git a/force-app/main/default/classes/queueable process/EnqueueNextQueueableProcessStep.cls b/force-app/main/default/classes/queueable process/EnqueueNextQueueableProcessStep.cls index 150b6a3..a02d0b2 100644 --- a/force-app/main/default/classes/queueable process/EnqueueNextQueueableProcessStep.cls +++ b/force-app/main/default/classes/queueable process/EnqueueNextQueueableProcessStep.cls @@ -68,11 +68,17 @@ public with sharing class EnqueueNextQueueableProcessStep implements System.Fina switch on context.getResult() { when SUCCESS { if (this.processSteps.size() > 0) { + // Remove the first element from the list - this was constructed using the .then() method. QueueableProcess nextProcessStep = this.processSteps.remove(0); + // Set the remaining steps on the next process step. nextProcessStep.processSteps = this.processSteps; + // Set the dataPassthrough on the next process step. nextProcessStep.dataPassthrough = dataPassthrough; + // Set the queueableContextHistory on the next process step. nextProcessStep.queueableContextHistory = this.queueableContextHistory; + // Set the finalizerContextHistory on the next process step. nextProcessStep.finalizerContextHistory = this.finalizerContextHistory; + // Enqueue the next process step. System.enqueueJob(nextProcessStep); } } diff --git a/force-app/main/default/classes/queueable process/QueueableProcess.cls b/force-app/main/default/classes/queueable process/QueueableProcess.cls index f20a5e5..6fa13d3 100644 --- a/force-app/main/default/classes/queueable process/QueueableProcess.cls +++ b/force-app/main/default/classes/queueable process/QueueableProcess.cls @@ -61,9 +61,11 @@ public abstract class QueueableProcess implements Queueable, Database.AllowsCall /** * @description This must be implemented by extending classes. Developers - implement this method with the work you - * want executed asynchronously. + * want executed asynchronously. The returned Object will be passed as dataPassthrough to the next step. + * + * @return Object - data to pass through to the next step in the process */ - abstract public void execute(); + abstract public Object execute(); /** * @description this is a default implementation of an handleError method. It's called by the finalizer if the @@ -85,23 +87,23 @@ public abstract class QueueableProcess implements Queueable, Database.AllowsCall */ public virtual void execute(QueueableContext context) { // if the queueableContextHistory is null, initialize it. - if (this.queueableContextHistory == null) { - this.queueableContextHistory = new List(); - } + this.queueableContextHistory = this.queueableContextHistory ?? + new List(); this.queueableContextHistory.add(context); + // Call the abstract method `execute` and capture its return value as the dataPassthrough for the next step. + Object nextDataPassthrough = execute(); + // create a new instance of the finalizer class. Note that we're passing in the list of remaining steps and the - // passthrough data. + // returned data from execute() as the passthrough data for the next step. Finalizer nextStep = new EnqueueNextQueueableProcessStep( this.processSteps, - this.dataPassthrough, + nextDataPassthrough, this.queueableContextHistory, this.finalizerContextHistory ); // Attach the finalizer to system context. This will take care of enqueueing the next QueueableProcess step in // the nextStep. System.attachFinalizer(nextStep); - // invoke the abstract method `execute`. see the comment above. - execute(); } } diff --git a/force-app/main/default/classes/queueable process/README.md b/force-app/main/default/classes/queueable process/README.md new file mode 100644 index 0000000..84469ce --- /dev/null +++ b/force-app/main/default/classes/queueable process/README.md @@ -0,0 +1,490 @@ +# QueueableProcess Framework + +The QueueableProcess framework provides an elegant way to chain Salesforce Queueable jobs together, allowing you to create complex asynchronous processes with data passing between steps. This implementation is similar to a Promise pattern but designed specifically for Salesforce's asynchronous execution model. + +## Table of Contents + +- [Overview](#overview) +- [Key Features](#key-features) +- [Quick Start](#quick-start) +- [Core Concepts](#core-concepts) +- [Creating Process Steps](#creating-process-steps) +- [Data Passing Between Steps](#data-passing-between-steps) +- [Error Handling](#error-handling) +- [Best Practices](#best-practices) +- [Examples](#examples) +- [API Reference](#api-reference) + +## Overview + +The QueueableProcess framework allows developers to: +- Chain multiple asynchronous operations together +- Pass data between process steps +- Handle errors at the step level or globally +- Maintain execution history and context +- Create reusable process components + +## Key Features + +- **Fluent API**: Chain steps together using the `then()` method +- **Data Passing**: Return values from each step become input for the next +- **Error Handling**: Per-step and global exception handling +- **Execution History**: Automatic tracking of QueueableContext and FinalizerContext +- **Callout Support**: Built-in support for HTTP callouts via `Database.AllowsCallouts` +- **Transaction Finalizers**: Automatic handling of step transitions and error recovery + +## Quick Start + +Here's a simple example of creating and running a QueueableProcess chain: + +```apex +// Create a process chain using your custom step classes +// Note: MyFirstStep, MySecondStep, and MyThirdStep are classes YOU must create +// that extend QueueableProcess. The constructor parameters are up to you to define. +QueueableProcess process = new MyFirstStep('some-id') + .then(new MySecondStep('another-id')) + .then(new MyThirdStep('final-id')); + +// Start the process with initial data (can be any Object) +// This Object will be available to MyFirstStep as this.dataPassthrough +Id jobId = process.start(0); +``` + +**Important**: You must create your own step classes that extend `QueueableProcess`. Each step class must: +- Extend `QueueableProcess` +- Have a no-argument constructor +- Implement the `execute()` method that returns an `Object` +- Define constructor parameters as needed for your business logic + +## Core Concepts + +### QueueableProcess Abstract Class + +All process steps must extend the `QueueableProcess` abstract class and implement the `execute()` method: + +```apex +public class MyProcessStep extends QueueableProcess { + private String recordId; + + public MyProcessStep(String recordId) { + this.recordId = recordId; + } + + public override Object execute() { + // Your business logic here + // Return data for the next step + return someResult; + } +} +``` + +### Data Flow + +1. **Initial Data**: Passed when calling `start(initialData)` +2. **Step Processing**: Each step receives data via `this.dataPassthrough` +3. **Return Value**: What you return becomes input for the next step +4. **Chain Execution**: Steps execute sequentially, not concurrently + +### Required Methods + +- **Constructor**: Must have a no-argument constructor for error handling +- **execute()**: Abstract method that returns `Object` for next step + +## Creating Process Steps + +### Basic Step Structure + +```apex +public class ProcessStepExample extends QueueableProcess { + private Id recordId; + + // No-arg constructor required for error handling + public ProcessStepExample() {} + + // Your business constructor + public ProcessStepExample(Id recordId) { + this.recordId = recordId; + } + + public override Object execute() { + // Access data from previous step + Integer previousValue = (Integer) this.dataPassthrough; + + // Perform your business logic + Account acc = [SELECT Id, Name FROM Account WHERE Id = :recordId]; + acc.NumberOfEmployees = previousValue + 1; + update acc; + + // Return data for next step + return acc.NumberOfEmployees; + } +} +``` + +### Constructor Requirements + +Every QueueableProcess implementation **must** have a no-argument constructor: + +```apex +public class MyStep extends QueueableProcess { + private String data; + + // REQUIRED: No-arg constructor for error handling + public MyStep() {} + + // Your business constructor + public MyStep(String data) { + this.data = data; + } +} +``` + +This no-arg constructor is used by the framework when instantiating your class for error handling. + +## Data Passing Between Steps + +### Accessing Previous Step Data + +```apex +public override Object execute() { + // Check if data was passed from previous step + if (this.dataPassthrough != null) { + String previousResult = (String) this.dataPassthrough; + // Use the data... + } + + // Return data for next step + return "Result for next step"; +} +``` + +### Data Type Handling + +The framework uses `Object` for maximum flexibility. You're responsible for casting: + +```apex +public override Object execute() { + // Safe casting with null check + Integer count = this.dataPassthrough != null ? + (Integer) this.dataPassthrough : 0; + + // Return appropriate type for next step + return count + 1; +} +``` + +### Complex Data Structures + +You can pass complex objects between steps: + +```apex +public class ProcessData { + public String accountId; + public Integer count; + public List results; +} + +public override Object execute() { + ProcessData data = (ProcessData) this.dataPassthrough; + // Process the data... + + ProcessData nextData = new ProcessData(); + nextData.accountId = data.accountId; + nextData.count = data.count + 1; + nextData.results = new List{'Step completed'}; + + return nextData; +} +``` + +## Error Handling + +### Per-Step Error Handling + +Override the `handleException()` method for custom error handling: + +```apex +public class MyProcessStep extends QueueableProcess { + public override Object execute() { + // Your logic that might throw an exception + return result; + } + + public override void handleException(Exception e) { + // Custom error handling for this step + Log.get().publish(new LogMessage('Step failed: ' + e.getMessage())); + + // You could also create compensating transactions, + // send notifications, etc. + } +} +``` + +### Global Error Handling + +If a step doesn't implement `handleException()`, the framework falls back to the default handler, which logs the exception. + +### Error Recovery + +When an exception occurs: +1. The framework identifies the failing step +2. Calls the step's `handleException()` method if implemented +3. Falls back to default handling if not implemented +4. The process chain stops execution + +## Best Practices + +### 1. Keep Steps Focused + +Each step should have a single responsibility: + +```apex +// Good: Focused responsibility +public class ValidateAccountStep extends QueueableProcess { + public override Object execute() { + // Only validate account data + return validationResult; + } +} + +// Avoid: Multiple responsibilities +public class ProcessAccountAndSendEmailAndUpdateRecords extends QueueableProcess { + // Too much in one step +} +``` + +### 2. Handle Null Data Gracefully + +Always check for null data from previous steps: + +```apex +public override Object execute() { + String data = this.dataPassthrough != null ? + (String) this.dataPassthrough : 'default'; + + // Continue processing... + return result; +} +``` + +### 3. Use Meaningful Return Values + +Return data that the next step actually needs: + +```apex +public override Object execute() { + Account acc = processAccount(); + + // Return what the next step needs, not everything + return acc.Id; // If next step only needs the ID + + // Or return a structured object for complex data + return new ProcessResult(acc.Id, acc.Name, someCalculatedValue); +} +``` + +### 4. Implement Error Handling + +Always implement error handling for production code: + +```apex +public override void handleException(Exception e) { + // Log the error + Log.get().publish(new LogMessage('Process failed', e)); + + // Handle cleanup or notifications + notifyAdministrators(e); + + // Don't throw from here unless you want to escalate +} +``` + +### 5. Use Descriptive Class Names + +Name your steps clearly to indicate their purpose: + +```apex +public class ValidateAccountDataStep extends QueueableProcess { } +public class CalculateCommissionStep extends QueueableProcess { } +public class SendWelcomeEmailStep extends QueueableProcess { } +``` + +## Examples + +### Example 1: Account Processing Chain + +```apex +public class AccountProcessingExample { + public static void processAccount(Id accountId) { + QueueableProcess process = new ValidateAccountStep(accountId) + .then(new CalculateMetricsStep(accountId)) + .then(new UpdateRelatedRecordsStep(accountId)) + .then(new SendNotificationStep(accountId)); + + process.start(accountId); + } +} + +public class ValidateAccountStep extends QueueableProcess { + private Id accountId; + + public ValidateAccountStep() {} + + public ValidateAccountStep(Id accountId) { + this.accountId = accountId; + } + + public override Object execute() { + Account acc = [SELECT Id, Name, Type FROM Account WHERE Id = :accountId]; + + if (String.isBlank(acc.Name)) { + throw new ProcessException('Account name is required'); + } + + return new ValidationResult(true, 'Account validated'); + } +} +``` + +### Example 2: Data Processing with Accumulation + +```apex +public class DataProcessingChain { + public static void processData() { + QueueableProcess process = new LoadDataStep() + .then(new TransformDataStep()) + .then(new ValidateDataStep()) + .then(new SaveDataStep()); + + process.start(null); + } +} + +public class LoadDataStep extends QueueableProcess { + public LoadDataStep() {} + + public override Object execute() { + List accounts = [SELECT Id, Name FROM Account LIMIT 100]; + return accounts; + } +} + +public class TransformDataStep extends QueueableProcess { + public TransformDataStep() {} + + public override Object execute() { + List accounts = (List) this.dataPassthrough; + + for (Account acc : accounts) { + acc.Name = acc.Name.toUpperCase(); + } + + return accounts; + } +} +``` + +### Example 3: Error Handling and Recovery + +```apex +public class RobustProcessStep extends QueueableProcess { + private String recordId; + + public RobustProcessStep() {} + + public RobustProcessStep(String recordId) { + this.recordId = recordId; + } + + public override Object execute() { + try { + // Risky operation + Account acc = [SELECT Id FROM Account WHERE Id = :recordId]; + acc.Name = 'Updated'; + update acc; + + return 'Success'; + } catch (DmlException e) { + // Let the framework handle this + throw new ProcessException('Failed to update account: ' + e.getMessage()); + } + } + + public override void handleException(Exception e) { + // Log the error + System.debug('Step failed: ' + e.getMessage()); + + // Create a compensating transaction + createErrorRecord(recordId, e.getMessage()); + + // Notify administrators + sendErrorNotification(e); + } + + private void createErrorRecord(String recordId, String errorMessage) { + // Implementation to track the error + } + + private void sendErrorNotification(Exception e) { + // Implementation to notify admins + } +} +``` + +## API Reference + +For detailed API documentation, refer to the QueueableProcess class documentation. + +### Common Patterns + +#### Conditional Step Execution + +```apex +public override Object execute() { + String condition = (String) this.dataPassthrough; + + if (condition == 'proceed') { + // Normal processing + return processNormally(); + } else { + // Skip processing but pass data through + return this.dataPassthrough; + } +} +``` + +#### Step Validation + +```apex +public override Object execute() { + if (this.dataPassthrough == null) { + throw new ProcessException('No data received from previous step'); + } + + ProcessData data = (ProcessData) this.dataPassthrough; + if (String.isBlank(data.accountId)) { + throw new ProcessException('Account ID is required'); + } + + // Continue processing... + return result; +} +``` + +## Troubleshooting + +### Common Issues + +1. **Missing No-Arg Constructor**: Ensure every step has a parameterless constructor +2. **Null Data Handling**: Always check for null before casting `dataPassthrough` +3. **Exception Propagation**: Exceptions stop the chain unless handled +4. **Governor Limits**: Each step runs in its own transaction context + +### Debugging Tips + +1. Use the execution histories to trace process flow +2. Implement comprehensive error handling with logging +3. Test each step individually before chaining +4. Use smaller data sets for testing complex chains + +This framework provides a powerful way to build complex asynchronous processes in Salesforce while maintaining clean, testable code. Follow the patterns and best practices outlined above for optimal results. \ No newline at end of file diff --git a/force-app/main/default/classes/queueable process/tests/ExampleQueueableProcessSteps.cls b/force-app/main/default/classes/queueable process/tests/ExampleQueueableProcessSteps.cls index d4c4c72..134aade 100644 --- a/force-app/main/default/classes/queueable process/tests/ExampleQueueableProcessSteps.cls +++ b/force-app/main/default/classes/queueable process/tests/ExampleQueueableProcessSteps.cls @@ -40,17 +40,21 @@ public with sharing class ExampleQueueableProcessSteps { /** * @description This is the main execute method required by the QueueableProcess abstract class. This is where * developers will place the code to execute asynchronously in this step. In this case, all it does is fetch - * an account and increment the shipping street by 1. #riviting. + * an account and increment the shipping street by 1. Returns the incremented dataPassthrough value. + * + * @return Object - the incremented dataPassthrough value for the next step */ - public override void execute() { + public override Object execute() { Account acct = fetchAccountByIdForDemoPurposes(this.accountId); Integer castedInteger = Integer.valueOf(acct.ShippingStreet); acct.ShippingStreet = String.valueOf(castedInteger + 1); + Integer nextDataPassthrough = null; if (this.dataPassthrough != null) { - this.dataPassthrough = 1 + (Integer) this.dataPassthrough; - acct.BillingStreet = String.valueOf(this.dataPassthrough); + nextDataPassthrough = 1 + (Integer) this.dataPassthrough; + acct.BillingStreet = String.valueOf(nextDataPassthrough); } Database.update(acct, false, AccessLevel.USER_MODE); + return nextDataPassthrough; } /** @@ -96,14 +100,17 @@ public with sharing class ExampleQueueableProcessSteps { /** * @description This is the main execute method required by the QueueableProcess abstract class. This is where * developers will place the code they want to execute asynchronously in this step. In this case, all it does - * update the account phone field when the data Passthrough isn't null. + * update the account phone field when the data Passthrough isn't null. Returns the same dataPassthrough value. + * + * @return Object - the same dataPassthrough value for the next step */ - public override void execute() { + public override Object execute() { Account acct = fetchAccountByIdForDemoPurposes(this.accountId); if (this.dataPassthrough != null) { acct.Phone = String.valueOf(this.dataPassthrough); } Database.update(acct, false, AccessLevel.USER_MODE); + return this.dataPassthrough; } } diff --git a/force-app/main/default/classes/test utilities/ParameterMatcher.cls b/force-app/main/default/classes/test utilities/ParameterMatcher.cls index ae2467e..74e9275 100644 --- a/force-app/main/default/classes/test utilities/ParameterMatcher.cls +++ b/force-app/main/default/classes/test utilities/ParameterMatcher.cls @@ -1,7 +1,6 @@ /** * @description Enum to define special parameter matching behavior for the Stub framework */ - public enum ParameterMatcher { /** * @description Matches any parameter value regardless of the actual runtime value diff --git a/force-app/main/default/classes/test utilities/ParameterMatcherTest.cls b/force-app/main/default/classes/test utilities/ParameterMatcherTest.cls index ab3514e..9a0bf23 100644 --- a/force-app/main/default/classes/test utilities/ParameterMatcherTest.cls +++ b/force-app/main/default/classes/test utilities/ParameterMatcherTest.cls @@ -1,15 +1,11 @@ @IsTest private class ParameterMatcherTest { - private class TestClass { - public String methodWithParameters(String param1, Integer param2) { - return param1 + String.valueOf(param2); - } - } - @IsTest static void testAnyParameterMatcher() { // Arrange - TestClass mockObject = (TestClass) new Stub.Builder(TestClass.class) + ParameterMatcherTestClass mockObject = (ParameterMatcherTestClass) new Stub.Builder( + ParameterMatcherTestClass.class + ) .mockingMethodCall('methodWithParameters') .withParameterTypes(String.class, Integer.class) .withParameterValues('testValue', ParameterMatcher.ANYVALUE) @@ -36,7 +32,9 @@ private class ParameterMatcherTest { @IsTest static void testMultipleAnyParameterMatchers() { // Arrange - TestClass mockObject = (TestClass) new Stub.Builder(TestClass.class) + ParameterMatcherTestClass mockObject = (ParameterMatcherTestClass) new Stub.Builder( + ParameterMatcherTestClass.class + ) .mockingMethodCall('methodWithParameters') .withParameterTypes(String.class, Integer.class) .withParameterValues(ParameterMatcher.ANYVALUE, ParameterMatcher.ANYVALUE) @@ -63,7 +61,9 @@ private class ParameterMatcherTest { @IsTest static void testMixedExactAndAnyMatchers() { // Arrange - TestClass mockObject = (TestClass) new Stub.Builder(TestClass.class) + ParameterMatcherTestClass mockObject = (ParameterMatcherTestClass) new Stub.Builder( + ParameterMatcherTestClass.class + ) // First stub with exact first parameter and ANY second parameter .mockingMethodCall('methodWithParameters') .withParameterTypes(String.class, Integer.class) diff --git a/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls b/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls new file mode 100644 index 0000000..948114a --- /dev/null +++ b/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls @@ -0,0 +1,9 @@ +/** + * @description This class is used to test the ParameterMatcher class. + */ +@IsTest +public class ParameterMatcherTestClass { + public String methodWithParameters(String param1, Integer param2) { + return param1 + String.valueOf(param2); + } +} diff --git a/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls-meta.xml b/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls-meta.xml new file mode 100644 index 0000000..5f399c3 --- /dev/null +++ b/force-app/main/default/classes/test utilities/ParameterMatcherTestClass.cls-meta.xml @@ -0,0 +1,5 @@ + + + 63.0 + Active + diff --git a/force-app/main/default/classes/test utilities/SObjectFactory.cls b/force-app/main/default/classes/test utilities/SObjectFactory.cls index ecd1511..1185adb 100644 --- a/force-app/main/default/classes/test utilities/SObjectFactory.cls +++ b/force-app/main/default/classes/test utilities/SObjectFactory.cls @@ -40,7 +40,7 @@ public with sharing class SObjectFactory { * @return A created SObject with required fields populated */ public static SObject createSObject(SObject prototype) { - return createSObject(prototype, null, false); + return createSObject(prototype, null, false, false); } /** @@ -53,7 +53,7 @@ public with sharing class SObjectFactory { SObject prototype, String usingDefaultsClassName ) { - return createSObject(prototype, usingDefaultsClassName, false); + return createSObject(prototype, usingDefaultsClassName, false, false); } /** @@ -65,7 +65,7 @@ public with sharing class SObjectFactory { * @return The created SObject. */ public static SObject createSObject(SObject prototype, Boolean forceInsert) { - return createSObject(prototype, null, forceInsert); + return createSObject(prototype, null, forceInsert, false); } /** @@ -81,11 +81,30 @@ public with sharing class SObjectFactory { SObject prototype, String usingDefaultsClassName, Boolean forceInsert + ) { + return createSObject(prototype, usingDefaultsClassName, forceInsert, false); + } + + /** + * @description Creates an SObject with the given prototype, using defaults from the specified class, optionally inserts it into the database, and optionally prevents fake ID generation. + * + * @param prototype The prototype SObject to be created. + * @param usingDefaultsClassName The name of the class providing default values for the SObject. + * @param forceInsert Indicates whether to insert the SObject into the database. + * @param preventFakeId Indicates whether to prevent fake ID generation when not inserting. + * + * @return The created SObject. + */ + public static SObject createSObject( + SObject prototype, + String usingDefaultsClassName, + Boolean forceInsert, + Boolean preventFakeId ) { prototype = internalCreateSObject(prototype, usingDefaultsClassName); if (forceInsert) { Database.insert(prototype, AccessLevel.SYSTEM_MODE); - } else { + } else if (!preventFakeId) { prototype.Id = IdFactory.get(prototype); } return prototype; @@ -120,7 +139,7 @@ public with sharing class SObjectFactory { * @return A list of created SObjects. */ public static List createSObjects(SObject prototype, Integer count) { - return createSObjects(prototype, count, null, false); + return createSObjects(prototype, count, null, false, false); } /** @@ -137,7 +156,7 @@ public with sharing class SObjectFactory { Integer count, String usingDefaultsClassName ) { - return createSObjects(prototype, count, usingDefaultsClassName, false); + return createSObjects(prototype, count, usingDefaultsClassName, false, false); } /** @@ -154,7 +173,7 @@ public with sharing class SObjectFactory { Integer count, Boolean forceInsert ) { - return createSObjects(prototype, count, null, forceInsert); + return createSObjects(prototype, count, null, forceInsert, false); } /** @@ -173,6 +192,28 @@ public with sharing class SObjectFactory { Integer count, String usingDefaultsClassName, Boolean forceInsert + ) { + return createSObjects(prototype, count, usingDefaultsClassName, forceInsert, false); + } + + /** + * @description Creates a list of SObjects based on a prototype, count, defaults class, force insert flag, and preventFakeId flag. + * + * @param prototype The prototype SObject to clone. + * @param count The number of SObjects to create. + * @param usingDefaultsClassName The name of the defaults class to use. + * @param forceInsert Whether to force insert the created SObjects. + * @param preventFakeId Whether to prevent fake ID generation when not inserting. + * + * @return A list of created SObjects. + */ + @SuppressWarnings('PMD.ExcessiveParameterList') + public static List createSObjects( + SObject prototype, + Integer count, + String usingDefaultsClassName, + Boolean forceInsert, + Boolean preventFakeId ) { List createdSObjects = new List(); SObject constructedFromPrototype = internalCreateSObject( @@ -187,7 +228,8 @@ public with sharing class SObjectFactory { SObjectFactoryHelper.mutateCloneToRespectNameAndAutonumberRules( clonedSObject, !forceInsert, - iterationCounter + iterationCounter, + preventFakeId ) ); } diff --git a/force-app/main/default/classes/test utilities/SObjectFactoryHelper.cls b/force-app/main/default/classes/test utilities/SObjectFactoryHelper.cls index 17ef687..c6481f5 100644 --- a/force-app/main/default/classes/test utilities/SObjectFactoryHelper.cls +++ b/force-app/main/default/classes/test utilities/SObjectFactoryHelper.cls @@ -31,6 +31,24 @@ public class SObjectFactoryHelper { SObject clonedSObject, Boolean fakeId, Integer iterationCounter + ) { + return mutateCloneToRespectNameAndAutonumberRules(clonedSObject, fakeId, iterationCounter, false); + } + + /** + * @description responsible for enforcing a cloned SObject's values honor uniqueness rules for name and + * autonumber fields + * @param clonedSObject SObject a cloned version of the prototyped SObject + * @param fakeId Boolean Populates a fakeId on the object unless it's to be inserted + * @param iterationCounter Integer Counter for ensuring name uniqueness + * @param preventFakeId Boolean Prevents fake ID generation even when fakeId is true + * @return SObject a mutated version of the cloned object with unique name and autonumber fields + */ + public static SObject mutateCloneToRespectNameAndAutonumberRules( + SObject clonedSObject, + Boolean fakeId, + Integer iterationCounter, + Boolean preventFakeId ) { String nameField = calculateNameField(clonedSObject); Boolean isNameFieldAutoNumber = nameFieldIsAutoNumber( @@ -44,7 +62,7 @@ public class SObjectFactoryHelper { (String) clonedSObject.get(nameField) + ' ' + iterationCounter ); } - if (fakeId) { + if (fakeId && !preventFakeId) { clonedSObject.Id = IdFactory.get(clonedSObject); } return clonedSObject;