From b40b62eaecda4cf0d8613c06d62dfeb70ebe02d5 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Fri, 25 Sep 2026 08:51:55 +0200 Subject: [PATCH 1/5] refactoring --- .../main/default/classes/AfterDeleteTest.cls | 2 +- .../main/default/classes/AfterInsertTest.cls | 2 +- .../default/classes/AfterUndeleteTest.cls | 2 +- .../main/default/classes/AfterUpdateTest.cls | 2 +- .../main/default/classes/BeforeDeleteTest.cls | 2 +- .../main/default/classes/BeforeInsertTest.cls | 2 +- .../main/default/classes/BeforeUpdateTest.cls | 2 +- .../default/classes/TriggerOrchestrator.cls | 1187 ++++++++--------- .../classes/TriggerOrchestratorTest.cls | 2 +- .../main/default/classes/TriggerTypes.cls | 816 +++++------ .../main/default/classes/TriggerTypesTest.cls | 2 +- 11 files changed, 970 insertions(+), 1051 deletions(-) diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index fd946b7..64e6191 100644 --- a/force-app/main/default/classes/AfterDeleteTest.cls +++ b/force-app/main/default/classes/AfterDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index 508db16..bafc8db 100644 --- a/force-app/main/default/classes/AfterInsertTest.cls +++ b/force-app/main/default/classes/AfterInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index 7829bc3..ebb7bf2 100644 --- a/force-app/main/default/classes/AfterUndeleteTest.cls +++ b/force-app/main/default/classes/AfterUndeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index 85de547..7a0c8ec 100644 --- a/force-app/main/default/classes/AfterUpdateTest.cls +++ b/force-app/main/default/classes/AfterUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index c223158..18768a8 100644 --- a/force-app/main/default/classes/BeforeDeleteTest.cls +++ b/force-app/main/default/classes/BeforeDeleteTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index d315515..0584e9b 100644 --- a/force-app/main/default/classes/BeforeInsertTest.cls +++ b/force-app/main/default/classes/BeforeInsertTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index d4987ad..c55a768 100644 --- a/force-app/main/default/classes/BeforeUpdateTest.cls +++ b/force-app/main/default/classes/BeforeUpdateTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index 68eee7e..d75ec53 100644 --- a/force-app/main/default/classes/TriggerOrchestrator.cls +++ b/force-app/main/default/classes/TriggerOrchestrator.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -295,6 +295,64 @@ public with sharing class TriggerOrchestrator { } } + @TestVisible + private class MetadataBypass { + private Map metadataByObjectName = this.resolveObjectMetadata(); + + public Boolean isSObjectTriggerBypassed(TriggerContext context) { + return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.objectRow.Bypass__c == true; + } + + public Boolean isHandlerBypassed(TriggerContext context, String handlerName) { + return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; + } + + private Map resolveObjectMetadata() { + Map metadataByObjectName = new Map(); + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + + for ( + TriggerObject__mdt triggerObject : (List) SOQL.of(TriggerObject__mdt.SObjectType) + .with(TriggerObject__mdt.ObjectAPIName__c, TriggerObject__mdt.Bypass__c) + .with(SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r').with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c)) + .systemMode() + .mockId('TriggerObject__mdt') + .toList() + ) { + metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), new ObjectMetadata(triggerObject)); + } + + return metadataByObjectName; + } + } + + @TestVisible + private class ObjectMetadata { + public TriggerObject__mdt objectRow = new TriggerObject__mdt(); + public Map handlerRowByName = new Map(); + + public ObjectMetadata(TriggerObject__mdt triggerObject) { + this.objectRow = triggerObject; + this.addHandlerRows(); + } + + private void addHandlerRows() { + for (TriggerHandler__mdt triggerHandler : this.objectRow.TriggerHandlers__r ?? new List()) { + this.addHandlerRow(triggerHandler); + } + } + + private void addHandlerRow(TriggerHandler__mdt triggerHandler) { + String handlerName = triggerHandler.ApexClassName__c.trim().toLowerCase(); + + this.handlerRowByName.put(handlerName, triggerHandler); + + if (handlerName.contains('.')) { + this.handlerRowByName.put(handlerName.substringAfterLast('.'), triggerHandler); + } + } + } + private class TriggerStack { private List executionStack = new List(); @@ -311,20 +369,6 @@ public with sharing class TriggerOrchestrator { } } - private class RunCounter { - private Map> runCountByRecordIdByScope = new Map>(); - - public void registerScope(String recursionScope) { - if (!this.runCountByRecordIdByScope.containsKey(recursionScope)) { - this.runCountByRecordIdByScope.put(recursionScope, new Map()); - } - } - - public Map getForRecursionScope(String recursionScope) { - return this.runCountByRecordIdByScope.get(recursionScope); - } - } - @TestVisible private class TriggerLogger { @TestVisible @@ -361,60 +405,55 @@ public with sharing class TriggerOrchestrator { } } - @TestVisible - private class MetadataBypass { - private Map metadataByObjectName = this.resolveObjectMetadata(); + private class TriggerError implements Error { + public String handlerName; + public SObjectType sObjectType; + public System.Exception thrownException; + public System.TriggerOperation triggerOperation; + public Set recordIds = new Set(); - public Boolean isSObjectTriggerBypassed(TriggerContext context) { - return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.objectRow.Bypass__c == true; + public String getHandlerName() { + return this.handlerName; } - public Boolean isHandlerBypassed(TriggerContext context, String handlerName) { - return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; + public SObjectType getSObjectType() { + return this.sObjectType; } - private Map resolveObjectMetadata() { - Map metadataByObjectName = new Map(); - String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); - - for ( - TriggerObject__mdt triggerObject : (List) SOQL.of(TriggerObject__mdt.SObjectType) - .with(TriggerObject__mdt.ObjectAPIName__c, TriggerObject__mdt.Bypass__c) - .with(SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r').with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c)) - .systemMode() - .mockId('TriggerObject__mdt') - .toList() - ) { - metadataByObjectName.put(triggerObject.ObjectAPIName__c.trim().toLowerCase(), new ObjectMetadata(triggerObject)); - } - - return metadataByObjectName; + public Exception getException() { + return this.thrownException; } - } - @TestVisible - private class ObjectMetadata { - public TriggerObject__mdt objectRow = new TriggerObject__mdt(); - public Map handlerRowByName = new Map(); + public System.TriggerOperation getOperation() { + return this.triggerOperation; + } - public ObjectMetadata(TriggerObject__mdt triggerObject) { - this.objectRow = triggerObject; - this.addHandlerRows(); + public Set getRecordIds() { + return this.recordIds; } - private void addHandlerRows() { - for (TriggerHandler__mdt triggerHandler : this.objectRow.TriggerHandlers__r ?? new List()) { - this.addHandlerRow(triggerHandler); - } + public TriggerError withException(System.Exception thrownException) { + this.thrownException = thrownException; + return this; } - private void addHandlerRow(TriggerHandler__mdt triggerHandler) { - String handlerName = triggerHandler.ApexClassName__c.trim().toLowerCase(); + public TriggerError withHandlerName(String handlerName) { + this.handlerName = handlerName; + return this; + } - this.handlerRowByName.put(handlerName, triggerHandler); + public TriggerError withContext(TriggerContext context) { + this.sObjectType = context.sObjectType; + this.triggerOperation = context.triggerOperation; + this.setRecordIds(context); + return this; + } - if (handlerName.contains('.')) { - this.handlerRowByName.put(handlerName.substringAfterLast('.'), triggerHandler); + private void setRecordIds(TriggerContext context) { + for (TriggerTypes.TriggerRecord record : context.triggerRecords) { + if (record.getId() != null) { + this.recordIds.add(record.getId()); + } } } } @@ -559,65 +598,13 @@ public with sharing class TriggerOrchestrator { } } - private class TriggerError implements Error { - public String handlerName; - public SObjectType sObjectType; - public System.Exception thrownException; - public System.TriggerOperation triggerOperation; - public Set recordIds = new Set(); - - public Exception getException() { - return this.thrownException; - } - - public String getHandlerName() { - return this.handlerName; - } - - public System.TriggerOperation getOperation() { - return this.triggerOperation; - } - - public SObjectType getSObjectType() { - return this.sObjectType; - } - - public Set getRecordIds() { - return this.recordIds; - } - - public TriggerError withException(System.Exception thrownException) { - this.thrownException = thrownException; - return this; - } - - public TriggerError withHandlerName(String handlerName) { - this.handlerName = handlerName; - return this; - } - - public TriggerError withContext(TriggerContext context) { - this.sObjectType = context.sObjectType; - this.triggerOperation = context.triggerOperation; - this.setRecordIds(context); - return this; - } - - private void setRecordIds(TriggerContext context) { - for (TriggerTypes.TriggerRecord record : context.triggerRecords) { - if (record.getId() != null) { - this.recordIds.add(record.getId()); - } - } - } - } - private abstract class HandlerAdapter { protected String handlerName; protected TriggerOrchestrator orchestrator; protected ParentDeclaration parentDeclaration; protected RelatedLoader relatedLoader; + protected HandlerLifecycle handlerLifecycle; protected List qualifiedRecords = new List(); @@ -626,11 +613,8 @@ public with sharing class TriggerOrchestrator { this.orchestrator = orchestrator; } - public abstract void execute(List records); - public abstract Boolean isBypassedByAddOn(); - public Boolean isBypassed() { - return this.isBypassedByApexOrMetadata() || this.isBypassedByAddOn(); + return this.isBypassedByApexOrMetadata() || this.handlerLifecycle.isBypassedByAddOn(); } private Boolean isBypassedByApexOrMetadata() { @@ -638,6 +622,16 @@ public with sharing class TriggerOrchestrator { TriggerOrchestrator.metadataBypass.isHandlerBypassed(this.orchestrator.context, this.handlerName); } + public abstract void execute(List records); + + protected void handleException(Exception e) { + this.logError(e); + + if (e instanceof TriggerTypes.TriggerLibException || !this.handlerLifecycle.continuesOnError()) { + throw e; + } + } + protected void logError(Exception e) { TriggerOrchestrator.triggerLogger.log(new TriggerError().withException(e).withHandlerName(this.handlerName).withContext(this.orchestrator.context)); } @@ -652,10 +646,7 @@ public with sharing class TriggerOrchestrator { this.populator = populator; this.parentDeclaration = new BeforeInsertParentDeclaration(this.populator); this.relatedLoader = new BeforeInsertRelatedLoader(this.populator); - } - - public override Boolean isBypassedByAddOn() { - return (this.populator instanceof BeforeInsert.Bypassable && ((BeforeInsert.Bypassable) this.populator).bypassOnBeforeInsertWhen()); + this.handlerLifecycle = new BeforeInsertHandlerLifecycle(this.populator); } public override void execute(List records) { @@ -669,7 +660,7 @@ public with sharing class TriggerOrchestrator { this.populate(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -698,22 +689,6 @@ public with sharing class TriggerOrchestrator { this.qualifiedRecords.add(record); this.populator.populateOnBeforeInsert(record); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.populator instanceof BeforeInsert.Finalizer)) { - return; - } - - ((BeforeInsert.Finalizer) this.populator).finalizeOnBeforeInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.populator instanceof BeforeInsert.ContinueOnError)) { - throw e; - } - } } private class BeforeInsertValidatorAdapter extends HandlerAdapter { @@ -725,10 +700,7 @@ public with sharing class TriggerOrchestrator { this.validator = validator; this.parentDeclaration = new BeforeInsertParentDeclaration(this.validator); this.relatedLoader = new BeforeInsertRelatedLoader(this.validator); - } - - public override Boolean isBypassedByAddOn() { - return (this.validator instanceof BeforeInsert.Bypassable && ((BeforeInsert.Bypassable) this.validator).bypassOnBeforeInsertWhen()); + this.handlerLifecycle = new BeforeInsertHandlerLifecycle(this.validator); } public override void execute(List records) { @@ -742,7 +714,7 @@ public with sharing class TriggerOrchestrator { this.validate(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -776,22 +748,6 @@ public with sharing class TriggerOrchestrator { throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeInsertWhen but attached no error in addErrorOnBeforeInsert.'); } } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.validator instanceof BeforeInsert.Finalizer)) { - return; - } - - ((BeforeInsert.Finalizer) this.validator).finalizeOnBeforeInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.validator instanceof BeforeInsert.ContinueOnError)) { - throw e; - } - } } private class AfterInsertWriterAdapter extends HandlerAdapter { @@ -805,10 +761,7 @@ public with sharing class TriggerOrchestrator { this.parentDeclaration = new AfterInsertParentDeclaration(this.writer); this.relatedLoader = new AfterInsertRelatedLoader(this.writer); this.unitOfWork = new AfterInsertUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - } - - public override Boolean isBypassedByAddOn() { - return (this.writer instanceof AfterInsert.Bypassable && ((AfterInsert.Bypassable) this.writer).bypassOnAfterInsertWhen()); + this.handlerLifecycle = new AfterInsertHandlerLifecycle(this.writer); } public override void execute(List records) { @@ -819,7 +772,7 @@ public with sharing class TriggerOrchestrator { this.write(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { this.handleException(e); @@ -835,14 +788,6 @@ public with sharing class TriggerOrchestrator { this.writer.writeOnAfterInsert(record, this.unitOfWork); } - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.writer instanceof AfterInsert.Finalizer)) { - return; - } - - ((AfterInsert.Finalizer) this.writer).finalizeOnAfterInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); - } - private void commitOwnUnitOfWork() { if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { return; @@ -850,14 +795,6 @@ public with sharing class TriggerOrchestrator { this.unitOfWork.commitWork(); } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.writer instanceof AfterInsert.ContinueOnError)) { - throw e; - } - } } private class AfterInsertDispatcherAdapter extends HandlerAdapter { @@ -869,10 +806,7 @@ public with sharing class TriggerOrchestrator { this.dispatcher = dispatcher; this.parentDeclaration = new AfterInsertParentDeclaration(this.dispatcher); this.relatedLoader = new AfterInsertRelatedLoader(this.dispatcher); - } - - public override Boolean isBypassedByAddOn() { - return (this.dispatcher instanceof AfterInsert.Bypassable && ((AfterInsert.Bypassable) this.dispatcher).bypassOnAfterInsertWhen()); + this.handlerLifecycle = new AfterInsertHandlerLifecycle(this.dispatcher); } public override void execute(List records) { @@ -884,7 +818,7 @@ public with sharing class TriggerOrchestrator { } this.dispatch(); - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -905,22 +839,6 @@ public with sharing class TriggerOrchestrator { this.dispatcher.dispatchOnAfterInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.dispatcher instanceof AfterInsert.Finalizer)) { - return; - } - - ((AfterInsert.Finalizer) this.dispatcher).finalizeOnAfterInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.dispatcher instanceof AfterInsert.ContinueOnError)) { - throw e; - } - } } private class BeforeUpdatePopulatorAdapter extends HandlerAdapter { @@ -933,13 +851,10 @@ public with sharing class TriggerOrchestrator { this.populator = populator; this.parentDeclaration = new BeforeUpdateParentDeclaration(this.populator); this.relatedLoader = new BeforeUpdateRelatedLoader(this.populator); + this.handlerLifecycle = new BeforeUpdateHandlerLifecycle(this.populator); this.recursionTracker = new BeforeUpdateRecursionTracker(this.populator, this.handlerName); } - public override Boolean isBypassedByAddOn() { - return (this.populator instanceof BeforeUpdate.Bypassable && ((BeforeUpdate.Bypassable) this.populator).bypassOnBeforeUpdateWhen()); - } - public override void execute(List records) { Integer dmlStatementsBefore = Limits.getDmlStatements(); Integer immediateEventsBefore = Limits.getPublishImmediateDML(); @@ -951,7 +866,7 @@ public with sharing class TriggerOrchestrator { this.populate(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -981,22 +896,6 @@ public with sharing class TriggerOrchestrator { this.qualifiedRecords.add(record); this.populator.populateOnBeforeUpdate(record); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.populator instanceof BeforeUpdate.Finalizer)) { - return; - } - - ((BeforeUpdate.Finalizer) this.populator).finalizeOnBeforeUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.populator instanceof BeforeUpdate.ContinueOnError)) { - throw e; - } - } } private class BeforeUpdateValidatorAdapter extends HandlerAdapter { @@ -1008,10 +907,7 @@ public with sharing class TriggerOrchestrator { this.validator = validator; this.parentDeclaration = new BeforeUpdateParentDeclaration(this.validator); this.relatedLoader = new BeforeUpdateRelatedLoader(this.validator); - } - - public override Boolean isBypassedByAddOn() { - return (this.validator instanceof BeforeUpdate.Bypassable && ((BeforeUpdate.Bypassable) this.validator).bypassOnBeforeUpdateWhen()); + this.handlerLifecycle = new BeforeUpdateHandlerLifecycle(this.validator); } public override void execute(List records) { @@ -1025,7 +921,7 @@ public with sharing class TriggerOrchestrator { this.validate(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -1059,22 +955,6 @@ public with sharing class TriggerOrchestrator { throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeUpdateWhen but attached no error in addErrorOnBeforeUpdate.'); } } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.validator instanceof BeforeUpdate.Finalizer)) { - return; - } - - ((BeforeUpdate.Finalizer) this.validator).finalizeOnBeforeUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.validator instanceof BeforeUpdate.ContinueOnError)) { - throw e; - } - } } private class AfterUpdateWriterAdapter extends HandlerAdapter { @@ -1089,13 +969,10 @@ public with sharing class TriggerOrchestrator { this.parentDeclaration = new AfterUpdateParentDeclaration(this.writer); this.relatedLoader = new AfterUpdateRelatedLoader(this.writer); this.unitOfWork = new AfterUpdateUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new AfterUpdateHandlerLifecycle(this.writer); this.recursionTracker = new AfterUpdateRecursionTracker(this.writer, this.handlerName); } - public override Boolean isBypassedByAddOn() { - return (this.writer instanceof AfterUpdate.Bypassable && ((AfterUpdate.Bypassable) this.writer).bypassOnAfterUpdateWhen()); - } - public override void execute(List records) { try { this.relatedLoader.load(records); @@ -1104,7 +981,7 @@ public with sharing class TriggerOrchestrator { this.write(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { this.handleException(e); @@ -1121,14 +998,6 @@ public with sharing class TriggerOrchestrator { this.writer.writeOnAfterUpdate(record, this.unitOfWork); } - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.writer instanceof AfterUpdate.Finalizer)) { - return; - } - - ((AfterUpdate.Finalizer) this.writer).finalizeOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); - } - private void commitOwnUnitOfWork() { if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { return; @@ -1136,14 +1005,6 @@ public with sharing class TriggerOrchestrator { this.unitOfWork.commitWork(); } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.writer instanceof AfterUpdate.ContinueOnError)) { - throw e; - } - } } private class AfterUpdateDispatcherAdapter extends HandlerAdapter { @@ -1156,13 +1017,10 @@ public with sharing class TriggerOrchestrator { this.dispatcher = dispatcher; this.parentDeclaration = new AfterUpdateParentDeclaration(this.dispatcher); this.relatedLoader = new AfterUpdateRelatedLoader(this.dispatcher); + this.handlerLifecycle = new AfterUpdateHandlerLifecycle(this.dispatcher); this.recursionTracker = new AfterUpdateRecursionTracker(this.dispatcher, this.handlerName); } - public override Boolean isBypassedByAddOn() { - return (this.dispatcher instanceof AfterUpdate.Bypassable && ((AfterUpdate.Bypassable) this.dispatcher).bypassOnAfterUpdateWhen()); - } - public override void execute(List records) { try { this.relatedLoader.load(records); @@ -1172,7 +1030,7 @@ public with sharing class TriggerOrchestrator { } this.dispatch(); - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -1194,22 +1052,6 @@ public with sharing class TriggerOrchestrator { this.dispatcher.dispatchOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.dispatcher instanceof AfterUpdate.Finalizer)) { - return; - } - - ((AfterUpdate.Finalizer) this.dispatcher).finalizeOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.dispatcher instanceof AfterUpdate.ContinueOnError)) { - throw e; - } - } } private class BeforeDeleteValidatorAdapter extends HandlerAdapter { @@ -1221,10 +1063,7 @@ public with sharing class TriggerOrchestrator { this.validator = validator; this.parentDeclaration = new BeforeDeleteParentDeclaration(this.validator); this.relatedLoader = new BeforeDeleteRelatedLoader(this.validator); - } - - public override Boolean isBypassedByAddOn() { - return (this.validator instanceof BeforeDelete.Bypassable && ((BeforeDelete.Bypassable) this.validator).bypassOnBeforeDeleteWhen()); + this.handlerLifecycle = new BeforeDeleteHandlerLifecycle(this.validator); } public override void execute(List records) { @@ -1235,7 +1074,7 @@ public with sharing class TriggerOrchestrator { this.validate(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -1256,22 +1095,6 @@ public with sharing class TriggerOrchestrator { throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeDeleteWhen but attached no error in addErrorOnBeforeDelete.'); } } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.validator instanceof BeforeDelete.Finalizer)) { - return; - } - - ((BeforeDelete.Finalizer) this.validator).finalizeOnBeforeDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.validator instanceof BeforeDelete.ContinueOnError)) { - throw e; - } - } } private class BeforeDeleteWriterAdapter extends HandlerAdapter { @@ -1285,10 +1108,7 @@ public with sharing class TriggerOrchestrator { this.parentDeclaration = new BeforeDeleteParentDeclaration(this.writer); this.relatedLoader = new BeforeDeleteRelatedLoader(this.writer); this.unitOfWork = new BeforeDeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - } - - public override Boolean isBypassedByAddOn() { - return (this.writer instanceof BeforeDelete.Bypassable && ((BeforeDelete.Bypassable) this.writer).bypassOnBeforeDeleteWhen()); + this.handlerLifecycle = new BeforeDeleteHandlerLifecycle(this.writer); } public override void execute(List records) { @@ -1299,7 +1119,7 @@ public with sharing class TriggerOrchestrator { this.write(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { this.handleException(e); @@ -1315,14 +1135,6 @@ public with sharing class TriggerOrchestrator { this.writer.writeOnBeforeDelete(record, this.unitOfWork); } - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.writer instanceof BeforeDelete.Finalizer)) { - return; - } - - ((BeforeDelete.Finalizer) this.writer).finalizeOnBeforeDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); - } - private void commitOwnUnitOfWork() { if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { return; @@ -1330,14 +1142,6 @@ public with sharing class TriggerOrchestrator { this.unitOfWork.commitWork(); } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.writer instanceof BeforeDelete.ContinueOnError)) { - throw e; - } - } } private class AfterDeleteWriterAdapter extends HandlerAdapter { @@ -1351,10 +1155,7 @@ public with sharing class TriggerOrchestrator { this.parentDeclaration = new AfterDeleteParentDeclaration(this.writer); this.relatedLoader = new AfterDeleteRelatedLoader(this.writer); this.unitOfWork = new AfterDeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - } - - public override Boolean isBypassedByAddOn() { - return (this.writer instanceof AfterDelete.Bypassable && ((AfterDelete.Bypassable) this.writer).bypassOnAfterDeleteWhen()); + this.handlerLifecycle = new AfterDeleteHandlerLifecycle(this.writer); } public override void execute(List records) { @@ -1365,7 +1166,7 @@ public with sharing class TriggerOrchestrator { this.write(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { this.handleException(e); @@ -1381,14 +1182,6 @@ public with sharing class TriggerOrchestrator { this.writer.writeOnAfterDelete(record, this.unitOfWork); } - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.writer instanceof AfterDelete.Finalizer)) { - return; - } - - ((AfterDelete.Finalizer) this.writer).finalizeOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); - } - private void commitOwnUnitOfWork() { if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { return; @@ -1396,14 +1189,6 @@ public with sharing class TriggerOrchestrator { this.unitOfWork.commitWork(); } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.writer instanceof AfterDelete.ContinueOnError)) { - throw e; - } - } } private class AfterDeleteDispatcherAdapter extends HandlerAdapter { @@ -1415,10 +1200,7 @@ public with sharing class TriggerOrchestrator { this.dispatcher = dispatcher; this.parentDeclaration = new AfterDeleteParentDeclaration(this.dispatcher); this.relatedLoader = new AfterDeleteRelatedLoader(this.dispatcher); - } - - public override Boolean isBypassedByAddOn() { - return (this.dispatcher instanceof AfterDelete.Bypassable && ((AfterDelete.Bypassable) this.dispatcher).bypassOnAfterDeleteWhen()); + this.handlerLifecycle = new AfterDeleteHandlerLifecycle(this.dispatcher); } public override void execute(List records) { @@ -1430,7 +1212,7 @@ public with sharing class TriggerOrchestrator { } this.dispatch(); - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -1451,22 +1233,6 @@ public with sharing class TriggerOrchestrator { this.dispatcher.dispatchOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.dispatcher instanceof AfterDelete.Finalizer)) { - return; - } - - ((AfterDelete.Finalizer) this.dispatcher).finalizeOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.dispatcher instanceof AfterDelete.ContinueOnError)) { - throw e; - } - } } private class AfterUndeleteWriterAdapter extends HandlerAdapter { @@ -1480,10 +1246,7 @@ public with sharing class TriggerOrchestrator { this.parentDeclaration = new AfterUndeleteParentDeclaration(this.writer); this.relatedLoader = new AfterUndeleteRelatedLoader(this.writer); this.unitOfWork = new AfterUndeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - } - - public override Boolean isBypassedByAddOn() { - return (this.writer instanceof AfterUndelete.Bypassable && ((AfterUndelete.Bypassable) this.writer).bypassOnAfterUndeleteWhen()); + this.handlerLifecycle = new AfterUndeleteHandlerLifecycle(this.writer); } public override void execute(List records) { @@ -1494,7 +1257,7 @@ public with sharing class TriggerOrchestrator { this.write(record); } - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { this.handleException(e); @@ -1510,14 +1273,6 @@ public with sharing class TriggerOrchestrator { this.writer.writeOnAfterUndelete(record, this.unitOfWork); } - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.writer instanceof AfterUndelete.Finalizer)) { - return; - } - - ((AfterUndelete.Finalizer) this.writer).finalizeOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(this.qualifiedRecords)); - } - private void commitOwnUnitOfWork() { if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { return; @@ -1525,14 +1280,6 @@ public with sharing class TriggerOrchestrator { this.unitOfWork.commitWork(); } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.writer instanceof AfterUndelete.ContinueOnError)) { - throw e; - } - } } private class AfterUndeleteDispatcherAdapter extends HandlerAdapter { @@ -1544,10 +1291,7 @@ public with sharing class TriggerOrchestrator { this.dispatcher = dispatcher; this.parentDeclaration = new AfterUndeleteParentDeclaration(this.dispatcher); this.relatedLoader = new AfterUndeleteRelatedLoader(this.dispatcher); - } - - public override Boolean isBypassedByAddOn() { - return (this.dispatcher instanceof AfterUndelete.Bypassable && ((AfterUndelete.Bypassable) this.dispatcher).bypassOnAfterUndeleteWhen()); + this.handlerLifecycle = new AfterUndeleteHandlerLifecycle(this.dispatcher); } public override void execute(List records) { @@ -1559,7 +1303,7 @@ public with sharing class TriggerOrchestrator { } this.dispatch(); - this.finalize(); + this.handlerLifecycle.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); } @@ -1580,22 +1324,6 @@ public with sharing class TriggerOrchestrator { this.dispatcher.dispatchOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(this.qualifiedRecords)); } - - private void finalize() { - if (this.qualifiedRecords.isEmpty() || !(this.dispatcher instanceof AfterUndelete.Finalizer)) { - return; - } - - ((AfterUndelete.Finalizer) this.dispatcher).finalizeOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(this.qualifiedRecords)); - } - - private void handleException(Exception e) { - this.logError(e); - - if (e instanceof TriggerTypes.TriggerLibException || !(this.dispatcher instanceof AfterUndelete.ContinueOnError)) { - throw e; - } - } } private abstract class ParentDeclaration { @@ -1736,78 +1464,282 @@ public with sharing class TriggerOrchestrator { } } - private abstract class RelatedLoader { - public void load(List records) { - Map relatedByProviderName = new Map(); + private class ParentLoader { + private Set lookupsForNewParents = new Set(); + private Set lookupsForOldParents = new Set(); - for (TriggerTypes.TriggerRecord record : records) { - record.setRelated(relatedByProviderName); - } + private Map> fieldPathsByLookup = new Map>(); + private Map> cachedParentsByLookup = new Map>(); - relatedByProviderName.putAll(this.queryRelated(records)); + private TriggerContext context; + + public ParentLoader(TriggerContext context) { + this.context = context; } - protected abstract Map queryRelated(List records); - } + public void load(List handlers) { + this.collectDeclarations(handlers); + this.queryNewParentsFromSavedRecords(); + this.queryAndAttachParents(); + } - private class BeforeInsertRelatedLoader extends RelatedLoader { - private BeforeInsert.Handler handler; + public void refreshNewParents(List records) { + if (records.isEmpty()) { + return; + } - public BeforeInsertRelatedLoader(BeforeInsert.Handler handler) { - this.handler = handler; + for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { + this.queryParents(lookupField, this.notQueriedNewParentIds(lookupField, records)); + this.attachNewParents(lookupField, records); + } } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - - if (!(this.handler instanceof BeforeInsert.RelatedQuery)) { - return relatedByProviderName; + private void collectDeclarations(List handlers) { + for (HandlerAdapter handler : handlers) { + this.declareNewParents(handler.parentDeclaration.getNewParentFields()); + this.declareOldParents(handler.parentDeclaration.getOldParentFields()); } + } - Map providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? - new Map(); - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + private void declareNewParents(Map parentFieldsByLookup) { + this.lookupsForNewParents.addAll(parentFieldsByLookup.keySet()); + this.declare(parentFieldsByLookup); + } - for (String providerName : providers.keySet()) { - BeforeInsert.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + private void declareOldParents(Map parentFieldsByLookup) { + this.lookupsForOldParents.addAll(parentFieldsByLookup.keySet()); + this.declare(parentFieldsByLookup); + } - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); + private void declare(Map parentFieldsByLookup) { + for (SObjectField lookupField : parentFieldsByLookup.keySet()) { + if (!this.fieldPathsByLookup.containsKey(lookupField)) { + this.fieldPathsByLookup.put(lookupField, new Set()); + } + if (!this.cachedParentsByLookup.containsKey(lookupField)) { + this.cachedParentsByLookup.put(lookupField, new Map()); } - relatedByProviderName.put(providerName, related); + this.fieldPathsByLookup.get(lookupField).addAll(parentFieldsByLookup.get(lookupField).getFields()); } - - return relatedByProviderName; } - } - private class AfterInsertRelatedLoader extends RelatedLoader { - private AfterInsert.Handler handler; - - public AfterInsertRelatedLoader(AfterInsert.Handler handler) { - this.handler = handler; - } + private void queryNewParentsFromSavedRecords() { + if (this.lookupsForNewParents.isEmpty() || !this.haveTriggeredRecordsIds()) { + return; + } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); + Set fieldsToQuery = new Set(); - if (!(this.handler instanceof AfterInsert.RelatedQuery)) { - return relatedByProviderName; + for (SObjectField lookupField : this.lookupsForNewParents) { + fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); } - Map providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? - new Map(); - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); - for (String providerName : providers.keySet()) { - AfterInsert.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + for (SObjectField lookupField : this.lookupsForNewParents) { + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + for (SObject savedRecord : savedRecords) { + SObject parent = savedRecord.getSObject(relationshipName); + + if (parent != null) { + cachedParentById.put(parent.Id, parent); + } + } + } + } + + private Boolean haveTriggeredRecordsIds() { + return this.context.triggerOperation == System.TriggerOperation.AFTER_INSERT || + this.context.triggerOperation == System.TriggerOperation.AFTER_UPDATE || + this.context.triggerOperation == System.TriggerOperation.AFTER_UNDELETE; + } + + private Set getFieldPathsForLookup(SObjectField lookupField) { + String relationshipName = lookupField.getDescribe().getRelationshipName(); + Set fieldPaths = new Set{ relationshipName + '.Id' }; + + for (String field : this.fieldPathsByLookup.get(lookupField)) { + fieldPaths.add(relationshipName + '.' + field); + } + + return fieldPaths; + } + + private void queryAndAttachParents() { + for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { + Set parentIdsToQuery = new Set(); + + parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); + parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); + + this.queryParents(lookupField, parentIdsToQuery); + + this.attachNewParents(lookupField, this.context.triggerRecords); + this.attachOldParents(lookupField, this.context.triggerRecords); + } + } + + private Set notQueriedNewParentIds(SObjectField lookupField, List records) { + Set parentIds = new Set(); + + if (!this.lookupsForNewParents.contains(lookupField)) { + return parentIds; + } + + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + + for (TriggerTypes.TriggerRecord record : records) { + Id parentId = record.getNewParentId(lookupField); + + if (parentId != null && !cachedParentById.containsKey(parentId)) { + parentIds.add(parentId); + } + } + + return parentIds; + } + + private Set notQueriedOldParentIds(SObjectField lookupField, List records) { + Set parentIds = new Set(); + + if (!this.lookupsForOldParents.contains(lookupField)) { + return parentIds; + } + + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + + for (TriggerTypes.TriggerRecord record : records) { + Id parentId = record.getOldParentId(lookupField); + + if (parentId != null && !cachedParentById.containsKey(parentId)) { + parentIds.add(parentId); + } + } + + return parentIds; + } + + private void queryParents(SObjectField lookupField, Set parentIdsToQuery) { + if (parentIdsToQuery.isEmpty()) { + return; + } + + Map queriedParentById = SOQL.of(lookupField.getDescribe().getReferenceTo()[0]) + .with(this.fieldPathsByLookup.get(lookupField)) + .byIds(parentIdsToQuery) + .systemMode() + .withoutSharing() + .toMap(); + + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + + for (Id parentId : parentIdsToQuery) { + cachedParentById.put(parentId, queriedParentById.get(parentId)); + } + } + + private void attachNewParents(SObjectField lookupField, List records) { + if (!this.lookupsForNewParents.contains(lookupField)) { + return; + } + + Map parentsById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); + + for (TriggerTypes.TriggerRecord record : records) { + record.setNewParent(relationshipName, parentsById.get(record.getNewParentId(lookupField))); + } + } + + private void attachOldParents(SObjectField lookupField, List records) { + if (!this.lookupsForOldParents.contains(lookupField)) { + return; + } + + Map parentsById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); + + for (TriggerTypes.TriggerRecord record : records) { + record.setOldParent(relationshipName, parentsById.get(record.getOldParentId(lookupField))); + } + } + } + + private abstract class RelatedLoader { + public void load(List records) { + Map relatedByProviderName = new Map(); + + for (TriggerTypes.TriggerRecord record : records) { + record.setRelated(relatedByProviderName); + } + + relatedByProviderName.putAll(this.queryRelated(records)); + } + + protected abstract Map queryRelated(List records); + } + + private class BeforeInsertRelatedLoader extends RelatedLoader { + private BeforeInsert.Handler handler; + + public BeforeInsertRelatedLoader(BeforeInsert.Handler handler) { + this.handler = handler; + } + + public override Map queryRelated(List records) { + Map relatedByProviderName = new Map(); + + if (!(this.handler instanceof BeforeInsert.RelatedQuery)) { + return relatedByProviderName; + } + + Map providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? + new Map(); + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + + for (String providerName : providers.keySet()) { + BeforeInsert.RecordsProvider provider = providers.get(providerName); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + relatedByProviderName.put(providerName, related); + } + + return relatedByProviderName; + } + } + + private class AfterInsertRelatedLoader extends RelatedLoader { + private AfterInsert.Handler handler; + + public AfterInsertRelatedLoader(AfterInsert.Handler handler) { + this.handler = handler; + } + + public override Map queryRelated(List records) { + Map relatedByProviderName = new Map(); + + if (!(this.handler instanceof AfterInsert.RelatedQuery)) { + return relatedByProviderName; + } + + Map providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? + new Map(); + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + + for (String providerName : providers.keySet()) { + AfterInsert.RecordsProvider provider = providers.get(providerName); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } relatedByProviderName.put(providerName, related); } @@ -1981,59 +1913,10 @@ public with sharing class TriggerOrchestrator { } } - private abstract class RecursionTracker { - protected Map runCountByRecordId; - protected Integer maxRunsPerRecord = 3; - - public Boolean isLimitReached(TriggerTypes.TriggerRecord record) { - return (this.runCountByRecordId.get(record.getId()) ?? 0) >= this.maxRunsPerRecord; - } - - public void countRun(TriggerTypes.TriggerRecord record) { - this.runCountByRecordId.put(record.getId(), (this.runCountByRecordId.get(record.getId()) ?? 0) + 1); - } - } - - private class BeforeUpdateRecursionTracker extends RecursionTracker { - public BeforeUpdateRecursionTracker(BeforeUpdate.Handler handler, String handlerName) { - TriggerOrchestrator.runCounter.registerScope(handlerName + ':BEFORE_UPDATE'); - this.runCountByRecordId = TriggerOrchestrator.runCounter.getForRecursionScope(handlerName + ':BEFORE_UPDATE'); - - if (handler instanceof BeforeUpdate.RecursionGuard) { - this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); - } - } - } - - private class AfterUpdateRecursionTracker extends RecursionTracker { - public AfterUpdateRecursionTracker(AfterUpdate.Handler handler, String handlerName) { - TriggerOrchestrator.runCounter.registerScope(handlerName + ':AFTER_UPDATE'); - this.runCountByRecordId = TriggerOrchestrator.runCounter.getForRecursionScope(handlerName + ':AFTER_UPDATE'); - - if (handler instanceof AfterUpdate.RecursionGuard) { - this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); - } - } - } - private abstract class UnitOfWorkResolver { public abstract UnitOfWork resolve(UnitOfWork sharedUnitOfWork); } - private class BeforeDeleteUnitOfWorkResolver extends UnitOfWorkResolver { - private BeforeDelete.Writer writer; - - public BeforeDeleteUnitOfWorkResolver(BeforeDelete.Writer writer) { - this.writer = writer; - } - - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof BeforeDelete.OwnUnitOfWork - ? new UnitOfWork(((BeforeDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnBeforeDelete()) - : this.writer instanceof BeforeDelete.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } - } - private class AfterInsertUnitOfWorkResolver extends UnitOfWorkResolver { private AfterInsert.Writer writer; @@ -2062,6 +1945,20 @@ public with sharing class TriggerOrchestrator { } } + private class BeforeDeleteUnitOfWorkResolver extends UnitOfWorkResolver { + private BeforeDelete.Writer writer; + + public BeforeDeleteUnitOfWorkResolver(BeforeDelete.Writer writer) { + this.writer = writer; + } + + public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { + return this.writer instanceof BeforeDelete.OwnUnitOfWork + ? new UnitOfWork(((BeforeDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnBeforeDelete()) + : this.writer instanceof BeforeDelete.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; + } + } + private class AfterDeleteUnitOfWorkResolver extends UnitOfWorkResolver { private AfterDelete.Writer writer; @@ -2141,210 +2038,232 @@ public with sharing class TriggerOrchestrator { } } - private class ParentLoader { - private Set lookupsForNewParents = new Set(); - private Set lookupsForOldParents = new Set(); - - private Map> fieldPathsByLookup = new Map>(); - private Map> cachedParentsByLookup = new Map>(); + private abstract class HandlerLifecycle { + public abstract Boolean isBypassedByAddOn(); + public abstract void finalize(List qualifiedRecords); + public abstract Boolean continuesOnError(); + } - private TriggerContext context; + private class BeforeInsertHandlerLifecycle extends HandlerLifecycle { + private BeforeInsert.Handler handler; - public ParentLoader(TriggerContext context) { - this.context = context; + public BeforeInsertHandlerLifecycle(BeforeInsert.Handler handler) { + this.handler = handler; } - public void load(List handlers) { - this.collectDeclarations(handlers); - this.queryNewParentsFromSavedRecords(); - this.queryAndAttachParents(); + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeInsert.Bypassable && ((BeforeInsert.Bypassable) this.handler).bypassOnBeforeInsertWhen()); } - private void collectDeclarations(List handlers) { - for (HandlerAdapter handler : handlers) { - this.declareNewParents(handler.parentDeclaration.getNewParentFields()); - this.declareOldParents(handler.parentDeclaration.getOldParentFields()); + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeInsert.Finalizer)) { + return; } + + ((BeforeInsert.Finalizer) this.handler).finalizeOnBeforeInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); } - private void queryNewParentsFromSavedRecords() { - if (this.lookupsForNewParents.isEmpty() || !this.haveTriggeredRecordsIds()) { - return; - } + public override Boolean continuesOnError() { + return this.handler instanceof BeforeInsert.ContinueOnError; + } + } - Set fieldsToQuery = new Set(); + private class AfterInsertHandlerLifecycle extends HandlerLifecycle { + private AfterInsert.Handler handler; - for (SObjectField lookupField : this.lookupsForNewParents) { - fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); + public AfterInsertHandlerLifecycle(AfterInsert.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterInsert.Bypassable && ((AfterInsert.Bypassable) this.handler).bypassOnAfterInsertWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterInsert.Finalizer)) { + return; } - List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); + ((AfterInsert.Finalizer) this.handler).finalizeOnAfterInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); + } - for (SObjectField lookupField : this.lookupsForNewParents) { - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); + public override Boolean continuesOnError() { + return this.handler instanceof AfterInsert.ContinueOnError; + } + } - for (SObject savedRecord : savedRecords) { - SObject parent = savedRecord.getSObject(relationshipName); + private class BeforeUpdateHandlerLifecycle extends HandlerLifecycle { + private BeforeUpdate.Handler handler; - if (parent != null) { - cachedParentById.put(parent.Id, parent); - } - } - } + public BeforeUpdateHandlerLifecycle(BeforeUpdate.Handler handler) { + this.handler = handler; } - private Set getFieldPathsForLookup(SObjectField lookupField) { - String relationshipName = lookupField.getDescribe().getRelationshipName(); - Set fieldPaths = new Set{ relationshipName + '.Id' }; + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeUpdate.Bypassable && ((BeforeUpdate.Bypassable) this.handler).bypassOnBeforeUpdateWhen()); + } - for (String field : this.fieldPathsByLookup.get(lookupField)) { - fieldPaths.add(relationshipName + '.' + field); + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeUpdate.Finalizer)) { + return; } - return fieldPaths; + ((BeforeUpdate.Finalizer) this.handler).finalizeOnBeforeUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); } - private Boolean haveTriggeredRecordsIds() { - return this.context.triggerOperation == System.TriggerOperation.AFTER_INSERT || - this.context.triggerOperation == System.TriggerOperation.AFTER_UPDATE || - this.context.triggerOperation == System.TriggerOperation.AFTER_UNDELETE; + public override Boolean continuesOnError() { + return this.handler instanceof BeforeUpdate.ContinueOnError; } + } - private void queryAndAttachParents() { - for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { - Set parentIdsToQuery = new Set(); - - parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); - parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); + private class AfterUpdateHandlerLifecycle extends HandlerLifecycle { + private AfterUpdate.Handler handler; - this.queryParents(lookupField, parentIdsToQuery); + public AfterUpdateHandlerLifecycle(AfterUpdate.Handler handler) { + this.handler = handler; + } - this.attachNewParents(lookupField, this.context.triggerRecords); - this.attachOldParents(lookupField, this.context.triggerRecords); - } + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterUpdate.Bypassable && ((AfterUpdate.Bypassable) this.handler).bypassOnAfterUpdateWhen()); } - public void refreshNewParents(List records) { - if (records.isEmpty()) { + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUpdate.Finalizer)) { return; } - for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { - this.queryParents(lookupField, this.notQueriedNewParentIds(lookupField, records)); - this.attachNewParents(lookupField, records); - } + ((AfterUpdate.Finalizer) this.handler).finalizeOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); } - private Set notQueriedNewParentIds(SObjectField lookupField, List records) { - Set parentIds = new Set(); + public override Boolean continuesOnError() { + return this.handler instanceof AfterUpdate.ContinueOnError; + } + } - if (!this.lookupsForNewParents.contains(lookupField)) { - return parentIds; - } + private class BeforeDeleteHandlerLifecycle extends HandlerLifecycle { + private BeforeDelete.Handler handler; - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + public BeforeDeleteHandlerLifecycle(BeforeDelete.Handler handler) { + this.handler = handler; + } - for (TriggerTypes.TriggerRecord record : records) { - Id parentId = record.getNewParentId(lookupField); + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeDelete.Bypassable && ((BeforeDelete.Bypassable) this.handler).bypassOnBeforeDeleteWhen()); + } - if (parentId != null && !cachedParentById.containsKey(parentId)) { - parentIds.add(parentId); - } + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeDelete.Finalizer)) { + return; } - return parentIds; + ((BeforeDelete.Finalizer) this.handler).finalizeOnBeforeDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); } - private Set notQueriedOldParentIds(SObjectField lookupField, List records) { - Set parentIds = new Set(); + public override Boolean continuesOnError() { + return this.handler instanceof BeforeDelete.ContinueOnError; + } + } - if (!this.lookupsForOldParents.contains(lookupField)) { - return parentIds; - } + private class AfterDeleteHandlerLifecycle extends HandlerLifecycle { + private AfterDelete.Handler handler; - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + public AfterDeleteHandlerLifecycle(AfterDelete.Handler handler) { + this.handler = handler; + } - for (TriggerTypes.TriggerRecord record : records) { - Id parentId = record.getOldParentId(lookupField); + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterDelete.Bypassable && ((AfterDelete.Bypassable) this.handler).bypassOnAfterDeleteWhen()); + } - if (parentId != null && !cachedParentById.containsKey(parentId)) { - parentIds.add(parentId); - } + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterDelete.Finalizer)) { + return; } - return parentIds; + ((AfterDelete.Finalizer) this.handler).finalizeOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); } - private void queryParents(SObjectField lookupField, Set parentIdsToQuery) { - if (parentIdsToQuery.isEmpty()) { - return; - } + public override Boolean continuesOnError() { + return this.handler instanceof AfterDelete.ContinueOnError; + } + } - Map queriedParentById = SOQL.of(lookupField.getDescribe().getReferenceTo()[0]) - .with(this.fieldPathsByLookup.get(lookupField)) - .byIds(parentIdsToQuery) - .systemMode() - .withoutSharing() - .toMap(); + private class AfterUndeleteHandlerLifecycle extends HandlerLifecycle { + private AfterUndelete.Handler handler; - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + public AfterUndeleteHandlerLifecycle(AfterUndelete.Handler handler) { + this.handler = handler; + } - for (Id parentId : parentIdsToQuery) { - cachedParentById.put(parentId, queriedParentById.get(parentId)); - } + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterUndelete.Bypassable && ((AfterUndelete.Bypassable) this.handler).bypassOnAfterUndeleteWhen()); } - private void attachNewParents(SObjectField lookupField, List records) { - if (!this.lookupsForNewParents.contains(lookupField)) { + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUndelete.Finalizer)) { return; } - Map parentsById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); + ((AfterUndelete.Finalizer) this.handler).finalizeOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(qualifiedRecords)); + } - for (TriggerTypes.TriggerRecord record : records) { - record.setNewParent(relationshipName, parentsById.get(record.getNewParentId(lookupField))); - } + public override Boolean continuesOnError() { + return this.handler instanceof AfterUndelete.ContinueOnError; } + } - private void attachOldParents(SObjectField lookupField, List records) { - if (!this.lookupsForOldParents.contains(lookupField)) { - return; - } + private abstract class RecursionTracker { + private Map runCountByRecordId; + protected Integer maxRunsPerRecord = 3; - Map parentsById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); + public RecursionTracker(String recursionScope) { + TriggerOrchestrator.runCounter.registerScope(recursionScope); + this.runCountByRecordId = TriggerOrchestrator.runCounter.getForRecursionScope(recursionScope); + } - for (TriggerTypes.TriggerRecord record : records) { - record.setOldParent(relationshipName, parentsById.get(record.getOldParentId(lookupField))); - } + public Boolean isLimitReached(TriggerTypes.TriggerRecord record) { + return (this.runCountByRecordId.get(record.getId()) ?? 0) >= this.maxRunsPerRecord; } - private void declareNewParents(Map parentFieldsByLookup) { - this.lookupsForNewParents.addAll(parentFieldsByLookup.keySet()); - this.declare(parentFieldsByLookup); + public void countRun(TriggerTypes.TriggerRecord record) { + this.runCountByRecordId.put(record.getId(), (this.runCountByRecordId.get(record.getId()) ?? 0) + 1); } + } - private void declareOldParents(Map parentFieldsByLookup) { - this.lookupsForOldParents.addAll(parentFieldsByLookup.keySet()); - this.declare(parentFieldsByLookup); + private class BeforeUpdateRecursionTracker extends RecursionTracker { + public BeforeUpdateRecursionTracker(BeforeUpdate.Handler handler, String handlerName) { + super(handlerName + ':BEFORE_UPDATE'); + + if (handler instanceof BeforeUpdate.RecursionGuard) { + this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); + } } + } - private void declare(Map parentFieldsByLookup) { - for (SObjectField lookupField : parentFieldsByLookup.keySet()) { - if (!this.fieldPathsByLookup.containsKey(lookupField)) { - this.fieldPathsByLookup.put(lookupField, new Set()); - } - if (!this.cachedParentsByLookup.containsKey(lookupField)) { - this.cachedParentsByLookup.put(lookupField, new Map()); - } + private class AfterUpdateRecursionTracker extends RecursionTracker { + public AfterUpdateRecursionTracker(AfterUpdate.Handler handler, String handlerName) { + super(handlerName + ':AFTER_UPDATE'); - this.fieldPathsByLookup.get(lookupField).addAll(parentFieldsByLookup.get(lookupField).getFields()); + if (handler instanceof AfterUpdate.RecursionGuard) { + this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); } } } + private class RunCounter { + private Map> runCountByRecordIdByScope = new Map>(); + + public void registerScope(String recursionScope) { + if (!this.runCountByRecordIdByScope.containsKey(recursionScope)) { + this.runCountByRecordIdByScope.put(recursionScope, new Map()); + } + } + + public Map getForRecursionScope(String recursionScope) { + return this.runCountByRecordIdByScope.get(recursionScope); + } + } + private class TriggerMock implements Mockable { private Map> mockedContextsByClassName = new Map>(); diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 4e822c4..0143dbd 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/force-app/main/default/classes/TriggerTypes.cls b/force-app/main/default/classes/TriggerTypes.cls index e4315be..75b3b3f 100644 --- a/force-app/main/default/classes/TriggerTypes.cls +++ b/force-app/main/default/classes/TriggerTypes.cls @@ -2,7 +2,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record @@ -503,48 +503,6 @@ public with sharing class TriggerTypes { TriggerTypes.RelatedRecords getRelated(String providerName); } - public interface ParentFields { - ParentFields with(SObjectField field); - ParentFields with(SObjectField field1, SObjectField field2); - ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3); - ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4); - ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5); - ParentFields with(Iterable fields); - // Relationship fields - ParentFields with(String relationshipName, SObjectField field); - ParentFields with(String relationshipName, SObjectField field1, SObjectField field2); - ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3); - ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4); - ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5); - ParentFields with(String relationshipName, Iterable fields); - - // for private use only - List getFields(); - } - - public interface RelatedRecords { - SObject getFirstWhereKeyEquals(Object key); - - List getAllWhereKeyEquals(Object key); - List getRecords(); - - Boolean isEmpty(); - } - - public interface UnitOfWork { - UnitOfWork toInsert(SObject record); - UnitOfWork toInsert(DML.Record record); - - UnitOfWork toUpdate(SObject record); - UnitOfWork toUpdate(DML.Record record); - - UnitOfWork toUpsert(SObject record, SObjectField externalIdField); - - UnitOfWork toDelete(SObject record); - - UnitOfWork toPublish(SObject event); - } - public interface InsertRecords { Set getIds(); @@ -605,311 +563,63 @@ public with sharing class TriggerTypes { Integer size(); } - // Implementation - - private static RecordTypesInfo RecordTypesInfo = new RecordTypesInfo(); - private static RecordsAggregator RecordsAggregator = new RecordsAggregator(); - - public static List toTriggerRecords(List newRecords, List oldRecords) { - List triggerRecords = new List(); - - Integer recordCount = (newRecords ?? oldRecords)?.size() ?? 0; - - for (Integer i = 0; i < recordCount; i++) { - triggerRecords.add(new TriggerRecord(newRecords?.get(i), oldRecords?.get(i))); - } - - return triggerRecords; - } - - private class TriggerParentFields implements ParentFields { - private List fields = new List(); - - public ParentFields with(SObjectField field) { - this.fields.add(field.toString()); - return this; - } - - public ParentFields with(SObjectField field1, SObjectField field2) { - this.fields.add(field1.toString()); - this.fields.add(field2.toString()); - return this; - } - - public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3) { - this.fields.add(field1.toString()); - this.fields.add(field2.toString()); - this.fields.add(field3.toString()); - return this; - } - - public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4) { - this.fields.add(field1.toString()); - this.fields.add(field2.toString()); - this.fields.add(field3.toString()); - this.fields.add(field4.toString()); - return this; - } - - public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5) { - this.fields.add(field1.toString()); - this.fields.add(field2.toString()); - this.fields.add(field3.toString()); - this.fields.add(field4.toString()); - this.fields.add(field5.toString()); - return this; - } - - public ParentFields with(Iterable fields) { - for (SObjectField field : fields) { - this.fields.add(field.toString()); - } - return this; - } - - public ParentFields with(String relationshipName, SObjectField field) { - this.fields.add(relationshipName + '.' + field.toString()); - return this; - } - - public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2) { - this.fields.add(relationshipName + '.' + field1.toString()); - this.fields.add(relationshipName + '.' + field2.toString()); - return this; - } - - public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3) { - this.fields.add(relationshipName + '.' + field1.toString()); - this.fields.add(relationshipName + '.' + field2.toString()); - this.fields.add(relationshipName + '.' + field3.toString()); - return this; - } - - public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4) { - this.fields.add(relationshipName + '.' + field1.toString()); - this.fields.add(relationshipName + '.' + field2.toString()); - this.fields.add(relationshipName + '.' + field3.toString()); - this.fields.add(relationshipName + '.' + field4.toString()); - return this; - } - - public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5) { - this.fields.add(relationshipName + '.' + field1.toString()); - this.fields.add(relationshipName + '.' + field2.toString()); - this.fields.add(relationshipName + '.' + field3.toString()); - this.fields.add(relationshipName + '.' + field4.toString()); - this.fields.add(relationshipName + '.' + field5.toString()); - return this; - } - - public ParentFields with(String relationshipName, Iterable fields) { - for (SObjectField field : fields) { - this.fields.add(relationshipName + '.' + field.toString()); - } - return this; - } - - public List getFields() { - return this.fields; - } - } - - public class ProvidedRecords implements RelatedRecords { - private List records = new List(); - private Map> recordsByKey = new Map>(); - - public ProvidedRecords(List rows) { - this.records = rows ?? new List(); - } - - public void groupUnderKey(String key, SObject row) { - if (key == null) { - return; - } - - if (!this.recordsByKey.containsKey(key)) { - this.recordsByKey.put(key, new List()); - } - - this.recordsByKey.get(key).add(row); - } - - public SObject getFirstWhereKeyEquals(Object key) { - List found = this.getAllWhereKeyEquals(key); - return found.isEmpty() ? null : found[0]; - } - - public List getAllWhereKeyEquals(Object key) { - return this.recordsByKey.get(String.valueOf(key)) ?? new List(); - } - - public List getRecords() { - return this.records; - } - - public Boolean isEmpty() { - return this.records.isEmpty(); - } - } - - public class InsertTriggerRecords implements InsertRecords { - private List records; - - public InsertTriggerRecords(List records) { - this.records = records; - } - - public Set getIds() { - return RecordsAggregator.ids(this.records); - } - - public Set getIdsOf(SObjectField field) { - return RecordsAggregator.idsOf(this.records, field); - } - - public Set getIdsOf(String relationshipName, SObjectField field) { - return RecordsAggregator.idsOf(this.records, relationshipName, field); - } - - public Set getValuesOf(SObjectField field) { - return RecordsAggregator.valuesOf(this.records, field); - } - - public Set getValuesOf(String relationshipName, SObjectField field) { - return RecordsAggregator.valuesOf(this.records, relationshipName, field); - } - - public List getRecords() { - return (List) this.records; - } - - public Integer size() { - return this.records.size(); - } - } - - public class UpdateTriggerRecords implements UpdateRecords { - private List records; - - public UpdateTriggerRecords(List records) { - this.records = records; - } - - public Set getIds() { - return RecordsAggregator.ids(this.records); - } - - public Set getIdsOf(SObjectField field) { - return RecordsAggregator.idsOf(this.records, field); - } - - public Set getIdsOf(String relationshipName, SObjectField field) { - return RecordsAggregator.idsOf(this.records, relationshipName, field); - } - - public Set getOldIdsOf(SObjectField field) { - return RecordsAggregator.oldIdsOf(this.records, field); - } - - public Set getOldIdsOf(String relationshipName, SObjectField field) { - return RecordsAggregator.oldIdsOf(this.records, relationshipName, field); - } - - public Set getValuesOf(SObjectField field) { - return RecordsAggregator.valuesOf(this.records, field); - } - - public Set getValuesOf(String relationshipName, SObjectField field) { - return RecordsAggregator.valuesOf(this.records, relationshipName, field); - } - - public Set getOldValuesOf(SObjectField field) { - return RecordsAggregator.oldValuesOf(this.records, field); - } - - public Set getOldValuesOf(String relationshipName, SObjectField field) { - return RecordsAggregator.oldValuesOf(this.records, relationshipName, field); - } - - public List getRecords() { - return (List) this.records; - } - - public Integer size() { - return this.records.size(); - } - } - - public class DeleteTriggerRecords implements DeleteRecords { - private List records; - - public DeleteTriggerRecords(List records) { - this.records = records; - } - - public Set getIds() { - return RecordsAggregator.ids(this.records); - } - - public Set getIdsOf(SObjectField field) { - return RecordsAggregator.idsOf(this.records, field); - } + public interface UnitOfWork { + UnitOfWork toInsert(SObject record); + UnitOfWork toInsert(DML.Record record); - public Set getIdsOf(String relationshipName, SObjectField field) { - return RecordsAggregator.idsOf(this.records, relationshipName, field); - } + UnitOfWork toUpdate(SObject record); + UnitOfWork toUpdate(DML.Record record); - public Set getValuesOf(SObjectField field) { - return RecordsAggregator.valuesOf(this.records, field); - } + UnitOfWork toUpsert(SObject record, SObjectField externalIdField); - public Set getValuesOf(String relationshipName, SObjectField field) { - return RecordsAggregator.valuesOf(this.records, relationshipName, field); - } + UnitOfWork toDelete(SObject record); - public List getRecords() { - return (List) this.records; - } + UnitOfWork toPublish(SObject event); + } - public Integer size() { - return this.records.size(); - } + public interface ParentFields { + ParentFields with(SObjectField field); + ParentFields with(SObjectField field1, SObjectField field2); + ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3); + ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4); + ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5); + ParentFields with(Iterable fields); + // Relationship fields + ParentFields with(String relationshipName, SObjectField field); + ParentFields with(String relationshipName, SObjectField field1, SObjectField field2); + ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3); + ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4); + ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5); + ParentFields with(String relationshipName, Iterable fields); + + // for private use only + List getFields(); } - public class UndeleteTriggerRecords implements UndeleteRecords { - private List records; + public interface RelatedRecords { + SObject getFirstWhereKeyEquals(Object key); - public UndeleteTriggerRecords(List records) { - this.records = records; - } + List getAllWhereKeyEquals(Object key); + List getRecords(); - public Set getIds() { - return RecordsAggregator.ids(this.records); - } + Boolean isEmpty(); + } - public Set getIdsOf(SObjectField field) { - return RecordsAggregator.idsOf(this.records, field); - } + // Implementation - public Set getIdsOf(String relationshipName, SObjectField field) { - return RecordsAggregator.idsOf(this.records, relationshipName, field); - } + private static RecordTypesInfo RecordTypesInfo = new RecordTypesInfo(); + private static RecordsAggregator RecordsAggregator = new RecordsAggregator(); - public Set getValuesOf(SObjectField field) { - return RecordsAggregator.valuesOf(this.records, field); - } + public static List toTriggerRecords(List newRecords, List oldRecords) { + List triggerRecords = new List(); - public Set getValuesOf(String relationshipName, SObjectField field) { - return RecordsAggregator.valuesOf(this.records, relationshipName, field); - } + Integer recordCount = (newRecords ?? oldRecords)?.size() ?? 0; - public List getRecords() { - return (List) this.records; + for (Integer i = 0; i < recordCount; i++) { + triggerRecords.add(new TriggerRecord(newRecords?.get(i), oldRecords?.get(i))); } - public Integer size() { - return this.records.size(); - } + return triggerRecords; } public class TriggerRecord implements TriggerTypes.InsertRecord, TriggerTypes.RejectableInsertRecord, TriggerTypes.UpdateRecord, TriggerTypes.RejectableUpdateRecord, TriggerTypes.DeleteRecord, TriggerTypes.RejectableDeleteRecord, TriggerTypes.UndeleteRecord { @@ -1163,117 +873,307 @@ public with sharing class TriggerTypes { return this.isLessThanOrEqualTo(field, value); } - public Boolean lessThanOrEqualTo(SObjectField field, DateTime value) { - Object fieldValue = this.getFieldValue(field); - return fieldValue != null && ((DateTime) fieldValue) <= value; + public Boolean lessThanOrEqualTo(SObjectField field, DateTime value) { + Object fieldValue = this.getFieldValue(field); + return fieldValue != null && ((DateTime) fieldValue) <= value; + } + + public Boolean lessThanOrEqualTo(SObjectField field, Date value) { + Object fieldValue = this.getFieldValue(field); + return fieldValue != null && ((Date) fieldValue) <= value; + } + + private Boolean isGreaterThan(SObjectField field, Decimal value) { + Decimal fieldValue = this.getFieldValueAsDecimal(field); + return fieldValue != null && fieldValue > value; + } + + private Boolean isGreaterThanOrEqualTo(SObjectField field, Decimal value) { + Decimal fieldValue = this.getFieldValueAsDecimal(field); + return fieldValue != null && fieldValue >= value; + } + + private Boolean isLessThan(SObjectField field, Decimal value) { + Decimal fieldValue = this.getFieldValueAsDecimal(field); + return fieldValue != null && fieldValue < value; + } + + private Boolean isLessThanOrEqualTo(SObjectField field, Decimal value) { + Decimal fieldValue = this.getFieldValueAsDecimal(field); + return fieldValue != null && fieldValue <= value; + } + + private Decimal getFieldValueAsDecimal(SObjectField field) { + Object value = this.getFieldValue(field); + return value == null ? null : Decimal.valueOf(value.toString()); + } + + private Object getFieldValue(SObjectField field) { + return (this.newRecord ?? this.oldRecord).get(field); + } + + private Object getOldFieldValue(SObjectField field) { + return this.oldRecord.get(field); + } + + public Id getId() { + return (this.newRecord ?? this.oldRecord).Id; + } + + public SObject getNewSObject() { + return this.newRecord; + } + + public SObject getOldSObject() { + return this.oldRecord; + } + + public SObject getNewParent(String relationshipName) { + return this.newParents.get(relationshipName); + } + + public SObject getOldParent(String relationshipName) { + return this.oldParents.get(relationshipName); + } + + public RelatedRecords getRelated(String providerName) { + if (!this.relatedByProviderName.containsKey(providerName)) { + throw new TriggerLibException('No related records provider named ' + providerName + ' is declared. Return it from the queryRelatedOn method of the handler first.'); + } + + return this.relatedByProviderName.get(providerName); + } + + public void put(SObjectField field, Object value) { + this.newRecord.put(field, value); + } + + public void addError(String error) { + (this.newRecord ?? this.oldRecord).addError(error); + } + + public void addError(SObjectField field, String error) { + (this.newRecord ?? this.oldRecord).addError(field, error); + } + + // Internal use only + + public SObject getSObject() { + return this.newRecord ?? this.oldRecord; + } + + public SObject getParent(String relationshipName) { + return this.newRecord == null ? this.oldParents.get(relationshipName) : this.newParents.get(relationshipName); + } + + public Id getNewParentId(SObjectField lookupField) { + return this.newRecord?.isSet(lookupField) == true ? (Id) this.newRecord.get(lookupField) : null; + } + + public Id getOldParentId(SObjectField lookupField) { + return this.oldRecord?.isSet(lookupField) == true ? (Id) this.oldRecord.get(lookupField) : null; + } + + public void setNewParent(String relationshipName, SObject parent) { + this.newParents.put(relationshipName, parent); + } + + public void setOldParent(String relationshipName, SObject parent) { + this.oldParents.put(relationshipName, parent); + } + + public void setRelated(Map relatedByProviderName) { + this.relatedByProviderName = relatedByProviderName; + } + } + + private without sharing class RecordTypesInfo { + private final Map> OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME = new Map>(); + + public Id getId(SObjectType objectType, String recordTypeDeveloperName) { + return this.byDeveloperName(objectType, recordTypeDeveloperName)?.getRecordTypeId(); + } + + public Boolean hasRecordTypes(SObjectType objectType) { + if (!this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.containsKey(objectType)) { + this.load(objectType); + } + + return this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.get(objectType).size() > 1; + } + + public Schema.RecordTypeInfo byDeveloperName(SObjectType objectType, String recordTypeDeveloperName) { + if (!this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.containsKey(objectType)) { + this.load(objectType); + } + + return this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.get(objectType).get(recordTypeDeveloperName); + } + + private void load(SObjectType objectType) { + Schema.DescribeSObjectResult describe = objectType.getDescribe(SObjectDescribeOptions.DEFERRED); + + this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.put(objectType, describe.getRecordTypeInfosByDeveloperName()); + } + } + + public class InsertTriggerRecords implements InsertRecords { + private List records; + + public InsertTriggerRecords(List records) { + this.records = records; + } + + public Set getIds() { + return RecordsAggregator.ids(this.records); + } + + public Set getIdsOf(SObjectField field) { + return RecordsAggregator.idsOf(this.records, field); + } + + public Set getIdsOf(String relationshipName, SObjectField field) { + return RecordsAggregator.idsOf(this.records, relationshipName, field); + } + + public Set getValuesOf(SObjectField field) { + return RecordsAggregator.valuesOf(this.records, field); + } + + public Set getValuesOf(String relationshipName, SObjectField field) { + return RecordsAggregator.valuesOf(this.records, relationshipName, field); + } + + public List getRecords() { + return (List) this.records; + } + + public Integer size() { + return this.records.size(); + } + } + + public class UpdateTriggerRecords implements UpdateRecords { + private List records; + + public UpdateTriggerRecords(List records) { + this.records = records; + } + + public Set getIds() { + return RecordsAggregator.ids(this.records); } - public Boolean lessThanOrEqualTo(SObjectField field, Date value) { - Object fieldValue = this.getFieldValue(field); - return fieldValue != null && ((Date) fieldValue) <= value; + public Set getIdsOf(SObjectField field) { + return RecordsAggregator.idsOf(this.records, field); } - private Boolean isGreaterThan(SObjectField field, Decimal value) { - Decimal fieldValue = this.getFieldValueAsDecimal(field); - return fieldValue != null && fieldValue > value; + public Set getIdsOf(String relationshipName, SObjectField field) { + return RecordsAggregator.idsOf(this.records, relationshipName, field); } - private Boolean isGreaterThanOrEqualTo(SObjectField field, Decimal value) { - Decimal fieldValue = this.getFieldValueAsDecimal(field); - return fieldValue != null && fieldValue >= value; + public Set getOldIdsOf(SObjectField field) { + return RecordsAggregator.oldIdsOf(this.records, field); } - private Boolean isLessThan(SObjectField field, Decimal value) { - Decimal fieldValue = this.getFieldValueAsDecimal(field); - return fieldValue != null && fieldValue < value; + public Set getOldIdsOf(String relationshipName, SObjectField field) { + return RecordsAggregator.oldIdsOf(this.records, relationshipName, field); } - private Boolean isLessThanOrEqualTo(SObjectField field, Decimal value) { - Decimal fieldValue = this.getFieldValueAsDecimal(field); - return fieldValue != null && fieldValue <= value; + public Set getValuesOf(SObjectField field) { + return RecordsAggregator.valuesOf(this.records, field); } - private Decimal getFieldValueAsDecimal(SObjectField field) { - Object value = this.getFieldValue(field); - return value == null ? null : Decimal.valueOf(value.toString()); + public Set getValuesOf(String relationshipName, SObjectField field) { + return RecordsAggregator.valuesOf(this.records, relationshipName, field); } - private Object getFieldValue(SObjectField field) { - return (this.newRecord ?? this.oldRecord).get(field); + public Set getOldValuesOf(SObjectField field) { + return RecordsAggregator.oldValuesOf(this.records, field); } - private Object getOldFieldValue(SObjectField field) { - return this.oldRecord.get(field); + public Set getOldValuesOf(String relationshipName, SObjectField field) { + return RecordsAggregator.oldValuesOf(this.records, relationshipName, field); } - public Id getId() { - return (this.newRecord ?? this.oldRecord).Id; + public List getRecords() { + return (List) this.records; } - public SObject getNewSObject() { - return this.newRecord; + public Integer size() { + return this.records.size(); } + } - public SObject getOldSObject() { - return this.oldRecord; + public class DeleteTriggerRecords implements DeleteRecords { + private List records; + + public DeleteTriggerRecords(List records) { + this.records = records; } - public SObject getNewParent(String relationshipName) { - return this.newParents.get(relationshipName); + public Set getIds() { + return RecordsAggregator.ids(this.records); } - public SObject getOldParent(String relationshipName) { - return this.oldParents.get(relationshipName); + public Set getIdsOf(SObjectField field) { + return RecordsAggregator.idsOf(this.records, field); } - public RelatedRecords getRelated(String providerName) { - if (!this.relatedByProviderName.containsKey(providerName)) { - throw new TriggerLibException('No related records provider named ' + providerName + ' is declared. Return it from the queryRelatedOn method of the handler first.'); - } + public Set getIdsOf(String relationshipName, SObjectField field) { + return RecordsAggregator.idsOf(this.records, relationshipName, field); + } - return this.relatedByProviderName.get(providerName); + public Set getValuesOf(SObjectField field) { + return RecordsAggregator.valuesOf(this.records, field); } - public void put(SObjectField field, Object value) { - this.newRecord.put(field, value); + public Set getValuesOf(String relationshipName, SObjectField field) { + return RecordsAggregator.valuesOf(this.records, relationshipName, field); } - public void addError(String error) { - (this.newRecord ?? this.oldRecord).addError(error); + public List getRecords() { + return (List) this.records; } - public void addError(SObjectField field, String error) { - (this.newRecord ?? this.oldRecord).addError(field, error); + public Integer size() { + return this.records.size(); } + } - // Internal use only + public class UndeleteTriggerRecords implements UndeleteRecords { + private List records; - public SObject getSObject() { - return this.newRecord ?? this.oldRecord; + public UndeleteTriggerRecords(List records) { + this.records = records; } - public SObject getParent(String relationshipName) { - return this.newRecord == null ? this.oldParents.get(relationshipName) : this.newParents.get(relationshipName); + public Set getIds() { + return RecordsAggregator.ids(this.records); } - public void setNewParent(String relationshipName, SObject parent) { - this.newParents.put(relationshipName, parent); + public Set getIdsOf(SObjectField field) { + return RecordsAggregator.idsOf(this.records, field); } - public Id getNewParentId(SObjectField lookupField) { - return this.newRecord?.isSet(lookupField) == true ? (Id) this.newRecord.get(lookupField) : null; + public Set getIdsOf(String relationshipName, SObjectField field) { + return RecordsAggregator.idsOf(this.records, relationshipName, field); } - public Id getOldParentId(SObjectField lookupField) { - return this.oldRecord?.isSet(lookupField) == true ? (Id) this.oldRecord.get(lookupField) : null; + public Set getValuesOf(SObjectField field) { + return RecordsAggregator.valuesOf(this.records, field); } - public void setOldParent(String relationshipName, SObject parent) { - this.oldParents.put(relationshipName, parent); + public Set getValuesOf(String relationshipName, SObjectField field) { + return RecordsAggregator.valuesOf(this.records, relationshipName, field); } - public void setRelated(Map relatedByProviderName) { - this.relatedByProviderName = relatedByProviderName; + public List getRecords() { + return (List) this.records; + } + + public Integer size() { + return this.records.size(); } } @@ -1401,33 +1301,133 @@ public with sharing class TriggerTypes { } } - private without sharing class RecordTypesInfo { - private final Map> OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME = new Map>(); + private class TriggerParentFields implements ParentFields { + private List fields = new List(); - public Id getId(SObjectType objectType, String recordTypeDeveloperName) { - return this.byDeveloperName(objectType, recordTypeDeveloperName)?.getRecordTypeId(); + public ParentFields with(SObjectField field) { + this.fields.add(field.toString()); + return this; } - public Boolean hasRecordTypes(SObjectType objectType) { - if (!this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.containsKey(objectType)) { - this.load(objectType); + public ParentFields with(SObjectField field1, SObjectField field2) { + this.fields.add(field1.toString()); + this.fields.add(field2.toString()); + return this; + } + + public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3) { + this.fields.add(field1.toString()); + this.fields.add(field2.toString()); + this.fields.add(field3.toString()); + return this; + } + + public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4) { + this.fields.add(field1.toString()); + this.fields.add(field2.toString()); + this.fields.add(field3.toString()); + this.fields.add(field4.toString()); + return this; + } + + public ParentFields with(SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5) { + this.fields.add(field1.toString()); + this.fields.add(field2.toString()); + this.fields.add(field3.toString()); + this.fields.add(field4.toString()); + this.fields.add(field5.toString()); + return this; + } + + public ParentFields with(Iterable fields) { + for (SObjectField field : fields) { + this.fields.add(field.toString()); } + return this; + } - return this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.get(objectType).size() > 1; + public ParentFields with(String relationshipName, SObjectField field) { + this.fields.add(relationshipName + '.' + field.toString()); + return this; } - public Schema.RecordTypeInfo byDeveloperName(SObjectType objectType, String recordTypeDeveloperName) { - if (!this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.containsKey(objectType)) { - this.load(objectType); + public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2) { + this.fields.add(relationshipName + '.' + field1.toString()); + this.fields.add(relationshipName + '.' + field2.toString()); + return this; + } + + public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3) { + this.fields.add(relationshipName + '.' + field1.toString()); + this.fields.add(relationshipName + '.' + field2.toString()); + this.fields.add(relationshipName + '.' + field3.toString()); + return this; + } + + public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4) { + this.fields.add(relationshipName + '.' + field1.toString()); + this.fields.add(relationshipName + '.' + field2.toString()); + this.fields.add(relationshipName + '.' + field3.toString()); + this.fields.add(relationshipName + '.' + field4.toString()); + return this; + } + + public ParentFields with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5) { + this.fields.add(relationshipName + '.' + field1.toString()); + this.fields.add(relationshipName + '.' + field2.toString()); + this.fields.add(relationshipName + '.' + field3.toString()); + this.fields.add(relationshipName + '.' + field4.toString()); + this.fields.add(relationshipName + '.' + field5.toString()); + return this; + } + + public ParentFields with(String relationshipName, Iterable fields) { + for (SObjectField field : fields) { + this.fields.add(relationshipName + '.' + field.toString()); } + return this; + } - return this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.get(objectType).get(recordTypeDeveloperName); + public List getFields() { + return this.fields; } + } - private void load(SObjectType objectType) { - Schema.DescribeSObjectResult describe = objectType.getDescribe(SObjectDescribeOptions.DEFERRED); + public class ProvidedRecords implements RelatedRecords { + private List records = new List(); + private Map> recordsByKey = new Map>(); - this.OBJECT_TYPE_TO_RECORD_TYPE_BY_DEVELOPER_NAME.put(objectType, describe.getRecordTypeInfosByDeveloperName()); + public ProvidedRecords(List rows) { + this.records = rows ?? new List(); + } + + public void groupUnderKey(String key, SObject row) { + if (key == null) { + return; + } + + if (!this.recordsByKey.containsKey(key)) { + this.recordsByKey.put(key, new List()); + } + + this.recordsByKey.get(key).add(row); + } + + public SObject getFirstWhereKeyEquals(Object key) { + List found = this.getAllWhereKeyEquals(key); + return found.isEmpty() ? null : found[0]; + } + + public List getAllWhereKeyEquals(Object key) { + return this.recordsByKey.get(String.valueOf(key)) ?? new List(); + } + + public List getRecords() { + return this.records; + } + + public Boolean isEmpty() { + return this.records.isEmpty(); } } diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index 29ac221..01ea93b 100644 --- a/force-app/main/default/classes/TriggerTypesTest.cls +++ b/force-app/main/default/classes/TriggerTypesTest.cls @@ -3,7 +3,7 @@ * Copyright (c) 2026 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/trigger-lib/blob/main/LICENSE) * - * v0.0.1 + * v0.0.4 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class From a45556ebd5d89ef5f5596ae6b41e75cac9d21507 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Fri, 25 Sep 2026 09:11:58 +0200 Subject: [PATCH 2/5] Add testFor --- .../after-insert/writer/AccountWelcomeTaskWriterTest.cls | 2 +- .../after-update/writer/AccountAddressCascadeWriterTest.cls | 2 +- .../after-update/writer/AccountOwnerTransferWriterTest.cls | 2 +- .../populator/AccountParentDefaultsPopulatorTest.cls | 2 +- .../before-insert/populator/AccountRatingPopulatorTest.cls | 2 +- .../populator/AccountShippingAddressPopulatorTest.cls | 2 +- .../validator/AccountCustomerDataValidatorTest.cls | 2 +- .../before-insert/validator/AccountParentTypeValidatorTest.cls | 2 +- .../populator/AccountRatingRefreshPopulatorTest.cls | 2 +- .../populator/AccountShippingSyncPopulatorTest.cls | 2 +- .../before-update/populator/AccountTypeAuditPopulatorTest.cls | 2 +- .../validator/AccountBillingCountryValidatorTest.cls | 2 +- .../before-update/validator/AccountDemotionValidatorTest.cls | 2 +- .../before-update/validator/AccountParentMoveValidatorTest.cls | 2 +- .../after-insert/writer/ContactFollowUpTaskWriterTest.cls | 2 +- .../after-insert/writer/ContactOwnerAlignmentWriterTest.cls | 2 +- .../populator/ContactEmailNormalizationPopulatorTest.cls | 2 +- .../populator/ContactMailingAddressPopulatorTest.cls | 2 +- .../before-insert/validator/ContactBirthdateValidatorTest.cls | 2 +- .../before-insert/validator/ContactEmailFormatValidatorTest.cls | 2 +- .../validator/ContactReachabilityValidatorTest.cls | 2 +- .../populator/ContactAccountTransferPopulatorTest.cls | 2 +- .../populator/ContactTitleDepartmentPopulatorTest.cls | 2 +- .../before-update/validator/ContactDoNotCallValidatorTest.cls | 2 +- .../validator/ContactEmailRemovalValidatorTest.cls | 2 +- .../after-update/writer/OpportunityAccountTypeWriterTest.cls | 2 +- .../after-update/writer/OpportunityWinTaskWriterTest.cls | 2 +- .../populator/OpportunityCloseDatePopulatorTest.cls | 2 +- .../populator/OpportunityTypeFromAccountPopulatorTest.cls | 2 +- .../before-insert/validator/OpportunityAmountValidatorTest.cls | 2 +- .../validator/OpportunityCloseDateValidatorTest.cls | 2 +- .../before-update/populator/OpportunityClosingPopulatorTest.cls | 2 +- .../populator/OpportunityForecastPopulatorTest.cls | 2 +- .../populator/OpportunityProbabilityPopulatorTest.cls | 2 +- .../validator/OpportunityAccountMoveValidatorTest.cls | 2 +- .../validator/OpportunityLargeDealValidatorTest.cls | 2 +- .../before-update/validator/OpportunityReopenValidatorTest.cls | 2 +- .../validator/OpportunityWinAmountValidatorTest.cls | 2 +- force-app/main/default/classes/AfterDeleteTest.cls | 2 +- force-app/main/default/classes/AfterInsertTest.cls | 2 +- force-app/main/default/classes/AfterUndeleteTest.cls | 2 +- force-app/main/default/classes/AfterUpdateTest.cls | 2 +- force-app/main/default/classes/BeforeDeleteTest.cls | 2 +- force-app/main/default/classes/BeforeInsertTest.cls | 2 +- force-app/main/default/classes/BeforeUpdateTest.cls | 2 +- force-app/main/default/classes/TriggerOrchestratorTest.cls | 2 +- force-app/main/default/classes/TriggerTypesTest.cls | 2 +- force-app/main/default/dependencies/dml-lib/DML_Test.cls | 2 +- force-app/main/default/dependencies/soql-lib/SOQL_Test.cls | 2 +- 49 files changed, 49 insertions(+), 49 deletions(-) diff --git a/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriterTest.cls b/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriterTest.cls index 2809037..d3f486f 100644 --- a/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriterTest.cls +++ b/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountWelcomeTaskWriter') private class AccountWelcomeTaskWriterTest { @IsTest static void writeOnAfterInsertWithCustomer() { diff --git a/examples/main/default/classes/account/after-update/writer/AccountAddressCascadeWriterTest.cls b/examples/main/default/classes/account/after-update/writer/AccountAddressCascadeWriterTest.cls index e9ed910..07c89c5 100644 --- a/examples/main/default/classes/account/after-update/writer/AccountAddressCascadeWriterTest.cls +++ b/examples/main/default/classes/account/after-update/writer/AccountAddressCascadeWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountAddressCascadeWriter') private class AccountAddressCascadeWriterTest { @IsTest static void writeOnAfterUpdateWithoutContacts() { diff --git a/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriterTest.cls b/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriterTest.cls index d57d750..dcd91b5 100644 --- a/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriterTest.cls +++ b/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountOwnerTransferWriter') private class AccountOwnerTransferWriterTest { @IsTest static void writeOnAfterUpdateWithoutOpenOpportunities() { diff --git a/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulatorTest.cls b/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulatorTest.cls index 7a71eb8..a7b94ae 100644 --- a/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulatorTest.cls +++ b/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountParentDefaultsPopulator') private class AccountParentDefaultsPopulatorTest { @IsTest static void populateOnBeforeInsertWithBlankIndustry() { diff --git a/examples/main/default/classes/account/before-insert/populator/AccountRatingPopulatorTest.cls b/examples/main/default/classes/account/before-insert/populator/AccountRatingPopulatorTest.cls index ea569f0..c4266fa 100644 --- a/examples/main/default/classes/account/before-insert/populator/AccountRatingPopulatorTest.cls +++ b/examples/main/default/classes/account/before-insert/populator/AccountRatingPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountRatingPopulator') private class AccountRatingPopulatorTest { @IsTest static void populateOnBeforeInsertWithFiveMillionRevenue() { diff --git a/examples/main/default/classes/account/before-insert/populator/AccountShippingAddressPopulatorTest.cls b/examples/main/default/classes/account/before-insert/populator/AccountShippingAddressPopulatorTest.cls index 2a67dd1..4c195d1 100644 --- a/examples/main/default/classes/account/before-insert/populator/AccountShippingAddressPopulatorTest.cls +++ b/examples/main/default/classes/account/before-insert/populator/AccountShippingAddressPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountShippingAddressPopulator') private class AccountShippingAddressPopulatorTest { @IsTest static void populateOnBeforeInsertWithBillingAddress() { diff --git a/examples/main/default/classes/account/before-insert/validator/AccountCustomerDataValidatorTest.cls b/examples/main/default/classes/account/before-insert/validator/AccountCustomerDataValidatorTest.cls index 9830fbc..1528ef0 100644 --- a/examples/main/default/classes/account/before-insert/validator/AccountCustomerDataValidatorTest.cls +++ b/examples/main/default/classes/account/before-insert/validator/AccountCustomerDataValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountCustomerDataValidator') private class AccountCustomerDataValidatorTest { @IsTest static void addErrorOnBeforeInsertWithBlankIndustry() { diff --git a/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidatorTest.cls b/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidatorTest.cls index a06d871..3b12076 100644 --- a/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidatorTest.cls +++ b/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountParentTypeValidator') private class AccountParentTypeValidatorTest { @IsTest static void addErrorOnBeforeInsertWithProspectParent() { diff --git a/examples/main/default/classes/account/before-update/populator/AccountRatingRefreshPopulatorTest.cls b/examples/main/default/classes/account/before-update/populator/AccountRatingRefreshPopulatorTest.cls index 413eb8c..eadaa81 100644 --- a/examples/main/default/classes/account/before-update/populator/AccountRatingRefreshPopulatorTest.cls +++ b/examples/main/default/classes/account/before-update/populator/AccountRatingRefreshPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountRatingRefreshPopulator') private class AccountRatingRefreshPopulatorTest { @IsTest static void populateOnBeforeUpdateWithFiveMillionRevenue() { diff --git a/examples/main/default/classes/account/before-update/populator/AccountShippingSyncPopulatorTest.cls b/examples/main/default/classes/account/before-update/populator/AccountShippingSyncPopulatorTest.cls index b64f40b..bbd3eae 100644 --- a/examples/main/default/classes/account/before-update/populator/AccountShippingSyncPopulatorTest.cls +++ b/examples/main/default/classes/account/before-update/populator/AccountShippingSyncPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountShippingSyncPopulator') private class AccountShippingSyncPopulatorTest { @IsTest static void populateOnBeforeUpdateWithShippingMatchingBilling() { diff --git a/examples/main/default/classes/account/before-update/populator/AccountTypeAuditPopulatorTest.cls b/examples/main/default/classes/account/before-update/populator/AccountTypeAuditPopulatorTest.cls index 77a1c02..acc17b5 100644 --- a/examples/main/default/classes/account/before-update/populator/AccountTypeAuditPopulatorTest.cls +++ b/examples/main/default/classes/account/before-update/populator/AccountTypeAuditPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountTypeAuditPopulator') private class AccountTypeAuditPopulatorTest { @IsTest static void populateOnBeforeUpdateWithChangedType() { diff --git a/examples/main/default/classes/account/before-update/validator/AccountBillingCountryValidatorTest.cls b/examples/main/default/classes/account/before-update/validator/AccountBillingCountryValidatorTest.cls index 9f360d5..996b511 100644 --- a/examples/main/default/classes/account/before-update/validator/AccountBillingCountryValidatorTest.cls +++ b/examples/main/default/classes/account/before-update/validator/AccountBillingCountryValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountBillingCountryValidator') private class AccountBillingCountryValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithClearedBillingCountry() { diff --git a/examples/main/default/classes/account/before-update/validator/AccountDemotionValidatorTest.cls b/examples/main/default/classes/account/before-update/validator/AccountDemotionValidatorTest.cls index 7ab79a7..5708568 100644 --- a/examples/main/default/classes/account/before-update/validator/AccountDemotionValidatorTest.cls +++ b/examples/main/default/classes/account/before-update/validator/AccountDemotionValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountDemotionValidator') private class AccountDemotionValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithCustomerChangedToProspect() { diff --git a/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidatorTest.cls b/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidatorTest.cls index d1997ac..82f6da1 100644 --- a/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidatorTest.cls +++ b/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AccountParentMoveValidator') private class AccountParentMoveValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithParentInAnotherCountry() { diff --git a/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriterTest.cls b/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriterTest.cls index c80faa7..8d0193e 100644 --- a/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriterTest.cls +++ b/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactFollowUpTaskWriter') private class ContactFollowUpTaskWriterTest { @IsTest static void writeOnAfterInsertWithWebLeadSource() { diff --git a/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriterTest.cls b/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriterTest.cls index 1bf2482..7c7fe4e 100644 --- a/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriterTest.cls +++ b/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactOwnerAlignmentWriter') private class ContactOwnerAlignmentWriterTest { @IsTest static void writeOnAfterInsertWithActiveAccountOwner() { diff --git a/examples/main/default/classes/contact/before-insert/populator/ContactEmailNormalizationPopulatorTest.cls b/examples/main/default/classes/contact/before-insert/populator/ContactEmailNormalizationPopulatorTest.cls index f342356..b596600 100644 --- a/examples/main/default/classes/contact/before-insert/populator/ContactEmailNormalizationPopulatorTest.cls +++ b/examples/main/default/classes/contact/before-insert/populator/ContactEmailNormalizationPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactEmailNormalizationPopulator') private class ContactEmailNormalizationPopulatorTest { @IsTest static void populateOnBeforeInsertWithMixedCaseEmail() { diff --git a/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulatorTest.cls b/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulatorTest.cls index e457e68..1f4b9b3 100644 --- a/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulatorTest.cls +++ b/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactMailingAddressPopulator') private class ContactMailingAddressPopulatorTest { @IsTest static void populateOnBeforeInsertWithAccountBillingAddress() { diff --git a/examples/main/default/classes/contact/before-insert/validator/ContactBirthdateValidatorTest.cls b/examples/main/default/classes/contact/before-insert/validator/ContactBirthdateValidatorTest.cls index f463a77..37d2318 100644 --- a/examples/main/default/classes/contact/before-insert/validator/ContactBirthdateValidatorTest.cls +++ b/examples/main/default/classes/contact/before-insert/validator/ContactBirthdateValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactBirthdateValidator') private class ContactBirthdateValidatorTest { @IsTest static void addErrorOnBeforeInsertWithFutureBirthdate() { diff --git a/examples/main/default/classes/contact/before-insert/validator/ContactEmailFormatValidatorTest.cls b/examples/main/default/classes/contact/before-insert/validator/ContactEmailFormatValidatorTest.cls index 274d382..866394a 100644 --- a/examples/main/default/classes/contact/before-insert/validator/ContactEmailFormatValidatorTest.cls +++ b/examples/main/default/classes/contact/before-insert/validator/ContactEmailFormatValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactEmailFormatValidator') private class ContactEmailFormatValidatorTest { @IsTest static void addErrorOnBeforeInsertWithEmailWithoutAtSign() { diff --git a/examples/main/default/classes/contact/before-insert/validator/ContactReachabilityValidatorTest.cls b/examples/main/default/classes/contact/before-insert/validator/ContactReachabilityValidatorTest.cls index e2e7596..13f444e 100644 --- a/examples/main/default/classes/contact/before-insert/validator/ContactReachabilityValidatorTest.cls +++ b/examples/main/default/classes/contact/before-insert/validator/ContactReachabilityValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactReachabilityValidator') private class ContactReachabilityValidatorTest { @IsTest static void addErrorOnBeforeInsertWithoutContactDetails() { diff --git a/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulatorTest.cls b/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulatorTest.cls index ab84ffd..48c5877 100644 --- a/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulatorTest.cls +++ b/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactAccountTransferPopulator') private class ContactAccountTransferPopulatorTest { @IsTest static void populateOnBeforeUpdateWithTransferredAccount() { diff --git a/examples/main/default/classes/contact/before-update/populator/ContactTitleDepartmentPopulatorTest.cls b/examples/main/default/classes/contact/before-update/populator/ContactTitleDepartmentPopulatorTest.cls index 5521d20..c1d08bb 100644 --- a/examples/main/default/classes/contact/before-update/populator/ContactTitleDepartmentPopulatorTest.cls +++ b/examples/main/default/classes/contact/before-update/populator/ContactTitleDepartmentPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactTitleDepartmentPopulator') private class ContactTitleDepartmentPopulatorTest { @IsTest static void populateOnBeforeUpdateWithKnownTitle() { diff --git a/examples/main/default/classes/contact/before-update/validator/ContactDoNotCallValidatorTest.cls b/examples/main/default/classes/contact/before-update/validator/ContactDoNotCallValidatorTest.cls index 783faa6..52b347e 100644 --- a/examples/main/default/classes/contact/before-update/validator/ContactDoNotCallValidatorTest.cls +++ b/examples/main/default/classes/contact/before-update/validator/ContactDoNotCallValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactDoNotCallValidator') private class ContactDoNotCallValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithDoNotCallWithoutDescription() { diff --git a/examples/main/default/classes/contact/before-update/validator/ContactEmailRemovalValidatorTest.cls b/examples/main/default/classes/contact/before-update/validator/ContactEmailRemovalValidatorTest.cls index e5ca9ed..24951c2 100644 --- a/examples/main/default/classes/contact/before-update/validator/ContactEmailRemovalValidatorTest.cls +++ b/examples/main/default/classes/contact/before-update/validator/ContactEmailRemovalValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:ContactEmailRemovalValidator') private class ContactEmailRemovalValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithClearedEmail() { diff --git a/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriterTest.cls b/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriterTest.cls index 73b705e..e2b92af 100644 --- a/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriterTest.cls +++ b/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityAccountTypeWriter') private class OpportunityAccountTypeWriterTest { @IsTest static void writeOnAfterUpdateWithWonOpportunity() { diff --git a/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriterTest.cls b/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriterTest.cls index 007919b..9ae575f 100644 --- a/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriterTest.cls +++ b/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriterTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityWinTaskWriter') private class OpportunityWinTaskWriterTest { @IsTest static void writeOnAfterUpdateWithWonOpportunity() { diff --git a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityCloseDatePopulatorTest.cls b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityCloseDatePopulatorTest.cls index 2665e0b..7aca9e5 100644 --- a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityCloseDatePopulatorTest.cls +++ b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityCloseDatePopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityCloseDatePopulator') private class OpportunityCloseDatePopulatorTest { @IsTest static void populateOnBeforeInsertWithoutCloseDate() { diff --git a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulatorTest.cls b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulatorTest.cls index 360c0d5..b6aab5f 100644 --- a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulatorTest.cls +++ b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityTypeFromAccountPopulator') private class OpportunityTypeFromAccountPopulatorTest { @IsTest static void populateOnBeforeInsertWithCustomerAccount() { diff --git a/examples/main/default/classes/opportunity/before-insert/validator/OpportunityAmountValidatorTest.cls b/examples/main/default/classes/opportunity/before-insert/validator/OpportunityAmountValidatorTest.cls index d77d131..b79873f 100644 --- a/examples/main/default/classes/opportunity/before-insert/validator/OpportunityAmountValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-insert/validator/OpportunityAmountValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityAmountValidator') private class OpportunityAmountValidatorTest { @IsTest static void addErrorOnBeforeInsertWithNegativeAmount() { diff --git a/examples/main/default/classes/opportunity/before-insert/validator/OpportunityCloseDateValidatorTest.cls b/examples/main/default/classes/opportunity/before-insert/validator/OpportunityCloseDateValidatorTest.cls index ba870da..4ea6369 100644 --- a/examples/main/default/classes/opportunity/before-insert/validator/OpportunityCloseDateValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-insert/validator/OpportunityCloseDateValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityCloseDateValidator') private class OpportunityCloseDateValidatorTest { @IsTest static void addErrorOnBeforeInsertWithPastCloseDate() { diff --git a/examples/main/default/classes/opportunity/before-update/populator/OpportunityClosingPopulatorTest.cls b/examples/main/default/classes/opportunity/before-update/populator/OpportunityClosingPopulatorTest.cls index 44dc281..22df40e 100644 --- a/examples/main/default/classes/opportunity/before-update/populator/OpportunityClosingPopulatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/populator/OpportunityClosingPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityClosingPopulator') private class OpportunityClosingPopulatorTest { @IsTest static void populateOnBeforeUpdateWithWonOpportunity() { diff --git a/examples/main/default/classes/opportunity/before-update/populator/OpportunityForecastPopulatorTest.cls b/examples/main/default/classes/opportunity/before-update/populator/OpportunityForecastPopulatorTest.cls index 352a466..21333f8 100644 --- a/examples/main/default/classes/opportunity/before-update/populator/OpportunityForecastPopulatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/populator/OpportunityForecastPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityForecastPopulator') private class OpportunityForecastPopulatorTest { @IsTest static void populateOnBeforeUpdateWithSmallAmount() { diff --git a/examples/main/default/classes/opportunity/before-update/populator/OpportunityProbabilityPopulatorTest.cls b/examples/main/default/classes/opportunity/before-update/populator/OpportunityProbabilityPopulatorTest.cls index dacd611..48b1faa 100644 --- a/examples/main/default/classes/opportunity/before-update/populator/OpportunityProbabilityPopulatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/populator/OpportunityProbabilityPopulatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityProbabilityPopulator') private class OpportunityProbabilityPopulatorTest { @IsTest static void populateOnBeforeUpdateWithStageChange() { diff --git a/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidatorTest.cls b/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidatorTest.cls index 532ecbe..6ea95b7 100644 --- a/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityAccountMoveValidator') private class OpportunityAccountMoveValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithMovedWonOpportunity() { diff --git a/examples/main/default/classes/opportunity/before-update/validator/OpportunityLargeDealValidatorTest.cls b/examples/main/default/classes/opportunity/before-update/validator/OpportunityLargeDealValidatorTest.cls index f341c1c..6d78b89 100644 --- a/examples/main/default/classes/opportunity/before-update/validator/OpportunityLargeDealValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/validator/OpportunityLargeDealValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityLargeDealValidator') private class OpportunityLargeDealValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithoutNextStep() { diff --git a/examples/main/default/classes/opportunity/before-update/validator/OpportunityReopenValidatorTest.cls b/examples/main/default/classes/opportunity/before-update/validator/OpportunityReopenValidatorTest.cls index d176cf2..92e4dfa 100644 --- a/examples/main/default/classes/opportunity/before-update/validator/OpportunityReopenValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/validator/OpportunityReopenValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityReopenValidator') private class OpportunityReopenValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithReopenedDeal() { diff --git a/examples/main/default/classes/opportunity/before-update/validator/OpportunityWinAmountValidatorTest.cls b/examples/main/default/classes/opportunity/before-update/validator/OpportunityWinAmountValidatorTest.cls index 77b64a0..9c82771 100644 --- a/examples/main/default/classes/opportunity/before-update/validator/OpportunityWinAmountValidatorTest.cls +++ b/examples/main/default/classes/opportunity/before-update/validator/OpportunityWinAmountValidatorTest.cls @@ -1,4 +1,4 @@ -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:OpportunityWinAmountValidator') private class OpportunityWinAmountValidatorTest { @IsTest static void addErrorOnBeforeUpdateWithoutAmount() { diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index 64e6191..45d88fd 100644 --- a/force-app/main/default/classes/AfterDeleteTest.cls +++ b/force-app/main/default/classes/AfterDeleteTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AfterDelete, ApexClass:TriggerOrchestrator') private class AfterDeleteTest { @IsTest static void writerWithoutAddOns() { diff --git a/force-app/main/default/classes/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index bafc8db..8ddec40 100644 --- a/force-app/main/default/classes/AfterInsertTest.cls +++ b/force-app/main/default/classes/AfterInsertTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AfterInsert, ApexClass:TriggerOrchestrator') private class AfterInsertTest { @IsTest static void writerWithoutAddOns() { diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index ebb7bf2..75dadd0 100644 --- a/force-app/main/default/classes/AfterUndeleteTest.cls +++ b/force-app/main/default/classes/AfterUndeleteTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AfterUndelete, ApexClass:TriggerOrchestrator') private class AfterUndeleteTest { @IsTest static void writerWithoutAddOns() { diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index 7a0c8ec..9bf27e7 100644 --- a/force-app/main/default/classes/AfterUpdateTest.cls +++ b/force-app/main/default/classes/AfterUpdateTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:AfterUpdate, ApexClass:TriggerOrchestrator') private class AfterUpdateTest { @IsTest static void writerWithoutAddOns() { diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index 18768a8..db3c667 100644 --- a/force-app/main/default/classes/BeforeDeleteTest.cls +++ b/force-app/main/default/classes/BeforeDeleteTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:BeforeDelete, ApexClass:TriggerOrchestrator') private class BeforeDeleteTest { @IsTest static void handlerWithoutAddOns() { diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index 0584e9b..58d594e 100644 --- a/force-app/main/default/classes/BeforeInsertTest.cls +++ b/force-app/main/default/classes/BeforeInsertTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:BeforeInsert, ApexClass:TriggerOrchestrator') private class BeforeInsertTest { @IsTest static void populatorWithoutAddOns() { diff --git a/force-app/main/default/classes/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index c55a768..4ce698e 100644 --- a/force-app/main/default/classes/BeforeUpdateTest.cls +++ b/force-app/main/default/classes/BeforeUpdateTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:BeforeUpdate, ApexClass:TriggerOrchestrator') private class BeforeUpdateTest { @IsTest static void populatorWithoutAddOns() { diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 0143dbd..ceb2c44 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -13,7 +13,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.ExcessiveClassLength,PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:TriggerOrchestrator') private class TriggerOrchestratorTest { @IsTest static void runOutsideTriggerContext() { diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index 01ea93b..19bf0a0 100644 --- a/force-app/main/default/classes/TriggerTypesTest.cls +++ b/force-app/main/default/classes/TriggerTypesTest.cls @@ -12,7 +12,7 @@ * - ApexUnitTestClassShouldHaveRunAs: The lib does not branch on the running user, so running as one proves nothing **/ @SuppressWarnings('PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount,PMD.ApexUnitTestClassShouldHaveRunAs') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:TriggerTypes') private class TriggerTypesTest { @IsTest static void isChangedWhenValueDiffers() { diff --git a/force-app/main/default/dependencies/dml-lib/DML_Test.cls b/force-app/main/default/dependencies/dml-lib/DML_Test.cls index 2185e6a..d233a0a 100644 --- a/force-app/main/default/dependencies/dml-lib/DML_Test.cls +++ b/force-app/main/default/dependencies/dml-lib/DML_Test.cls @@ -12,7 +12,7 @@ * - NcssTypeCount: It is a library and we tried to put everything into ONE class **/ @SuppressWarnings('PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.ApexUnitTestClassShouldHaveRunAs,PMD.NcssMethodCount,PMD.NcssTypeCount') -@IsTest +@IsTest(IsParallel=true testFor='ApexClass:DML') private class DML_Test { @IsTest diff --git a/force-app/main/default/dependencies/soql-lib/SOQL_Test.cls b/force-app/main/default/dependencies/soql-lib/SOQL_Test.cls index 0aaade6..f61cf5e 100644 --- a/force-app/main/default/dependencies/soql-lib/SOQL_Test.cls +++ b/force-app/main/default/dependencies/soql-lib/SOQL_Test.cls @@ -11,7 +11,7 @@ * - NcssTypeCount: It is a library and we tried to put everything into ONE test class **/ @SuppressWarnings('PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.NcssTypeCount') -@IsTest(IsParallel=true) +@IsTest(IsParallel=true testFor='ApexClass:SOQL') private class SOQL_Test { @IsTest static void ofSObjectType() { From cfd1c799e98c88a072767806bf43b2f21160466e Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Fri, 25 Sep 2026 09:28:11 +0200 Subject: [PATCH 3/5] refactoring --- README.md | 2 +- .../writer/AccountWelcomeTaskWriter.cls | 2 +- .../writer/AccountOwnerTransferWriter.cls | 4 +-- .../AccountParentDefaultsPopulator.cls | 2 +- .../validator/AccountParentTypeValidator.cls | 4 +-- .../validator/AccountParentMoveValidator.cls | 8 ++--- .../writer/ContactFollowUpTaskWriter.cls | 2 +- .../writer/ContactOwnerAlignmentWriter.cls | 4 +-- .../ContactMailingAddressPopulator.cls | 2 +- .../ContactAccountTransferPopulator.cls | 4 +-- .../writer/OpportunityAccountTypeWriter.cls | 2 +- .../writer/OpportunityWinTaskWriter.cls | 2 +- .../OpportunityTypeFromAccountPopulator.cls | 2 +- .../OpportunityAccountMoveValidator.cls | 4 +-- .../main/default/classes/AfterDeleteTest.cls | 2 +- .../main/default/classes/AfterInsertTest.cls | 2 +- .../default/classes/AfterUndeleteTest.cls | 2 +- .../main/default/classes/AfterUpdateTest.cls | 4 +-- .../main/default/classes/BeforeDeleteTest.cls | 2 +- .../main/default/classes/BeforeInsertTest.cls | 4 +-- .../main/default/classes/BeforeUpdateTest.cls | 4 +-- .../classes/TriggerOrchestratorTest.cls | 16 ++++----- .../main/default/classes/TriggerTypes.cls | 30 +++++++++------- .../main/default/classes/TriggerTypesTest.cls | 34 +++++++++---------- website/.vitepress/scripts/generate.mjs | 14 ++++---- .../add-ons/prior-parent-query.md | 2 +- website/after-delete/record-api.md | 2 +- website/after-delete/writer.md | 4 +-- website/after-insert/add-ons/parent-query.md | 2 +- website/after-insert/index.md | 2 +- .../after-undelete/add-ons/parent-query.md | 2 +- website/after-undelete/index.md | 2 +- website/after-update/add-ons/parent-query.md | 2 +- .../add-ons/prior-parent-query.md | 2 +- website/api/parent-fields.md | 8 +---- website/api/record-collections.md | 2 +- website/api/record.md | 6 ++-- .../add-ons/prior-parent-query.md | 2 +- website/before-delete/record-api.md | 2 +- website/before-insert/add-ons/parent-query.md | 2 +- website/before-insert/index.md | 2 +- website/before-update/add-ons/parent-query.md | 2 +- .../add-ons/prior-parent-query.md | 2 +- website/before-update/index.md | 2 +- 44 files changed, 103 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 3a7b14e..333d3c2 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ public with sharing class AccountWelcomeTaskWriter implements AfterInsert.Writer public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { Account newAccount = (Account) record.getNewSObject(); - User owner = (User) record.getNewParent('Owner'); + User owner = (User) record.getNewParent(Account.OwnerId); unitOfWork.toInsert(new Task(WhatId = record.getId(), OwnerId = newAccount.OwnerId, Subject = 'Onboarding call - ' + newAccount.Name, Description = 'Assigned to ' + owner?.Name)); } diff --git a/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriter.cls b/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriter.cls index c46cdc9..36b85d6 100644 --- a/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriter.cls +++ b/examples/main/default/classes/account/after-insert/writer/AccountWelcomeTaskWriter.cls @@ -9,7 +9,7 @@ public with sharing class AccountWelcomeTaskWriter implements AfterInsert.Writer public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { Account newAccount = (Account) record.getNewSObject(); - User owner = (User) record.getNewParent('Owner'); + User owner = (User) record.getNewParent(Account.OwnerId); unitOfWork.toInsert( new Task( diff --git a/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriter.cls b/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriter.cls index 900c54a..77c871e 100644 --- a/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriter.cls +++ b/examples/main/default/classes/account/after-update/writer/AccountOwnerTransferWriter.cls @@ -23,8 +23,8 @@ public with sharing class AccountOwnerTransferWriter implements AfterUpdate.Writ public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { Account newAccount = (Account) record.getNewSObject(); - User oldOwner = (User) record.getOldParent('Owner'); - User newOwner = (User) record.getNewParent('Owner'); + User oldOwner = (User) record.getOldParent(Account.OwnerId); + User newOwner = (User) record.getNewParent(Account.OwnerId); String handoverNote = Date.today().format() + ' - account handed over from ' + oldOwner?.Name + ' to ' + newOwner?.Name; for (SObject row : record.getRelated('openOpportunities').getAllWhereKeyEquals(record.getId())) { diff --git a/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulator.cls b/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulator.cls index 1fafa49..9b0477d 100644 --- a/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulator.cls +++ b/examples/main/default/classes/account/before-insert/populator/AccountParentDefaultsPopulator.cls @@ -8,7 +8,7 @@ public with sharing class AccountParentDefaultsPopulator implements BeforeInsert } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - Account parent = (Account) record.getNewParent('Parent'); + Account parent = (Account) record.getNewParent(Account.ParentId); if (parent == null) { return; diff --git a/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidator.cls b/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidator.cls index 7730d3e..043f908 100644 --- a/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidator.cls +++ b/examples/main/default/classes/account/before-insert/validator/AccountParentTypeValidator.cls @@ -4,13 +4,13 @@ public with sharing class AccountParentTypeValidator implements BeforeInsert.Val } public Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { - Account parent = (Account) record.getNewParent('Parent'); + Account parent = (Account) record.getNewParent(Account.ParentId); return record.isNotNull(Account.ParentId) && parent?.Type == 'Prospect'; } public void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record) { - Account parent = (Account) record.getNewParent('Parent'); + Account parent = (Account) record.getNewParent(Account.ParentId); record.addError(Account.ParentId, 'Parent account "' + parent.Name + '" is still a Prospect and cannot head a hierarchy. Qualify it first.'); } diff --git a/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidator.cls b/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidator.cls index 5249c0c..e4ec657 100644 --- a/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidator.cls +++ b/examples/main/default/classes/account/before-update/validator/AccountParentMoveValidator.cls @@ -12,15 +12,15 @@ public with sharing class AccountParentMoveValidator implements BeforeUpdate.Val return false; } - Account oldParent = (Account) record.getOldParent('Parent'); - Account newParent = (Account) record.getNewParent('Parent'); + Account oldParent = (Account) record.getOldParent(Account.ParentId); + Account newParent = (Account) record.getNewParent(Account.ParentId); return oldParent != null && newParent != null && oldParent.BillingCountry != newParent.BillingCountry; } public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) { - Account oldParent = (Account) record.getOldParent('Parent'); - Account newParent = (Account) record.getNewParent('Parent'); + Account oldParent = (Account) record.getOldParent(Account.ParentId); + Account newParent = (Account) record.getNewParent(Account.ParentId); record.addError( Account.ParentId, diff --git a/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriter.cls b/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriter.cls index e10f8fc..af7fbc4 100644 --- a/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriter.cls +++ b/examples/main/default/classes/contact/after-insert/writer/ContactFollowUpTaskWriter.cls @@ -9,7 +9,7 @@ public with sharing class ContactFollowUpTaskWriter implements AfterInsert.Write public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { Contact newContact = (Contact) record.getNewSObject(); - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Contact.AccountId); unitOfWork.toInsert( new Task( diff --git a/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriter.cls b/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriter.cls index 25ad3fa..ccf5c64 100644 --- a/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriter.cls +++ b/examples/main/default/classes/contact/after-insert/writer/ContactOwnerAlignmentWriter.cls @@ -11,13 +11,13 @@ public with sharing class ContactOwnerAlignmentWriter implements AfterInsert.Wri public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { Contact newContact = (Contact) record.getNewSObject(); - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Contact.AccountId); return account?.Owner?.IsActive == true && account.OwnerId != newContact.OwnerId; } public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Contact.AccountId); unitOfWork.toUpdate(new Contact(Id = record.getId(), OwnerId = account.OwnerId)); } diff --git a/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulator.cls b/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulator.cls index d5170ed..42c776f 100644 --- a/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulator.cls +++ b/examples/main/default/classes/contact/before-insert/populator/ContactMailingAddressPopulator.cls @@ -12,7 +12,7 @@ public with sharing class ContactMailingAddressPopulator implements BeforeInsert } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Contact.AccountId); if (account == null) { return; diff --git a/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulator.cls b/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulator.cls index 7ff2ffb..96bcfdb 100644 --- a/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulator.cls +++ b/examples/main/default/classes/contact/before-update/populator/ContactAccountTransferPopulator.cls @@ -17,8 +17,8 @@ public with sharing class ContactAccountTransferPopulator implements BeforeUpdat public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { Contact newContact = (Contact) record.getNewSObject(); - Account oldAccount = (Account) record.getOldParent('Account'); - Account newAccount = (Account) record.getNewParent('Account'); + Account oldAccount = (Account) record.getOldParent(Contact.AccountId); + Account newAccount = (Account) record.getNewParent(Contact.AccountId); String auditLine = Date.today().format() + ' - transferred from ' + (oldAccount?.Name ?? 'a deleted account') + ' to ' + (newAccount?.Name ?? 'no account') + '.'; diff --git a/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls b/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls index 734daf7..03b984c 100644 --- a/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls +++ b/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls @@ -8,7 +8,7 @@ public with sharing class OpportunityAccountTypeWriter implements AfterUpdate.Wr } public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Opportunity.AccountId); return record.isChangedTo(Opportunity.StageName, 'Closed Won') && account != null && account.Type != 'Customer - Direct'; } diff --git a/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriter.cls b/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriter.cls index e61aff1..7d97a55 100644 --- a/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriter.cls +++ b/examples/main/default/classes/opportunity/after-update/writer/OpportunityWinTaskWriter.cls @@ -9,7 +9,7 @@ public with sharing class OpportunityWinTaskWriter implements AfterUpdate.Writer public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { Opportunity newOpportunity = (Opportunity) record.getNewSObject(); - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Opportunity.AccountId); unitOfWork.toInsert( new Task( diff --git a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulator.cls b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulator.cls index fcec946..11535b5 100644 --- a/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulator.cls +++ b/examples/main/default/classes/opportunity/before-insert/populator/OpportunityTypeFromAccountPopulator.cls @@ -14,7 +14,7 @@ public with sharing class OpportunityTypeFromAccountPopulator implements BeforeI } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - Account account = (Account) record.getNewParent('Account'); + Account account = (Account) record.getNewParent(Opportunity.AccountId); Boolean isCustomerAccount = account?.Type?.startsWith('Customer') == true; record.put(Opportunity.Type, isCustomerAccount ? 'Existing Customer - Upgrade' : 'New Customer'); diff --git a/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidator.cls b/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidator.cls index 50d6609..15e022c 100644 --- a/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidator.cls +++ b/examples/main/default/classes/opportunity/before-update/validator/OpportunityAccountMoveValidator.cls @@ -12,8 +12,8 @@ public with sharing class OpportunityAccountMoveValidator implements BeforeUpdat } public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) { - Account oldAccount = (Account) record.getOldParent('Account'); - Account newAccount = (Account) record.getNewParent('Account'); + Account oldAccount = (Account) record.getOldParent(Opportunity.AccountId); + Account newAccount = (Account) record.getNewParent(Opportunity.AccountId); record.addError( Opportunity.AccountId, diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index 45d88fd..51ad6f6 100644 --- a/force-app/main/default/classes/AfterDeleteTest.cls +++ b/force-app/main/default/classes/AfterDeleteTest.cls @@ -615,7 +615,7 @@ private class AfterDeleteTest { } public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { - unitOfWork.toInsert(new Task(Subject = (String) record.getOldParent('Account')?.get(Account.Name))); + unitOfWork.toInsert(new Task(Subject = (String) record.getOldParent(Contact.AccountId)?.get(Account.Name))); } } diff --git a/force-app/main/default/classes/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index 8ddec40..54d84e7 100644 --- a/force-app/main/default/classes/AfterInsertTest.cls +++ b/force-app/main/default/classes/AfterInsertTest.cls @@ -698,7 +698,7 @@ private class AfterInsertTest { } public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { - unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent('Account')?.get(Account.Name))); + unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent(Contact.AccountId)?.get(Account.Name))); } } diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index 75dadd0..6dae9e4 100644 --- a/force-app/main/default/classes/AfterUndeleteTest.cls +++ b/force-app/main/default/classes/AfterUndeleteTest.cls @@ -610,7 +610,7 @@ private class AfterUndeleteTest { } public void writeOnAfterUndelete(TriggerTypes.UndeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { - unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent('Account')?.get(Account.Name))); + unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent(Contact.AccountId)?.get(Account.Name))); } } diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index 9bf27e7..a48b15b 100644 --- a/force-app/main/default/classes/AfterUpdateTest.cls +++ b/force-app/main/default/classes/AfterUpdateTest.cls @@ -924,7 +924,7 @@ private class AfterUpdateTest { } public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { - unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent('Account')?.get(Account.Name))); + unitOfWork.toInsert(new Task(Subject = (String) record.getNewParent(Contact.AccountId)?.get(Account.Name))); } } @@ -944,7 +944,7 @@ private class AfterUpdateTest { } public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { - unitOfWork.toInsert(new Task(Subject = (String) record.getOldParent('Account')?.get(Account.Name))); + unitOfWork.toInsert(new Task(Subject = (String) record.getOldParent(Contact.AccountId)?.get(Account.Name))); } } diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index db3c667..edef497 100644 --- a/force-app/main/default/classes/BeforeDeleteTest.cls +++ b/force-app/main/default/classes/BeforeDeleteTest.cls @@ -386,7 +386,7 @@ private class BeforeDeleteTest { } public void addErrorOnBeforeDelete(TriggerTypes.RejectableDeleteRecord record) { - record.addError('Contacts of ' + (String) record.getOldParent('Account')?.get(Account.Name) + ' cannot be deleted.'); + record.addError('Contacts of ' + (String) record.getOldParent(Contact.AccountId)?.get(Account.Name) + ' cannot be deleted.'); } } diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index 58d594e..d648ce3 100644 --- a/force-app/main/default/classes/BeforeInsertTest.cls +++ b/force-app/main/default/classes/BeforeInsertTest.cls @@ -1053,7 +1053,7 @@ private class BeforeInsertTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Department, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } } @@ -1331,7 +1331,7 @@ private class BeforeInsertTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Department, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } } diff --git a/force-app/main/default/classes/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index 4ce698e..0ed6bfe 100644 --- a/force-app/main/default/classes/BeforeUpdateTest.cls +++ b/force-app/main/default/classes/BeforeUpdateTest.cls @@ -995,7 +995,7 @@ private class BeforeUpdateTest { } public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { - record.put(Contact.Department, record.getOldParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getOldParent(Contact.AccountId)?.get(Account.Name)); } } @@ -1113,7 +1113,7 @@ private class BeforeUpdateTest { } public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { - record.put(Contact.Department, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } } diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index ceb2c44..08c0bed 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -2238,7 +2238,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { - record.put(Contact.Department, record.getOldParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getOldParent(Contact.AccountId)?.get(Account.Name)); } } @@ -2254,7 +2254,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Description, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Description, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { @@ -2262,7 +2262,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { - record.put(Contact.Description, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Description, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } } @@ -2276,7 +2276,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Description, record.getNewParent('Account')?.get(Account.Industry)); + record.put(Contact.Description, record.getNewParent(Contact.AccountId)?.get(Account.Industry)); } } @@ -2544,7 +2544,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Description, record.getNewParent('Account')?.get(Account.Industry)); + record.put(Contact.Description, record.getNewParent(Contact.AccountId)?.get(Account.Industry)); } } @@ -2558,7 +2558,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Description, record.getNewParent('Account')?.getSObject('Owner')?.get(User.LastName)); + record.put(Contact.Description, record.getNewParent(Contact.AccountId)?.getSObject('Owner')?.get(User.LastName)); } } @@ -2734,7 +2734,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { - record.put(Contact.Department, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } public Map queryParentsOnBeforeUpdate() { @@ -2746,7 +2746,7 @@ private class TriggerOrchestratorTest { } public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { - record.put(Contact.Department, record.getNewParent('Account')?.get(Account.Name)); + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); } } diff --git a/force-app/main/default/classes/TriggerTypes.cls b/force-app/main/default/classes/TriggerTypes.cls index 75b3b3f..181bff0 100644 --- a/force-app/main/default/classes/TriggerTypes.cls +++ b/force-app/main/default/classes/TriggerTypes.cls @@ -79,7 +79,7 @@ public with sharing class TriggerTypes { SObject getNewSObject(); - SObject getNewParent(String relationshipName); + SObject getNewParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); @@ -142,7 +142,7 @@ public with sharing class TriggerTypes { SObject getNewSObject(); - SObject getNewParent(String relationshipName); + SObject getNewParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); @@ -225,8 +225,8 @@ public with sharing class TriggerTypes { SObject getNewSObject(); SObject getOldSObject(); - SObject getNewParent(String relationshipName); - SObject getOldParent(String relationshipName); + SObject getNewParent(SObjectField lookupField); + SObject getOldParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); @@ -308,8 +308,8 @@ public with sharing class TriggerTypes { SObject getNewSObject(); SObject getOldSObject(); - SObject getNewParent(String relationshipName); - SObject getOldParent(String relationshipName); + SObject getNewParent(SObjectField lookupField); + SObject getOldParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); @@ -373,7 +373,7 @@ public with sharing class TriggerTypes { SObject getOldSObject(); - SObject getOldParent(String relationshipName); + SObject getOldParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); } @@ -434,7 +434,7 @@ public with sharing class TriggerTypes { SObject getOldSObject(); - SObject getOldParent(String relationshipName); + SObject getOldParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); @@ -498,7 +498,7 @@ public with sharing class TriggerTypes { SObject getNewSObject(); - SObject getNewParent(String relationshipName); + SObject getNewParent(SObjectField lookupField); TriggerTypes.RelatedRecords getRelated(String providerName); } @@ -928,12 +928,12 @@ public with sharing class TriggerTypes { return this.oldRecord; } - public SObject getNewParent(String relationshipName) { - return this.newParents.get(relationshipName); + public SObject getNewParent(SObjectField lookupField) { + return this.newParents.get(lookupField.getDescribe().getRelationshipName()); } - public SObject getOldParent(String relationshipName) { - return this.oldParents.get(relationshipName); + public SObject getOldParent(SObjectField lookupField) { + return this.oldParents.get(lookupField.getDescribe().getRelationshipName()); } public RelatedRecords getRelated(String providerName) { @@ -966,6 +966,10 @@ public with sharing class TriggerTypes { return this.newRecord == null ? this.oldParents.get(relationshipName) : this.newParents.get(relationshipName); } + public SObject getOldParent(String relationshipName) { + return this.oldParents.get(relationshipName); + } + public Id getNewParentId(SObjectField lookupField) { return this.newRecord?.isSet(lookupField) == true ? (Id) this.newRecord.get(lookupField) : null; } diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index 19bf0a0..f4103e4 100644 --- a/force-app/main/default/classes/TriggerTypesTest.cls +++ b/force-app/main/default/classes/TriggerTypesTest.cls @@ -3463,7 +3463,7 @@ private class TriggerTypesTest { record.setNewParent('Account', parentAccount); // Test - SObject result = record.getNewParent('Account'); + SObject result = record.getNewParent(Contact.AccountId); // Verify Assert.areEqual(parentAccount, result, 'The enriched parent should be returned.'); @@ -3482,7 +3482,7 @@ private class TriggerTypesTest { record.setOldParent('Account', priorParentAccount); // Test - SObject result = record.getOldParent('Account'); + SObject result = record.getOldParent(Contact.AccountId); // Verify Assert.areEqual(priorParentAccount, result, 'The enriched parent should be returned.'); @@ -3501,7 +3501,7 @@ private class TriggerTypesTest { record.setNewParent('Account', new Account(Id = firstAccountId, Name = 'Current Parent')); // Test - SObject result = record.getOldParent('Account'); + SObject result = record.getOldParent(Contact.AccountId); // Verify Assert.areEqual('Prior Parent', result.get(Account.Name), 'The old parent should be returned.'); @@ -3520,28 +3520,28 @@ private class TriggerTypesTest { record.setOldParent('Account', new Account(Id = secondAccountId, Name = 'Prior Parent')); // Test - SObject result = record.getNewParent('Account'); + SObject result = record.getNewParent(Contact.AccountId); // Verify Assert.areEqual('Current Parent', result.get(Account.Name), 'The new parent should be returned.'); } @IsTest - static void getNewParentWhenRelationshipIsNotEnriched() { + static void getNewParentWhenLookupIsNotEnriched() { // Setup Id accountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType); TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New', AccountId = accountId), null); record.setNewParent('Account', new Account(Id = accountId, Name = 'Parent')); // Test - SObject result = record.getNewParent('ReportsTo'); + SObject result = record.getNewParent(Contact.ReportsToId); // Verify Assert.isNull(result, 'The parent should be null.'); } @IsTest - static void getOldParentWhenRelationshipIsNotEnriched() { + static void getOldParentWhenLookupIsNotEnriched() { // Setup Id accountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType); TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord( @@ -3551,7 +3551,7 @@ private class TriggerTypesTest { record.setOldParent('Account', new Account(Id = accountId, Name = 'Parent')); // Test - SObject result = record.getOldParent('ReportsTo'); + SObject result = record.getOldParent(Contact.ReportsToId); // Verify Assert.isNull(result, 'The parent should be null.'); @@ -3568,7 +3568,7 @@ private class TriggerTypesTest { record.setNewParent('Account', new Account(Id = accountId, Name = 'Parent')); // Test - SObject result = record.getOldParent('Account'); + SObject result = record.getOldParent(Contact.AccountId); // Verify Assert.isNull(result, 'The parent should be null.'); @@ -3585,21 +3585,21 @@ private class TriggerTypesTest { record.setOldParent('Account', new Account(Id = accountId, Name = 'Parent')); // Test - SObject result = record.getNewParent('Account'); + SObject result = record.getNewParent(Contact.AccountId); // Verify Assert.isNull(result, 'The parent should be null.'); } @IsTest - static void getNewParentWhenOnlyLetterCaseDiffers() { + static void getNewParentWhenOtherLookupPointsToSameObject() { // Setup - Id accountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType); - TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New', AccountId = accountId), null); - record.setNewParent('Account', new Account(Id = accountId, Name = 'Parent')); + Id managerId = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New', ReportsToId = managerId), null); + record.setNewParent('ReportsTo', new Contact(Id = managerId, LastName = 'Manager')); // Test - SObject result = record.getNewParent('account'); + SObject result = record.getNewParent(Contact.MasterRecordId); // Verify Assert.isNull(result, 'The parent should be null.'); @@ -3615,7 +3615,7 @@ private class TriggerTypesTest { record.setNewParent('Account', new Account(Id = secondAccountId, Name = 'Re-pointed Parent')); // Test - SObject result = record.getNewParent('Account'); + SObject result = record.getNewParent(Contact.AccountId); // Verify Assert.areEqual('Re-pointed Parent', result.get(Account.Name), 'The parent should be replaced.'); @@ -3631,7 +3631,7 @@ private class TriggerTypesTest { record.setNewParent('ReportsTo', new Contact(Id = contactId, LastName = 'Manager')); // Test - SObject result = record.getNewParent('Account'); + SObject result = record.getNewParent(Contact.AccountId); // Verify Assert.areEqual('Parent', result.get(Account.Name), 'The first parent should be kept.'); diff --git a/website/.vitepress/scripts/generate.mjs b/website/.vitepress/scripts/generate.mjs index e06b572..c8b1876 100644 --- a/website/.vitepress/scripts/generate.mjs +++ b/website/.vitepress/scripts/generate.mjs @@ -298,15 +298,15 @@ function skeletonBody(context, roleName, addOnName) { if (addOnName === 'ParentQuery') { body.predicate = [ - `return ${accountChanged}record.getNewParent('Account') != null;` + `return ${accountChanged}record.getNewParent(Contact.AccountId) != null;` ]; if (roleName === 'Populator') body.action = [ - "record.put(Contact.Description, ((Account) record.getNewParent('Account')).Name);" + "record.put(Contact.Description, ((Account) record.getNewParent(Contact.AccountId)).Name);" ]; if (roleName === 'Writer') body.action = [ - "Account accountRecord = (Account) record.getNewParent('Account');", + "Account accountRecord = (Account) record.getNewParent(Contact.AccountId);", '', reviewTask( 'accountRecord.Id', @@ -317,15 +317,15 @@ function skeletonBody(context, roleName, addOnName) { if (addOnName === 'PriorParentQuery') { body.predicate = [ - `return ${accountChanged}record.getOldParent('Account') != null;` + `return ${accountChanged}record.getOldParent(Contact.AccountId) != null;` ]; if (roleName === 'Populator') body.action = [ - "record.put(Contact.Description, 'Moved from ' + ((Account) record.getOldParent('Account')).Name);" + "record.put(Contact.Description, 'Moved from ' + ((Account) record.getOldParent(Contact.AccountId)).Name);" ]; if (roleName === 'Writer') body.action = [ - "Account previousAccount = (Account) record.getOldParent('Account');", + "Account previousAccount = (Account) record.getOldParent(Contact.AccountId);", '', reviewTask( 'previousAccount.Id', @@ -334,7 +334,7 @@ function skeletonBody(context, roleName, addOnName) { ]; if (roleName === 'Validator') body.action = [ - "record.addError('Remove the contact from ' + ((Account) record.getOldParent('Account')).Name + ' first.');" + "record.addError('Remove the contact from ' + ((Account) record.getOldParent(Contact.AccountId)).Name + ' first.');" ]; } diff --git a/website/after-delete/add-ons/prior-parent-query.md b/website/after-delete/add-ons/prior-parent-query.md index 14d751c..92a9430 100644 --- a/website/after-delete/add-ons/prior-parent-query.md +++ b/website/after-delete/add-ons/prior-parent-query.md @@ -23,7 +23,7 @@ Read fields of the parent the old row pointed to, such as a contact's former acc ## Rules {#rules} -- **Read by relationship name.** Use `getOldParent('Account')` for `AccountId`. The name is case-sensitive. For the Id alone, read the lookup from `getOldSObject()`. +- **Read by lookup field.** Pass the lookup field you declared, such as `getOldParent(Contact.AccountId)`. For the Id alone, read the lookup from `getOldSObject()`. - **Read as it is now.** The parent is queried when the trigger runs, so its fields show current values. It is null when the lookup was empty or the parent no longer exists. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. - **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. diff --git a/website/after-delete/record-api.md b/website/after-delete/record-api.md index c49cb8b..3098be8 100644 --- a/website/after-delete/record-api.md +++ b/website/after-delete/record-api.md @@ -17,5 +17,5 @@ description: What an after delete handler receives - DeleteRecord and DeleteReco ## Rules {#rules} - **Ids without rows.** `getId()` and `getIds()` return the deleted Ids, but SOQL no longer finds those rows. Key queries by `getIdsOf(Contact.AccountId)`. -- **Lookups hold only the Id.** `((Contact) record.getOldSObject()).Account` is null. Declare a [PriorParentQuery](/after-delete/add-ons/prior-parent-query) and read `record.getOldParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getOldSObject()).Account` is null. Declare a [PriorParentQuery](/after-delete/add-ons/prior-parent-query) and read `record.getOldParent(Contact.AccountId)`. - **`0` and `false` are values.** They are not null, empty or blank. diff --git a/website/after-delete/writer.md b/website/after-delete/writer.md index c588c6d..58c993c 100644 --- a/website/after-delete/writer.md +++ b/website/after-delete/writer.md @@ -26,12 +26,12 @@ public with sharing class ContactRemovalTaskWriter implements AfterDelete.Writer } public Boolean writeOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { - return record.isNull(Contact.MasterRecordId) && record.getOldParent('Account') != null; + return record.isNull(Contact.MasterRecordId) && record.getOldParent(Contact.AccountId) != null; } public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { Contact contactRecord = (Contact) record.getOldSObject(); - Account accountRecord = (Account) record.getOldParent('Account'); + Account accountRecord = (Account) record.getOldParent(Contact.AccountId); unitOfWork.toInsert(new Task(WhatId = contactRecord.AccountId, OwnerId = accountRecord.OwnerId, Subject = 'Contact removed: ' + contactRecord.LastName)); } diff --git a/website/after-insert/add-ons/parent-query.md b/website/after-insert/add-ons/parent-query.md index a59ea1a..f48d1bb 100644 --- a/website/after-insert/add-ons/parent-query.md +++ b/website/after-insert/add-ons/parent-query.md @@ -27,7 +27,7 @@ Read fields of the record a lookup points to, such as a new contact's account, w ## Rules {#rules} -- **Read by relationship name.** Use `getNewParent('Account')` for `AccountId` and `getNewParent('Owner')` for `OwnerId`. The name is case-sensitive. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per chunk.** One SOQL query on the saved records loads every declared parent, even when no record qualifies. diff --git a/website/after-insert/index.md b/website/after-insert/index.md index 6dfc895..7ef4592 100644 --- a/website/after-insert/index.md +++ b/website/after-insert/index.md @@ -19,7 +19,7 @@ Runs in **after insert**, after new records are saved. ## Rules {#rules} -- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/after-insert/add-ons/parent-query) and read `record.getNewParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/after-insert/add-ons/parent-query) and read `record.getNewParent(Contact.AccountId)`. - **Updating the new record saves it again.** A Writer's `toUpdate` with the record Id runs before update and after update for it. - **One role per class.** A class that implements both roles runs only as a Writer. - **Called per chunk.** A 1,000-record insert is 5 runs, so each Dispatcher and Finalizer can run 5 times. Static fields keep their values across chunks. diff --git a/website/after-undelete/add-ons/parent-query.md b/website/after-undelete/add-ons/parent-query.md index e0f9bf3..c9e9958 100644 --- a/website/after-undelete/add-ons/parent-query.md +++ b/website/after-undelete/add-ons/parent-query.md @@ -23,7 +23,7 @@ Read fields of the record a lookup points to, such as a restored contact's accou ## Rules {#rules} -- **Read by relationship name.** Use `getNewParent('Account')` for `AccountId` and `getNewParent('Owner')` for `OwnerId`. The name is case-sensitive. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Contact.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Declare every field you read.** Reading a field that no handler declared throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per chunk.** One SOQL query on the restored records reads every declared parent, even when no record qualifies. diff --git a/website/after-undelete/index.md b/website/after-undelete/index.md index 4e85b91..a473d2c 100644 --- a/website/after-undelete/index.md +++ b/website/after-undelete/index.md @@ -20,7 +20,7 @@ Runs in **after undelete**, after records are restored from the Recycle Bin. ## Rules {#rules} - **No Validator.** Salesforce has no before undelete event. To block a restore, call `record.getNewSObject().addError(…)` from a Writer or Dispatcher and list that handler first. The record stays in the Recycle Bin. -- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/after-undelete/add-ons/parent-query) and read `record.getNewParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/after-undelete/add-ons/parent-query) and read `record.getNewParent(Contact.AccountId)`. - **Only the top-level record's trigger runs.** Salesforce restores cascade-deleted children with their parent, but runs only the parent's after undelete trigger. Handle the children from the parent's handlers. - **Called per chunk.** `afterUndeleteHandlers()` runs for every chunk of up to 200 records. Restoring 1,000 records means 5 runs and 5 Dispatcher calls. diff --git a/website/after-update/add-ons/parent-query.md b/website/after-update/add-ons/parent-query.md index c8c5ccb..10a36f3 100644 --- a/website/after-update/add-ons/parent-query.md +++ b/website/after-update/add-ons/parent-query.md @@ -27,7 +27,7 @@ Read fields of the record a lookup points to, such as an opportunity's account, ## Rules {#rules} -- **Read by relationship name.** Use `getNewParent('Account')` for `AccountId` and `getNewParent('Owner')` for `OwnerId`. The name is case-sensitive. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Opportunity.AccountId)` or `getNewParent(Opportunity.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Declare every field you read.** The parent holds only the fields the active handlers declared. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per chunk.** One query on the trigger records loads every declared parent, even when no record qualifies. diff --git a/website/after-update/add-ons/prior-parent-query.md b/website/after-update/add-ons/prior-parent-query.md index 306facb..a07ee7d 100644 --- a/website/after-update/add-ons/prior-parent-query.md +++ b/website/after-update/add-ons/prior-parent-query.md @@ -25,7 +25,7 @@ Read fields of the parent the old row pointed to, such as the previous owner, wi ## Rules {#rules} -- **Read with `getOldParent`.** Use `getOldParent('Owner')` for `OwnerId`. It is null when the old lookup was empty or the parent no longer exists. +- **Read with `getOldParent`.** Pass the lookup field you declared, such as `getOldParent(Account.OwnerId)`. It is null when the old lookup was empty or the parent no longer exists. - **Each side needs its own declaration.** `getOldParent` needs PriorParentQuery and `getNewParent` needs [ParentQuery](/after-update/add-ons/parent-query), even when the lookup did not change. Implement both to compare the two parents. - **Current values.** The previous parent is queried now, so its fields show today's values. - **At most one query per lookup per chunk.** It runs even when no record qualifies. diff --git a/website/api/parent-fields.md b/website/api/parent-fields.md index b4c0a97..351917d 100644 --- a/website/api/parent-fields.md +++ b/website/api/parent-fields.md @@ -14,13 +14,7 @@ Lists the parent (lookup) fields a ParentQuery or PriorParentQuery add-on loads. The add-on method returns a `Map`. The key is a lookup field on the trigger object, and the fields belong to the object it points to. -| Key | Read back with | -|---|---| -| `Contact.AccountId` | `getNewParent('Account')` | -| `Contact.OwnerId` | `getNewParent('Owner')` | -| `Invoice__c.Region__c` | `getNewParent('Region__r')` | - -The relationship name is case-sensitive. +Read the parent back with the same key, such as `record.getNewParent(Contact.AccountId)`. ## Methods {#methods} diff --git a/website/api/record-collections.md b/website/api/record-collections.md index 77b3bc2..de68c13 100644 --- a/website/api/record-collections.md +++ b/website/api/record-collections.md @@ -68,7 +68,7 @@ public interface UpdateRecords { - **They read the new row.** In the delete contexts they read the old row. - **No nulls, no duplicates.** Each call returns a new set. -- **Parents by relationship name.** Pass the name `getNewParent` takes, such as `'Account'`. A record without a loaded parent adds nothing. +- **Parents by relationship name.** Pass the relationship name of the declared lookup, such as `'Account'` for `Contact.AccountId`. A record without a loaded parent adds nothing. - **Ids only from Id fields.** `getIdsOf` throws on a field that does not hold Ids, such as `Contact.Email`. Use `getValuesOf` there. - **Do not change `getRecords()`.** It is the library's own list, not a copy. Call `.clone()` before you add or remove elements. diff --git a/website/api/record.md b/website/api/record.md index b2788b2..f40d0ee 100644 --- a/website/api/record.md +++ b/website/api/record.md @@ -25,8 +25,8 @@ All interfaces are nested in `TriggerTypes`. | Member | Insert | Rejectable Insert | Update | Rejectable Update | Delete | Rejectable Delete | Undelete | |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | `getId()`, `getRelated(providerName)` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| `getNewSObject()`, `getNewParent(relationshipName)` | ✓ | ✓ | ✓ | ✓ | | | ✓ | -| `getOldSObject()`, `getOldParent(relationshipName)` | | | ✓ | ✓ | ✓ | ✓ | | +| `getNewSObject()`, `getNewParent(lookupField)` | ✓ | ✓ | ✓ | ✓ | | | ✓ | +| `getOldSObject()`, `getOldParent(lookupField)` | | | ✓ | ✓ | ✓ | ✓ | | | `put(field, value)` | ✓ | | ✓ | | | | | | `addError(error)`, `addError(field, error)` | | ✓ | | ✓ | | ✓ | | | value checks and record type checks | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -37,7 +37,7 @@ A missing member is a compile error, such as `isChanged` in an insert handler. - **`getId()`** is null in before insert. - **`getNewSObject()`** is the live row in before insert and before update. In the after contexts it is read-only. - **`getOldSObject()`** is read-only. Stop a delete with `record.addError(…)` in a [BeforeDelete.Validator](/before-delete/validator). -- **`getNewParent('Account')`** returns the parent a [ParentQuery](/api/parent-fields) loaded, or null. `getOldParent` reads PriorParentQuery. The name is case-sensitive. The row itself holds only the lookup Id: `getNewSObject().Account` is null. +- **`getNewParent(Contact.AccountId)`** returns the parent a [ParentQuery](/api/parent-fields) loaded, or null. `getOldParent` reads PriorParentQuery. Pass the lookup field you declared. The row itself holds only the lookup Id: `getNewSObject().Account` is null. - **`put`** works only in before insert and before update. In after insert and after update it compiles but throws a `FinalException` that nothing can catch. Change saved records with a [Writer](/api/unit-of-work). - **`addError(field, error)`** shows the error at record level on `Name` and address fields. The record is still rejected. diff --git a/website/before-delete/add-ons/prior-parent-query.md b/website/before-delete/add-ons/prior-parent-query.md index 538570d..2e8fdab 100644 --- a/website/before-delete/add-ons/prior-parent-query.md +++ b/website/before-delete/add-ons/prior-parent-query.md @@ -23,7 +23,7 @@ Read fields of the parent the old row pointed to, such as a deleted contact's ac ## Rules {#rules} -- **Read by relationship name.** Use `getOldParent('Account')` for `AccountId` and `getOldParent('Parent')` for `ParentId`. The name is case-sensitive. +- **Read by lookup field.** Pass the lookup field you declared, such as `getOldParent(Contact.AccountId)` or `getOldParent(Account.ParentId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. diff --git a/website/before-delete/record-api.md b/website/before-delete/record-api.md index 5ac358b..67728b3 100644 --- a/website/before-delete/record-api.md +++ b/website/before-delete/record-api.md @@ -17,5 +17,5 @@ description: What a before delete Validator or Writer receives - DeleteRecord, R ## Rules {#rules} - **Read-only, but takes errors.** Writing to `getOldSObject()` throws. A Validator's `record.addError('…')` blocks the delete of that record. -- **Lookups hold only the Id.** `((Contact) record.getOldSObject()).Account` is null. Declare a [PriorParentQuery](/before-delete/add-ons/prior-parent-query) and read `getOldParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getOldSObject()).Account` is null. Declare a [PriorParentQuery](/before-delete/add-ons/prior-parent-query) and read `getOldParent(Contact.AccountId)`. - **No merge winner yet.** `MasterRecordId` is set only in [AfterDelete](/after-delete/record-api). diff --git a/website/before-insert/add-ons/parent-query.md b/website/before-insert/add-ons/parent-query.md index a911a74..deb47f5 100644 --- a/website/before-insert/add-ons/parent-query.md +++ b/website/before-insert/add-ons/parent-query.md @@ -27,7 +27,7 @@ Read fields of the record a lookup points to, such as a contact's account, witho ## Rules {#rules} -- **Read by relationship name.** Use `getNewParent('Account')` for `AccountId` and `getNewParent('Parent')` for `ParentId`. The name is case-sensitive. The parent is null when the lookup is empty or no record has that Id. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.ParentId)`. The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. A Populator that changes it can add one. diff --git a/website/before-insert/index.md b/website/before-insert/index.md index 3298c66..5920f1e 100644 --- a/website/before-insert/index.md +++ b/website/before-insert/index.md @@ -20,7 +20,7 @@ Runs in **before insert**, before new records are saved. ## Rules {#rules} - **No Id yet.** `record.getId()` is null and `records.getIds()` is empty. Never key a map by the record Id here. -- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/before-insert/add-ons/parent-query) and read `record.getNewParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/before-insert/add-ons/parent-query) and read `record.getNewParent(Contact.AccountId)`. - **One role per class.** A class that implements both roles runs only as a Populator. List Populators first, so Validators see the values they set. - **Called per chunk.** `beforeInsertHandlers()` runs for every chunk of up to 200 records. Handlers created there start with empty instance fields each time; static fields last the whole transaction. diff --git a/website/before-update/add-ons/parent-query.md b/website/before-update/add-ons/parent-query.md index 5aae9f2..9c6a5b2 100644 --- a/website/before-update/add-ons/parent-query.md +++ b/website/before-update/add-ons/parent-query.md @@ -27,7 +27,7 @@ Read fields of the record a lookup points to, such as a contact's current accoun ## Rules {#rules} -- **Read by relationship name.** Use `getNewParent('Account')` for `AccountId` and `getNewParent('Parent')` for `ParentId`. The name is case-sensitive. The parent is null when the lookup is empty or no record has that Id. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.ParentId)`. The parent is null when the lookup is empty or no record has that Id. - **Later handlers see the new parent.** When a Populator changes the lookup, handlers after it get the new parent. A parent not loaded yet costs one more query. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. - **One query per lookup.** Each declared lookup costs at most one SOQL query before the first handler, even when no record qualifies. A [PriorParentQuery](/before-update/add-ons/prior-parent-query) on the same lookup shares that query. diff --git a/website/before-update/add-ons/prior-parent-query.md b/website/before-update/add-ons/prior-parent-query.md index 7ef300e..aaebbe8 100644 --- a/website/before-update/add-ons/prior-parent-query.md +++ b/website/before-update/add-ons/prior-parent-query.md @@ -27,7 +27,7 @@ Read fields of the parent the old row pointed to, such as a contact's previous a ## Rules {#rules} -- **Read with `getOldParent`.** `record.getOldParent('Account')` is null when the old lookup was empty or the parent has been deleted since. +- **Read with `getOldParent`.** `record.getOldParent(Contact.AccountId)` is null when the old lookup was empty or the parent has been deleted since. - **Current field values.** The previous parent is queried when the trigger runs, so its fields show their values now, not when the record pointed to it. - **One query for both sides.** With a [ParentQuery](/before-update/add-ons/parent-query) on the same lookup, both parents load in one query, and each gets every field either side declared. diff --git a/website/before-update/index.md b/website/before-update/index.md index 71f42f0..a4346fa 100644 --- a/website/before-update/index.md +++ b/website/before-update/index.md @@ -21,7 +21,7 @@ Runs in **before update**, before the changes are saved, with the old and new va - **The old row is read-only.** Writing to `getOldSObject()` or calling `addError` on it throws a `FinalException` that no `catch` stops. The update fails. - **Updates re-enter.** A later update of the same records in the transaction runs BeforeUpdate again. A Populator acts on a record at most 3 times per transaction by default. A Validator runs on every pass. -- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/before-update/add-ons/parent-query) and read `record.getNewParent('Account')`. +- **Lookups hold only the Id.** `((Contact) record.getNewSObject()).Account` is null. Declare a [ParentQuery](/before-update/add-ons/parent-query) and read `record.getNewParent(Contact.AccountId)`. - **One role per class.** A class that implements both roles runs only as a Populator. List Populators first, so Validators see the values they set. ::: warning From 09a0806902238e98e70aafb66e9b96b16f89e289 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Fri, 25 Sep 2026 10:07:58 +0200 Subject: [PATCH 4/5] minimal queries --- .../main/default/classes/BeforeInsertTest.cls | 13 +- .../default/classes/TriggerOrchestrator.cls | 338 ++++++++++-------- .../classes/TriggerOrchestratorTest.cls | 213 ++++++++++- .../main/default/classes/TriggerTypes.cls | 33 +- .../main/default/classes/TriggerTypesTest.cls | 89 +++++ website/.vitepress/context-facts.mjs | 4 +- .../add-ons/prior-parent-query.md | 2 +- website/after-delete/add-ons/related-query.md | 2 +- website/after-insert/add-ons/parent-query.md | 2 +- website/after-insert/add-ons/related-query.md | 2 +- .../after-undelete/add-ons/parent-query.md | 2 +- .../after-undelete/add-ons/related-query.md | 2 +- website/after-update/add-ons/parent-query.md | 2 +- .../add-ons/prior-parent-query.md | 2 +- website/after-update/add-ons/related-query.md | 2 +- website/api/parent-fields.md | 2 +- website/api/record-collections.md | 2 +- website/api/related-records.md | 4 +- .../add-ons/continue-on-error.md | 2 +- .../add-ons/prior-parent-query.md | 2 +- .../before-delete/add-ons/related-query.md | 2 +- .../add-ons/continue-on-error.md | 2 +- website/before-insert/add-ons/parent-query.md | 2 +- .../before-insert/add-ons/related-query.md | 2 +- website/before-update/add-ons/parent-query.md | 6 +- .../before-update/add-ons/related-query.md | 2 +- website/guide/error-handling.md | 2 +- website/guide/execution-order.md | 15 +- website/guide/related-query.md | 2 +- website/guide/testing.md | 2 +- website/introduction/design-principles.md | 2 +- 31 files changed, 561 insertions(+), 198 deletions(-) diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index d648ce3..49a56f5 100644 --- a/force-app/main/default/classes/BeforeInsertTest.cls +++ b/force-app/main/default/classes/BeforeInsertTest.cls @@ -866,7 +866,6 @@ private class BeforeInsertTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Account globex = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Globex'); - SOQL.mock(Account.SObjectType).thenReturn(acme); SOQL.mock(Account.SObjectType).thenReturn(globex); Contact doe = new Contact(LastName = 'Doe', AccountId = acme.Id); @@ -1182,10 +1181,18 @@ private class BeforeInsertTest { } } - private class TitlePopulatorWithDmlRelatedQuery extends TitlePopulator implements BeforeInsert.RelatedQuery { + private class TitlePopulatorWithDmlRelatedQuery implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { public Map queryRelatedOnBeforeInsert() { Database.setSavepoint(); - return new Map(); + return new Map{ 'accounts' => new RelatedAccountProvider() }; + } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getRelated('accounts').isEmpty() ? 'CEO' : 'CFO'); } } diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index d75ec53..7be60a7 100644 --- a/force-app/main/default/classes/TriggerOrchestrator.cls +++ b/force-app/main/default/classes/TriggerOrchestrator.cls @@ -462,7 +462,7 @@ public with sharing class TriggerOrchestrator { public void execute(TriggerOrchestrator orchestrator) { List activeAdapters = this.getActiveAdapters(orchestrator); - orchestrator.parentLoader.load(activeAdapters); + orchestrator.parentLoader.collectDeclarations(activeAdapters); for (HandlerAdapter adapter : activeAdapters) { adapter.execute(orchestrator.context.triggerRecords); @@ -654,7 +654,7 @@ public with sharing class TriggerOrchestrator { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.populate(record); @@ -708,7 +708,7 @@ public with sharing class TriggerOrchestrator { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.validate(record); @@ -766,7 +766,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.write(record); @@ -811,7 +811,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); @@ -860,7 +860,7 @@ public with sharing class TriggerOrchestrator { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.populate(record); @@ -915,7 +915,7 @@ public with sharing class TriggerOrchestrator { Integer immediateEventsBefore = Limits.getPublishImmediateDML(); try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.validate(record); @@ -975,7 +975,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.write(record); @@ -1023,7 +1023,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); @@ -1068,7 +1068,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.validate(record); @@ -1113,7 +1113,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.write(record); @@ -1160,7 +1160,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.write(record); @@ -1205,7 +1205,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); @@ -1251,7 +1251,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.write(record); @@ -1296,7 +1296,7 @@ public with sharing class TriggerOrchestrator { public override void execute(List records) { try { - this.relatedLoader.load(records); + this.relatedLoader.attachTo(records); for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); @@ -1464,23 +1464,47 @@ public with sharing class TriggerOrchestrator { } } - private class ParentLoader { + private class ParentLoader implements TriggerTypes.ParentLoader { private Set lookupsForNewParents = new Set(); private Set lookupsForOldParents = new Set(); + private Set loadedLookups = new Set(); + private Map lookupByRelationshipName = new Map(); private Map> fieldPathsByLookup = new Map>(); private Map> cachedParentsByLookup = new Map>(); + private Boolean areSavedRecordsQueried = false; + private TriggerContext context; public ParentLoader(TriggerContext context) { this.context = context; } - public void load(List handlers) { - this.collectDeclarations(handlers); - this.queryNewParentsFromSavedRecords(); - this.queryAndAttachParents(); + public void collectDeclarations(List handlers) { + for (HandlerAdapter handler : handlers) { + this.declareNewParents(handler.parentDeclaration.getNewParentFields()); + this.declareOldParents(handler.parentDeclaration.getOldParentFields()); + } + + for (TriggerTypes.TriggerRecord record : this.context.triggerRecords) { + record.setParentLoader(this); + } + } + + public void load(String relationshipName) { + SObjectField lookupField = this.lookupByRelationshipName.get(relationshipName); + + if (lookupField == null || this.loadedLookups.contains(lookupField)) { + return; + } + + if (this.lookupsForNewParents.contains(lookupField)) { + this.queryNewParentsFromSavedRecords(); + } + + this.queryAndAttachParents(lookupField); + this.loadedLookups.add(lookupField); } public void refreshNewParents(List records) { @@ -1488,19 +1512,12 @@ public with sharing class TriggerOrchestrator { return; } - for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { + for (SObjectField lookupField : this.loadedLookups) { this.queryParents(lookupField, this.notQueriedNewParentIds(lookupField, records)); this.attachNewParents(lookupField, records); } } - private void collectDeclarations(List handlers) { - for (HandlerAdapter handler : handlers) { - this.declareNewParents(handler.parentDeclaration.getNewParentFields()); - this.declareOldParents(handler.parentDeclaration.getOldParentFields()); - } - } - private void declareNewParents(Map parentFieldsByLookup) { this.lookupsForNewParents.addAll(parentFieldsByLookup.keySet()); this.declare(parentFieldsByLookup); @@ -1520,12 +1537,13 @@ public with sharing class TriggerOrchestrator { this.cachedParentsByLookup.put(lookupField, new Map()); } + this.lookupByRelationshipName.put(lookupField.getDescribe().getRelationshipName(), lookupField); this.fieldPathsByLookup.get(lookupField).addAll(parentFieldsByLookup.get(lookupField).getFields()); } } private void queryNewParentsFromSavedRecords() { - if (this.lookupsForNewParents.isEmpty() || !this.haveTriggeredRecordsIds()) { + if (this.areSavedRecordsQueried || !this.haveTriggeredRecordsIds()) { return; } @@ -1549,6 +1567,8 @@ public with sharing class TriggerOrchestrator { } } } + + this.areSavedRecordsQueried = true; } private Boolean haveTriggeredRecordsIds() { @@ -1568,18 +1588,16 @@ public with sharing class TriggerOrchestrator { return fieldPaths; } - private void queryAndAttachParents() { - for (SObjectField lookupField : this.fieldPathsByLookup.keySet()) { - Set parentIdsToQuery = new Set(); + private void queryAndAttachParents(SObjectField lookupField) { + Set parentIdsToQuery = new Set(); - parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); - parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); + parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); + parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); - this.queryParents(lookupField, parentIdsToQuery); + this.queryParents(lookupField, parentIdsToQuery); - this.attachNewParents(lookupField, this.context.triggerRecords); - this.attachOldParents(lookupField, this.context.triggerRecords); - } + this.attachNewParents(lookupField, this.context.triggerRecords); + this.attachOldParents(lookupField, this.context.triggerRecords); } private Set notQueriedNewParentIds(SObjectField lookupField, List records) { @@ -1668,248 +1686,270 @@ public with sharing class TriggerOrchestrator { } } - private abstract class RelatedLoader { - public void load(List records) { - Map relatedByProviderName = new Map(); + private abstract class RelatedLoader implements TriggerTypes.RelatedLoader { + protected List records = new List(); + + private Map relatedByProviderName = new Map(); + + public void attachTo(List records) { + this.records = records; for (TriggerTypes.TriggerRecord record : records) { - record.setRelated(relatedByProviderName); + record.setRelated(this.relatedByProviderName); + record.setRelatedLoader(this); } + } + + public void load(String providerName) { + if (this.relatedByProviderName.containsKey(providerName)) { + return; + } + + TriggerTypes.RelatedRecords related = this.queryRelated(providerName); - relatedByProviderName.putAll(this.queryRelated(records)); + if (related != null) { + this.relatedByProviderName.put(providerName, related); + } } - protected abstract Map queryRelated(List records); + protected abstract TriggerTypes.RelatedRecords queryRelated(String providerName); } private class BeforeInsertRelatedLoader extends RelatedLoader { private BeforeInsert.Handler handler; + private Map providers; public BeforeInsertRelatedLoader(BeforeInsert.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof BeforeInsert.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? - new Map(); - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + if (this.providers == null) { + this.providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? new Map(); + } - for (String providerName : providers.keySet()) { - BeforeInsert.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + BeforeInsert.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } + + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class AfterInsertRelatedLoader extends RelatedLoader { private AfterInsert.Handler handler; + private Map providers; public AfterInsertRelatedLoader(AfterInsert.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof AfterInsert.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? - new Map(); - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(records); + if (this.providers == null) { + this.providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? new Map(); + } - for (String providerName : providers.keySet()) { - AfterInsert.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + AfterInsert.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } + + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class BeforeUpdateRelatedLoader extends RelatedLoader { private BeforeUpdate.Handler handler; + private Map providers; public BeforeUpdateRelatedLoader(BeforeUpdate.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof BeforeUpdate.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((BeforeUpdate.RelatedQuery) this.handler).queryRelatedOnBeforeUpdate() ?? - new Map(); - TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(records); + if (this.providers == null) { + this.providers = ((BeforeUpdate.RelatedQuery) this.handler).queryRelatedOnBeforeUpdate() ?? new Map(); + } - for (String providerName : providers.keySet()) { - BeforeUpdate.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); + BeforeUpdate.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } + + TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class AfterUpdateRelatedLoader extends RelatedLoader { private AfterUpdate.Handler handler; + private Map providers; public AfterUpdateRelatedLoader(AfterUpdate.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof AfterUpdate.RelatedQuery)) { - return relatedByProviderName; + return null; + } + + if (this.providers == null) { + this.providers = ((AfterUpdate.RelatedQuery) this.handler).queryRelatedOnAfterUpdate() ?? new Map(); } - Map providers = ((AfterUpdate.RelatedQuery) this.handler).queryRelatedOnAfterUpdate() ?? - new Map(); - TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(records); + AfterUpdate.RecordsProvider provider = this.providers.get(providerName); - for (String providerName : providers.keySet()) { - AfterUpdate.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); + if (provider == null) { + return null; + } - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class BeforeDeleteRelatedLoader extends RelatedLoader { private BeforeDelete.Handler handler; + private Map providers; public BeforeDeleteRelatedLoader(BeforeDelete.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof BeforeDelete.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((BeforeDelete.RelatedQuery) this.handler).queryRelatedOnBeforeDelete() ?? - new Map(); - TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(records); + if (this.providers == null) { + this.providers = ((BeforeDelete.RelatedQuery) this.handler).queryRelatedOnBeforeDelete() ?? new Map(); + } - for (String providerName : providers.keySet()) { - BeforeDelete.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); + BeforeDelete.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } - relatedByProviderName.put(providerName, related); + TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class AfterDeleteRelatedLoader extends RelatedLoader { private AfterDelete.Handler handler; + private Map providers; public AfterDeleteRelatedLoader(AfterDelete.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof AfterDelete.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((AfterDelete.RelatedQuery) this.handler).queryRelatedOnAfterDelete() ?? - new Map(); - TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(records); + if (this.providers == null) { + this.providers = ((AfterDelete.RelatedQuery) this.handler).queryRelatedOnAfterDelete() ?? new Map(); + } - for (String providerName : providers.keySet()) { - AfterDelete.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); + AfterDelete.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } + + TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } private class AfterUndeleteRelatedLoader extends RelatedLoader { private AfterUndelete.Handler handler; + private Map providers; public AfterUndeleteRelatedLoader(AfterUndelete.Handler handler) { this.handler = handler; } - public override Map queryRelated(List records) { - Map relatedByProviderName = new Map(); - + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { if (!(this.handler instanceof AfterUndelete.RelatedQuery)) { - return relatedByProviderName; + return null; } - Map providers = ((AfterUndelete.RelatedQuery) this.handler).queryRelatedOnAfterUndelete() ?? - new Map(); - TriggerTypes.UndeleteRecords undeleteRecords = new TriggerTypes.UndeleteTriggerRecords(records); + if (this.providers == null) { + this.providers = ((AfterUndelete.RelatedQuery) this.handler).queryRelatedOnAfterUndelete() ?? new Map(); + } - for (String providerName : providers.keySet()) { - AfterUndelete.RecordsProvider provider = providers.get(providerName); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(undeleteRecords)); + AfterUndelete.RecordsProvider provider = this.providers.get(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } + if (provider == null) { + return null; + } + + TriggerTypes.UndeleteRecords undeleteRecords = new TriggerTypes.UndeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(undeleteRecords)); - relatedByProviderName.put(providerName, related); + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); } - return relatedByProviderName; + return related; } } diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 08c0bed..91c4af7 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -1466,6 +1466,50 @@ private class TriggerOrchestratorTest { Assert.areEqual(0, queriesAfter - queriesBefore, 'The accounts should not be queried.'); } + @IsTest + static void parentQueryWhenRecordDoesNotQualify() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock() + .beforeInsertFor(AccountNamePopulator.class) + .with(new Contact(LastName = 'Roe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType))); + + // Test + Test.startTest(); + Integer queriesBefore = SOQL.getQueries(); + TriggerOrchestrator.runTestFor(new AccountNamePopulator()); + Integer queriesAfter = SOQL.getQueries(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, queriesAfter - queriesBefore, 'The accounts should not be queried.'); + } + + @IsTest + static void parentQueryWhenOtherLookupIsNotRead() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); + Contact manager = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Manager'); + SOQL.mock(Account.SObjectType).thenReturn(acme); + + TriggerOrchestrator.mock().beforeInsertFor(AccountNamePopulatorWithManagerQuery.class).with(new Contact(LastName = 'Doe', AccountId = acme.Id, ReportsToId = manager.Id)); + + // Test + Test.startTest(); + Integer queriesBefore = SOQL.getQueries(); + TriggerOrchestrator.runTestFor(new AccountNamePopulatorWithManagerQuery()); + Integer queriesAfter = SOQL.getQueries(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, queriesAfter - queriesBefore, 'Only the accounts should be queried.'); + } + @IsTest static void parentQueryWhenParentIsNotFound() { // Setup @@ -1603,21 +1647,63 @@ private class TriggerOrchestratorTest { Assert.areEqual(new Set{ 'Doe', 'Roe' }, provider.lastNames, 'The provider should receive every record.'); } + @IsTest + static void relatedQueryWhenProviderIsNotRead() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + LastNameProvider provider = new LastNameProvider(); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithUnreadRelatedQuery.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithUnreadRelatedQuery(provider)); + + // Verify + Assert.isNull(provider.lastNames, 'The provider should not query.'); + } + + @IsTest + static void relatedQueryWhenOtherProviderIsNotRead() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + LastNameProvider otherProvider = new LastNameProvider(); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithTwoRelatedQueries.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithTwoRelatedQueries(new LastNameProvider(), otherProvider)); + + // Verify + Assert.isNull(otherProvider.lastNames, 'The other provider should not query.'); + } + @IsTest static void relatedQueryWithNullProviders() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); - Contact doe = new Contact(LastName = 'Doe'); + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithNullRelatedQuery.class).with(new Contact(LastName = 'Doe')); - TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithNullRelatedQuery.class).with(doe); + Exception expectedException = null; // Test - TriggerOrchestrator.runTestFor(new TitlePopulatorWithNullRelatedQuery()); + try { + TriggerOrchestrator.runTestFor(new TitlePopulatorWithNullRelatedQuery()); + } catch (Exception e) { + expectedException = e; + } // Verify - Assert.areEqual('CEO', doe.Title, 'The populator should set the title.'); + Assert.areEqual( + 'No related records provider named titles is declared. Return it from the queryRelatedOn method of the handler first.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); } @IsTest @@ -1714,6 +1800,30 @@ private class TriggerOrchestratorTest { Assert.areEqual(1, queriesAfter - queriesBefore, 'The related accounts should be queried once.'); } + @IsTest + static void relatedQueryWhenReadByManyRecords() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); + SOQL.mock(Account.SObjectType).thenReturn(acme); + + TriggerOrchestrator.mock() + .beforeInsertFor(RelatedAccountNamePopulator.class) + .with(new List{ new Contact(LastName = 'Doe', AccountId = acme.Id), new Contact(LastName = 'Doe', AccountId = acme.Id) }); + + // Test + Test.startTest(); + Integer queriesBefore = SOQL.getQueries(); + TriggerOrchestrator.runTestFor(new RelatedAccountNamePopulator()); + Integer queriesAfter = SOQL.getQueries(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, queriesAfter - queriesBefore, 'The related accounts should be queried once.'); + } + @IsTest static void qualificationWithRelatedQuery() { // Setup @@ -2280,6 +2390,23 @@ private class TriggerOrchestratorTest { } } + private class AccountNamePopulatorWithManagerQuery implements BeforeInsert.Populator, BeforeInsert.ParentQuery { + public Map queryParentsOnBeforeInsert() { + return new Map{ + Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name), + Contact.ReportsToId => TriggerTypes.ParentFields.with(Contact.LastName) + }; + } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Department, record.getNewParent(Contact.AccountId)?.get(Account.Name)); + } + } + private class AccountRepointingPopulator implements BeforeInsert.Populator, BeforeUpdate.Populator { private Id accountId; @@ -2402,7 +2529,7 @@ private class TriggerOrchestratorTest { } } - private class TitlePopulatorWithRelatedQuery extends TitlePopulator implements BeforeInsert.RelatedQuery { + private class TitlePopulatorWithRelatedQuery implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { private BeforeInsert.RecordsProvider provider; public TitlePopulatorWithRelatedQuery(BeforeInsert.RecordsProvider provider) { @@ -2412,30 +2539,100 @@ private class TriggerOrchestratorTest { public Map queryRelatedOnBeforeInsert() { return new Map{ 'lastNames' => this.provider }; } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getRelated('lastNames').isEmpty() ? 'CEO' : 'CFO'); + } + } + + private class TitlePopulatorWithUnreadRelatedQuery extends TitlePopulator implements BeforeInsert.RelatedQuery { + private BeforeInsert.RecordsProvider provider; + + public TitlePopulatorWithUnreadRelatedQuery(BeforeInsert.RecordsProvider provider) { + this.provider = provider; + } + + public Map queryRelatedOnBeforeInsert() { + return new Map{ 'lastNames' => this.provider }; + } } - private class TitlePopulatorWithNullRelatedQuery extends TitlePopulator implements BeforeInsert.RelatedQuery { + private class TitlePopulatorWithTwoRelatedQueries implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { + private BeforeInsert.RecordsProvider provider; + private BeforeInsert.RecordsProvider otherProvider; + + public TitlePopulatorWithTwoRelatedQueries(BeforeInsert.RecordsProvider provider, BeforeInsert.RecordsProvider otherProvider) { + this.provider = provider; + this.otherProvider = otherProvider; + } + + public Map queryRelatedOnBeforeInsert() { + return new Map{ 'lastNames' => this.provider, 'otherLastNames' => this.otherProvider }; + } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getRelated('lastNames').isEmpty() ? 'CEO' : 'CFO'); + } + } + + private class TitlePopulatorWithNullRelatedQuery implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { public Map queryRelatedOnBeforeInsert() { return null; } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getRelated('titles').isEmpty() ? 'CEO' : 'CFO'); + } } - private class AccountNamePopulatorWithRelatedQuery extends AccountNamePopulator implements BeforeInsert.RelatedQuery { + private class AccountNamePopulatorWithRelatedQuery implements BeforeInsert.Populator, BeforeInsert.ParentQuery, BeforeInsert.RelatedQuery { private BeforeInsert.RecordsProvider provider; public AccountNamePopulatorWithRelatedQuery(BeforeInsert.RecordsProvider provider) { this.provider = provider; } + public Map queryParentsOnBeforeInsert() { + return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; + } + public Map queryRelatedOnBeforeInsert() { return new Map{ 'accountNames' => this.provider }; } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Department, record.getRelated('accountNames').isEmpty() ? 'None' : 'Some'); + } } - private class FailingRelatedQueryPopulatorWithContinueOnError extends TitlePopulator implements BeforeInsert.RelatedQuery, BeforeInsert.ContinueOnError { + private class FailingRelatedQueryPopulatorWithContinueOnError implements BeforeInsert.Populator, BeforeInsert.RelatedQuery, BeforeInsert.ContinueOnError { public Map queryRelatedOnBeforeInsert() { throw new TestException('The related query failed.'); } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getRelated('accounts').isEmpty() ? 'CEO' : 'CFO'); + } } private class DmlRecordInsertWriter implements AfterInsert.Writer { diff --git a/force-app/main/default/classes/TriggerTypes.cls b/force-app/main/default/classes/TriggerTypes.cls index 181bff0..50b0091 100644 --- a/force-app/main/default/classes/TriggerTypes.cls +++ b/force-app/main/default/classes/TriggerTypes.cls @@ -605,6 +605,14 @@ public with sharing class TriggerTypes { Boolean isEmpty(); } + public interface ParentLoader { + void load(String relationshipName); + } + + public interface RelatedLoader { + void load(String providerName); + } + // Implementation private static RecordTypesInfo RecordTypesInfo = new RecordTypesInfo(); @@ -631,6 +639,9 @@ public with sharing class TriggerTypes { private Map relatedByProviderName = new Map(); + private ParentLoader parentLoader; + private RelatedLoader relatedLoader; + private SObjectType sObjectType; public TriggerRecord(SObject newRow, SObject oldRow) { @@ -929,14 +940,20 @@ public with sharing class TriggerTypes { } public SObject getNewParent(SObjectField lookupField) { - return this.newParents.get(lookupField.getDescribe().getRelationshipName()); + String relationshipName = lookupField.getDescribe().getRelationshipName(); + + this.parentLoader?.load(relationshipName); + + return this.newParents.get(relationshipName); } public SObject getOldParent(SObjectField lookupField) { - return this.oldParents.get(lookupField.getDescribe().getRelationshipName()); + return this.getOldParent(lookupField.getDescribe().getRelationshipName()); } public RelatedRecords getRelated(String providerName) { + this.relatedLoader?.load(providerName); + if (!this.relatedByProviderName.containsKey(providerName)) { throw new TriggerLibException('No related records provider named ' + providerName + ' is declared. Return it from the queryRelatedOn method of the handler first.'); } @@ -963,10 +980,14 @@ public with sharing class TriggerTypes { } public SObject getParent(String relationshipName) { + this.parentLoader?.load(relationshipName); + return this.newRecord == null ? this.oldParents.get(relationshipName) : this.newParents.get(relationshipName); } public SObject getOldParent(String relationshipName) { + this.parentLoader?.load(relationshipName); + return this.oldParents.get(relationshipName); } @@ -989,6 +1010,14 @@ public with sharing class TriggerTypes { public void setRelated(Map relatedByProviderName) { this.relatedByProviderName = relatedByProviderName; } + + public void setParentLoader(ParentLoader parentLoader) { + this.parentLoader = parentLoader; + } + + public void setRelatedLoader(RelatedLoader relatedLoader) { + this.relatedLoader = relatedLoader; + } } private without sharing class RecordTypesInfo { diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index f4103e4..7e978d3 100644 --- a/force-app/main/default/classes/TriggerTypesTest.cls +++ b/force-app/main/default/classes/TriggerTypesTest.cls @@ -3488,6 +3488,34 @@ private class TriggerTypesTest { Assert.areEqual(priorParentAccount, result, 'The enriched parent should be returned.'); } + @IsTest + static void getNewParentWithParentLoader() { + // Setup + TestParentLoader parentLoader = new TestParentLoader(); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New'), null); + record.setParentLoader(parentLoader); + + // Test + record.getNewParent(Contact.AccountId); + + // Verify + Assert.areEqual(new List{ 'Account' }, parentLoader.loadedRelationshipNames, 'The parent should be loaded before it is read.'); + } + + @IsTest + static void getOldParentWithParentLoader() { + // Setup + TestParentLoader parentLoader = new TestParentLoader(); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New'), new Contact(LastName = 'Old')); + record.setParentLoader(parentLoader); + + // Test + record.getOldParent(Contact.AccountId); + + // Verify + Assert.areEqual(new List{ 'Account' }, parentLoader.loadedRelationshipNames, 'The parent should be loaded before it is read.'); + } + @IsTest static void getOldParentWhenBothSidesAreEnriched() { // Setup @@ -4024,6 +4052,21 @@ private class TriggerTypesTest { Assert.areEqual(openOpportunities, result, 'The related records should be returned.'); } + @IsTest + static void getRelatedWithRelatedLoader() { + // Setup + TestRelatedLoader relatedLoader = new TestRelatedLoader(); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'New'), null); + record.setRelated(new Map{ 'openOpportunities' => new TriggerTypes.ProvidedRecords(new List()) }); + record.setRelatedLoader(relatedLoader); + + // Test + record.getRelated('openOpportunities'); + + // Verify + Assert.areEqual(new List{ 'openOpportunities' }, relatedLoader.loadedProviderNames, 'The related records should be loaded before they are read.'); + } + @IsTest static void getRelatedWhenNameIsUnknown() { // Setup @@ -4308,6 +4351,36 @@ private class TriggerTypesTest { Assert.areEqual(new Set{ previousOwnerId }, oldOwnerIds, 'The old parent Ids should be returned.'); } + @IsTest + static void updateRecordsGetValuesOfWithParentLoader() { + // Setup + TestParentLoader parentLoader = new TestParentLoader(); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'A'), new Contact(LastName = 'A')); + record.setParentLoader(parentLoader); + TriggerTypes.UpdateRecords records = new TriggerTypes.UpdateTriggerRecords(new List{ record }); + + // Test + records.getValuesOf('Account.Parent', Account.Name); + + // Verify + Assert.areEqual(new List{ 'Account' }, parentLoader.loadedRelationshipNames, 'The parent should be loaded before it is read.'); + } + + @IsTest + static void updateRecordsGetOldValuesOfWithParentLoader() { + // Setup + TestParentLoader parentLoader = new TestParentLoader(); + TriggerTypes.TriggerRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'A'), new Contact(LastName = 'A')); + record.setParentLoader(parentLoader); + TriggerTypes.UpdateRecords records = new TriggerTypes.UpdateTriggerRecords(new List{ record }); + + // Test + records.getOldValuesOf('Account', Account.Name); + + // Verify + Assert.areEqual(new List{ 'Account' }, parentLoader.loadedRelationshipNames, 'The parent should be loaded before it is read.'); + } + @IsTest static void deleteRecordsGetValuesOfWithRelationship() { // Setup @@ -4440,4 +4513,20 @@ private class TriggerTypesTest { // Verify Assert.isTrue(records[1].isChangedFromTo(Contact.LastName, 'Second Before', 'Second'), 'The new and old records should be paired by position.'); } + + private class TestRelatedLoader implements TriggerTypes.RelatedLoader { + public List loadedProviderNames = new List(); + + public void load(String providerName) { + this.loadedProviderNames.add(providerName); + } + } + + private class TestParentLoader implements TriggerTypes.ParentLoader { + public List loadedRelationshipNames = new List(); + + public void load(String relationshipName) { + this.loadedRelationshipNames.add(relationshipName); + } + } } diff --git a/website/.vitepress/context-facts.mjs b/website/.vitepress/context-facts.mjs index 4774a59..a816ffa 100644 --- a/website/.vitepress/context-facts.mjs +++ b/website/.vitepress/context-facts.mjs @@ -83,7 +83,7 @@ export const addOnFacts = { summary: 'Query children, siblings or other records once per handler per run; read them with `getRelated`.', called: - 'once per handler per run, at this handler’s turn, before its first predicate', + 'at most once per handler per run, at this handler’s first `getRelated`', returns: 'provider name → `RecordsProvider`' }, OwnUnitOfWork: { @@ -123,7 +123,7 @@ export const supportFacts = { RecordsProvider: { query: { called: - 'once per provider, at the handler’s turn, with every record in the chunk', + 'once per provider, at the handler’s first `getRelated` with its name, with every record in the chunk', returns: 'the rows to index by key; `null` counts as no rows' }, keyOf: { diff --git a/website/after-delete/add-ons/prior-parent-query.md b/website/after-delete/add-ons/prior-parent-query.md index 92a9430..331783d 100644 --- a/website/after-delete/add-ons/prior-parent-query.md +++ b/website/after-delete/add-ons/prior-parent-query.md @@ -26,7 +26,7 @@ Read fields of the parent the old row pointed to, such as a contact's former acc - **Read by lookup field.** Pass the lookup field you declared, such as `getOldParent(Contact.AccountId)`. For the Id alone, read the lookup from `getOldSObject()`. - **Read as it is now.** The parent is queried when the trigger runs, so its fields show current values. It is null when the lookup was empty or the parent no longer exists. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. -- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. +- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, when a handler first reads its parent. A lookup no handler reads costs nothing. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/after-delete/add-ons/related-query.md b/website/after-delete/add-ons/related-query.md index 83ac281..1bebbe8 100644 --- a/website/after-delete/add-ons/related-query.md +++ b/website/after-delete/add-ons/related-query.md @@ -30,7 +30,7 @@ Query children, siblings or other records once per handler per run, and read the ## Rules {#rules} - **Inbound lookups may be stale.** Records that looked up to a deleted row may still point at it during this trigger. Exclude them with `AND ReportsToId NOT IN :records.getIds()`. -- **Providers run even when nothing qualifies.** They run before the first predicate and cost their SOQL on every chunk. +- **Providers run on the first read.** A provider queries when the handler first calls `getRelated` with its name, at most once per chunk. A read in a predicate costs its SOQL even when nothing qualifies. - **Write once per parent.** Two deleted contacts of one account both see the same remaining contacts. Write to the account once from a [Finalizer](/after-delete/add-ons/finalizer). - **Set sharing on the provider.** Its SOQL runs under the provider class's own sharing keyword. An inner class does not inherit its outer class's keyword. diff --git a/website/after-insert/add-ons/parent-query.md b/website/after-insert/add-ons/parent-query.md index f48d1bb..0c177b3 100644 --- a/website/after-insert/add-ons/parent-query.md +++ b/website/after-insert/add-ons/parent-query.md @@ -30,7 +30,7 @@ Read fields of the record a lookup points to, such as a new contact's account, w - **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per chunk.** One SOQL query on the saved records loads every declared parent, even when no record qualifies. +- **One query per chunk.** The first parent a handler reads runs one SOQL query on the saved records, which loads every declared parent. A chunk that reads no parent costs nothing. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/after-insert/add-ons/related-query.md b/website/after-insert/add-ons/related-query.md index a2a6c26..8109f09 100644 --- a/website/after-insert/add-ons/related-query.md +++ b/website/after-insert/add-ons/related-query.md @@ -30,7 +30,7 @@ Query children, siblings or other records once per handler per run, and read the ## Rules {#rules} - **SOQL sees the new records.** They are saved but not committed. Add `Id NOT IN :records.getIds()` to leave them out. -- **Providers query even when no record qualifies.** Return an empty list from `query` when no record can qualify. +- **Providers query on the first read.** A provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when no record qualifies, so return an empty list from `query` when no record can qualify. - **Keys are case-sensitive.** Normalize text keys the same way in `keyOf` and when you read. - **Unknown names throw.** `getRelated` with a name the handler did not return throws `TriggerTypes.TriggerLibException`, even with ContinueOnError. diff --git a/website/after-undelete/add-ons/parent-query.md b/website/after-undelete/add-ons/parent-query.md index c9e9958..b933e5e 100644 --- a/website/after-undelete/add-ons/parent-query.md +++ b/website/after-undelete/add-ons/parent-query.md @@ -26,7 +26,7 @@ Read fields of the record a lookup points to, such as a restored contact's accou - **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Contact.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Declare every field you read.** Reading a field that no handler declared throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per chunk.** One SOQL query on the restored records reads every declared parent, even when no record qualifies. +- **One query per chunk.** The first parent a handler reads runs one SOQL query on the restored records, which loads every declared parent. A chunk that reads no parent costs nothing. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/after-undelete/add-ons/related-query.md b/website/after-undelete/add-ons/related-query.md index 240b05e..1d6face 100644 --- a/website/after-undelete/add-ons/related-query.md +++ b/website/after-undelete/add-ons/related-query.md @@ -29,7 +29,7 @@ Query children, siblings or other records once per handler per run, and read the ## Rules {#rules} -- **SOQL runs even when no record qualifies.** Return an empty list when no record can qualify. +- **SOQL runs on the first read.** A provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when no record qualifies, so return an empty list when no record can qualify. - **SOQL sees the restored records.** Add `Id NOT IN :records.getIds()` to look only at other records. - **Exact keys.** Keys are compared as text, case included. Normalize text keys the same way on both sides. - **Unknown names throw.** `record.getRelated('')` with a name the handler did not return throws `TriggerTypes.TriggerLibException`, even with ContinueOnError. diff --git a/website/after-update/add-ons/parent-query.md b/website/after-update/add-ons/parent-query.md index 10a36f3..55afa99 100644 --- a/website/after-update/add-ons/parent-query.md +++ b/website/after-update/add-ons/parent-query.md @@ -30,7 +30,7 @@ Read fields of the record a lookup points to, such as an opportunity's account, - **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Opportunity.AccountId)` or `getNewParent(Opportunity.OwnerId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Declare every field you read.** The parent holds only the fields the active handlers declared. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per chunk.** One query on the trigger records loads every declared parent, even when no record qualifies. +- **One query per chunk.** The first parent a handler reads runs one query on the trigger records, which loads every declared parent. A chunk that reads no parent costs nothing. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/after-update/add-ons/prior-parent-query.md b/website/after-update/add-ons/prior-parent-query.md index a07ee7d..a988865 100644 --- a/website/after-update/add-ons/prior-parent-query.md +++ b/website/after-update/add-ons/prior-parent-query.md @@ -28,7 +28,7 @@ Read fields of the parent the old row pointed to, such as the previous owner, wi - **Read with `getOldParent`.** Pass the lookup field you declared, such as `getOldParent(Account.OwnerId)`. It is null when the old lookup was empty or the parent no longer exists. - **Each side needs its own declaration.** `getOldParent` needs PriorParentQuery and `getNewParent` needs [ParentQuery](/after-update/add-ons/parent-query), even when the lookup did not change. Implement both to compare the two parents. - **Current values.** The previous parent is queried now, so its fields show today's values. -- **At most one query per lookup per chunk.** It runs even when no record qualifies. +- **At most one query per lookup per chunk.** It runs when a handler first reads that lookup's parent. A lookup no handler reads costs nothing. ::: warning Parents are read in system mode without sharing. diff --git a/website/after-update/add-ons/related-query.md b/website/after-update/add-ons/related-query.md index cd58598..eaf1bc3 100644 --- a/website/after-update/add-ons/related-query.md +++ b/website/after-update/add-ons/related-query.md @@ -35,7 +35,7 @@ Query children, siblings or other records once per handler per run, and read the - **Read your own providers by name.** Call `record.getRelated('contacts').getAllWhereKeyEquals(record.getId())`. An unknown name throws `TriggerTypes.TriggerLibException`, even with ContinueOnError. - **Keys match exactly, case included.** Normalize text keys the same way in `keyOf` and in the lookup. -- **Providers run even when nothing qualifies.** Return an empty list from `query` when no record can qualify. +- **Providers run on the first read.** A provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when nothing qualifies, so return an empty list from `query` when no record can qualify. - **Both rows.** `records.getIdsOf(…)` reads the new rows and `records.getOldIdsOf(…)` the old ones. SOQL sees the saved new values. Add `Id NOT IN :records.getIds()` to leave out the trigger records. ::: warning diff --git a/website/api/parent-fields.md b/website/api/parent-fields.md index 351917d..2583f63 100644 --- a/website/api/parent-fields.md +++ b/website/api/parent-fields.md @@ -40,7 +40,7 @@ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name).with('Owner', - **The declared fields and the `Id`.** Reading any other field throws an `SObjectException`. - **Declare what you read.** Declarations of all handlers are merged, so a handler may see another handler's fields. If that handler is switched off, the field is gone. Declare every field in the handler that reads it. -- **Every record, qualified or not.** Parents load before any predicate runs, so the query costs SOQL even when nothing qualifies. +- **Every record, qualified or not.** The first read of a lookup's parent loads the parents of every record in the chunk. A lookup whose parent no handler reads costs no SOQL. - **Once per chunk.** The parent queries run per chunk, never per record. - **No sharing.** Parents are read in system mode, so a handler can see records and fields the user cannot. - **Polymorphic lookups are limited.** For `WhatId`, `WhoId` or an `OwnerId` that can hold a queue, some parents come back null. In after insert, after update and after undelete, a field outside the `Name` object makes the query fail. diff --git a/website/api/record-collections.md b/website/api/record-collections.md index de68c13..1b0c9bd 100644 --- a/website/api/record-collections.md +++ b/website/api/record-collections.md @@ -68,7 +68,7 @@ public interface UpdateRecords { - **They read the new row.** In the delete contexts they read the old row. - **No nulls, no duplicates.** Each call returns a new set. -- **Parents by relationship name.** Pass the relationship name of the declared lookup, such as `'Account'` for `Contact.AccountId`. A record without a loaded parent adds nothing. +- **Parents by relationship name.** Pass the relationship name of the declared lookup, such as `'Account'` for `Contact.AccountId` or `'Region__r'` for `Invoice__c.Region__c`. The name is case-sensitive. A record without a loaded parent adds nothing. - **Ids only from Id fields.** `getIdsOf` throws on a field that does not hold Ids, such as `Contact.Email`. Use `getValuesOf` there. - **Do not change `getRecords()`.** It is the library's own list, not a copy. Call `.clone()` before you add or remove elements. diff --git a/website/api/related-records.md b/website/api/related-records.md index 4056700..4f14c54 100644 --- a/website/api/related-records.md +++ b/website/api/related-records.md @@ -14,10 +14,10 @@ Each context declares its own `RecordsProvider`, so `query` gets that context's -- **Runs before the first predicate.** Two handlers that use the same provider class query twice. +- **Runs on the first read.** `query` runs when the handler first calls `getRelated` with the provider's name. Two handlers that use the same provider class query twice. - **Any query you like.** Use inline SOQL, SOQL Lib or a selector. - **Your sharing.** The query runs under the provider class's own sharing keyword. Declare one on every provider class. -- **Parents are loaded already.** In `query`, `records.getIdsOf('Account', Account.OwnerId)` works. +- **Parents load on the first read.** In `query`, `records.getIdsOf('Account', Account.OwnerId)` works. - **Normalize text keys.** Keys are compared exactly, case included. Lowercase them in `keyOf` and when you read. ## RelatedRecords {#relatedrecords} diff --git a/website/before-delete/add-ons/continue-on-error.md b/website/before-delete/add-ons/continue-on-error.md index eeef88a..3c45640 100644 --- a/website/before-delete/add-ons/continue-on-error.md +++ b/website/before-delete/add-ons/continue-on-error.md @@ -23,7 +23,7 @@ Log and swallow the handler's exceptions, so later handlers still run and the de ## Rules {#rules} -- **The rest of the handler is skipped.** After an exception, this handler's remaining records and its Finalizer are skipped. An exception in a provider skips every record. +- **The rest of the handler is skipped.** After an exception, this handler's remaining records and its Finalizer are skipped. An exception in a provider stops the handler where the provider is first read. - **A Writer gets the automatic unit of work.** Unless it implements OwnUnitOfWork, its writes commit right after it, so a failure never touches other Writers' writes. - **Some errors still fail the delete.** [Library exceptions](/api/trigger-orchestrator#triggerlibexception), `System.LimitException` and exceptions outside the handler's own methods, such as `bypassOnBeforeDeleteWhen()`, `queryPriorParentsOnBeforeDelete()` or the final commit, are never swallowed. - **Add a [Logger](/guide/error-handling#logger).** Without one, a swallowed exception leaves no trace. diff --git a/website/before-delete/add-ons/prior-parent-query.md b/website/before-delete/add-ons/prior-parent-query.md index 2e8fdab..1c62b45 100644 --- a/website/before-delete/add-ons/prior-parent-query.md +++ b/website/before-delete/add-ons/prior-parent-query.md @@ -26,7 +26,7 @@ Read fields of the parent the old row pointed to, such as a deleted contact's ac - **Read by lookup field.** Pass the lookup field you declared, such as `getOldParent(Contact.AccountId)` or `getOldParent(Account.ParentId)`. - **Check for null.** The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. +- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, when a handler first reads its parent. A lookup no handler reads costs nothing. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/before-delete/add-ons/related-query.md b/website/before-delete/add-ons/related-query.md index 4cb848a..45fab7f 100644 --- a/website/before-delete/add-ons/related-query.md +++ b/website/before-delete/add-ons/related-query.md @@ -32,7 +32,7 @@ Query children, siblings or other records once per handler per run, and read the - **An unknown name throws.** `getRelated` throws `TriggerTypes.TriggerLibException` when no provider has that name. - **Counts include the records being deleted.** A query here still returns them. To count what will remain, add `Id NOT IN :records.getIds()`. - **Last chance for cascade children.** Children removed by a cascade delete run no delete triggers of their own. Read them here. -- **Runs even when nothing qualifies.** Return an empty list from `query` when no record can qualify. +- **Runs on the first read.** A provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when nothing qualifies, so return an empty list from `query` when no record can qualify. ::: warning Sharing can hide rows. A `with sharing` provider misses rows the user cannot see, so a guard can let a delete through. Use `without sharing` when it must see every row. diff --git a/website/before-insert/add-ons/continue-on-error.md b/website/before-insert/add-ons/continue-on-error.md index 8afe186..2b82f69 100644 --- a/website/before-insert/add-ons/continue-on-error.md +++ b/website/before-insert/add-ons/continue-on-error.md @@ -28,5 +28,5 @@ Log and swallow the handler's exceptions, so later handlers still run and the in - **Add a [Logger](/guide/error-handling#logger).** Without one, a swallowed exception leaves no trace. ::: warning -Some exceptions still fail the insert: DML in the handler, a Validator that attaches no error, `TriggerTypes.TriggerLibException`, `System.LimitException`, and exceptions in `bypassOnBeforeInsertWhen()` or while parents load. +Some exceptions still fail the insert: DML in the handler, a Validator that attaches no error, `TriggerTypes.TriggerLibException`, `System.LimitException`, and exceptions in `bypassOnBeforeInsertWhen()`, `queryParentsOnBeforeInsert()` or the parent query after a Populator. ::: diff --git a/website/before-insert/add-ons/parent-query.md b/website/before-insert/add-ons/parent-query.md index deb47f5..e2aa819 100644 --- a/website/before-insert/add-ons/parent-query.md +++ b/website/before-insert/add-ons/parent-query.md @@ -29,7 +29,7 @@ Read fields of the record a lookup points to, such as a contact's account, witho - **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.ParentId)`. The parent is null when the lookup is empty or no record has that Id. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, even when no record qualifies. A Populator that changes it can add one. +- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, when a handler first reads its parent. A lookup no handler reads costs nothing. A Populator that changes it after that read can add one. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/before-insert/add-ons/related-query.md b/website/before-insert/add-ons/related-query.md index 721d6cc..24edf91 100644 --- a/website/before-insert/add-ons/related-query.md +++ b/website/before-insert/add-ons/related-query.md @@ -60,7 +60,7 @@ public with sharing class ContactDuplicateEmailValidator implements BeforeInsert - **No Ids yet.** `records.getIds()` is empty. Filter by field values with `records.getValuesOf(…)` or by lookups with `records.getIdsOf(…)`. - **SOQL cannot see this save.** The new records are not in the database yet. Compare them with each other in a Populator's [Finalizer](/before-insert/add-ons/finalizer). - **Keys match exactly, case included.** Normalize text keys the same way in `keyOf` and in the lookup, as `[Duplicate email]` does with `toLowerCase()`. -- **Providers query even when nothing qualifies.** Return an empty list from `query` when no record can qualify. +- **Providers query on the first read.** A provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when nothing qualifies, so return an empty list from `query` when no record can qualify. ::: warning Each provider has its own sharing. Declare a keyword on every provider class; an inner class does not inherit it. A `with sharing` duplicate check misses records the user cannot see. diff --git a/website/before-update/add-ons/parent-query.md b/website/before-update/add-ons/parent-query.md index 9c6a5b2..394dad8 100644 --- a/website/before-update/add-ons/parent-query.md +++ b/website/before-update/add-ons/parent-query.md @@ -27,10 +27,10 @@ Read fields of the record a lookup points to, such as a contact's current accoun ## Rules {#rules} -- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Account.ParentId)`. The parent is null when the lookup is empty or no record has that Id. -- **Later handlers see the new parent.** When a Populator changes the lookup, handlers after it get the new parent. A parent not loaded yet costs one more query. +- **Read by lookup field.** Pass the lookup field you declared, such as `getNewParent(Contact.AccountId)` or `getNewParent(Opportunity.AccountId)`. The parent is null when the lookup is empty or no record has that Id. +- **Later handlers see the new parent.** When a Populator changes the lookup, handlers after it get the new parent. If the lookup was read before, a parent not loaded yet costs one more query. - **Only declared fields.** The parent holds the declared fields and its `Id`. Reading any other field throws an `SObjectException`. Add grandparent fields with `.with('Owner', User.IsActive)`. -- **One query per lookup.** Each declared lookup costs at most one SOQL query before the first handler, even when no record qualifies. A [PriorParentQuery](/before-update/add-ons/prior-parent-query) on the same lookup shares that query. +- **One query per lookup.** Each declared lookup costs at most one SOQL query per chunk, when a handler first reads its parent. A lookup no handler reads costs nothing. A [PriorParentQuery](/before-update/add-ons/prior-parent-query) on the same lookup shares that query. ::: warning Parents are read in system mode without sharing, so a handler can see records the user cannot. diff --git a/website/before-update/add-ons/related-query.md b/website/before-update/add-ons/related-query.md index eebaab9..e3b7ba4 100644 --- a/website/before-update/add-ons/related-query.md +++ b/website/before-update/add-ons/related-query.md @@ -32,7 +32,7 @@ Query children, siblings or other records once per handler per run, and read the - **SOQL sees the saved values.** A query on the records being updated returns their values from before this update. Two records in one chunk that change to the same email do not find each other; compare them in a [Finalizer](/before-update/add-ons/finalizer). - **Exclude the records themselves.** To look at other records of the same object, add `Id NOT IN :records.getIds()`. - **Keys match exactly.** Key lookups are case-sensitive. Normalize text keys the same way in `keyOf` and in the lookup. -- **Runs on every pass.** Providers query for every chunk and every nested update, even when no record qualifies. +- **Runs on every pass that reads it.** In each chunk and nested update, a provider queries when the handler first calls `getRelated` with its name. A read in a predicate queries even when no record qualifies. ::: warning The provider sets the sharing. Its SOQL runs under the provider class's own sharing keyword. Use `without sharing` when a check must see every record. diff --git a/website/guide/error-handling.md b/website/guide/error-handling.md index 61e8b8f..e498caf 100644 --- a/website/guide/error-handling.md +++ b/website/guide/error-handling.md @@ -66,7 +66,7 @@ This code runs outside every handler. An exception there is not logged, Continue - `Handlers()`; - `bypassOnWhen()`, `ownUnitOfWorkOn()` and `maxRunsPerRecordOn()`; -- the ParentQuery and PriorParentQuery methods and their parent queries; +- the ParentQuery and PriorParentQuery methods, and the parent query after a Populator changes a lookup; - the shared unit of work's commit. ## Fail One Record, Not the Whole Save {#one-record} diff --git a/website/guide/execution-order.md b/website/guide/execution-order.md index 8a00b2b..6e68190 100644 --- a/website/guide/execution-order.md +++ b/website/guide/execution-order.md @@ -15,9 +15,9 @@ Each run calls `Handlers()`, loads parents, runs providers and commits agai 1. **Run bypasses.** `TriggerOrchestrator.bypass()` (`all()`, `sObject(…)`, `orchestrator(…)`) or a checked `TriggerObject__mdt.Bypass__c` ends the run before any of your code. So does an orchestrator that does not implement the context. 2. **Handler list.** `Handlers()` runs. A class that implements two roles runs only as the first: Populator over Validator, Validator over Writer, Writer over Dispatcher. 3. **Handler bypasses.** `bypass().handler(…)`, then `TriggerHandler__mdt.Bypass__c`, then `bypassOnWhen()`. A skipped handler declares no parents. -4. **Parents load.** One load for all remaining handlers. +4. **Parent declarations.** The remaining handlers declare their parents. A lookup is queried the first time a handler reads its parent. 5. **Each handler runs, in list order.** - - Its RelatedQuery providers run over all records. + - Each RelatedQuery provider runs over all records the first time the handler reads it. - Per record, the predicate decides and the action runs. A Dispatcher collects the records and dispatches once. - The Finalizer runs when at least one record qualified. - A Writer with OwnUnitOfWork or ContinueOnError commits its own unit of work. @@ -32,13 +32,14 @@ SOQL queries a run spends outside your code: |---|---| | Bypass metadata | 0, once per transaction | | Logger discovery | 1, once per transaction | -| Parents in before contexts and after delete | 1 per declared lookup | -| Parents in after insert, update and undelete | 1 on the trigger object, plus 1 per lookup it missed and, in after update, per PriorParentQuery lookup | -| Parents after a Populator | 1 per lookup re-pointed to a parent not loaded yet | -| RelatedQuery providers | what each `query` runs, per handler | +| Parents in before contexts and after delete | 1 per lookup read | +| Parents in after insert, update and undelete | 1 on the trigger object at the first ParentQuery read, plus 1 per lookup read that it missed and, in after update, per PriorParentQuery lookup read | +| Parents after a Populator | 1 per lookup already read and re-pointed to a parent not loaded yet | +| RelatedQuery providers | what each `query` runs, per handler that reads it | - **Declarations merge.** Handlers that declare the same lookup share one query. -- **Parents and providers run before the first predicate,** even when no record qualifies. +- **Parents load on the first read.** A declared lookup whose parent no handler reads costs no query. +- **Providers run on the first read.** A provider its handler never reads costs no query. A read in a predicate runs it even when no record qualifies. - **Never query in a predicate or an action.** They run once per record. Declare a ParentQuery or a RelatedQuery, or query once in a Finalizer. ## DML Cost {#dml-cost} diff --git a/website/guide/related-query.md b/website/guide/related-query.md index 978273d..b456f00 100644 --- a/website/guide/related-query.md +++ b/website/guide/related-query.md @@ -126,7 +126,7 @@ public String keyOf(SObject row) { ### A Declared Parent's Field {#parent-field} -Parents load before providers run. Declare `Account.OwnerId` with a ParentQuery, then collect it by the relationship name: +Parents load on the first read, also inside `query`. Declare `Account.OwnerId` with a ParentQuery, then collect it by the relationship name: ```apex public List query(TriggerTypes.InsertRecords records) { diff --git a/website/guide/testing.md b/website/guide/testing.md index 9da62bc..946ab78 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -14,7 +14,7 @@ Test a handler without DML: build records in memory, call its methods and assert | `new TriggerTypes.InsertTriggerRecords(records)`, and the `Update`, `Delete` and `Undelete` variants | the collection a Finalizer, dispatch or `query` receives | | `new TriggerTypes.ProvidedRecords(rows)` with `groupUnderKey(key, row)` | provider results | | `new TriggerTypes.RandomIdGenerator().get(SObjectType)` | a fake Id | -| `record.setNewParent(relationshipName, parent)`, `record.setOldParent(…)` | what `getNewParent` and `getOldParent` return | +| `record.setNewParent(relationshipName, parent)`, `record.setOldParent(…)` | what `getNewParent` and `getOldParent` return; key it by the lookup's relationship name, such as `'Account'` for `Contact.AccountId` | | `record.setRelated(providers)` | what `getRelated` returns | `setNewParent`, `setOldParent` and `setRelated` are marked internal use only and may change. diff --git a/website/introduction/design-principles.md b/website/introduction/design-principles.md index 882ce80..d30bbeb 100644 --- a/website/introduction/design-principles.md +++ b/website/introduction/design-principles.md @@ -18,7 +18,7 @@ Handlers run in list order. Each predicate runs at its handler's turn, so it see ## No Loops, No SOQL per Record {#no-loops} -The library loops over the records. Handlers declare the parents and related records they need, and the library loads them before the handler runs. +The library loops over the records. Handlers declare the parents and related records they need, and the library loads them in bulk, never per record. ## Before Contexts Populate, After Contexts Act {#before-populates-after-acts} From 2a5aed87e3d3022d0a8c5ca4c2f3ee7d6c7bdc02 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Fri, 25 Sep 2026 11:16:09 +0200 Subject: [PATCH 5/5] Refactoring --- .../main/default/classes/AfterDelete.cls | 227 ++ .../main/default/classes/AfterInsert.cls | 227 ++ .../main/default/classes/AfterUndelete.cls | 227 ++ .../main/default/classes/AfterUpdate.cls | 253 ++ .../main/default/classes/BeforeDelete.cls | 226 ++ .../main/default/classes/BeforeInsert.cls | 235 ++ .../main/default/classes/BeforeUpdate.cls | 258 +++ .../default/classes/TriggerOrchestrator.cls | 2028 ++--------------- website/.vitepress/apex-api.mjs | 20 +- 9 files changed, 1856 insertions(+), 1845 deletions(-) diff --git a/force-app/main/default/classes/AfterDelete.cls b/force-app/main/default/classes/AfterDelete.cls index 1a2a807..f707922 100644 --- a/force-app/main/default/classes/AfterDelete.cls +++ b/force-app/main/default/classes/AfterDelete.cls @@ -45,4 +45,231 @@ public class AfterDelete { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (AfterDelete.Handler handler : ((TriggerOrchestrator.AfterDelete) orchestrator.orchestrator).afterDeleteHandlers()) { + if (handler instanceof AfterDelete.Writer) { + adapters.add(new AfterDelete.WriterAdapter((AfterDelete.Writer) handler, orchestrator)); + } else if (handler instanceof AfterDelete.Dispatcher) { + adapters.add(new AfterDelete.DispatcherAdapter((AfterDelete.Dispatcher) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class WriterAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterDelete.Writer writer; + private TriggerOrchestrator.UnitOfWork unitOfWork; + + public WriterAdapter(AfterDelete.Writer writer, TriggerOrchestrator orchestrator) { + super(writer, orchestrator); + + this.writer = writer; + this.parentDeclaration = new AfterDelete.ParentDeclaration(this.writer); + this.relatedLoader = new AfterDelete.RelatedLoader(this.writer); + this.unitOfWork = new AfterDelete.UnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new AfterDelete.HandlerLifecycle(this.writer); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.write(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + this.commitOwnUnitOfWork(); + } catch (Exception e) { + this.handleException(e); + } + } + + private void write(TriggerTypes.TriggerRecord record) { + if (!this.writer.writeOnAfterDeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + this.writer.writeOnAfterDelete(record, this.unitOfWork); + } + + private void commitOwnUnitOfWork() { + if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { + return; + } + + this.unitOfWork.commitWork(); + } + } + + public class DispatcherAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterDelete.Dispatcher dispatcher; + + public DispatcherAdapter(AfterDelete.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { + super(dispatcher, orchestrator); + + this.dispatcher = dispatcher; + this.parentDeclaration = new AfterDelete.ParentDeclaration(this.dispatcher); + this.relatedLoader = new AfterDelete.RelatedLoader(this.dispatcher); + this.handlerLifecycle = new AfterDelete.HandlerLifecycle(this.dispatcher); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.qualify(record); + } + + this.dispatch(); + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + } + + private void qualify(TriggerTypes.TriggerRecord record) { + if (!this.dispatcher.dispatchOnAfterDeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + } + + private void dispatch() { + if (this.qualifiedRecords.isEmpty()) { + return; + } + + this.dispatcher.dispatchOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private AfterDelete.Handler handler; + + public ParentDeclaration(AfterDelete.Handler handler) { + this.handler = handler; + } + + public override Map getOldParentFields() { + if (this.handler instanceof AfterDelete.PriorParentQuery) { + return ((AfterDelete.PriorParentQuery) this.handler).queryPriorParentsOnAfterDelete(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private AfterDelete.Handler handler; + private Map providers; + + public RelatedLoader(AfterDelete.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof AfterDelete.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((AfterDelete.RelatedQuery) this.handler).queryRelatedOnAfterDelete() ?? new Map(); + } + + AfterDelete.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class UnitOfWorkResolver extends TriggerOrchestrator.UnitOfWorkResolver { + private AfterDelete.Writer writer; + + public UnitOfWorkResolver(AfterDelete.Writer writer) { + this.writer = writer; + } + + public override TriggerOrchestrator.UnitOfWork resolve(TriggerOrchestrator.UnitOfWork sharedUnitOfWork) { + return this.writer instanceof AfterDelete.OwnUnitOfWork + ? new TriggerOrchestrator.UnitOfWork(((AfterDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterDelete()) + : this.writer instanceof AfterDelete.ContinueOnError ? new TriggerOrchestrator.UnitOfWork() : sharedUnitOfWork; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private AfterDelete.Handler handler; + + public HandlerLifecycle(AfterDelete.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterDelete.Bypassable && ((AfterDelete.Bypassable) this.handler).bypassOnAfterDeleteWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterDelete.Finalizer)) { + return; + } + + ((AfterDelete.Finalizer) this.handler).finalizeOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof AfterDelete.ContinueOnError; + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterDeleteMockable, TriggerOrchestrator.AfterDelete { + public MockedContext() { + super(System.TriggerOperation.AFTER_DELETE); + } + + public TriggerOrchestrator.AfterDeleteMockable with(SObject row) { + return this.with(new List{ row }); + } + + public TriggerOrchestrator.AfterDeleteMockable with(List rows) { + this.oldRecords.addAll(this.assignIds(rows)); + return this; + } + + public TriggerOrchestrator.AfterDeleteMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.AfterDeleteMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List afterDeleteHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterDelete + ? ((TriggerOrchestrator.AfterDelete) this.handlerOrOrchestrator).afterDeleteHandlers() + : new List{ (AfterDelete.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/AfterInsert.cls b/force-app/main/default/classes/AfterInsert.cls index d5d0b7d..d53590a 100644 --- a/force-app/main/default/classes/AfterInsert.cls +++ b/force-app/main/default/classes/AfterInsert.cls @@ -45,4 +45,231 @@ public class AfterInsert { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (AfterInsert.Handler handler : ((TriggerOrchestrator.AfterInsert) orchestrator.orchestrator).afterInsertHandlers()) { + if (handler instanceof AfterInsert.Writer) { + adapters.add(new AfterInsert.WriterAdapter((AfterInsert.Writer) handler, orchestrator)); + } else if (handler instanceof AfterInsert.Dispatcher) { + adapters.add(new AfterInsert.DispatcherAdapter((AfterInsert.Dispatcher) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class WriterAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterInsert.Writer writer; + private TriggerOrchestrator.UnitOfWork unitOfWork; + + public WriterAdapter(AfterInsert.Writer writer, TriggerOrchestrator orchestrator) { + super(writer, orchestrator); + + this.writer = writer; + this.parentDeclaration = new AfterInsert.ParentDeclaration(this.writer); + this.relatedLoader = new AfterInsert.RelatedLoader(this.writer); + this.unitOfWork = new AfterInsert.UnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new AfterInsert.HandlerLifecycle(this.writer); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.write(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + this.commitOwnUnitOfWork(); + } catch (Exception e) { + this.handleException(e); + } + } + + private void write(TriggerTypes.TriggerRecord record) { + if (!this.writer.writeOnAfterInsertWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + this.writer.writeOnAfterInsert(record, this.unitOfWork); + } + + private void commitOwnUnitOfWork() { + if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { + return; + } + + this.unitOfWork.commitWork(); + } + } + + public class DispatcherAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterInsert.Dispatcher dispatcher; + + public DispatcherAdapter(AfterInsert.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { + super(dispatcher, orchestrator); + + this.dispatcher = dispatcher; + this.parentDeclaration = new AfterInsert.ParentDeclaration(this.dispatcher); + this.relatedLoader = new AfterInsert.RelatedLoader(this.dispatcher); + this.handlerLifecycle = new AfterInsert.HandlerLifecycle(this.dispatcher); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.qualify(record); + } + + this.dispatch(); + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + } + + private void qualify(TriggerTypes.TriggerRecord record) { + if (!this.dispatcher.dispatchOnAfterInsertWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + } + + private void dispatch() { + if (this.qualifiedRecords.isEmpty()) { + return; + } + + this.dispatcher.dispatchOnAfterInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private AfterInsert.Handler handler; + + public ParentDeclaration(AfterInsert.Handler handler) { + this.handler = handler; + } + + public override Map getNewParentFields() { + if (this.handler instanceof AfterInsert.ParentQuery) { + return ((AfterInsert.ParentQuery) this.handler).queryParentsOnAfterInsert(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private AfterInsert.Handler handler; + private Map providers; + + public RelatedLoader(AfterInsert.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof AfterInsert.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? new Map(); + } + + AfterInsert.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class UnitOfWorkResolver extends TriggerOrchestrator.UnitOfWorkResolver { + private AfterInsert.Writer writer; + + public UnitOfWorkResolver(AfterInsert.Writer writer) { + this.writer = writer; + } + + public override TriggerOrchestrator.UnitOfWork resolve(TriggerOrchestrator.UnitOfWork sharedUnitOfWork) { + return this.writer instanceof AfterInsert.OwnUnitOfWork + ? new TriggerOrchestrator.UnitOfWork(((AfterInsert.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterInsert()) + : this.writer instanceof AfterInsert.ContinueOnError ? new TriggerOrchestrator.UnitOfWork() : sharedUnitOfWork; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private AfterInsert.Handler handler; + + public HandlerLifecycle(AfterInsert.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterInsert.Bypassable && ((AfterInsert.Bypassable) this.handler).bypassOnAfterInsertWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterInsert.Finalizer)) { + return; + } + + ((AfterInsert.Finalizer) this.handler).finalizeOnAfterInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof AfterInsert.ContinueOnError; + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterInsertMockable, TriggerOrchestrator.AfterInsert { + public MockedContext() { + super(System.TriggerOperation.AFTER_INSERT); + } + + public TriggerOrchestrator.AfterInsertMockable with(SObject row) { + return this.with(new List{ row }); + } + + public TriggerOrchestrator.AfterInsertMockable with(List rows) { + this.newRecords.addAll(this.assignIds(rows)); + return this; + } + + public TriggerOrchestrator.AfterInsertMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.AfterInsertMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List afterInsertHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterInsert + ? ((TriggerOrchestrator.AfterInsert) this.handlerOrOrchestrator).afterInsertHandlers() + : new List{ (AfterInsert.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/AfterUndelete.cls b/force-app/main/default/classes/AfterUndelete.cls index cdee615..003b2be 100644 --- a/force-app/main/default/classes/AfterUndelete.cls +++ b/force-app/main/default/classes/AfterUndelete.cls @@ -45,4 +45,231 @@ public class AfterUndelete { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (AfterUndelete.Handler handler : ((TriggerOrchestrator.AfterUndelete) orchestrator.orchestrator).afterUndeleteHandlers()) { + if (handler instanceof AfterUndelete.Writer) { + adapters.add(new AfterUndelete.WriterAdapter((AfterUndelete.Writer) handler, orchestrator)); + } else if (handler instanceof AfterUndelete.Dispatcher) { + adapters.add(new AfterUndelete.DispatcherAdapter((AfterUndelete.Dispatcher) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class WriterAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterUndelete.Writer writer; + private TriggerOrchestrator.UnitOfWork unitOfWork; + + public WriterAdapter(AfterUndelete.Writer writer, TriggerOrchestrator orchestrator) { + super(writer, orchestrator); + + this.writer = writer; + this.parentDeclaration = new AfterUndelete.ParentDeclaration(this.writer); + this.relatedLoader = new AfterUndelete.RelatedLoader(this.writer); + this.unitOfWork = new AfterUndelete.UnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new AfterUndelete.HandlerLifecycle(this.writer); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.write(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + this.commitOwnUnitOfWork(); + } catch (Exception e) { + this.handleException(e); + } + } + + private void write(TriggerTypes.TriggerRecord record) { + if (!this.writer.writeOnAfterUndeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + this.writer.writeOnAfterUndelete(record, this.unitOfWork); + } + + private void commitOwnUnitOfWork() { + if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { + return; + } + + this.unitOfWork.commitWork(); + } + } + + public class DispatcherAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterUndelete.Dispatcher dispatcher; + + public DispatcherAdapter(AfterUndelete.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { + super(dispatcher, orchestrator); + + this.dispatcher = dispatcher; + this.parentDeclaration = new AfterUndelete.ParentDeclaration(this.dispatcher); + this.relatedLoader = new AfterUndelete.RelatedLoader(this.dispatcher); + this.handlerLifecycle = new AfterUndelete.HandlerLifecycle(this.dispatcher); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.qualify(record); + } + + this.dispatch(); + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + } + + private void qualify(TriggerTypes.TriggerRecord record) { + if (!this.dispatcher.dispatchOnAfterUndeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + } + + private void dispatch() { + if (this.qualifiedRecords.isEmpty()) { + return; + } + + this.dispatcher.dispatchOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(this.qualifiedRecords)); + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private AfterUndelete.Handler handler; + + public ParentDeclaration(AfterUndelete.Handler handler) { + this.handler = handler; + } + + public override Map getNewParentFields() { + if (this.handler instanceof AfterUndelete.ParentQuery) { + return ((AfterUndelete.ParentQuery) this.handler).queryParentsOnAfterUndelete(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private AfterUndelete.Handler handler; + private Map providers; + + public RelatedLoader(AfterUndelete.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof AfterUndelete.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((AfterUndelete.RelatedQuery) this.handler).queryRelatedOnAfterUndelete() ?? new Map(); + } + + AfterUndelete.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.UndeleteRecords undeleteRecords = new TriggerTypes.UndeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(undeleteRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class UnitOfWorkResolver extends TriggerOrchestrator.UnitOfWorkResolver { + private AfterUndelete.Writer writer; + + public UnitOfWorkResolver(AfterUndelete.Writer writer) { + this.writer = writer; + } + + public override TriggerOrchestrator.UnitOfWork resolve(TriggerOrchestrator.UnitOfWork sharedUnitOfWork) { + return this.writer instanceof AfterUndelete.OwnUnitOfWork + ? new TriggerOrchestrator.UnitOfWork(((AfterUndelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterUndelete()) + : this.writer instanceof AfterUndelete.ContinueOnError ? new TriggerOrchestrator.UnitOfWork() : sharedUnitOfWork; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private AfterUndelete.Handler handler; + + public HandlerLifecycle(AfterUndelete.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterUndelete.Bypassable && ((AfterUndelete.Bypassable) this.handler).bypassOnAfterUndeleteWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUndelete.Finalizer)) { + return; + } + + ((AfterUndelete.Finalizer) this.handler).finalizeOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof AfterUndelete.ContinueOnError; + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterUndeleteMockable, TriggerOrchestrator.AfterUndelete { + public MockedContext() { + super(System.TriggerOperation.AFTER_UNDELETE); + } + + public TriggerOrchestrator.AfterUndeleteMockable with(SObject row) { + return this.with(new List{ row }); + } + + public TriggerOrchestrator.AfterUndeleteMockable with(List rows) { + this.newRecords.addAll(this.assignIds(rows)); + return this; + } + + public TriggerOrchestrator.AfterUndeleteMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.AfterUndeleteMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List afterUndeleteHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterUndelete + ? ((TriggerOrchestrator.AfterUndelete) this.handlerOrOrchestrator).afterUndeleteHandlers() + : new List{ (AfterUndelete.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/AfterUpdate.cls b/force-app/main/default/classes/AfterUpdate.cls index e008fca..1479f2f 100644 --- a/force-app/main/default/classes/AfterUpdate.cls +++ b/force-app/main/default/classes/AfterUpdate.cls @@ -53,4 +53,257 @@ public class AfterUpdate { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (AfterUpdate.Handler handler : ((TriggerOrchestrator.AfterUpdate) orchestrator.orchestrator).afterUpdateHandlers()) { + if (handler instanceof AfterUpdate.Writer) { + adapters.add(new AfterUpdate.WriterAdapter((AfterUpdate.Writer) handler, orchestrator)); + } else if (handler instanceof AfterUpdate.Dispatcher) { + adapters.add(new AfterUpdate.DispatcherAdapter((AfterUpdate.Dispatcher) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class WriterAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterUpdate.Writer writer; + private TriggerOrchestrator.UnitOfWork unitOfWork; + private TriggerOrchestrator.RecursionTracker recursionTracker; + + public WriterAdapter(AfterUpdate.Writer writer, TriggerOrchestrator orchestrator) { + super(writer, orchestrator); + + this.writer = writer; + this.parentDeclaration = new AfterUpdate.ParentDeclaration(this.writer); + this.relatedLoader = new AfterUpdate.RelatedLoader(this.writer); + this.unitOfWork = new AfterUpdate.UnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new AfterUpdate.HandlerLifecycle(this.writer); + this.recursionTracker = new AfterUpdate.RecursionTracker(this.writer, this.handlerName); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.write(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + this.commitOwnUnitOfWork(); + } catch (Exception e) { + this.handleException(e); + } + } + + private void write(TriggerTypes.TriggerRecord record) { + if (this.recursionTracker.isLimitReached(record) || !this.writer.writeOnAfterUpdateWhen(record)) { + return; + } + + this.recursionTracker.countRun(record); + this.qualifiedRecords.add(record); + this.writer.writeOnAfterUpdate(record, this.unitOfWork); + } + + private void commitOwnUnitOfWork() { + if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { + return; + } + + this.unitOfWork.commitWork(); + } + } + + public class DispatcherAdapter extends TriggerOrchestrator.HandlerAdapter { + private AfterUpdate.Dispatcher dispatcher; + private TriggerOrchestrator.RecursionTracker recursionTracker; + + public DispatcherAdapter(AfterUpdate.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { + super(dispatcher, orchestrator); + + this.dispatcher = dispatcher; + this.parentDeclaration = new AfterUpdate.ParentDeclaration(this.dispatcher); + this.relatedLoader = new AfterUpdate.RelatedLoader(this.dispatcher); + this.handlerLifecycle = new AfterUpdate.HandlerLifecycle(this.dispatcher); + this.recursionTracker = new AfterUpdate.RecursionTracker(this.dispatcher, this.handlerName); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.qualify(record); + } + + this.dispatch(); + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + } + + private void qualify(TriggerTypes.TriggerRecord record) { + if (this.recursionTracker.isLimitReached(record) || !this.dispatcher.dispatchOnAfterUpdateWhen(record)) { + return; + } + + this.recursionTracker.countRun(record); + this.qualifiedRecords.add(record); + } + + private void dispatch() { + if (this.qualifiedRecords.isEmpty()) { + return; + } + + this.dispatcher.dispatchOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private AfterUpdate.Handler handler; + + public ParentDeclaration(AfterUpdate.Handler handler) { + this.handler = handler; + } + + public override Map getNewParentFields() { + if (this.handler instanceof AfterUpdate.ParentQuery) { + return ((AfterUpdate.ParentQuery) this.handler).queryParentsOnAfterUpdate(); + } + + return new Map(); + } + + public override Map getOldParentFields() { + if (this.handler instanceof AfterUpdate.PriorParentQuery) { + return ((AfterUpdate.PriorParentQuery) this.handler).queryPriorParentsOnAfterUpdate(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private AfterUpdate.Handler handler; + private Map providers; + + public RelatedLoader(AfterUpdate.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof AfterUpdate.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((AfterUpdate.RelatedQuery) this.handler).queryRelatedOnAfterUpdate() ?? new Map(); + } + + AfterUpdate.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class UnitOfWorkResolver extends TriggerOrchestrator.UnitOfWorkResolver { + private AfterUpdate.Writer writer; + + public UnitOfWorkResolver(AfterUpdate.Writer writer) { + this.writer = writer; + } + + public override TriggerOrchestrator.UnitOfWork resolve(TriggerOrchestrator.UnitOfWork sharedUnitOfWork) { + return this.writer instanceof AfterUpdate.OwnUnitOfWork + ? new TriggerOrchestrator.UnitOfWork(((AfterUpdate.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterUpdate()) + : this.writer instanceof AfterUpdate.ContinueOnError ? new TriggerOrchestrator.UnitOfWork() : sharedUnitOfWork; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private AfterUpdate.Handler handler; + + public HandlerLifecycle(AfterUpdate.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof AfterUpdate.Bypassable && ((AfterUpdate.Bypassable) this.handler).bypassOnAfterUpdateWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUpdate.Finalizer)) { + return; + } + + ((AfterUpdate.Finalizer) this.handler).finalizeOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof AfterUpdate.ContinueOnError; + } + } + + public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + public RecursionTracker(AfterUpdate.Handler handler, String handlerName) { + super(handlerName + ':AFTER_UPDATE'); + + if (handler instanceof AfterUpdate.RecursionGuard) { + this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); + } + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.AfterUpdateMockable, TriggerOrchestrator.AfterUpdate { + public MockedContext() { + super(System.TriggerOperation.AFTER_UPDATE); + } + + public TriggerOrchestrator.AfterUpdateMockable with(SObject newRow, SObject oldRow) { + return this.with(new List{ newRow }, new List{ oldRow }); + } + + public TriggerOrchestrator.AfterUpdateMockable with(List newRows, List oldRows) { + this.shareIds(newRows, oldRows); + this.newRecords.addAll(newRows); + this.oldRecords.addAll(oldRows); + return this; + } + + public TriggerOrchestrator.AfterUpdateMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.AfterUpdateMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List afterUpdateHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterUpdate + ? ((TriggerOrchestrator.AfterUpdate) this.handlerOrOrchestrator).afterUpdateHandlers() + : new List{ (AfterUpdate.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/BeforeDelete.cls b/force-app/main/default/classes/BeforeDelete.cls index ac08bd2..176d719 100644 --- a/force-app/main/default/classes/BeforeDelete.cls +++ b/force-app/main/default/classes/BeforeDelete.cls @@ -45,4 +45,230 @@ public class BeforeDelete { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (BeforeDelete.Handler handler : ((TriggerOrchestrator.BeforeDelete) orchestrator.orchestrator).beforeDeleteHandlers()) { + if (handler instanceof BeforeDelete.Validator) { + adapters.add(new BeforeDelete.ValidatorAdapter((BeforeDelete.Validator) handler, orchestrator)); + } else if (handler instanceof BeforeDelete.Writer) { + adapters.add(new BeforeDelete.WriterAdapter((BeforeDelete.Writer) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class ValidatorAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeDelete.Validator validator; + + public ValidatorAdapter(BeforeDelete.Validator validator, TriggerOrchestrator orchestrator) { + super(validator, orchestrator); + + this.validator = validator; + this.parentDeclaration = new BeforeDelete.ParentDeclaration(this.validator); + this.relatedLoader = new BeforeDelete.RelatedLoader(this.validator); + this.handlerLifecycle = new BeforeDelete.HandlerLifecycle(this.validator); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.validate(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + } + + private void validate(TriggerTypes.TriggerRecord record) { + if (!this.validator.addErrorOnBeforeDeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + + Integer errorsBefore = record.getOldSObject().getErrors().size(); + + this.validator.addErrorOnBeforeDelete(record); + + if (record.getOldSObject().getErrors().size() == errorsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeDeleteWhen but attached no error in addErrorOnBeforeDelete.'); + } + } + } + + public class WriterAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeDelete.Writer writer; + private TriggerOrchestrator.UnitOfWork unitOfWork; + + public WriterAdapter(BeforeDelete.Writer writer, TriggerOrchestrator orchestrator) { + super(writer, orchestrator); + + this.writer = writer; + this.parentDeclaration = new BeforeDelete.ParentDeclaration(this.writer); + this.relatedLoader = new BeforeDelete.RelatedLoader(this.writer); + this.unitOfWork = new BeforeDelete.UnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); + this.handlerLifecycle = new BeforeDelete.HandlerLifecycle(this.writer); + } + + public override void execute(List records) { + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.write(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + this.commitOwnUnitOfWork(); + } catch (Exception e) { + this.handleException(e); + } + } + + private void write(TriggerTypes.TriggerRecord record) { + if (!this.writer.writeOnBeforeDeleteWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + this.writer.writeOnBeforeDelete(record, this.unitOfWork); + } + + private void commitOwnUnitOfWork() { + if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { + return; + } + + this.unitOfWork.commitWork(); + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private BeforeDelete.Handler handler; + + public ParentDeclaration(BeforeDelete.Handler handler) { + this.handler = handler; + } + + public override Map getOldParentFields() { + if (this.handler instanceof BeforeDelete.PriorParentQuery) { + return ((BeforeDelete.PriorParentQuery) this.handler).queryPriorParentsOnBeforeDelete(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private BeforeDelete.Handler handler; + private Map providers; + + public RelatedLoader(BeforeDelete.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof BeforeDelete.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((BeforeDelete.RelatedQuery) this.handler).queryRelatedOnBeforeDelete() ?? new Map(); + } + + BeforeDelete.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class UnitOfWorkResolver extends TriggerOrchestrator.UnitOfWorkResolver { + private BeforeDelete.Writer writer; + + public UnitOfWorkResolver(BeforeDelete.Writer writer) { + this.writer = writer; + } + + public override TriggerOrchestrator.UnitOfWork resolve(TriggerOrchestrator.UnitOfWork sharedUnitOfWork) { + return this.writer instanceof BeforeDelete.OwnUnitOfWork + ? new TriggerOrchestrator.UnitOfWork(((BeforeDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnBeforeDelete()) + : this.writer instanceof BeforeDelete.ContinueOnError ? new TriggerOrchestrator.UnitOfWork() : sharedUnitOfWork; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private BeforeDelete.Handler handler; + + public HandlerLifecycle(BeforeDelete.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeDelete.Bypassable && ((BeforeDelete.Bypassable) this.handler).bypassOnBeforeDeleteWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeDelete.Finalizer)) { + return; + } + + ((BeforeDelete.Finalizer) this.handler).finalizeOnBeforeDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof BeforeDelete.ContinueOnError; + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.BeforeDeleteMockable, TriggerOrchestrator.BeforeDelete { + public MockedContext() { + super(System.TriggerOperation.BEFORE_DELETE); + } + + public TriggerOrchestrator.BeforeDeleteMockable with(SObject row) { + return this.with(new List{ row }); + } + + public TriggerOrchestrator.BeforeDeleteMockable with(List rows) { + this.oldRecords.addAll(this.assignIds(rows)); + return this; + } + + public TriggerOrchestrator.BeforeDeleteMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.BeforeDeleteMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List beforeDeleteHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeDelete + ? ((TriggerOrchestrator.BeforeDelete) this.handlerOrOrchestrator).beforeDeleteHandlers() + : new List{ (BeforeDelete.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/BeforeInsert.cls b/force-app/main/default/classes/BeforeInsert.cls index 588c41a..f36b68e 100644 --- a/force-app/main/default/classes/BeforeInsert.cls +++ b/force-app/main/default/classes/BeforeInsert.cls @@ -41,4 +41,239 @@ public class BeforeInsert { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (BeforeInsert.Handler handler : ((TriggerOrchestrator.BeforeInsert) orchestrator.orchestrator).beforeInsertHandlers()) { + if (handler instanceof BeforeInsert.Populator) { + adapters.add(new BeforeInsert.PopulatorAdapter((BeforeInsert.Populator) handler, orchestrator)); + } else if (handler instanceof BeforeInsert.Validator) { + adapters.add(new BeforeInsert.ValidatorAdapter((BeforeInsert.Validator) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class PopulatorAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeInsert.Populator populator; + + public PopulatorAdapter(BeforeInsert.Populator populator, TriggerOrchestrator orchestrator) { + super(populator, orchestrator); + + this.populator = populator; + this.parentDeclaration = new BeforeInsert.ParentDeclaration(this.populator); + this.relatedLoader = new BeforeInsert.RelatedLoader(this.populator); + this.handlerLifecycle = new BeforeInsert.HandlerLifecycle(this.populator); + } + + public override void execute(List records) { + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.populate(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in before insert. Move that work to an after insert Writer.' + ); + + this.logError(guardException); + + throw guardException; + } + + this.orchestrator.parentLoader.refreshNewParents(this.qualifiedRecords); + } + + private void populate(TriggerTypes.TriggerRecord record) { + if (!this.populator.populateOnBeforeInsertWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + this.populator.populateOnBeforeInsert(record); + } + } + + public class ValidatorAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeInsert.Validator validator; + + public ValidatorAdapter(BeforeInsert.Validator validator, TriggerOrchestrator orchestrator) { + super(validator, orchestrator); + + this.validator = validator; + this.parentDeclaration = new BeforeInsert.ParentDeclaration(this.validator); + this.relatedLoader = new BeforeInsert.RelatedLoader(this.validator); + this.handlerLifecycle = new BeforeInsert.HandlerLifecycle(this.validator); + } + + public override void execute(List records) { + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.validate(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in before insert. Move that work to an after insert Writer.' + ); + + this.logError(guardException); + + throw guardException; + } + } + + private void validate(TriggerTypes.TriggerRecord record) { + if (!this.validator.addErrorOnBeforeInsertWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + + Integer errorsBefore = record.getNewSObject().getErrors().size(); + + this.validator.addErrorOnBeforeInsert(record); + + if (record.getNewSObject().getErrors().size() == errorsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeInsertWhen but attached no error in addErrorOnBeforeInsert.'); + } + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private BeforeInsert.Handler handler; + + public ParentDeclaration(BeforeInsert.Handler handler) { + this.handler = handler; + } + + public override Map getNewParentFields() { + if (this.handler instanceof BeforeInsert.ParentQuery) { + return ((BeforeInsert.ParentQuery) this.handler).queryParentsOnBeforeInsert(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private BeforeInsert.Handler handler; + private Map providers; + + public RelatedLoader(BeforeInsert.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof BeforeInsert.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? new Map(); + } + + BeforeInsert.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private BeforeInsert.Handler handler; + + public HandlerLifecycle(BeforeInsert.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeInsert.Bypassable && ((BeforeInsert.Bypassable) this.handler).bypassOnBeforeInsertWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeInsert.Finalizer)) { + return; + } + + ((BeforeInsert.Finalizer) this.handler).finalizeOnBeforeInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof BeforeInsert.ContinueOnError; + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.BeforeInsertMockable, TriggerOrchestrator.BeforeInsert { + public MockedContext() { + super(System.TriggerOperation.BEFORE_INSERT); + } + + public TriggerOrchestrator.BeforeInsertMockable with(SObject row) { + return this.with(new List{ row }); + } + + public TriggerOrchestrator.BeforeInsertMockable with(List rows) { + this.newRecords.addAll(rows); + return this; + } + + public TriggerOrchestrator.BeforeInsertMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.BeforeInsertMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List beforeInsertHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeInsert + ? ((TriggerOrchestrator.BeforeInsert) this.handlerOrOrchestrator).beforeInsertHandlers() + : new List{ (BeforeInsert.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/BeforeUpdate.cls b/force-app/main/default/classes/BeforeUpdate.cls index 2b52323..65ef6f4 100644 --- a/force-app/main/default/classes/BeforeUpdate.cls +++ b/force-app/main/default/classes/BeforeUpdate.cls @@ -49,4 +49,262 @@ public class BeforeUpdate { public interface Handler { } + + // Implementation + + public class ExecutionStrategy extends TriggerOrchestrator.ExecutionStrategy { + public override List adaptHandlers(TriggerOrchestrator orchestrator) { + List adapters = new List(); + + for (BeforeUpdate.Handler handler : ((TriggerOrchestrator.BeforeUpdate) orchestrator.orchestrator).beforeUpdateHandlers()) { + if (handler instanceof BeforeUpdate.Populator) { + adapters.add(new BeforeUpdate.PopulatorAdapter((BeforeUpdate.Populator) handler, orchestrator)); + } else if (handler instanceof BeforeUpdate.Validator) { + adapters.add(new BeforeUpdate.ValidatorAdapter((BeforeUpdate.Validator) handler, orchestrator)); + } + } + + return adapters; + } + } + + public class PopulatorAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeUpdate.Populator populator; + private TriggerOrchestrator.RecursionTracker recursionTracker; + + public PopulatorAdapter(BeforeUpdate.Populator populator, TriggerOrchestrator orchestrator) { + super(populator, orchestrator); + + this.populator = populator; + this.parentDeclaration = new BeforeUpdate.ParentDeclaration(this.populator); + this.relatedLoader = new BeforeUpdate.RelatedLoader(this.populator); + this.handlerLifecycle = new BeforeUpdate.HandlerLifecycle(this.populator); + this.recursionTracker = new BeforeUpdate.RecursionTracker(this.populator, this.handlerName); + } + + public override void execute(List records) { + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.populate(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in before update. Move that work to an after update Writer.' + ); + + this.logError(guardException); + + throw guardException; + } + + this.orchestrator.parentLoader.refreshNewParents(this.qualifiedRecords); + } + + private void populate(TriggerTypes.TriggerRecord record) { + if (this.recursionTracker.isLimitReached(record) || !this.populator.populateOnBeforeUpdateWhen(record)) { + return; + } + + this.recursionTracker.countRun(record); + this.qualifiedRecords.add(record); + this.populator.populateOnBeforeUpdate(record); + } + } + + public class ValidatorAdapter extends TriggerOrchestrator.HandlerAdapter { + private BeforeUpdate.Validator validator; + + public ValidatorAdapter(BeforeUpdate.Validator validator, TriggerOrchestrator orchestrator) { + super(validator, orchestrator); + + this.validator = validator; + this.parentDeclaration = new BeforeUpdate.ParentDeclaration(this.validator); + this.relatedLoader = new BeforeUpdate.RelatedLoader(this.validator); + this.handlerLifecycle = new BeforeUpdate.HandlerLifecycle(this.validator); + } + + public override void execute(List records) { + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + + try { + this.relatedLoader.attachTo(records); + + for (TriggerTypes.TriggerRecord record : records) { + this.validate(record); + } + + this.handlerLifecycle.finalize(this.qualifiedRecords); + } catch (Exception e) { + this.handleException(e); + } + + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in before update. Move that work to an after update Writer.' + ); + + this.logError(guardException); + + throw guardException; + } + } + + private void validate(TriggerTypes.TriggerRecord record) { + if (!this.validator.addErrorOnBeforeUpdateWhen(record)) { + return; + } + + this.qualifiedRecords.add(record); + + Integer errorsBefore = record.getNewSObject().getErrors().size(); + + this.validator.addErrorOnBeforeUpdate(record); + + if (record.getNewSObject().getErrors().size() == errorsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeUpdateWhen but attached no error in addErrorOnBeforeUpdate.'); + } + } + } + + public class ParentDeclaration extends TriggerOrchestrator.ParentDeclaration { + private BeforeUpdate.Handler handler; + + public ParentDeclaration(BeforeUpdate.Handler handler) { + this.handler = handler; + } + + public override Map getNewParentFields() { + if (this.handler instanceof BeforeUpdate.ParentQuery) { + return ((BeforeUpdate.ParentQuery) this.handler).queryParentsOnBeforeUpdate(); + } + + return new Map(); + } + + public override Map getOldParentFields() { + if (this.handler instanceof BeforeUpdate.PriorParentQuery) { + return ((BeforeUpdate.PriorParentQuery) this.handler).queryPriorParentsOnBeforeUpdate(); + } + + return new Map(); + } + } + + public class RelatedLoader extends TriggerOrchestrator.RelatedLoader { + private BeforeUpdate.Handler handler; + private Map providers; + + public RelatedLoader(BeforeUpdate.Handler handler) { + this.handler = handler; + } + + public override TriggerTypes.RelatedRecords queryRelated(String providerName) { + if (!(this.handler instanceof BeforeUpdate.RelatedQuery)) { + return null; + } + + if (this.providers == null) { + this.providers = ((BeforeUpdate.RelatedQuery) this.handler).queryRelatedOnBeforeUpdate() ?? new Map(); + } + + BeforeUpdate.RecordsProvider provider = this.providers.get(providerName); + + if (provider == null) { + return null; + } + + TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); + TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); + + for (SObject row : related.getRecords()) { + related.groupUnderKey(provider.keyOf(row), row); + } + + return related; + } + } + + public class HandlerLifecycle extends TriggerOrchestrator.HandlerLifecycle { + private BeforeUpdate.Handler handler; + + public HandlerLifecycle(BeforeUpdate.Handler handler) { + this.handler = handler; + } + + public override Boolean isBypassedByAddOn() { + return (this.handler instanceof BeforeUpdate.Bypassable && ((BeforeUpdate.Bypassable) this.handler).bypassOnBeforeUpdateWhen()); + } + + public override void finalize(List qualifiedRecords) { + if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeUpdate.Finalizer)) { + return; + } + + ((BeforeUpdate.Finalizer) this.handler).finalizeOnBeforeUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); + } + + public override Boolean continuesOnError() { + return this.handler instanceof BeforeUpdate.ContinueOnError; + } + } + + public class RecursionTracker extends TriggerOrchestrator.RecursionTracker { + public RecursionTracker(BeforeUpdate.Handler handler, String handlerName) { + super(handlerName + ':BEFORE_UPDATE'); + + if (handler instanceof BeforeUpdate.RecursionGuard) { + this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); + } + } + } + + public class MockedContext extends TriggerOrchestrator.MockedContext implements TriggerOrchestrator.BeforeUpdateMockable, TriggerOrchestrator.BeforeUpdate { + public MockedContext() { + super(System.TriggerOperation.BEFORE_UPDATE); + } + + public TriggerOrchestrator.BeforeUpdateMockable with(SObject newRow, SObject oldRow) { + return this.with(new List{ newRow }, new List{ oldRow }); + } + + public TriggerOrchestrator.BeforeUpdateMockable with(List newRows, List oldRows) { + this.shareIds(newRows, oldRows); + this.newRecords.addAll(newRows); + this.oldRecords.addAll(oldRows); + return this; + } + + public TriggerOrchestrator.BeforeUpdateMockable withParent(SObjectField lookupField, SObject parent) { + return this.withParent(lookupField, new List{ parent }); + } + + public TriggerOrchestrator.BeforeUpdateMockable withParent(SObjectField lookupField, List parents) { + this.addParents(lookupField, parents); + return this; + } + + public List beforeUpdateHandlers() { + return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeUpdate + ? ((TriggerOrchestrator.BeforeUpdate) this.handlerOrOrchestrator).beforeUpdateHandlers() + : new List{ (BeforeUpdate.Handler) this.handlerOrOrchestrator }; + } + } } diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index 7be60a7..36acf7f 100644 --- a/force-app/main/default/classes/TriggerOrchestrator.cls +++ b/force-app/main/default/classes/TriggerOrchestrator.cls @@ -96,56 +96,49 @@ public with sharing class TriggerOrchestrator { AfterUndeleteMockable afterUndeleteFor(System.Type handlerOrOrchestratorType); } - @TestVisible - private interface BeforeInsertMockable { + public interface BeforeInsertMockable { BeforeInsertMockable with(SObject row); BeforeInsertMockable with(List rows); BeforeInsertMockable withParent(SObjectField lookupField, SObject parent); BeforeInsertMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface AfterInsertMockable { + public interface AfterInsertMockable { AfterInsertMockable with(SObject row); AfterInsertMockable with(List rows); AfterInsertMockable withParent(SObjectField lookupField, SObject parent); AfterInsertMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface BeforeUpdateMockable { + public interface BeforeUpdateMockable { BeforeUpdateMockable with(SObject newRow, SObject oldRow); BeforeUpdateMockable with(List newRows, List oldRows); BeforeUpdateMockable withParent(SObjectField lookupField, SObject parent); BeforeUpdateMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface AfterUpdateMockable { + public interface AfterUpdateMockable { AfterUpdateMockable with(SObject newRow, SObject oldRow); AfterUpdateMockable with(List newRows, List oldRows); AfterUpdateMockable withParent(SObjectField lookupField, SObject parent); AfterUpdateMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface BeforeDeleteMockable { + public interface BeforeDeleteMockable { BeforeDeleteMockable with(SObject row); BeforeDeleteMockable with(List rows); BeforeDeleteMockable withParent(SObjectField lookupField, SObject parent); BeforeDeleteMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface AfterDeleteMockable { + public interface AfterDeleteMockable { AfterDeleteMockable with(SObject row); AfterDeleteMockable with(List rows); AfterDeleteMockable withParent(SObjectField lookupField, SObject parent); AfterDeleteMockable withParent(SObjectField lookupField, List parents); } - @TestVisible - private interface AfterUndeleteMockable { + public interface AfterUndeleteMockable { AfterUndeleteMockable with(SObject row); AfterUndeleteMockable with(List rows); AfterUndeleteMockable withParent(SObjectField lookupField, SObject parent); @@ -164,9 +157,9 @@ public with sharing class TriggerOrchestrator { @TestVisible private TriggerContext context; - private Object orchestrator; - private ParentLoader parentLoader; - private UnitOfWork sharedUnitOfWork; + public Object orchestrator; + public ParentLoader parentLoader; + public UnitOfWork sharedUnitOfWork; @TestVisible private TriggerOrchestrator(Object orchestrator) { @@ -194,19 +187,19 @@ public with sharing class TriggerOrchestrator { } if (this.context.triggerOperation == System.TriggerOperation.BEFORE_INSERT && this.orchestrator instanceof TriggerOrchestrator.BeforeInsert) { - executionStrategy = new BeforeInsertExecutionStrategy(); + executionStrategy = new BeforeInsert.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.AFTER_INSERT && this.orchestrator instanceof TriggerOrchestrator.AfterInsert) { - executionStrategy = new AfterInsertExecutionStrategy(); + executionStrategy = new AfterInsert.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.BEFORE_UPDATE && this.orchestrator instanceof TriggerOrchestrator.BeforeUpdate) { - executionStrategy = new BeforeUpdateExecutionStrategy(); + executionStrategy = new BeforeUpdate.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.AFTER_UPDATE && this.orchestrator instanceof TriggerOrchestrator.AfterUpdate) { - executionStrategy = new AfterUpdateExecutionStrategy(); + executionStrategy = new AfterUpdate.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.BEFORE_DELETE && this.orchestrator instanceof TriggerOrchestrator.BeforeDelete) { - executionStrategy = new BeforeDeleteExecutionStrategy(); + executionStrategy = new BeforeDelete.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.AFTER_DELETE && this.orchestrator instanceof TriggerOrchestrator.AfterDelete) { - executionStrategy = new AfterDeleteExecutionStrategy(); + executionStrategy = new AfterDelete.ExecutionStrategy(); } else if (this.context.triggerOperation == System.TriggerOperation.AFTER_UNDELETE && this.orchestrator instanceof TriggerOrchestrator.AfterUndelete) { - executionStrategy = new AfterUndeleteExecutionStrategy(); + executionStrategy = new AfterUndelete.ExecutionStrategy(); } else { return; } @@ -458,7 +451,7 @@ public with sharing class TriggerOrchestrator { } } - private abstract class ExecutionStrategy { + public abstract class ExecutionStrategy { public void execute(TriggerOrchestrator orchestrator) { List activeAdapters = this.getActiveAdapters(orchestrator); @@ -486,119 +479,7 @@ public with sharing class TriggerOrchestrator { protected abstract List adaptHandlers(TriggerOrchestrator orchestrator); } - private class BeforeInsertExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (BeforeInsert.Handler handler : ((TriggerOrchestrator.BeforeInsert) orchestrator.orchestrator).beforeInsertHandlers()) { - if (handler instanceof BeforeInsert.Populator) { - adapters.add(new BeforeInsertPopulatorAdapter((BeforeInsert.Populator) handler, orchestrator)); - } else if (handler instanceof BeforeInsert.Validator) { - adapters.add(new BeforeInsertValidatorAdapter((BeforeInsert.Validator) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class AfterInsertExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (AfterInsert.Handler handler : ((TriggerOrchestrator.AfterInsert) orchestrator.orchestrator).afterInsertHandlers()) { - if (handler instanceof AfterInsert.Writer) { - adapters.add(new AfterInsertWriterAdapter((AfterInsert.Writer) handler, orchestrator)); - } else if (handler instanceof AfterInsert.Dispatcher) { - adapters.add(new AfterInsertDispatcherAdapter((AfterInsert.Dispatcher) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class BeforeUpdateExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (BeforeUpdate.Handler handler : ((TriggerOrchestrator.BeforeUpdate) orchestrator.orchestrator).beforeUpdateHandlers()) { - if (handler instanceof BeforeUpdate.Populator) { - adapters.add(new BeforeUpdatePopulatorAdapter((BeforeUpdate.Populator) handler, orchestrator)); - } else if (handler instanceof BeforeUpdate.Validator) { - adapters.add(new BeforeUpdateValidatorAdapter((BeforeUpdate.Validator) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class AfterUpdateExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (AfterUpdate.Handler handler : ((TriggerOrchestrator.AfterUpdate) orchestrator.orchestrator).afterUpdateHandlers()) { - if (handler instanceof AfterUpdate.Writer) { - adapters.add(new AfterUpdateWriterAdapter((AfterUpdate.Writer) handler, orchestrator)); - } else if (handler instanceof AfterUpdate.Dispatcher) { - adapters.add(new AfterUpdateDispatcherAdapter((AfterUpdate.Dispatcher) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class BeforeDeleteExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (BeforeDelete.Handler handler : ((TriggerOrchestrator.BeforeDelete) orchestrator.orchestrator).beforeDeleteHandlers()) { - if (handler instanceof BeforeDelete.Validator) { - adapters.add(new BeforeDeleteValidatorAdapter((BeforeDelete.Validator) handler, orchestrator)); - } else if (handler instanceof BeforeDelete.Writer) { - adapters.add(new BeforeDeleteWriterAdapter((BeforeDelete.Writer) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class AfterDeleteExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (AfterDelete.Handler handler : ((TriggerOrchestrator.AfterDelete) orchestrator.orchestrator).afterDeleteHandlers()) { - if (handler instanceof AfterDelete.Writer) { - adapters.add(new AfterDeleteWriterAdapter((AfterDelete.Writer) handler, orchestrator)); - } else if (handler instanceof AfterDelete.Dispatcher) { - adapters.add(new AfterDeleteDispatcherAdapter((AfterDelete.Dispatcher) handler, orchestrator)); - } - } - - return adapters; - } - } - - private class AfterUndeleteExecutionStrategy extends ExecutionStrategy { - public override List adaptHandlers(TriggerOrchestrator orchestrator) { - List adapters = new List(); - - for (AfterUndelete.Handler handler : ((TriggerOrchestrator.AfterUndelete) orchestrator.orchestrator).afterUndeleteHandlers()) { - if (handler instanceof AfterUndelete.Writer) { - adapters.add(new AfterUndeleteWriterAdapter((AfterUndelete.Writer) handler, orchestrator)); - } else if (handler instanceof AfterUndelete.Dispatcher) { - adapters.add(new AfterUndeleteDispatcherAdapter((AfterUndelete.Dispatcher) handler, orchestrator)); - } - } - - return adapters; - } - } - - private abstract class HandlerAdapter { + public abstract class HandlerAdapter { protected String handlerName; protected TriggerOrchestrator orchestrator; @@ -637,1397 +518,272 @@ public with sharing class TriggerOrchestrator { } } - private class BeforeInsertPopulatorAdapter extends HandlerAdapter { - private BeforeInsert.Populator populator; - - public BeforeInsertPopulatorAdapter(BeforeInsert.Populator populator, TriggerOrchestrator orchestrator) { - super(populator, orchestrator); - - this.populator = populator; - this.parentDeclaration = new BeforeInsertParentDeclaration(this.populator); - this.relatedLoader = new BeforeInsertRelatedLoader(this.populator); - this.handlerLifecycle = new BeforeInsertHandlerLifecycle(this.populator); + public abstract class ParentDeclaration { + public virtual Map getNewParentFields() { + return new Map(); } - public override void execute(List records) { - Integer dmlStatementsBefore = Limits.getDmlStatements(); - Integer immediateEventsBefore = Limits.getPublishImmediateDML(); - - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.populate(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); - } + public virtual Map getOldParentFields() { + return new Map(); + } + } - Integer dmlStatementsAfter = Limits.getDmlStatements(); - Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + public class ParentLoader implements TriggerTypes.ParentLoader { + private Set lookupsForNewParents = new Set(); + private Set lookupsForOldParents = new Set(); + private Set loadedLookups = new Set(); - if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { - TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( - this.handlerName + ' performed DML in before insert. Move that work to an after insert Writer.' - ); + private Map lookupByRelationshipName = new Map(); + private Map> fieldPathsByLookup = new Map>(); + private Map> cachedParentsByLookup = new Map>(); - this.logError(guardException); + private Boolean areSavedRecordsQueried = false; - throw guardException; - } + private TriggerContext context; - this.orchestrator.parentLoader.refreshNewParents(this.qualifiedRecords); + public ParentLoader(TriggerContext context) { + this.context = context; } - private void populate(TriggerTypes.TriggerRecord record) { - if (!this.populator.populateOnBeforeInsertWhen(record)) { - return; + public void collectDeclarations(List handlers) { + for (HandlerAdapter handler : handlers) { + this.declareNewParents(handler.parentDeclaration.getNewParentFields()); + this.declareOldParents(handler.parentDeclaration.getOldParentFields()); } - this.qualifiedRecords.add(record); - this.populator.populateOnBeforeInsert(record); - } - } - - private class BeforeInsertValidatorAdapter extends HandlerAdapter { - private BeforeInsert.Validator validator; - - public BeforeInsertValidatorAdapter(BeforeInsert.Validator validator, TriggerOrchestrator orchestrator) { - super(validator, orchestrator); - - this.validator = validator; - this.parentDeclaration = new BeforeInsertParentDeclaration(this.validator); - this.relatedLoader = new BeforeInsertRelatedLoader(this.validator); - this.handlerLifecycle = new BeforeInsertHandlerLifecycle(this.validator); + for (TriggerTypes.TriggerRecord record : this.context.triggerRecords) { + record.setParentLoader(this); + } } - public override void execute(List records) { - Integer dmlStatementsBefore = Limits.getDmlStatements(); - Integer immediateEventsBefore = Limits.getPublishImmediateDML(); - - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.validate(record); - } + public void load(String relationshipName) { + SObjectField lookupField = this.lookupByRelationshipName.get(relationshipName); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); + if (lookupField == null || this.loadedLookups.contains(lookupField)) { + return; } - Integer dmlStatementsAfter = Limits.getDmlStatements(); - Integer immediateEventsAfter = Limits.getPublishImmediateDML(); - - if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { - TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( - this.handlerName + ' performed DML in before insert. Move that work to an after insert Writer.' - ); - - this.logError(guardException); - - throw guardException; + if (this.lookupsForNewParents.contains(lookupField)) { + this.queryNewParentsFromSavedRecords(); } + + this.queryAndAttachParents(lookupField); + this.loadedLookups.add(lookupField); } - private void validate(TriggerTypes.TriggerRecord record) { - if (!this.validator.addErrorOnBeforeInsertWhen(record)) { + public void refreshNewParents(List records) { + if (records.isEmpty()) { return; } - this.qualifiedRecords.add(record); - - Integer errorsBefore = record.getNewSObject().getErrors().size(); - - this.validator.addErrorOnBeforeInsert(record); - - if (record.getNewSObject().getErrors().size() == errorsBefore) { - throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeInsertWhen but attached no error in addErrorOnBeforeInsert.'); + for (SObjectField lookupField : this.loadedLookups) { + this.queryParents(lookupField, this.notQueriedNewParentIds(lookupField, records)); + this.attachNewParents(lookupField, records); } } - } - - private class AfterInsertWriterAdapter extends HandlerAdapter { - private AfterInsert.Writer writer; - private UnitOfWork unitOfWork; - public AfterInsertWriterAdapter(AfterInsert.Writer writer, TriggerOrchestrator orchestrator) { - super(writer, orchestrator); - - this.writer = writer; - this.parentDeclaration = new AfterInsertParentDeclaration(this.writer); - this.relatedLoader = new AfterInsertRelatedLoader(this.writer); - this.unitOfWork = new AfterInsertUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - this.handlerLifecycle = new AfterInsertHandlerLifecycle(this.writer); + private void declareNewParents(Map parentFieldsByLookup) { + this.lookupsForNewParents.addAll(parentFieldsByLookup.keySet()); + this.declare(parentFieldsByLookup); } - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); + private void declareOldParents(Map parentFieldsByLookup) { + this.lookupsForOldParents.addAll(parentFieldsByLookup.keySet()); + this.declare(parentFieldsByLookup); + } - for (TriggerTypes.TriggerRecord record : records) { - this.write(record); + private void declare(Map parentFieldsByLookup) { + for (SObjectField lookupField : parentFieldsByLookup.keySet()) { + if (!this.fieldPathsByLookup.containsKey(lookupField)) { + this.fieldPathsByLookup.put(lookupField, new Set()); + } + if (!this.cachedParentsByLookup.containsKey(lookupField)) { + this.cachedParentsByLookup.put(lookupField, new Map()); } - this.handlerLifecycle.finalize(this.qualifiedRecords); - this.commitOwnUnitOfWork(); - } catch (Exception e) { - this.handleException(e); + this.lookupByRelationshipName.put(lookupField.getDescribe().getRelationshipName(), lookupField); + this.fieldPathsByLookup.get(lookupField).addAll(parentFieldsByLookup.get(lookupField).getFields()); } } - private void write(TriggerTypes.TriggerRecord record) { - if (!this.writer.writeOnAfterInsertWhen(record)) { + private void queryNewParentsFromSavedRecords() { + if (this.areSavedRecordsQueried || !this.haveTriggeredRecordsIds()) { return; } - this.qualifiedRecords.add(record); - this.writer.writeOnAfterInsert(record, this.unitOfWork); - } + Set fieldsToQuery = new Set(); - private void commitOwnUnitOfWork() { - if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { - return; + for (SObjectField lookupField : this.lookupsForNewParents) { + fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); } - this.unitOfWork.commitWork(); - } - } - - private class AfterInsertDispatcherAdapter extends HandlerAdapter { - private AfterInsert.Dispatcher dispatcher; - - public AfterInsertDispatcherAdapter(AfterInsert.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { - super(dispatcher, orchestrator); + List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); - this.dispatcher = dispatcher; - this.parentDeclaration = new AfterInsertParentDeclaration(this.dispatcher); - this.relatedLoader = new AfterInsertRelatedLoader(this.dispatcher); - this.handlerLifecycle = new AfterInsertHandlerLifecycle(this.dispatcher); - } + for (SObjectField lookupField : this.lookupsForNewParents) { + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); + for (SObject savedRecord : savedRecords) { + SObject parent = savedRecord.getSObject(relationshipName); - for (TriggerTypes.TriggerRecord record : records) { - this.qualify(record); + if (parent != null) { + cachedParentById.put(parent.Id, parent); + } } - - this.dispatch(); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); } - } - private void qualify(TriggerTypes.TriggerRecord record) { - if (!this.dispatcher.dispatchOnAfterInsertWhen(record)) { - return; - } + this.areSavedRecordsQueried = true; + } - this.qualifiedRecords.add(record); + private Boolean haveTriggeredRecordsIds() { + return this.context.triggerOperation == System.TriggerOperation.AFTER_INSERT || + this.context.triggerOperation == System.TriggerOperation.AFTER_UPDATE || + this.context.triggerOperation == System.TriggerOperation.AFTER_UNDELETE; } - private void dispatch() { - if (this.qualifiedRecords.isEmpty()) { - return; + private Set getFieldPathsForLookup(SObjectField lookupField) { + String relationshipName = lookupField.getDescribe().getRelationshipName(); + Set fieldPaths = new Set{ relationshipName + '.Id' }; + + for (String field : this.fieldPathsByLookup.get(lookupField)) { + fieldPaths.add(relationshipName + '.' + field); } - this.dispatcher.dispatchOnAfterInsert(new TriggerTypes.InsertTriggerRecords(this.qualifiedRecords)); + return fieldPaths; } - } - - private class BeforeUpdatePopulatorAdapter extends HandlerAdapter { - private BeforeUpdate.Populator populator; - private RecursionTracker recursionTracker; - public BeforeUpdatePopulatorAdapter(BeforeUpdate.Populator populator, TriggerOrchestrator orchestrator) { - super(populator, orchestrator); + private void queryAndAttachParents(SObjectField lookupField) { + Set parentIdsToQuery = new Set(); - this.populator = populator; - this.parentDeclaration = new BeforeUpdateParentDeclaration(this.populator); - this.relatedLoader = new BeforeUpdateRelatedLoader(this.populator); - this.handlerLifecycle = new BeforeUpdateHandlerLifecycle(this.populator); - this.recursionTracker = new BeforeUpdateRecursionTracker(this.populator, this.handlerName); - } + parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); + parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); - public override void execute(List records) { - Integer dmlStatementsBefore = Limits.getDmlStatements(); - Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + this.queryParents(lookupField, parentIdsToQuery); - try { - this.relatedLoader.attachTo(records); + this.attachNewParents(lookupField, this.context.triggerRecords); + this.attachOldParents(lookupField, this.context.triggerRecords); + } - for (TriggerTypes.TriggerRecord record : records) { - this.populate(record); - } + private Set notQueriedNewParentIds(SObjectField lookupField, List records) { + Set parentIds = new Set(); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); + if (!this.lookupsForNewParents.contains(lookupField)) { + return parentIds; } - Integer dmlStatementsAfter = Limits.getDmlStatements(); - Integer immediateEventsAfter = Limits.getPublishImmediateDML(); - - if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { - TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( - this.handlerName + ' performed DML in before update. Move that work to an after update Writer.' - ); + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - this.logError(guardException); + for (TriggerTypes.TriggerRecord record : records) { + Id parentId = record.getNewParentId(lookupField); - throw guardException; + if (parentId != null && !cachedParentById.containsKey(parentId)) { + parentIds.add(parentId); + } } - this.orchestrator.parentLoader.refreshNewParents(this.qualifiedRecords); + return parentIds; } - private void populate(TriggerTypes.TriggerRecord record) { - if (this.recursionTracker.isLimitReached(record) || !this.populator.populateOnBeforeUpdateWhen(record)) { - return; + private Set notQueriedOldParentIds(SObjectField lookupField, List records) { + Set parentIds = new Set(); + + if (!this.lookupsForOldParents.contains(lookupField)) { + return parentIds; } - this.recursionTracker.countRun(record); - this.qualifiedRecords.add(record); - this.populator.populateOnBeforeUpdate(record); - } - } + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - private class BeforeUpdateValidatorAdapter extends HandlerAdapter { - private BeforeUpdate.Validator validator; + for (TriggerTypes.TriggerRecord record : records) { + Id parentId = record.getOldParentId(lookupField); - public BeforeUpdateValidatorAdapter(BeforeUpdate.Validator validator, TriggerOrchestrator orchestrator) { - super(validator, orchestrator); + if (parentId != null && !cachedParentById.containsKey(parentId)) { + parentIds.add(parentId); + } + } - this.validator = validator; - this.parentDeclaration = new BeforeUpdateParentDeclaration(this.validator); - this.relatedLoader = new BeforeUpdateRelatedLoader(this.validator); - this.handlerLifecycle = new BeforeUpdateHandlerLifecycle(this.validator); + return parentIds; } - public override void execute(List records) { - Integer dmlStatementsBefore = Limits.getDmlStatements(); - Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + private void queryParents(SObjectField lookupField, Set parentIdsToQuery) { + if (parentIdsToQuery.isEmpty()) { + return; + } - try { - this.relatedLoader.attachTo(records); + Map queriedParentById = SOQL.of(lookupField.getDescribe().getReferenceTo()[0]) + .with(this.fieldPathsByLookup.get(lookupField)) + .byIds(parentIdsToQuery) + .systemMode() + .withoutSharing() + .toMap(); - for (TriggerTypes.TriggerRecord record : records) { - this.validate(record); - } + Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); + for (Id parentId : parentIdsToQuery) { + cachedParentById.put(parentId, queriedParentById.get(parentId)); } + } - Integer dmlStatementsAfter = Limits.getDmlStatements(); - Integer immediateEventsAfter = Limits.getPublishImmediateDML(); - - if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { - TriggerTypes.TriggerLibException guardException = new TriggerTypes.TriggerLibException( - this.handlerName + ' performed DML in before update. Move that work to an after update Writer.' - ); + private void attachNewParents(SObjectField lookupField, List records) { + if (!this.lookupsForNewParents.contains(lookupField)) { + return; + } - this.logError(guardException); + Map parentsById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); - throw guardException; + for (TriggerTypes.TriggerRecord record : records) { + record.setNewParent(relationshipName, parentsById.get(record.getNewParentId(lookupField))); } } - private void validate(TriggerTypes.TriggerRecord record) { - if (!this.validator.addErrorOnBeforeUpdateWhen(record)) { + private void attachOldParents(SObjectField lookupField, List records) { + if (!this.lookupsForOldParents.contains(lookupField)) { return; } - this.qualifiedRecords.add(record); - - Integer errorsBefore = record.getNewSObject().getErrors().size(); - - this.validator.addErrorOnBeforeUpdate(record); + Map parentsById = this.cachedParentsByLookup.get(lookupField); + String relationshipName = lookupField.getDescribe().getRelationshipName(); - if (record.getNewSObject().getErrors().size() == errorsBefore) { - throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeUpdateWhen but attached no error in addErrorOnBeforeUpdate.'); + for (TriggerTypes.TriggerRecord record : records) { + record.setOldParent(relationshipName, parentsById.get(record.getOldParentId(lookupField))); } } } - private class AfterUpdateWriterAdapter extends HandlerAdapter { - private AfterUpdate.Writer writer; - private UnitOfWork unitOfWork; - private RecursionTracker recursionTracker; + public abstract class RelatedLoader implements TriggerTypes.RelatedLoader { + protected List records = new List(); + + private Map relatedByProviderName = new Map(); - public AfterUpdateWriterAdapter(AfterUpdate.Writer writer, TriggerOrchestrator orchestrator) { - super(writer, orchestrator); + public void attachTo(List records) { + this.records = records; - this.writer = writer; - this.parentDeclaration = new AfterUpdateParentDeclaration(this.writer); - this.relatedLoader = new AfterUpdateRelatedLoader(this.writer); - this.unitOfWork = new AfterUpdateUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - this.handlerLifecycle = new AfterUpdateHandlerLifecycle(this.writer); - this.recursionTracker = new AfterUpdateRecursionTracker(this.writer, this.handlerName); + for (TriggerTypes.TriggerRecord record : records) { + record.setRelated(this.relatedByProviderName); + record.setRelatedLoader(this); + } } - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.write(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - this.commitOwnUnitOfWork(); - } catch (Exception e) { - this.handleException(e); - } - } - - private void write(TriggerTypes.TriggerRecord record) { - if (this.recursionTracker.isLimitReached(record) || !this.writer.writeOnAfterUpdateWhen(record)) { - return; - } - - this.recursionTracker.countRun(record); - this.qualifiedRecords.add(record); - this.writer.writeOnAfterUpdate(record, this.unitOfWork); - } - - private void commitOwnUnitOfWork() { - if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { - return; - } - - this.unitOfWork.commitWork(); - } - } - - private class AfterUpdateDispatcherAdapter extends HandlerAdapter { - private AfterUpdate.Dispatcher dispatcher; - private RecursionTracker recursionTracker; - - public AfterUpdateDispatcherAdapter(AfterUpdate.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { - super(dispatcher, orchestrator); - - this.dispatcher = dispatcher; - this.parentDeclaration = new AfterUpdateParentDeclaration(this.dispatcher); - this.relatedLoader = new AfterUpdateRelatedLoader(this.dispatcher); - this.handlerLifecycle = new AfterUpdateHandlerLifecycle(this.dispatcher); - this.recursionTracker = new AfterUpdateRecursionTracker(this.dispatcher, this.handlerName); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.qualify(record); - } - - this.dispatch(); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); - } - } - - private void qualify(TriggerTypes.TriggerRecord record) { - if (this.recursionTracker.isLimitReached(record) || !this.dispatcher.dispatchOnAfterUpdateWhen(record)) { - return; - } - - this.recursionTracker.countRun(record); - this.qualifiedRecords.add(record); - } - - private void dispatch() { - if (this.qualifiedRecords.isEmpty()) { - return; - } - - this.dispatcher.dispatchOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(this.qualifiedRecords)); - } - } - - private class BeforeDeleteValidatorAdapter extends HandlerAdapter { - private BeforeDelete.Validator validator; - - public BeforeDeleteValidatorAdapter(BeforeDelete.Validator validator, TriggerOrchestrator orchestrator) { - super(validator, orchestrator); - - this.validator = validator; - this.parentDeclaration = new BeforeDeleteParentDeclaration(this.validator); - this.relatedLoader = new BeforeDeleteRelatedLoader(this.validator); - this.handlerLifecycle = new BeforeDeleteHandlerLifecycle(this.validator); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.validate(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); - } - } - - private void validate(TriggerTypes.TriggerRecord record) { - if (!this.validator.addErrorOnBeforeDeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - - Integer errorsBefore = record.getOldSObject().getErrors().size(); - - this.validator.addErrorOnBeforeDelete(record); - - if (record.getOldSObject().getErrors().size() == errorsBefore) { - throw new TriggerTypes.TriggerLibException(this.handlerName + ' qualified a record in addErrorOnBeforeDeleteWhen but attached no error in addErrorOnBeforeDelete.'); - } - } - } - - private class BeforeDeleteWriterAdapter extends HandlerAdapter { - private BeforeDelete.Writer writer; - private UnitOfWork unitOfWork; - - public BeforeDeleteWriterAdapter(BeforeDelete.Writer writer, TriggerOrchestrator orchestrator) { - super(writer, orchestrator); - - this.writer = writer; - this.parentDeclaration = new BeforeDeleteParentDeclaration(this.writer); - this.relatedLoader = new BeforeDeleteRelatedLoader(this.writer); - this.unitOfWork = new BeforeDeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - this.handlerLifecycle = new BeforeDeleteHandlerLifecycle(this.writer); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.write(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - this.commitOwnUnitOfWork(); - } catch (Exception e) { - this.handleException(e); - } - } - - private void write(TriggerTypes.TriggerRecord record) { - if (!this.writer.writeOnBeforeDeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - this.writer.writeOnBeforeDelete(record, this.unitOfWork); - } - - private void commitOwnUnitOfWork() { - if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { - return; - } - - this.unitOfWork.commitWork(); - } - } - - private class AfterDeleteWriterAdapter extends HandlerAdapter { - private AfterDelete.Writer writer; - private UnitOfWork unitOfWork; - - public AfterDeleteWriterAdapter(AfterDelete.Writer writer, TriggerOrchestrator orchestrator) { - super(writer, orchestrator); - - this.writer = writer; - this.parentDeclaration = new AfterDeleteParentDeclaration(this.writer); - this.relatedLoader = new AfterDeleteRelatedLoader(this.writer); - this.unitOfWork = new AfterDeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - this.handlerLifecycle = new AfterDeleteHandlerLifecycle(this.writer); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.write(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - this.commitOwnUnitOfWork(); - } catch (Exception e) { - this.handleException(e); - } - } - - private void write(TriggerTypes.TriggerRecord record) { - if (!this.writer.writeOnAfterDeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - this.writer.writeOnAfterDelete(record, this.unitOfWork); - } - - private void commitOwnUnitOfWork() { - if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { - return; - } - - this.unitOfWork.commitWork(); - } - } - - private class AfterDeleteDispatcherAdapter extends HandlerAdapter { - private AfterDelete.Dispatcher dispatcher; - - public AfterDeleteDispatcherAdapter(AfterDelete.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { - super(dispatcher, orchestrator); - - this.dispatcher = dispatcher; - this.parentDeclaration = new AfterDeleteParentDeclaration(this.dispatcher); - this.relatedLoader = new AfterDeleteRelatedLoader(this.dispatcher); - this.handlerLifecycle = new AfterDeleteHandlerLifecycle(this.dispatcher); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.qualify(record); - } - - this.dispatch(); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); - } - } - - private void qualify(TriggerTypes.TriggerRecord record) { - if (!this.dispatcher.dispatchOnAfterDeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - } - - private void dispatch() { - if (this.qualifiedRecords.isEmpty()) { - return; - } - - this.dispatcher.dispatchOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(this.qualifiedRecords)); - } - } - - private class AfterUndeleteWriterAdapter extends HandlerAdapter { - private AfterUndelete.Writer writer; - private UnitOfWork unitOfWork; - - public AfterUndeleteWriterAdapter(AfterUndelete.Writer writer, TriggerOrchestrator orchestrator) { - super(writer, orchestrator); - - this.writer = writer; - this.parentDeclaration = new AfterUndeleteParentDeclaration(this.writer); - this.relatedLoader = new AfterUndeleteRelatedLoader(this.writer); - this.unitOfWork = new AfterUndeleteUnitOfWorkResolver(this.writer).resolve(this.orchestrator.sharedUnitOfWork); - this.handlerLifecycle = new AfterUndeleteHandlerLifecycle(this.writer); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.write(record); - } - - this.handlerLifecycle.finalize(this.qualifiedRecords); - this.commitOwnUnitOfWork(); - } catch (Exception e) { - this.handleException(e); - } - } - - private void write(TriggerTypes.TriggerRecord record) { - if (!this.writer.writeOnAfterUndeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - this.writer.writeOnAfterUndelete(record, this.unitOfWork); - } - - private void commitOwnUnitOfWork() { - if (this.qualifiedRecords.isEmpty() || this.unitOfWork === this.orchestrator.sharedUnitOfWork) { - return; - } - - this.unitOfWork.commitWork(); - } - } - - private class AfterUndeleteDispatcherAdapter extends HandlerAdapter { - private AfterUndelete.Dispatcher dispatcher; - - public AfterUndeleteDispatcherAdapter(AfterUndelete.Dispatcher dispatcher, TriggerOrchestrator orchestrator) { - super(dispatcher, orchestrator); - - this.dispatcher = dispatcher; - this.parentDeclaration = new AfterUndeleteParentDeclaration(this.dispatcher); - this.relatedLoader = new AfterUndeleteRelatedLoader(this.dispatcher); - this.handlerLifecycle = new AfterUndeleteHandlerLifecycle(this.dispatcher); - } - - public override void execute(List records) { - try { - this.relatedLoader.attachTo(records); - - for (TriggerTypes.TriggerRecord record : records) { - this.qualify(record); - } - - this.dispatch(); - this.handlerLifecycle.finalize(this.qualifiedRecords); - } catch (Exception e) { - this.handleException(e); - } - } - - private void qualify(TriggerTypes.TriggerRecord record) { - if (!this.dispatcher.dispatchOnAfterUndeleteWhen(record)) { - return; - } - - this.qualifiedRecords.add(record); - } - - private void dispatch() { - if (this.qualifiedRecords.isEmpty()) { - return; - } - - this.dispatcher.dispatchOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(this.qualifiedRecords)); - } - } - - private abstract class ParentDeclaration { - public virtual Map getNewParentFields() { - return new Map(); - } - - public virtual Map getOldParentFields() { - return new Map(); - } - } - - private class BeforeInsertParentDeclaration extends ParentDeclaration { - private BeforeInsert.Handler handler; - - public BeforeInsertParentDeclaration(BeforeInsert.Handler handler) { - this.handler = handler; - } - - public override Map getNewParentFields() { - if (this.handler instanceof BeforeInsert.ParentQuery) { - return ((BeforeInsert.ParentQuery) this.handler).queryParentsOnBeforeInsert(); - } - - return new Map(); - } - } - - private class AfterInsertParentDeclaration extends ParentDeclaration { - private AfterInsert.Handler handler; - - public AfterInsertParentDeclaration(AfterInsert.Handler handler) { - this.handler = handler; - } - - public override Map getNewParentFields() { - if (this.handler instanceof AfterInsert.ParentQuery) { - return ((AfterInsert.ParentQuery) this.handler).queryParentsOnAfterInsert(); - } - - return new Map(); - } - } - - private class BeforeUpdateParentDeclaration extends ParentDeclaration { - private BeforeUpdate.Handler handler; - - public BeforeUpdateParentDeclaration(BeforeUpdate.Handler handler) { - this.handler = handler; - } - - public override Map getNewParentFields() { - if (this.handler instanceof BeforeUpdate.ParentQuery) { - return ((BeforeUpdate.ParentQuery) this.handler).queryParentsOnBeforeUpdate(); - } - - return new Map(); - } - - public override Map getOldParentFields() { - if (this.handler instanceof BeforeUpdate.PriorParentQuery) { - return ((BeforeUpdate.PriorParentQuery) this.handler).queryPriorParentsOnBeforeUpdate(); - } - - return new Map(); - } - } - - private class AfterUpdateParentDeclaration extends ParentDeclaration { - private AfterUpdate.Handler handler; - - public AfterUpdateParentDeclaration(AfterUpdate.Handler handler) { - this.handler = handler; - } - - public override Map getNewParentFields() { - if (this.handler instanceof AfterUpdate.ParentQuery) { - return ((AfterUpdate.ParentQuery) this.handler).queryParentsOnAfterUpdate(); - } - - return new Map(); - } - - public override Map getOldParentFields() { - if (this.handler instanceof AfterUpdate.PriorParentQuery) { - return ((AfterUpdate.PriorParentQuery) this.handler).queryPriorParentsOnAfterUpdate(); - } - - return new Map(); - } - } - - private class BeforeDeleteParentDeclaration extends ParentDeclaration { - private BeforeDelete.Handler handler; - - public BeforeDeleteParentDeclaration(BeforeDelete.Handler handler) { - this.handler = handler; - } - - public override Map getOldParentFields() { - if (this.handler instanceof BeforeDelete.PriorParentQuery) { - return ((BeforeDelete.PriorParentQuery) this.handler).queryPriorParentsOnBeforeDelete(); - } - - return new Map(); - } - } - - private class AfterDeleteParentDeclaration extends ParentDeclaration { - private AfterDelete.Handler handler; - - public AfterDeleteParentDeclaration(AfterDelete.Handler handler) { - this.handler = handler; - } - - public override Map getOldParentFields() { - if (this.handler instanceof AfterDelete.PriorParentQuery) { - return ((AfterDelete.PriorParentQuery) this.handler).queryPriorParentsOnAfterDelete(); - } - - return new Map(); - } - } - - private class AfterUndeleteParentDeclaration extends ParentDeclaration { - private AfterUndelete.Handler handler; - - public AfterUndeleteParentDeclaration(AfterUndelete.Handler handler) { - this.handler = handler; - } - - public override Map getNewParentFields() { - if (this.handler instanceof AfterUndelete.ParentQuery) { - return ((AfterUndelete.ParentQuery) this.handler).queryParentsOnAfterUndelete(); - } - - return new Map(); - } - } - - private class ParentLoader implements TriggerTypes.ParentLoader { - private Set lookupsForNewParents = new Set(); - private Set lookupsForOldParents = new Set(); - private Set loadedLookups = new Set(); - - private Map lookupByRelationshipName = new Map(); - private Map> fieldPathsByLookup = new Map>(); - private Map> cachedParentsByLookup = new Map>(); - - private Boolean areSavedRecordsQueried = false; - - private TriggerContext context; - - public ParentLoader(TriggerContext context) { - this.context = context; - } - - public void collectDeclarations(List handlers) { - for (HandlerAdapter handler : handlers) { - this.declareNewParents(handler.parentDeclaration.getNewParentFields()); - this.declareOldParents(handler.parentDeclaration.getOldParentFields()); - } - - for (TriggerTypes.TriggerRecord record : this.context.triggerRecords) { - record.setParentLoader(this); - } - } - - public void load(String relationshipName) { - SObjectField lookupField = this.lookupByRelationshipName.get(relationshipName); - - if (lookupField == null || this.loadedLookups.contains(lookupField)) { - return; - } - - if (this.lookupsForNewParents.contains(lookupField)) { - this.queryNewParentsFromSavedRecords(); - } - - this.queryAndAttachParents(lookupField); - this.loadedLookups.add(lookupField); - } - - public void refreshNewParents(List records) { - if (records.isEmpty()) { - return; - } - - for (SObjectField lookupField : this.loadedLookups) { - this.queryParents(lookupField, this.notQueriedNewParentIds(lookupField, records)); - this.attachNewParents(lookupField, records); - } - } - - private void declareNewParents(Map parentFieldsByLookup) { - this.lookupsForNewParents.addAll(parentFieldsByLookup.keySet()); - this.declare(parentFieldsByLookup); - } - - private void declareOldParents(Map parentFieldsByLookup) { - this.lookupsForOldParents.addAll(parentFieldsByLookup.keySet()); - this.declare(parentFieldsByLookup); - } - - private void declare(Map parentFieldsByLookup) { - for (SObjectField lookupField : parentFieldsByLookup.keySet()) { - if (!this.fieldPathsByLookup.containsKey(lookupField)) { - this.fieldPathsByLookup.put(lookupField, new Set()); - } - if (!this.cachedParentsByLookup.containsKey(lookupField)) { - this.cachedParentsByLookup.put(lookupField, new Map()); - } - - this.lookupByRelationshipName.put(lookupField.getDescribe().getRelationshipName(), lookupField); - this.fieldPathsByLookup.get(lookupField).addAll(parentFieldsByLookup.get(lookupField).getFields()); - } - } - - private void queryNewParentsFromSavedRecords() { - if (this.areSavedRecordsQueried || !this.haveTriggeredRecordsIds()) { - return; - } - - Set fieldsToQuery = new Set(); - - for (SObjectField lookupField : this.lookupsForNewParents) { - fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); - } - - List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); - - for (SObjectField lookupField : this.lookupsForNewParents) { - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); - - for (SObject savedRecord : savedRecords) { - SObject parent = savedRecord.getSObject(relationshipName); - - if (parent != null) { - cachedParentById.put(parent.Id, parent); - } - } - } - - this.areSavedRecordsQueried = true; - } - - private Boolean haveTriggeredRecordsIds() { - return this.context.triggerOperation == System.TriggerOperation.AFTER_INSERT || - this.context.triggerOperation == System.TriggerOperation.AFTER_UPDATE || - this.context.triggerOperation == System.TriggerOperation.AFTER_UNDELETE; - } - - private Set getFieldPathsForLookup(SObjectField lookupField) { - String relationshipName = lookupField.getDescribe().getRelationshipName(); - Set fieldPaths = new Set{ relationshipName + '.Id' }; - - for (String field : this.fieldPathsByLookup.get(lookupField)) { - fieldPaths.add(relationshipName + '.' + field); - } - - return fieldPaths; - } - - private void queryAndAttachParents(SObjectField lookupField) { - Set parentIdsToQuery = new Set(); - - parentIdsToQuery.addAll(this.notQueriedNewParentIds(lookupField, this.context.triggerRecords)); - parentIdsToQuery.addAll(this.notQueriedOldParentIds(lookupField, this.context.triggerRecords)); - - this.queryParents(lookupField, parentIdsToQuery); - - this.attachNewParents(lookupField, this.context.triggerRecords); - this.attachOldParents(lookupField, this.context.triggerRecords); - } - - private Set notQueriedNewParentIds(SObjectField lookupField, List records) { - Set parentIds = new Set(); - - if (!this.lookupsForNewParents.contains(lookupField)) { - return parentIds; - } - - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - - for (TriggerTypes.TriggerRecord record : records) { - Id parentId = record.getNewParentId(lookupField); - - if (parentId != null && !cachedParentById.containsKey(parentId)) { - parentIds.add(parentId); - } - } - - return parentIds; - } - - private Set notQueriedOldParentIds(SObjectField lookupField, List records) { - Set parentIds = new Set(); - - if (!this.lookupsForOldParents.contains(lookupField)) { - return parentIds; - } - - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - - for (TriggerTypes.TriggerRecord record : records) { - Id parentId = record.getOldParentId(lookupField); - - if (parentId != null && !cachedParentById.containsKey(parentId)) { - parentIds.add(parentId); - } - } - - return parentIds; - } - - private void queryParents(SObjectField lookupField, Set parentIdsToQuery) { - if (parentIdsToQuery.isEmpty()) { - return; - } - - Map queriedParentById = SOQL.of(lookupField.getDescribe().getReferenceTo()[0]) - .with(this.fieldPathsByLookup.get(lookupField)) - .byIds(parentIdsToQuery) - .systemMode() - .withoutSharing() - .toMap(); - - Map cachedParentById = this.cachedParentsByLookup.get(lookupField); - - for (Id parentId : parentIdsToQuery) { - cachedParentById.put(parentId, queriedParentById.get(parentId)); - } - } - - private void attachNewParents(SObjectField lookupField, List records) { - if (!this.lookupsForNewParents.contains(lookupField)) { - return; - } - - Map parentsById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); - - for (TriggerTypes.TriggerRecord record : records) { - record.setNewParent(relationshipName, parentsById.get(record.getNewParentId(lookupField))); - } - } - - private void attachOldParents(SObjectField lookupField, List records) { - if (!this.lookupsForOldParents.contains(lookupField)) { - return; - } - - Map parentsById = this.cachedParentsByLookup.get(lookupField); - String relationshipName = lookupField.getDescribe().getRelationshipName(); - - for (TriggerTypes.TriggerRecord record : records) { - record.setOldParent(relationshipName, parentsById.get(record.getOldParentId(lookupField))); - } - } - } - - private abstract class RelatedLoader implements TriggerTypes.RelatedLoader { - protected List records = new List(); - - private Map relatedByProviderName = new Map(); - - public void attachTo(List records) { - this.records = records; - - for (TriggerTypes.TriggerRecord record : records) { - record.setRelated(this.relatedByProviderName); - record.setRelatedLoader(this); - } - } - - public void load(String providerName) { - if (this.relatedByProviderName.containsKey(providerName)) { - return; - } - - TriggerTypes.RelatedRecords related = this.queryRelated(providerName); - - if (related != null) { - this.relatedByProviderName.put(providerName, related); - } - } - - protected abstract TriggerTypes.RelatedRecords queryRelated(String providerName); - } - - private class BeforeInsertRelatedLoader extends RelatedLoader { - private BeforeInsert.Handler handler; - private Map providers; - - public BeforeInsertRelatedLoader(BeforeInsert.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof BeforeInsert.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((BeforeInsert.RelatedQuery) this.handler).queryRelatedOnBeforeInsert() ?? new Map(); - } - - BeforeInsert.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class AfterInsertRelatedLoader extends RelatedLoader { - private AfterInsert.Handler handler; - private Map providers; - - public AfterInsertRelatedLoader(AfterInsert.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof AfterInsert.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((AfterInsert.RelatedQuery) this.handler).queryRelatedOnAfterInsert() ?? new Map(); - } - - AfterInsert.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.InsertRecords insertRecords = new TriggerTypes.InsertTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(insertRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class BeforeUpdateRelatedLoader extends RelatedLoader { - private BeforeUpdate.Handler handler; - private Map providers; - - public BeforeUpdateRelatedLoader(BeforeUpdate.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof BeforeUpdate.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((BeforeUpdate.RelatedQuery) this.handler).queryRelatedOnBeforeUpdate() ?? new Map(); - } - - BeforeUpdate.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class AfterUpdateRelatedLoader extends RelatedLoader { - private AfterUpdate.Handler handler; - private Map providers; - - public AfterUpdateRelatedLoader(AfterUpdate.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof AfterUpdate.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((AfterUpdate.RelatedQuery) this.handler).queryRelatedOnAfterUpdate() ?? new Map(); - } - - AfterUpdate.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.UpdateRecords updateRecords = new TriggerTypes.UpdateTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(updateRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class BeforeDeleteRelatedLoader extends RelatedLoader { - private BeforeDelete.Handler handler; - private Map providers; - - public BeforeDeleteRelatedLoader(BeforeDelete.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof BeforeDelete.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((BeforeDelete.RelatedQuery) this.handler).queryRelatedOnBeforeDelete() ?? new Map(); - } - - BeforeDelete.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class AfterDeleteRelatedLoader extends RelatedLoader { - private AfterDelete.Handler handler; - private Map providers; - - public AfterDeleteRelatedLoader(AfterDelete.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof AfterDelete.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((AfterDelete.RelatedQuery) this.handler).queryRelatedOnAfterDelete() ?? new Map(); - } - - AfterDelete.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; - } - - TriggerTypes.DeleteRecords deleteRecords = new TriggerTypes.DeleteTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(deleteRecords)); - - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); - } - - return related; - } - } - - private class AfterUndeleteRelatedLoader extends RelatedLoader { - private AfterUndelete.Handler handler; - private Map providers; - - public AfterUndeleteRelatedLoader(AfterUndelete.Handler handler) { - this.handler = handler; - } - - public override TriggerTypes.RelatedRecords queryRelated(String providerName) { - if (!(this.handler instanceof AfterUndelete.RelatedQuery)) { - return null; - } - - if (this.providers == null) { - this.providers = ((AfterUndelete.RelatedQuery) this.handler).queryRelatedOnAfterUndelete() ?? new Map(); - } - - AfterUndelete.RecordsProvider provider = this.providers.get(providerName); - - if (provider == null) { - return null; + public void load(String providerName) { + if (this.relatedByProviderName.containsKey(providerName)) { + return; } - TriggerTypes.UndeleteRecords undeleteRecords = new TriggerTypes.UndeleteTriggerRecords(this.records); - TriggerTypes.ProvidedRecords related = new TriggerTypes.ProvidedRecords(provider.query(undeleteRecords)); + TriggerTypes.RelatedRecords related = this.queryRelated(providerName); - for (SObject row : related.getRecords()) { - related.groupUnderKey(provider.keyOf(row), row); + if (related != null) { + this.relatedByProviderName.put(providerName, related); } - - return related; - } - } - - private abstract class UnitOfWorkResolver { - public abstract UnitOfWork resolve(UnitOfWork sharedUnitOfWork); - } - - private class AfterInsertUnitOfWorkResolver extends UnitOfWorkResolver { - private AfterInsert.Writer writer; - - public AfterInsertUnitOfWorkResolver(AfterInsert.Writer writer) { - this.writer = writer; - } - - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof AfterInsert.OwnUnitOfWork - ? new UnitOfWork(((AfterInsert.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterInsert()) - : this.writer instanceof AfterInsert.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } - } - - private class AfterUpdateUnitOfWorkResolver extends UnitOfWorkResolver { - private AfterUpdate.Writer writer; - - public AfterUpdateUnitOfWorkResolver(AfterUpdate.Writer writer) { - this.writer = writer; - } - - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof AfterUpdate.OwnUnitOfWork - ? new UnitOfWork(((AfterUpdate.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterUpdate()) - : this.writer instanceof AfterUpdate.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } - } - - private class BeforeDeleteUnitOfWorkResolver extends UnitOfWorkResolver { - private BeforeDelete.Writer writer; - - public BeforeDeleteUnitOfWorkResolver(BeforeDelete.Writer writer) { - this.writer = writer; - } - - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof BeforeDelete.OwnUnitOfWork - ? new UnitOfWork(((BeforeDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnBeforeDelete()) - : this.writer instanceof BeforeDelete.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } - } - - private class AfterDeleteUnitOfWorkResolver extends UnitOfWorkResolver { - private AfterDelete.Writer writer; - - public AfterDeleteUnitOfWorkResolver(AfterDelete.Writer writer) { - this.writer = writer; } - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof AfterDelete.OwnUnitOfWork - ? new UnitOfWork(((AfterDelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterDelete()) - : this.writer instanceof AfterDelete.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } + protected abstract TriggerTypes.RelatedRecords queryRelated(String providerName); } - private class AfterUndeleteUnitOfWorkResolver extends UnitOfWorkResolver { - private AfterUndelete.Writer writer; - - public AfterUndeleteUnitOfWorkResolver(AfterUndelete.Writer writer) { - this.writer = writer; - } - - public override UnitOfWork resolve(UnitOfWork sharedUnitOfWork) { - return this.writer instanceof AfterUndelete.OwnUnitOfWork - ? new UnitOfWork(((AfterUndelete.OwnUnitOfWork) this.writer).ownUnitOfWorkOnAfterUndelete()) - : this.writer instanceof AfterUndelete.ContinueOnError ? new UnitOfWork() : sharedUnitOfWork; - } + public abstract class UnitOfWorkResolver { + public abstract UnitOfWork resolve(UnitOfWork sharedUnitOfWork); } - private class UnitOfWork implements TriggerTypes.UnitOfWork { + public class UnitOfWork implements TriggerTypes.UnitOfWork { private DML.Committable dml; public UnitOfWork() { @@ -2078,181 +834,13 @@ public with sharing class TriggerOrchestrator { } } - private abstract class HandlerLifecycle { + public abstract class HandlerLifecycle { public abstract Boolean isBypassedByAddOn(); public abstract void finalize(List qualifiedRecords); public abstract Boolean continuesOnError(); } - private class BeforeInsertHandlerLifecycle extends HandlerLifecycle { - private BeforeInsert.Handler handler; - - public BeforeInsertHandlerLifecycle(BeforeInsert.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof BeforeInsert.Bypassable && ((BeforeInsert.Bypassable) this.handler).bypassOnBeforeInsertWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeInsert.Finalizer)) { - return; - } - - ((BeforeInsert.Finalizer) this.handler).finalizeOnBeforeInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof BeforeInsert.ContinueOnError; - } - } - - private class AfterInsertHandlerLifecycle extends HandlerLifecycle { - private AfterInsert.Handler handler; - - public AfterInsertHandlerLifecycle(AfterInsert.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof AfterInsert.Bypassable && ((AfterInsert.Bypassable) this.handler).bypassOnAfterInsertWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterInsert.Finalizer)) { - return; - } - - ((AfterInsert.Finalizer) this.handler).finalizeOnAfterInsert(new TriggerTypes.InsertTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof AfterInsert.ContinueOnError; - } - } - - private class BeforeUpdateHandlerLifecycle extends HandlerLifecycle { - private BeforeUpdate.Handler handler; - - public BeforeUpdateHandlerLifecycle(BeforeUpdate.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof BeforeUpdate.Bypassable && ((BeforeUpdate.Bypassable) this.handler).bypassOnBeforeUpdateWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeUpdate.Finalizer)) { - return; - } - - ((BeforeUpdate.Finalizer) this.handler).finalizeOnBeforeUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof BeforeUpdate.ContinueOnError; - } - } - - private class AfterUpdateHandlerLifecycle extends HandlerLifecycle { - private AfterUpdate.Handler handler; - - public AfterUpdateHandlerLifecycle(AfterUpdate.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof AfterUpdate.Bypassable && ((AfterUpdate.Bypassable) this.handler).bypassOnAfterUpdateWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUpdate.Finalizer)) { - return; - } - - ((AfterUpdate.Finalizer) this.handler).finalizeOnAfterUpdate(new TriggerTypes.UpdateTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof AfterUpdate.ContinueOnError; - } - } - - private class BeforeDeleteHandlerLifecycle extends HandlerLifecycle { - private BeforeDelete.Handler handler; - - public BeforeDeleteHandlerLifecycle(BeforeDelete.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof BeforeDelete.Bypassable && ((BeforeDelete.Bypassable) this.handler).bypassOnBeforeDeleteWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof BeforeDelete.Finalizer)) { - return; - } - - ((BeforeDelete.Finalizer) this.handler).finalizeOnBeforeDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof BeforeDelete.ContinueOnError; - } - } - - private class AfterDeleteHandlerLifecycle extends HandlerLifecycle { - private AfterDelete.Handler handler; - - public AfterDeleteHandlerLifecycle(AfterDelete.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof AfterDelete.Bypassable && ((AfterDelete.Bypassable) this.handler).bypassOnAfterDeleteWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterDelete.Finalizer)) { - return; - } - - ((AfterDelete.Finalizer) this.handler).finalizeOnAfterDelete(new TriggerTypes.DeleteTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof AfterDelete.ContinueOnError; - } - } - - private class AfterUndeleteHandlerLifecycle extends HandlerLifecycle { - private AfterUndelete.Handler handler; - - public AfterUndeleteHandlerLifecycle(AfterUndelete.Handler handler) { - this.handler = handler; - } - - public override Boolean isBypassedByAddOn() { - return (this.handler instanceof AfterUndelete.Bypassable && ((AfterUndelete.Bypassable) this.handler).bypassOnAfterUndeleteWhen()); - } - - public override void finalize(List qualifiedRecords) { - if (qualifiedRecords.isEmpty() || !(this.handler instanceof AfterUndelete.Finalizer)) { - return; - } - - ((AfterUndelete.Finalizer) this.handler).finalizeOnAfterUndelete(new TriggerTypes.UndeleteTriggerRecords(qualifiedRecords)); - } - - public override Boolean continuesOnError() { - return this.handler instanceof AfterUndelete.ContinueOnError; - } - } - - private abstract class RecursionTracker { + public abstract class RecursionTracker { private Map runCountByRecordId; protected Integer maxRunsPerRecord = 3; @@ -2270,26 +858,6 @@ public with sharing class TriggerOrchestrator { } } - private class BeforeUpdateRecursionTracker extends RecursionTracker { - public BeforeUpdateRecursionTracker(BeforeUpdate.Handler handler, String handlerName) { - super(handlerName + ':BEFORE_UPDATE'); - - if (handler instanceof BeforeUpdate.RecursionGuard) { - this.maxRunsPerRecord = ((BeforeUpdate.RecursionGuard) handler).maxRunsPerRecordOnBeforeUpdate(); - } - } - } - - private class AfterUpdateRecursionTracker extends RecursionTracker { - public AfterUpdateRecursionTracker(AfterUpdate.Handler handler, String handlerName) { - super(handlerName + ':AFTER_UPDATE'); - - if (handler instanceof AfterUpdate.RecursionGuard) { - this.maxRunsPerRecord = ((AfterUpdate.RecursionGuard) handler).maxRunsPerRecordOnAfterUpdate(); - } - } - } - private class RunCounter { private Map> runCountByRecordIdByScope = new Map>(); @@ -2308,43 +876,43 @@ public with sharing class TriggerOrchestrator { private Map> mockedContextsByClassName = new Map>(); public BeforeInsertMockable beforeInsertFor(System.Type handlerOrOrchestratorType) { - BeforeInsertMockedContext mockedContext = new BeforeInsertMockedContext(); + BeforeInsert.MockedContext mockedContext = new BeforeInsert.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public AfterInsertMockable afterInsertFor(System.Type handlerOrOrchestratorType) { - AfterInsertMockedContext mockedContext = new AfterInsertMockedContext(); + AfterInsert.MockedContext mockedContext = new AfterInsert.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public BeforeUpdateMockable beforeUpdateFor(System.Type handlerOrOrchestratorType) { - BeforeUpdateMockedContext mockedContext = new BeforeUpdateMockedContext(); + BeforeUpdate.MockedContext mockedContext = new BeforeUpdate.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public AfterUpdateMockable afterUpdateFor(System.Type handlerOrOrchestratorType) { - AfterUpdateMockedContext mockedContext = new AfterUpdateMockedContext(); + AfterUpdate.MockedContext mockedContext = new AfterUpdate.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public BeforeDeleteMockable beforeDeleteFor(System.Type handlerOrOrchestratorType) { - BeforeDeleteMockedContext mockedContext = new BeforeDeleteMockedContext(); + BeforeDelete.MockedContext mockedContext = new BeforeDelete.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public AfterDeleteMockable afterDeleteFor(System.Type handlerOrOrchestratorType) { - AfterDeleteMockedContext mockedContext = new AfterDeleteMockedContext(); + AfterDelete.MockedContext mockedContext = new AfterDelete.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } public AfterUndeleteMockable afterUndeleteFor(System.Type handlerOrOrchestratorType) { - AfterUndeleteMockedContext mockedContext = new AfterUndeleteMockedContext(); + AfterUndelete.MockedContext mockedContext = new AfterUndelete.MockedContext(); this.addMockedContext(handlerOrOrchestratorType, mockedContext); return mockedContext; } @@ -2369,7 +937,7 @@ public with sharing class TriggerOrchestrator { } } - private abstract class MockedContext { + public abstract class MockedContext { protected System.TriggerOperation triggerOperation; protected List newRecords = new List(); @@ -2435,218 +1003,4 @@ public with sharing class TriggerOrchestrator { return typedRecords; } } - - private class BeforeInsertMockedContext extends MockedContext implements BeforeInsertMockable, TriggerOrchestrator.BeforeInsert { - public BeforeInsertMockedContext() { - super(System.TriggerOperation.BEFORE_INSERT); - } - - public BeforeInsertMockable with(SObject row) { - return this.with(new List{ row }); - } - - public BeforeInsertMockable with(List rows) { - this.newRecords.addAll(rows); - return this; - } - - public BeforeInsertMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public BeforeInsertMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List beforeInsertHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeInsert - ? ((TriggerOrchestrator.BeforeInsert) this.handlerOrOrchestrator).beforeInsertHandlers() - : new List{ (BeforeInsert.Handler) this.handlerOrOrchestrator }; - } - } - - private class AfterInsertMockedContext extends MockedContext implements AfterInsertMockable, TriggerOrchestrator.AfterInsert { - public AfterInsertMockedContext() { - super(System.TriggerOperation.AFTER_INSERT); - } - - public AfterInsertMockable with(SObject row) { - return this.with(new List{ row }); - } - - public AfterInsertMockable with(List rows) { - this.newRecords.addAll(this.assignIds(rows)); - return this; - } - - public AfterInsertMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public AfterInsertMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List afterInsertHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterInsert - ? ((TriggerOrchestrator.AfterInsert) this.handlerOrOrchestrator).afterInsertHandlers() - : new List{ (AfterInsert.Handler) this.handlerOrOrchestrator }; - } - } - - private class BeforeUpdateMockedContext extends MockedContext implements BeforeUpdateMockable, TriggerOrchestrator.BeforeUpdate { - public BeforeUpdateMockedContext() { - super(System.TriggerOperation.BEFORE_UPDATE); - } - - public BeforeUpdateMockable with(SObject newRow, SObject oldRow) { - return this.with(new List{ newRow }, new List{ oldRow }); - } - - public BeforeUpdateMockable with(List newRows, List oldRows) { - this.shareIds(newRows, oldRows); - this.newRecords.addAll(newRows); - this.oldRecords.addAll(oldRows); - return this; - } - - public BeforeUpdateMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public BeforeUpdateMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List beforeUpdateHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeUpdate - ? ((TriggerOrchestrator.BeforeUpdate) this.handlerOrOrchestrator).beforeUpdateHandlers() - : new List{ (BeforeUpdate.Handler) this.handlerOrOrchestrator }; - } - } - - private class AfterUpdateMockedContext extends MockedContext implements AfterUpdateMockable, TriggerOrchestrator.AfterUpdate { - public AfterUpdateMockedContext() { - super(System.TriggerOperation.AFTER_UPDATE); - } - - public AfterUpdateMockable with(SObject newRow, SObject oldRow) { - return this.with(new List{ newRow }, new List{ oldRow }); - } - - public AfterUpdateMockable with(List newRows, List oldRows) { - this.shareIds(newRows, oldRows); - this.newRecords.addAll(newRows); - this.oldRecords.addAll(oldRows); - return this; - } - - public AfterUpdateMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public AfterUpdateMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List afterUpdateHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterUpdate - ? ((TriggerOrchestrator.AfterUpdate) this.handlerOrOrchestrator).afterUpdateHandlers() - : new List{ (AfterUpdate.Handler) this.handlerOrOrchestrator }; - } - } - - private class BeforeDeleteMockedContext extends MockedContext implements BeforeDeleteMockable, TriggerOrchestrator.BeforeDelete { - public BeforeDeleteMockedContext() { - super(System.TriggerOperation.BEFORE_DELETE); - } - - public BeforeDeleteMockable with(SObject row) { - return this.with(new List{ row }); - } - - public BeforeDeleteMockable with(List rows) { - this.oldRecords.addAll(this.assignIds(rows)); - return this; - } - - public BeforeDeleteMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public BeforeDeleteMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List beforeDeleteHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.BeforeDelete - ? ((TriggerOrchestrator.BeforeDelete) this.handlerOrOrchestrator).beforeDeleteHandlers() - : new List{ (BeforeDelete.Handler) this.handlerOrOrchestrator }; - } - } - - private class AfterDeleteMockedContext extends MockedContext implements AfterDeleteMockable, TriggerOrchestrator.AfterDelete { - public AfterDeleteMockedContext() { - super(System.TriggerOperation.AFTER_DELETE); - } - - public AfterDeleteMockable with(SObject row) { - return this.with(new List{ row }); - } - - public AfterDeleteMockable with(List rows) { - this.oldRecords.addAll(this.assignIds(rows)); - return this; - } - - public AfterDeleteMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public AfterDeleteMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List afterDeleteHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterDelete - ? ((TriggerOrchestrator.AfterDelete) this.handlerOrOrchestrator).afterDeleteHandlers() - : new List{ (AfterDelete.Handler) this.handlerOrOrchestrator }; - } - } - - private class AfterUndeleteMockedContext extends MockedContext implements AfterUndeleteMockable, TriggerOrchestrator.AfterUndelete { - public AfterUndeleteMockedContext() { - super(System.TriggerOperation.AFTER_UNDELETE); - } - - public AfterUndeleteMockable with(SObject row) { - return this.with(new List{ row }); - } - - public AfterUndeleteMockable with(List rows) { - this.newRecords.addAll(this.assignIds(rows)); - return this; - } - - public AfterUndeleteMockable withParent(SObjectField lookupField, SObject parent) { - return this.withParent(lookupField, new List{ parent }); - } - - public AfterUndeleteMockable withParent(SObjectField lookupField, List parents) { - this.addParents(lookupField, parents); - return this; - } - - public List afterUndeleteHandlers() { - return this.handlerOrOrchestrator instanceof TriggerOrchestrator.AfterUndelete - ? ((TriggerOrchestrator.AfterUndelete) this.handlerOrOrchestrator).afterUndeleteHandlers() - : new List{ (AfterUndelete.Handler) this.handlerOrOrchestrator }; - } - } } diff --git a/website/.vitepress/apex-api.mjs b/website/.vitepress/apex-api.mjs index 278aa6c..0120fe3 100644 --- a/website/.vitepress/apex-api.mjs +++ b/website/.vitepress/apex-api.mjs @@ -569,20 +569,24 @@ function extractClassBodies(source) { } export function parseAdapterHonours() { - const bodies = extractClassBodies( - stripComments(readClass('TriggerOrchestrator')) - ); + const bodies = new Map(); const honours = {}; + for (const context of model.contexts) { + for (const [className, body] of extractClassBodies( + stripComments(readClass(context.name)) + )) { + bodies.set(`${context.name}.${className}`, body); + } + } + for (const context of model.contexts) { honours[context.name] = {}; for (const role of context.roles) { - const adapterName = `${context.name}${role}Adapter`; + const adapterName = `${context.name}.${role}Adapter`; if (!bodies.has(adapterName)) { - throw new Error( - `apex-api: TriggerOrchestrator.cls has no ${adapterName}` - ); + throw new Error(`apex-api: ${context.name}.cls has no ${role}Adapter`); } const visited = new Set(); @@ -601,7 +605,7 @@ export function parseAdapterHonours() { if (context.addOns.includes(match[1])) found.add(match[1]); } - for (const match of body.matchAll(/\bnew\s+(\w+)\s*\(/g)) { + for (const match of body.matchAll(/\bnew\s+([\w.]+)\s*\(/g)) { if (bodies.has(match[1]) && !visited.has(match[1])) queue.push(match[1]); }