From a1462537b884a13739307bf2393d68fa3d6c8571 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 26 Sep 2026 18:34:20 +0200 Subject: [PATCH 1/2] v0.3.0 --- .../after-update/writer/OpportunityAccountTypeWriter.cls | 6 +++++- force-app/main/default/classes/TriggerTypes.cls | 3 ++- package/main/default/classes/TriggerTypes.cls | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) 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 03b984c..6a6b43f 100644 --- a/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls +++ b/examples/main/default/classes/opportunity/after-update/writer/OpportunityAccountTypeWriter.cls @@ -8,9 +8,13 @@ public with sharing class OpportunityAccountTypeWriter implements AfterUpdate.Wr } public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + if (!record.isChangedTo(Opportunity.StageName, 'Closed Won')) { + return false; + } + Account account = (Account) record.getNewParent(Opportunity.AccountId); - return record.isChangedTo(Opportunity.StageName, 'Closed Won') && account != null && account.Type != 'Customer - Direct'; + return account != null && account.Type != 'Customer - Direct'; } public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { diff --git a/force-app/main/default/classes/TriggerTypes.cls b/force-app/main/default/classes/TriggerTypes.cls index 058e53b..e37697e 100644 --- a/force-app/main/default/classes/TriggerTypes.cls +++ b/force-app/main/default/classes/TriggerTypes.cls @@ -812,7 +812,8 @@ public with sharing class TriggerTypes { } public Boolean isChangedToNotNull(SObjectField field) { - return this.isNotNull(field) && this.isChanged(field); + Object fieldValue = this.getFieldValue(field); + return fieldValue != null && fieldValue != this.getOldFieldValue(field); } public Boolean isChangedFrom(SObjectField field, Object value) { diff --git a/package/main/default/classes/TriggerTypes.cls b/package/main/default/classes/TriggerTypes.cls index 82ed805..359a62d 100644 --- a/package/main/default/classes/TriggerTypes.cls +++ b/package/main/default/classes/TriggerTypes.cls @@ -812,7 +812,8 @@ global with sharing class TriggerTypes { } public Boolean isChangedToNotNull(SObjectField field) { - return this.isNotNull(field) && this.isChanged(field); + Object fieldValue = this.getFieldValue(field); + return fieldValue != null && fieldValue != this.getOldFieldValue(field); } public Boolean isChangedFrom(SObjectField field, Object value) { From dce8b87d1f6529dbcbeaa297eb5e89b18516c633 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Mon, 26 Oct 2026 18:28:45 +0100 Subject: [PATCH 2/2] runFromMetadata --- .../default/classes/TriggerOrchestrator.cls | 155 +++- .../classes/TriggerOrchestratorTest.cls | 698 +++++++++++++++++- .../fields/Order__c.field-meta.xml | 11 + .../default/classes/TriggerOrchestrator.cls | 155 +++- .../classes/TriggerOrchestratorTest.cls | 698 +++++++++++++++++- .../fields/Order__c.field-meta.xml | 11 + website/api/custom-metadata.md | 24 +- website/api/trigger-orchestrator.md | 34 +- website/guide/bypasses.md | 4 +- website/guide/error-handling.md | 2 +- website/guide/execution-order.md | 6 +- website/guide/orchestrator.md | 25 +- website/guide/testing.md | 23 + website/introduction/design-principles.md | 6 +- 14 files changed, 1820 insertions(+), 32 deletions(-) create mode 100644 force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml create mode 100644 package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index a38e59e..a65f8dc 100644 --- a/force-app/main/default/classes/TriggerOrchestrator.cls +++ b/force-app/main/default/classes/TriggerOrchestrator.cls @@ -63,6 +63,10 @@ public with sharing class TriggerOrchestrator { new TriggerOrchestrator(orchestrator).run(); } + public static void runFromMetadata() { + new TriggerOrchestrator(new MetadataOrchestrator((Trigger.new ?? Trigger.old)?.getSObjectType())).run(); + } + public static Bypassable bypass() { return TriggerOrchestrator.triggerBypass; } @@ -300,6 +304,10 @@ public with sharing class TriggerOrchestrator { return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; } + public List getHandlerRows(SObjectType sObjectType) { + return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.objectRow.TriggerHandlers__r ?? new List(); + } + private Map resolveObjectMetadata() { Map metadataByObjectName = new Map(); String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); @@ -307,7 +315,13 @@ public with sharing class TriggerOrchestrator { 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)) + .with( + SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r') + .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) + .orderBy(TriggerHandler__mdt.Order__c) + .nullsLast() + .orderBy(TriggerHandler__mdt.ApexClassName__c) + ) .systemMode() .mockId('TriggerObject__mdt') .toList() @@ -346,6 +360,141 @@ public with sharing class TriggerOrchestrator { } } + @TestVisible + private class MetadataOrchestrator implements TriggerOrchestrator.BeforeInsert, TriggerOrchestrator.AfterInsert, TriggerOrchestrator.BeforeUpdate, TriggerOrchestrator.AfterUpdate, TriggerOrchestrator.BeforeDelete, TriggerOrchestrator.AfterDelete, TriggerOrchestrator.AfterUndelete { + private SObjectType sObjectType; + + public MetadataOrchestrator(SObjectType sObjectType) { + this.sObjectType = sObjectType; + } + + public List beforeInsertHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeInsert.Handler) { + handlers.add((BeforeInsert.Handler) handler); + } + } + + return handlers; + } + + public List afterInsertHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterInsert.Handler) { + handlers.add((AfterInsert.Handler) handler); + } + } + + return handlers; + } + + public List beforeUpdateHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeUpdate.Handler) { + handlers.add((BeforeUpdate.Handler) handler); + } + } + + return handlers; + } + + public List afterUpdateHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterUpdate.Handler) { + handlers.add((AfterUpdate.Handler) handler); + } + } + + return handlers; + } + + public List beforeDeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeDelete.Handler) { + handlers.add((BeforeDelete.Handler) handler); + } + } + + return handlers; + } + + public List afterDeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterDelete.Handler) { + handlers.add((AfterDelete.Handler) handler); + } + } + + return handlers; + } + + public List afterUndeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterUndelete.Handler) { + handlers.add((AfterUndelete.Handler) handler); + } + } + + return handlers; + } + + private List newHandlers() { + List handlers = new List(); + + for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType)) { + if (handlerRow.Bypass__c != true) { + handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim())); + } + } + + return handlers; + } + + private Object newHandler(String className) { + System.Type handlerType = Type.forName(className) ?? Type.forName(null, className); + + if (handlerType == null) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' was not found.'); + } + + Object handler; + + try { + handler = handlerType.newInstance(); + } catch (Exception e) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' could not be instantiated.', e); + } + + if ( + handler instanceof BeforeInsert.Handler || + handler instanceof AfterInsert.Handler || + handler instanceof BeforeUpdate.Handler || + handler instanceof AfterUpdate.Handler || + handler instanceof BeforeDelete.Handler || + handler instanceof AfterDelete.Handler || + handler instanceof AfterUndelete.Handler + ) { + return handler; + } + + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement a handler interface.'); + } + } + private class TriggerStack { private List executionStack = new List(); @@ -455,6 +604,10 @@ public with sharing class TriggerOrchestrator { public void execute(TriggerOrchestrator orchestrator) { List activeExecutors = this.getActiveExecutors(orchestrator); + if (activeExecutors.isEmpty()) { + return; + } + orchestrator.parentLoader.collectDeclarations(activeExecutors); for (HandlerExecutor executor : activeExecutors) { diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 729d4c2..4a7c0c2 100644 --- a/force-app/main/default/classes/TriggerOrchestratorTest.cls +++ b/force-app/main/default/classes/TriggerOrchestratorTest.cls @@ -1225,6 +1225,696 @@ private class TriggerOrchestratorTest { Assert.isNull(doe.Title, 'The handler should not run.'); } + @IsTest + static void runFromMetadataOutsideTriggerContext() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runFromMetadata(); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual('Called outside of a trigger context.', expectedException?.getMessage(), 'The exception message should match the expected one.'); + } + + @IsTest + static void runFromMetadataOnBeforeInsert() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_INSERT', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterInsert() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_INSERT', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnBeforeUpdate() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_UPDATE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterUpdate() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_UPDATE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnBeforeDelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeDeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_DELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterDelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_DELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterUndelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUndeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_UNDELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataInQueryOrder() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 2, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + }, + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.CeoDepartmentPopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('Management', doe.Department, 'The handlers should run in the order of their records.'); + } + + @IsTest + static void runFromMetadataWithPaddedName() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TriggerOrchestratorTest.TitlePopulator ', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The padded class name should be resolved.'); + } + + @IsTest + static void runFromMetadataWithBypassedRow() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => true + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The bypassed handler should not run.'); + } + + @IsTest + static void runFromMetadataWithBypassedUnknownClass() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', + String.valueOf(TriggerHandler__mdt.Bypass__c) => true + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isNull(expectedException, 'The run should not throw an exception.'); + } + + @IsTest + static void runFromMetadataWithHandlerOfOtherContext() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isNull(expectedException, 'The run should not throw an exception.'); + } + + @IsTest + static void runFromMetadataWithRowsOfOtherObject() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The handler of another object should not run.'); + } + + @IsTest + static void runFromMetadataWithUnknownClass() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.MissingHandler was not found.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void runFromMetadataWithoutNoArgConstructor() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.NestedRunPopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.NestedRunPopulator could not be instantiated.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void runFromMetadataWithClassThatIsNotHandler() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TestLogger', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.TestLogger does not implement a handler interface.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + @IsTest static void finalizerAfterLastRecord() { // Setup @@ -2150,7 +2840,7 @@ private class TriggerOrchestratorTest { Assert.isInstanceOfType(expectedException, System.TypeException.class, 'The unimplemented context should fail.'); } - private class OperationStamper implements BeforeInsert.Populator, AfterInsert.Writer, BeforeUpdate.Populator, AfterUpdate.Writer, BeforeDelete.Writer, AfterDelete.Writer, AfterUndelete.Writer { + public class OperationStamper implements BeforeInsert.Populator, AfterInsert.Writer, BeforeUpdate.Populator, AfterUpdate.Writer, BeforeDelete.Writer, AfterDelete.Writer, AfterUndelete.Writer { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); } @@ -2230,7 +2920,7 @@ private class TriggerOrchestratorTest { } } - private class CeoDepartmentPopulator implements BeforeInsert.Populator { + public class CeoDepartmentPopulator implements BeforeInsert.Populator { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.Title, 'CEO'); } @@ -2243,7 +2933,7 @@ private class TriggerOrchestratorTest { private class RolelessHandler implements BeforeInsert.Handler { } - private class NestedRunPopulator implements BeforeInsert.Populator { + public class NestedRunPopulator implements BeforeInsert.Populator { private Object nestedOrchestrator; public NestedRunPopulator(Object nestedOrchestrator) { @@ -2807,7 +3497,7 @@ private class TriggerOrchestratorTest { } } - private virtual class TitlePopulator implements BeforeInsert.Populator, BeforeUpdate.Populator { + public virtual class TitlePopulator implements BeforeInsert.Populator, BeforeUpdate.Populator { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); } diff --git a/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml b/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml new file mode 100644 index 0000000..873aef1 --- /dev/null +++ b/force-app/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Order__c + SubscriberControlled + + 18 + false + 0 + Number + false + diff --git a/package/main/default/classes/TriggerOrchestrator.cls b/package/main/default/classes/TriggerOrchestrator.cls index fc6a098..c53190a 100644 --- a/package/main/default/classes/TriggerOrchestrator.cls +++ b/package/main/default/classes/TriggerOrchestrator.cls @@ -63,6 +63,10 @@ global with sharing class TriggerOrchestrator { new TriggerOrchestrator(orchestrator).run(); } + global static void runFromMetadata() { + new TriggerOrchestrator(new MetadataOrchestrator((Trigger.new ?? Trigger.old)?.getSObjectType())).run(); + } + global static Bypassable bypass() { return TriggerOrchestrator.triggerBypass; } @@ -297,6 +301,10 @@ global with sharing class TriggerOrchestrator { return this.metadataByObjectName.get(context.sObjectType.toString().toLowerCase())?.handlerRowByName.get(handlerName.toLowerCase())?.Bypass__c == true; } + public List getHandlerRows(SObjectType sObjectType) { + return this.metadataByObjectName.get(sObjectType.toString().toLowerCase())?.objectRow.TriggerHandlers__r ?? new List(); + } + private Map resolveObjectMetadata() { Map metadataByObjectName = new Map(); String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); @@ -304,7 +312,13 @@ global with sharing class TriggerOrchestrator { 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)) + .with( + SOQL.SubQuery.of(namespacePrefix + 'TriggerHandlers__r') + .with(TriggerHandler__mdt.ApexClassName__c, TriggerHandler__mdt.Bypass__c, TriggerHandler__mdt.Order__c) + .orderBy(TriggerHandler__mdt.Order__c) + .nullsLast() + .orderBy(TriggerHandler__mdt.ApexClassName__c) + ) .systemMode() .mockId('TriggerObject__mdt') .toList() @@ -343,6 +357,141 @@ global with sharing class TriggerOrchestrator { } } + @TestVisible + private class MetadataOrchestrator implements TriggerOrchestrator.BeforeInsert, TriggerOrchestrator.AfterInsert, TriggerOrchestrator.BeforeUpdate, TriggerOrchestrator.AfterUpdate, TriggerOrchestrator.BeforeDelete, TriggerOrchestrator.AfterDelete, TriggerOrchestrator.AfterUndelete { + private SObjectType sObjectType; + + public MetadataOrchestrator(SObjectType sObjectType) { + this.sObjectType = sObjectType; + } + + public List beforeInsertHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeInsert.Handler) { + handlers.add((BeforeInsert.Handler) handler); + } + } + + return handlers; + } + + public List afterInsertHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterInsert.Handler) { + handlers.add((AfterInsert.Handler) handler); + } + } + + return handlers; + } + + public List beforeUpdateHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeUpdate.Handler) { + handlers.add((BeforeUpdate.Handler) handler); + } + } + + return handlers; + } + + public List afterUpdateHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterUpdate.Handler) { + handlers.add((AfterUpdate.Handler) handler); + } + } + + return handlers; + } + + public List beforeDeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof BeforeDelete.Handler) { + handlers.add((BeforeDelete.Handler) handler); + } + } + + return handlers; + } + + public List afterDeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterDelete.Handler) { + handlers.add((AfterDelete.Handler) handler); + } + } + + return handlers; + } + + public List afterUndeleteHandlers() { + List handlers = new List(); + + for (Object handler : this.newHandlers()) { + if (handler instanceof AfterUndelete.Handler) { + handlers.add((AfterUndelete.Handler) handler); + } + } + + return handlers; + } + + private List newHandlers() { + List handlers = new List(); + + for (TriggerHandler__mdt handlerRow : TriggerOrchestrator.metadataBypass.getHandlerRows(this.sObjectType)) { + if (handlerRow.Bypass__c != true) { + handlers.add(this.newHandler(handlerRow.ApexClassName__c.trim())); + } + } + + return handlers; + } + + private Object newHandler(String className) { + System.Type handlerType = Type.forName(className) ?? Type.forName(null, className); + + if (handlerType == null) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' was not found.'); + } + + Object handler; + + try { + handler = handlerType.newInstance(); + } catch (Exception e) { + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' could not be instantiated.', e); + } + + if ( + handler instanceof BeforeInsert.Handler || + handler instanceof AfterInsert.Handler || + handler instanceof BeforeUpdate.Handler || + handler instanceof AfterUpdate.Handler || + handler instanceof BeforeDelete.Handler || + handler instanceof AfterDelete.Handler || + handler instanceof AfterUndelete.Handler + ) { + return handler; + } + + throw new TriggerTypes.TriggerLibException('Handler class ' + className + ' does not implement a handler interface.'); + } + } + private class TriggerStack { private List executionStack = new List(); @@ -452,6 +601,10 @@ global with sharing class TriggerOrchestrator { public void execute(TriggerOrchestrator orchestrator) { List activeExecutors = this.getActiveExecutors(orchestrator); + if (activeExecutors.isEmpty()) { + return; + } + orchestrator.parentLoader.collectDeclarations(activeExecutors); for (HandlerExecutor executor : activeExecutors) { diff --git a/package/main/default/classes/TriggerOrchestratorTest.cls b/package/main/default/classes/TriggerOrchestratorTest.cls index 729d4c2..4a7c0c2 100644 --- a/package/main/default/classes/TriggerOrchestratorTest.cls +++ b/package/main/default/classes/TriggerOrchestratorTest.cls @@ -1225,6 +1225,696 @@ private class TriggerOrchestratorTest { Assert.isNull(doe.Title, 'The handler should not run.'); } + @IsTest + static void runFromMetadataOutsideTriggerContext() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runFromMetadata(); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual('Called outside of a trigger context.', expectedException?.getMessage(), 'The exception message should match the expected one.'); + } + + @IsTest + static void runFromMetadataOnBeforeInsert() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_INSERT', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterInsert() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_INSERT', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnBeforeUpdate() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_UPDATE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterUpdate() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe, new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_UPDATE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnBeforeDelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeDeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('BEFORE_DELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterDelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_DELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataOnAfterUndelete() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.OperationStamper', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUndeleteFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('AFTER_UNDELETE', doe.Description, 'The registered handler should run.'); + } + + @IsTest + static void runFromMetadataInQueryOrder() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 2, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + }, + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.CeoDepartmentPopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('Management', doe.Department, 'The handlers should run in the order of their records.'); + } + + @IsTest + static void runFromMetadataWithPaddedName() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => ' TriggerOrchestratorTest.TitlePopulator ', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The padded class name should be resolved.'); + } + + @IsTest + static void runFromMetadataWithBypassedRow() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => true + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The bypassed handler should not run.'); + } + + @IsTest + static void runFromMetadataWithBypassedUnknownClass() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', + String.valueOf(TriggerHandler__mdt.Bypass__c) => true + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isNull(expectedException, 'The run should not throw an exception.'); + } + + @IsTest + static void runFromMetadataWithHandlerOfOtherContext() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().afterInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isNull(expectedException, 'The run should not throw an exception.'); + } + + @IsTest + static void runFromMetadataWithRowsOfOtherObject() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt accountSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Account', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TitlePopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ accountSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + + // Verify + Assert.isNull(doe.Title, 'The handler of another object should not run.'); + } + + @IsTest + static void runFromMetadataWithUnknownClass() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.MissingHandler', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.MissingHandler was not found.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void runFromMetadataWithoutNoArgConstructor() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.NestedRunPopulator', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.NestedRunPopulator could not be instantiated.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void runFromMetadataWithClassThatIsNotHandler() { + // Setup + String namespacePrefix = String.valueOf(TriggerObject__mdt.SObjectType).removeEnd('TriggerObject__mdt'); + TriggerObject__mdt contactSetting = (TriggerObject__mdt) JSON.deserialize( + JSON.serialize( + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerObject__mdt.SObjectType) }, + String.valueOf(TriggerObject__mdt.ObjectAPIName__c) => 'Contact', + String.valueOf(TriggerObject__mdt.Bypass__c) => false, + namespacePrefix + 'TriggerHandlers__r' => new Map{ + 'totalSize' => 1, + 'done' => true, + 'records' => new List{ + new Map{ + 'attributes' => new Map{ 'type' => String.valueOf(TriggerHandler__mdt.SObjectType) }, + String.valueOf(TriggerHandler__mdt.ApexClassName__c) => 'TriggerOrchestratorTest.TestLogger', + String.valueOf(TriggerHandler__mdt.Bypass__c) => false + } + } + } + } + ), + TriggerObject__mdt.class + ); + SOQL.mock('TriggerObject__mdt').thenReturn(new List{ contactSetting }); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeInsertFor(TriggerOrchestrator.MetadataOrchestrator.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new TriggerOrchestrator.MetadataOrchestrator(Contact.SObjectType)); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'Handler class TriggerOrchestratorTest.TestLogger does not implement a handler interface.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + @IsTest static void finalizerAfterLastRecord() { // Setup @@ -2150,7 +2840,7 @@ private class TriggerOrchestratorTest { Assert.isInstanceOfType(expectedException, System.TypeException.class, 'The unimplemented context should fail.'); } - private class OperationStamper implements BeforeInsert.Populator, AfterInsert.Writer, BeforeUpdate.Populator, AfterUpdate.Writer, BeforeDelete.Writer, AfterDelete.Writer, AfterUndelete.Writer { + public class OperationStamper implements BeforeInsert.Populator, AfterInsert.Writer, BeforeUpdate.Populator, AfterUpdate.Writer, BeforeDelete.Writer, AfterDelete.Writer, AfterUndelete.Writer { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); } @@ -2230,7 +2920,7 @@ private class TriggerOrchestratorTest { } } - private class CeoDepartmentPopulator implements BeforeInsert.Populator { + public class CeoDepartmentPopulator implements BeforeInsert.Populator { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.Title, 'CEO'); } @@ -2243,7 +2933,7 @@ private class TriggerOrchestratorTest { private class RolelessHandler implements BeforeInsert.Handler { } - private class NestedRunPopulator implements BeforeInsert.Populator { + public class NestedRunPopulator implements BeforeInsert.Populator { private Object nestedOrchestrator; public NestedRunPopulator(Object nestedOrchestrator) { @@ -2807,7 +3497,7 @@ private class TriggerOrchestratorTest { } } - private virtual class TitlePopulator implements BeforeInsert.Populator, BeforeUpdate.Populator { + public virtual class TitlePopulator implements BeforeInsert.Populator, BeforeUpdate.Populator { public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); } diff --git a/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml b/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml new file mode 100644 index 0000000..873aef1 --- /dev/null +++ b/package/main/default/objects/TriggerHandler__mdt/fields/Order__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Order__c + SubscriberControlled + + 18 + false + 0 + Number + false + diff --git a/website/api/custom-metadata.md b/website/api/custom-metadata.md index c76f147..97607ae 100644 --- a/website/api/custom-metadata.md +++ b/website/api/custom-metadata.md @@ -1,10 +1,10 @@ --- -description: 'TriggerObject__mdt and TriggerHandler__mdt reference: switch off every handler on an object, or one handler class, org-wide without deploying code; fields, the ApexClassName__c format, when the records are read, and how to deploy them.' +description: 'TriggerObject__mdt and TriggerHandler__mdt reference: switch off every handler on an object, or one handler class, org-wide without deploying code, and register the handlers of a runFromMetadata() trigger in Order__c order; fields, the ApexClassName__c format, when the records are read, and how to deploy them.' --- # Custom Metadata -Two custom metadata types switch handlers off for the whole org, without code changes. Create the records in Setup under **Custom Metadata Types → Manage Records**, or deploy them. +Two custom metadata types switch handlers off for the whole org, without code changes. For a trigger that calls [`runFromMetadata()`](/api/trigger-orchestrator#run-from-metadata), they also register its handlers. Create the records in Setup under **Custom Metadata Types → Manage Records**, or deploy them. ## TriggerObject__mdt {#trigger-object} @@ -17,34 +17,39 @@ One record per object. - **The whole run is skipped.** No handler, parent query or provider runs, and no Logger method is called. - **Also the parent of handler records.** `TriggerHandler__mdt` records need it, even with `Bypass__c` unchecked. +- **Keep one record per object.** With several, only one is read, and the handler records under the others are ignored. ## TriggerHandler__mdt {#trigger-handler} -One record per handler class to switch off on one object. +One record per handler class on one object. | Field | Holds | |---|---| | `TriggerObject__c` | the `TriggerObject__mdt` record of the object | | `ApexClassName__c` | the handler's class name | | `Bypass__c` | checked: this handler is off on that object | +| `Order__c` | the run order under `runFromMetadata()`, lowest first; blank runs last | +- **Two roles.** Under `runFromMetadata()`, every record runs its class unless `Bypass__c` is checked. Under `run(orchestrator)`, a record only switches its class off, and `Order__c` is ignored. - **In every context.** A class registered in before insert and before update is off in both. To switch off one context only, use that context's Bypassable add-on. - **Only on that object.** A class used on Account and on Contact needs a record under each object. ### ApexClassName__c Format {#class-name-format} -The value is matched against the class name without its outer class. Case does not matter. +For a bypass, the value is matched against the class name without its outer class. Case does not matter. | Handler class | Values that match | |---|---| | top-level `AccountRatingPopulator` | `AccountRatingPopulator` | | inner `AccountRules.RatingPopulator` | `RatingPopulator` or `AccountRules.RatingPopulator` | -- **A misspelled name** matches nothing, and nothing warns you. +- **`runFromMetadata()` needs the full name,** such as `AccountRules.RatingPopulator`, or `ns.AccountRatingPopulator` for a class in another namespace. It throws on a name it cannot find. +- **A misspelled name** in a bypass matches nothing, and nothing warns you. +- **Inner classes with the same name can clash.** `SalesRules.RatingPopulator` and `ServiceRules.RatingPopulator` share the bypass key `RatingPopulator`, so one record can switch off both. ## When They Are Read {#reading} -- **Once per transaction.** The first `run` or `bypass()` call reads all records with one query. Changes apply from the next transaction. +- **Once per transaction.** The first `run`, `runFromMetadata` or `bypass()` call reads all records with one query. Changes apply from the next transaction. - **No SOQL limit cost.** Custom metadata queries do not count toward the limit. - **Tests see the org's records.** Tests that run the orchestrator can [mock the query](/guide/testing#mock-metadata). @@ -87,7 +92,11 @@ The file name is `..md-meta.xml`, for example in `force-app Bypass__c - true + false + + + Order__c + 10 ``` @@ -96,3 +105,4 @@ The file name is `..md-meta.xml`, for example in `force-app - **Declare `xmlns:xsd`.** Without it, the deployment fails with an `UNKNOWN_EXCEPTION` that names no component. - **`TriggerObject__c` holds the DeveloperName alone,** such as `Account`, not `TriggerObject.Account`. +- **`Order__c` is an `xsd:double`.** Leave the `` block out for a blank order. diff --git a/website/api/trigger-orchestrator.md b/website/api/trigger-orchestrator.md index 1381115..f0b6ba9 100644 --- a/website/api/trigger-orchestrator.md +++ b/website/api/trigger-orchestrator.md @@ -1,5 +1,5 @@ --- -description: 'TriggerOrchestrator reference: run() from the trigger, the seven registration interfaces, the bypass() builder, the Logger and Error interfaces, and TriggerLibException.' +description: 'TriggerOrchestrator reference: run() and runFromMetadata() from the trigger, the seven registration interfaces, the bypass() builder, the Logger and Error interfaces, and TriggerLibException.' --- # TriggerOrchestrator @@ -23,6 +23,31 @@ public static void run(Object orchestrator) - **Handlers run in list order.** Each handler goes through every record of the chunk before the next handler starts. - **Switched off, it returns at once.** `bypass()` or a [`TriggerObject__mdt`](/api/custom-metadata) record can switch off the object or the orchestrator. +## runFromMetadata {#run-from-metadata} + +Runs the handlers that [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) records register for the trigger's object. There is no orchestrator class, so the trigger's package never refers to the handlers, and they can live in packages that depend on it. + +**Signature** + +```apex +public static void runFromMetadata() +``` + +**Example** + +```apex +trigger AccountTrigger on Account(before insert, after insert, before update, after update, before delete, after delete, after undelete) { + TriggerOrchestrator.runFromMetadata(); +} +``` + +- **Every record of the object runs,** unless its `Bypass__c` is checked. +- **`Order__c` is run order,** lowest first. Records without it run last. Ties run in class name order. +- **The class decides the contexts.** It runs in every context whose role it implements, at the same `Order__c`. +- **A new instance per run.** The class needs a public no-argument constructor. Every record's class is created in every context, so keep constructors cheap. +- **A record that cannot run throws.** A class that is not found, cannot be created or implements no handler interface fails the save with a `TriggerTypes.TriggerLibException`. +- **`bypass().orchestrator(…)` does not apply.** Use `sObject(…)`, `handler(…)` or the record's `Bypass__c`. + ## Registration Interfaces {#registration} Your orchestrator implements one interface per context. Its method returns that context's handlers in run order. @@ -119,7 +144,7 @@ public interface Logger { - **`log(error)`** runs for every exception in a handler's turn, before the library rethrows it or ContinueOnError swallows it. - **`flush()`** runs at the end of each outermost run, also when it failed. -- **Found automatically.** Implement it in one class and register nothing. With two implementations, the first call to `run` or `bypass()` throws. +- **Found automatically.** Implement it in one class and register nothing. With two implementations, the first call to `run`, `runFromMetadata` or `bypass()` throws. - **Not logged:** exceptions outside a handler's turn, listed in [Errors & Logging](/guide/error-handling#never-logged). ## Error {#error} @@ -147,8 +172,11 @@ public interface Error { | Message | Thrown when | |---|---| -| `Called outside of a trigger context.` | `run` is called outside a trigger | +| `Called outside of a trigger context.` | `run` or `runFromMetadata` is called outside a trigger | | `Multiple implementations of TriggerOrchestrator.Logger found. Only one implementation is allowed.` | the org has two Logger classes | +| `Handler class was not found.` | a `TriggerHandler__mdt` record of a `runFromMetadata` object names a class that does not exist or is not public | +| `Handler class could not be instantiated.` | the class has no public no-argument constructor, or its constructor threw; the cause holds the original exception | +| `Handler class does not implement a handler interface.` | the class implements no context's handler interface | | ` performed DML in before insert. Move that work to an after insert Writer.` | a before insert Populator or Validator ran DML or published an immediate event | | ` performed DML in before update. Move that work to an after update Writer.` | the same in before update | | ` qualified a record in addErrorOnWhen but attached no error in addErrorOn.` | a Validator's predicate returned true and its error method attached no error | diff --git a/website/guide/bypasses.md b/website/guide/bypasses.md index 53f86ad..deb9092 100644 --- a/website/guide/bypasses.md +++ b/website/guide/bypasses.md @@ -24,7 +24,7 @@ try { ## From Custom Metadata {#metadata} -Check `Bypass__c` on a `TriggerObject__mdt` record to switch off every handler on an object for every user, or on a `TriggerHandler__mdt` record under it to switch off one class. Changes apply from the next transaction. Fields, deployment and test mocks: [Custom Metadata](/api/custom-metadata). +Check `Bypass__c` on a `TriggerObject__mdt` record to switch off every handler on an object for every user, or on a `TriggerHandler__mdt` record under it to switch off one class. Under `runFromMetadata()`, a bypassed record's class is not even created. Changes apply from the next transaction. Fields, deployment and test mocks: [Custom Metadata](/api/custom-metadata). ## From the Handler {#bypassable} @@ -34,7 +34,7 @@ Implement the context's Bypassable. `bypassOnWhen()` runs once per handler - **Checked last.** The Apex and metadata bypasses win. - **Checked before the first handler runs.** A flag set by an earlier handler in the same run applies from the next run. -- **One context only.** The other bypasses cover every context. To skip a class in one context, use Bypassable or leave it out of that context's list. +- **One context only.** The other bypasses cover every context. To skip a class in one context, use Bypassable, or leave it out of that context's list when an orchestrator lists the handlers. - **To skip only some records,** return false from the predicate instead. ## Per User {#per-user} diff --git a/website/guide/error-handling.md b/website/guide/error-handling.md index e498caf..b16001d 100644 --- a/website/guide/error-handling.md +++ b/website/guide/error-handling.md @@ -64,7 +64,7 @@ Implement the context's ContinueOnError to let the save go on when the handler f This code runs outside every handler. An exception there is not logged, ContinueOnError does not apply, and the save fails: -- `Handlers()`; +- `Handlers()`, and under `runFromMetadata()`, creating the registered classes; - `bypassOnWhen()`, `ownUnitOfWorkOn()` and `maxRunsPerRecordOn()`; - the ParentQuery and PriorParentQuery methods, and the parent query after a Populator changes a lookup; - the shared unit of work's commit. diff --git a/website/guide/execution-order.md b/website/guide/execution-order.md index 6e68190..34c16cb 100644 --- a/website/guide/execution-order.md +++ b/website/guide/execution-order.md @@ -13,8 +13,8 @@ Each run calls `Handlers()`, loads parents, runs providers and commits agai ## Order of a Run {#order} 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. +2. **Handler list.** `Handlers()` runs. Under `runFromMetadata()`, the library instead creates the class of every unbypassed `TriggerHandler__mdt` record of the object, in `Order__c` order, and keeps those that implement the context. 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. When no handler is left, the run ends here, and only the Logger step follows. 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.** - Each RelatedQuery provider runs over all records the first time the handler reads it. @@ -30,7 +30,7 @@ SOQL queries a run spends outside your code: | Source | SOQL queries | |---|---| -| Bypass metadata | 0, once per transaction | +| Bypass and registration metadata | 0, once per transaction | | Logger discovery | 1, once per transaction | | 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 | diff --git a/website/guide/orchestrator.md b/website/guide/orchestrator.md index cef3002..89ea796 100644 --- a/website/guide/orchestrator.md +++ b/website/guide/orchestrator.md @@ -1,10 +1,10 @@ --- -description: Wire a Salesforce trigger to Trigger Lib - the one-line trigger, the orchestrator and its handler lists, run order, one class in several contexts, handler instances per chunk and the before to after handoff. +description: Wire a Salesforce trigger to Trigger Lib - the one-line trigger, the orchestrator and its handler lists, handlers registered in custom metadata, run order, one class in several contexts, handler instances per chunk and the before to after handoff. --- # Trigger & Orchestrator -The trigger passes an orchestrator to `TriggerOrchestrator.run(…)`. The orchestrator lists the handlers of each context in run order. +The trigger passes an orchestrator to `TriggerOrchestrator.run(…)`. The orchestrator lists the handlers of each context in run order. A trigger can instead call `TriggerOrchestrator.runFromMetadata()` and take its handlers from [custom metadata](#from-metadata). ## Trigger {#trigger} @@ -24,9 +24,27 @@ Implement one registration interface per context, such as `TriggerOrchestrator.B - **List classes that implement a role.** A class that implements only the context's `Handler` interface compiles and never runs. - **One role per class.** A class that implements both roles of a context runs only as the first: Populator over Validator, Validator over Writer, Writer over Dispatcher. +## Handlers in Custom Metadata {#from-metadata} + +When the trigger's package can't compile against every handler, call `runFromMetadata()` instead of `run(…)`. A core package, for example, can't refer to handlers that live in packages that depend on it: + +```apex +trigger AccountTrigger on Account(before insert, after insert, before update, after update, before delete, after delete, after undelete) { + TriggerOrchestrator.runFromMetadata(); +} +``` + +Each [`TriggerHandler__mdt`](/api/custom-metadata#trigger-handler) record under the object's `TriggerObject__mdt` record registers one class. + +- **No orchestrator class.** The trigger's package never refers to the handlers. +- **The class decides the contexts.** It runs in every context whose role it implements. +- **`Order__c` is the list order,** as below. +- **A record whose class can't run fails the save.** Test that every record resolves: see [Registration Tests](/guide/testing#registration). + ## Handler Order {#order} - **List order is run order**, whatever the role. Each handler finishes before the next one starts. +- **Under `runFromMetadata()`, `Order__c` is the list order.** Lowest runs first, records without it run last, and ties run in class name order. - **Later handlers see earlier changes.** List Populators before Validators. - **Writers commit last.** By default, the Writers' DML commits once, after the last handler. @@ -59,7 +77,7 @@ public with sharing class ContactBirthdateRangeValidator implements BeforeInsert ``` - **Method names carry the context**, so they never clash. -- **Register the class in each context's list.** +- **Register the class in each context's list.** Under `runFromMetadata()`, one record covers every context. - **Add-ons are per context too**, such as `BeforeInsert.ParentQuery` and `BeforeUpdate.ParentQuery`. - **Bypasses cover every context.** A `TriggerHandler__mdt` record switches the class off in every context of its object. To skip one context, use that context's Bypassable. @@ -71,6 +89,7 @@ The library calls the handler list method on every run: per context, per chunk o - **Static fields last the whole transaction.** - **A static "already ran" flag skips later chunks.** Gate the predicate on a change such as `isChanged` instead, or use RecursionGuard in the update contexts. - **Constructor arguments are allowed.** They are evaluated on every run, so keep SOQL out of them. +- **Under `runFromMetadata()`, there are no constructor arguments.** The library creates every registered class in every context with its public no-argument constructor, so keep that constructor cheap. ## Before to After Handoff {#before-after-handoff} diff --git a/website/guide/testing.md b/website/guide/testing.md index df026ec..b1d06aa 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -166,6 +166,8 @@ static void beforeInsertHandlersCopyBillingAddress() { } ``` +A trigger that calls `runFromMetadata()` has no orchestrator class to run. Test its handlers one by one, and its records with a [registration test](#registration). + ## Registration Tests {#registration} A handler missing from the orchestrator never runs. Test the list: @@ -181,6 +183,26 @@ static void afterInsertHandlersContainsWelcomeTaskWriter() { } ``` +Under `runFromMetadata()`, a record whose class can't be found fails every save on the object. Test that the deployed records resolve: + +```apex +@IsTest +static void accountHandlerRecordsResolve() { + // Setup + List missingClasses = new List(); + + // Test + for (TriggerHandler__mdt handlerRecord : [SELECT ApexClassName__c FROM TriggerHandler__mdt WHERE TriggerObject__r.ObjectAPIName__c = 'Account']) { + if (Type.forName(handlerRecord.ApexClassName__c.trim()) == null) { + missingClasses.add(handlerRecord.ApexClassName__c); + } + } + + // Verify + Assert.areEqual(new List(), missingClasses, 'Every registered class should exist.'); +} +``` + ## Mock the Metadata and the Logger {#mock-metadata} Mock the bypass metadata and the Logger search before anything refers to `TriggerOrchestrator`, so before `TriggerOrchestrator.mock()`. Otherwise the org's deployed bypass records and its real Logger apply to your test: @@ -191,6 +213,7 @@ SOQL.mock('ApexTypeImplementor').thenReturn(new List()); ``` - **A metadata bypass.** Return `new TriggerObject__mdt(ObjectAPIName__c = 'Account', Bypass__c = true)` instead of the empty list. +- **Handler records.** `TriggerHandler__mdt` rows, with `Order__c`, sit in the parent's `TriggerHandlers__r` relationship. A plain constructor can't set it, so build the parent with `JSON.deserialize`. - **An Apex bypass.** Call `TriggerOrchestrator.bypass().handler(…)` or `.sObject(…)` before `runTestFor`. `.orchestrator(…)` does not apply under `runTestFor`. - **A Logger.** Set `TriggerOrchestrator.triggerLogger.logger` to a class of your own that collects the errors. Use it to test ContinueOnError. diff --git a/website/introduction/design-principles.md b/website/introduction/design-principles.md index d30bbeb..de268b2 100644 --- a/website/introduction/design-principles.md +++ b/website/introduction/design-principles.md @@ -6,7 +6,7 @@ description: The rules behind Trigger Lib's API - one handler per concern, one r ## One Handler per Concern {#one-handler-per-concern} -Each object has one trigger and one orchestrator. Each handler does one thing, so it is easy to name, test and switch off. +Each object has one trigger and one orchestrator, or handlers registered in custom metadata. Each handler does one thing, so it is easy to name, test and switch off. ## One Role per Context {#one-role-per-context} @@ -14,7 +14,7 @@ A role says what a class does. Keep one role per class in each context: a class ## List Order Is the Dependency {#registration-order} -Handlers run in list order. Each predicate runs at its handler's turn, so it sees what earlier handlers wrote. +Handlers run in list order, or in `Order__c` order under `runFromMetadata()`. Each predicate runs at its handler's turn, so it sees what earlier handlers wrote. ## No Loops, No SOQL per Record {#no-loops} @@ -38,4 +38,4 @@ In the update contexts, a Populator, Writer or Dispatcher acts on the same recor ## No Required Metadata {#no-required-metadata} -The library works with zero custom metadata records. Metadata only switches an object or a handler off. +The library works with zero custom metadata records. Metadata switches an object or a handler off. It registers handlers only for a trigger that calls `runFromMetadata()`.