diff --git a/force-app/main/default/classes/AfterDelete.cls b/force-app/main/default/classes/AfterDelete.cls index 74def77..b4e0936 100644 --- a/force-app/main/default/classes/AfterDelete.cls +++ b/force-app/main/default/classes/AfterDelete.cls @@ -82,10 +82,30 @@ public class AfterDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterDeleteWhen or writeOnAfterDelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterDeleteWhen or writeOnAfterDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ public class AfterDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterDeleteWhen. Move that work to an after delete Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterDeleteWhen. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index 34eb438..2d3788a 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -368,6 +368,192 @@ private class AfterDeleteTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(DmlAccountWriter.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterDeleteWhen or writeOnAfterDelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(DmlQualificationDispatcher.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterDeleteWhen. Move that work to an after delete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(AccountWriterWithDmlFinalizer.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterDeleteFor(DmlDispatcher.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlAccountWriter.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterDeleteWhen or writeOnAfterDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlQualificationDispatcher.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterDeleteWhen. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterDeleteFor(AccountWriterWithSoqlFinalizer.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlDispatcher.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void priorParentQuery() { // Setup @@ -605,6 +791,85 @@ private class AfterDeleteTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterDelete.ContinueOnError { } + private class DmlAccountWriter implements AfterDelete.Writer { + public Boolean writeOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterDelete.Finalizer { + public void finalizeOnAfterDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterDelete.Dispatcher { + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + } + } + + private class DmlDispatcher implements AfterDelete.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterDelete.Writer { + public Boolean writeOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterDelete.Dispatcher { + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + } + } + + private class SoqlDispatcher implements AfterDelete.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterDelete.Finalizer { + public Integer accounts; + + public void finalizeOnAfterDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class PriorAccountNameWriter implements AfterDelete.Writer, AfterDelete.PriorParentQuery { public Map queryPriorParentsOnAfterDelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/AfterInsert.cls b/force-app/main/default/classes/AfterInsert.cls index b44316c..5fd185a 100644 --- a/force-app/main/default/classes/AfterInsert.cls +++ b/force-app/main/default/classes/AfterInsert.cls @@ -82,10 +82,30 @@ public class AfterInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterInsertWhen or writeOnAfterInsert. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterInsertWhen or writeOnAfterInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ public class AfterInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterInsertWhen. Move that work to an after insert Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterInsertWhen. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/force-app/main/default/classes/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index 6fab566..b18f065 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -374,11 +374,244 @@ private class AfterInsertTest { TriggerOrchestrator.mock().afterInsertFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterInsertWhen or writeOnAfterInsert. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(DmlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterInsertWhen. Move that work to an after insert Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(DmlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWithLoggerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + TriggerOrchestrator.mock().afterInsertFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + // Test - TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } // Verify - Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should register the account.'); + Assert.areEqual(expectedException, logger.errors[0].getException(), 'The guard should log the exception it throws.'); + } + + @IsTest + static void writerWhenDmlIsPerformedBeforeRun() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Database.setSavepoint(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriter.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The writer should register the account.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterInsertFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterInsertWhen or writeOnAfterInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(SoqlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterInsertWhen. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(SoqlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + + @IsTest + static void writerWhenRelatedQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock() + .afterInsertFor(RelatedAccountNameWriter.class) + .with(new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType))); + + // Test + TriggerOrchestrator.runTestFor(new RelatedAccountNameWriter()); + + // Verify + Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records().size(), 'The writer should read what its provider queried.'); + } + + @IsTest + static void writerWhenParentQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock() + .afterInsertFor(AccountNameWriter.class) + .with(new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType))); + + // Test + TriggerOrchestrator.runTestFor(new AccountNameWriter()); + + // Verify + Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records().size(), 'The writer should read the parent the library queried.'); } @IsTest @@ -764,6 +997,74 @@ private class AfterInsertTest { } } + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterInsert.Finalizer { + public void finalizeOnAfterInsert(TriggerTypes.InsertRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterInsert.Dispatcher { + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + } + } + + private class DmlDispatcher implements AfterInsert.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterInsert.Writer { + public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterInsert.Dispatcher { + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + } + } + + private class SoqlDispatcher implements AfterInsert.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterInsert.Finalizer { + public Integer accounts; + + public void finalizeOnAfterInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterInsert.Writer, AfterInsert.ParentQuery { public Map queryParentsOnAfterInsert() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/AfterUndelete.cls b/force-app/main/default/classes/AfterUndelete.cls index d91f693..5628897 100644 --- a/force-app/main/default/classes/AfterUndelete.cls +++ b/force-app/main/default/classes/AfterUndelete.cls @@ -82,10 +82,30 @@ public class AfterUndelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ public class AfterUndelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterUndeleteWhen. Move that work to an after undelete Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterUndeleteWhen. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index 61678ae..b25de31 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -360,6 +360,176 @@ private class AfterUndeleteTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterUndeleteWhen. Move that work to an after undelete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterUndeleteWhen. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterUndeleteFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -600,6 +770,85 @@ private class AfterUndeleteTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterUndelete.ContinueOnError { } + private class DmlAccountWriter implements AfterUndelete.Writer { + public Boolean writeOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUndelete(TriggerTypes.UndeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterUndelete.Finalizer { + public void finalizeOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterUndelete.Dispatcher { + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + } + } + + private class DmlDispatcher implements AfterUndelete.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterUndelete.Writer { + public Boolean writeOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUndelete(TriggerTypes.UndeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterUndelete.Dispatcher { + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + } + } + + private class SoqlDispatcher implements AfterUndelete.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterUndelete.Finalizer { + public Integer accounts; + + public void finalizeOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterUndelete.Writer, AfterUndelete.ParentQuery { public Map queryParentsOnAfterUndelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/AfterUpdate.cls b/force-app/main/default/classes/AfterUpdate.cls index 66616fe..efeab7d 100644 --- a/force-app/main/default/classes/AfterUpdate.cls +++ b/force-app/main/default/classes/AfterUpdate.cls @@ -92,10 +92,31 @@ public class AfterUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterUpdateWhen or writeOnAfterUpdate. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in writeOnAfterUpdateWhen or writeOnAfterUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -138,10 +159,28 @@ public class AfterUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterUpdateWhen. Move that work to an after update Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterUpdateWhen. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index a3a9da0..2a8f80c 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -583,6 +583,192 @@ private class AfterUpdateTest { Assert.areEqual(3, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(DmlAccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterUpdateWhen or writeOnAfterUpdate. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(DmlQualificationDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterUpdateWhen. Move that work to an after update Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithDmlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(DmlDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlAccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterUpdateWhen or writeOnAfterUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlQualificationDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterUpdateWhen. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -914,6 +1100,85 @@ private class AfterUpdateTest { } } + private class DmlAccountWriter implements AfterUpdate.Writer { + public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterUpdate.Finalizer { + public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterUpdate.Dispatcher { + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + } + } + + private class DmlDispatcher implements AfterUpdate.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterUpdate.Writer { + public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterUpdate.Dispatcher { + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + } + } + + private class SoqlDispatcher implements AfterUpdate.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterUpdate.Writer, AfterUpdate.ParentQuery { public Map queryParentsOnAfterUpdate() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/BeforeDelete.cls b/force-app/main/default/classes/BeforeDelete.cls index 2578a5b..2f6ef12 100644 --- a/force-app/main/default/classes/BeforeDelete.cls +++ b/force-app/main/default/classes/BeforeDelete.cls @@ -80,10 +80,30 @@ public class BeforeDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that work to a before delete Writer.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -125,10 +145,30 @@ public class BeforeDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index 9e87da8..a086526 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -233,20 +233,156 @@ private class BeforeDeleteTest { } @IsTest - static void handlerWhenHandlerPerformsDml() { + static void validatorWhenHandlerPerformsDml() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); - Contact doe = new Contact(LastName = 'Doe'); + TriggerOrchestrator.mock().beforeDeleteFor(DmlDeletionBlocker.class).with(new Contact(LastName = 'Doe')); - TriggerOrchestrator.mock().beforeDeleteFor(DmlDeletionBlocker.class).with(doe); + Exception expectedException = null; // Test - TriggerOrchestrator.runTestFor(new DmlDeletionBlocker()); + try { + TriggerOrchestrator.runTestFor(new DmlDeletionBlocker()); + } catch (Exception e) { + expectedException = e; + } // Verify - Assert.areEqual('Doe cannot be deleted.', doe.getErrors()[0].getMessage(), 'The handler should block the deletion.'); + Assert.areEqual( + 'DmlDeletionBlocker performed DML in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that work to a before delete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeDeleteFor(SoqlDeletionBlocker.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlDeletionBlocker()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlDeletionBlocker performed SOQL in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DeletionBlockerWithSoqlFinalizer blocker = new DeletionBlockerWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeDeleteFor(DeletionBlockerWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(blocker); + + // Verify + Assert.areEqual(0, blocker.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeDeleteFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); } @IsTest @@ -376,6 +512,59 @@ private class BeforeDeleteTest { } } + private class DmlAccountWriter implements BeforeDelete.Writer { + public Boolean writeOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnBeforeDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements BeforeDelete.Finalizer { + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + } + } + + private class SoqlDeletionBlocker implements BeforeDelete.Validator { + public Boolean addErrorOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeDelete(TriggerTypes.RejectableDeleteRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlAccountWriter implements BeforeDelete.Writer { + public Boolean writeOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnBeforeDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class DeletionBlockerWithSoqlFinalizer extends DeletionBlocker implements BeforeDelete.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements BeforeDelete.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class PriorAccountDeletionBlocker implements BeforeDelete.Validator, BeforeDelete.PriorParentQuery { public Map queryPriorParentsOnBeforeDelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/force-app/main/default/classes/BeforeInsert.cls b/force-app/main/default/classes/BeforeInsert.cls index 5b7617d..9b513eb 100644 --- a/force-app/main/default/classes/BeforeInsert.cls +++ b/force-app/main/default/classes/BeforeInsert.cls @@ -79,10 +79,20 @@ public class BeforeInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.populate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -131,10 +141,20 @@ public class BeforeInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in addErrorOnBeforeInsertWhen or addErrorOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); diff --git a/force-app/main/default/classes/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index 5505361..9763789 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -840,6 +840,198 @@ private class BeforeInsertTest { ); } + @IsTest + static void populatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulator performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlValidator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlValidator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlValidator performed SOQL in addErrorOnBeforeInsertWhen or addErrorOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlQualificationPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationPopulator performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWithLoggerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual(expectedException, logger.errors[0].getException(), 'The guard should log the exception it throws.'); + } + + @IsTest + static void populatorWithContinueOnErrorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulatorWithContinueOnError.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulatorWithContinueOnError()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulatorWithContinueOnError performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TitlePopulatorWithSoqlFinalizer populator = new TitlePopulatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(populator); + + // Verify + Assert.areEqual(0, populator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DoeValidatorWithSoqlFinalizer validator = new DoeValidatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeInsertFor(DoeValidatorWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(validator); + + // Verify + Assert.areEqual(0, validator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void populatorWhenRelatedQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType)); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithRelatedQuery.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRelatedQuery()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The populator should read what its provider queried.'); + } + + @IsTest + static void populatorWhenParentQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType)); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithParentQuery.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithParentQuery()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The populator should read the parent the library queried.'); + } + @IsTest static void parentQuery() { // Setup @@ -1224,6 +1416,83 @@ private class BeforeInsertTest { private class FailingDmlValidatorWithContinueOnError extends FailingDmlValidator implements BeforeInsert.ContinueOnError { } + private virtual class SoqlPopulator implements BeforeInsert.Populator { + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Description, 'Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlPopulatorWithContinueOnError extends SoqlPopulator implements BeforeInsert.ContinueOnError { + } + + private class SoqlValidator implements BeforeInsert.Validator { + public Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlQualificationPopulator implements BeforeInsert.Populator { + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, 'CEO'); + } + } + + private class TitlePopulatorWithSoqlFinalizer extends TitlePopulator implements BeforeInsert.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class DoeValidatorWithSoqlFinalizer extends DoeValidator implements BeforeInsert.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class TitlePopulatorWithRelatedQuery implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { + public Map queryRelatedOnBeforeInsert() { + 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'); + } + } + + private class TitlePopulatorWithParentQuery implements BeforeInsert.Populator, BeforeInsert.ParentQuery { + public Map queryParentsOnBeforeInsert() { + return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; + } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getNewParent(Contact.AccountId) == null ? 'CEO' : 'CFO'); + } + } + private class AccountRepointingPopulator implements BeforeInsert.Populator { private Id accountId; diff --git a/force-app/main/default/classes/BeforeUpdate.cls b/force-app/main/default/classes/BeforeUpdate.cls index fb09e11..0db11f3 100644 --- a/force-app/main/default/classes/BeforeUpdate.cls +++ b/force-app/main/default/classes/BeforeUpdate.cls @@ -89,10 +89,21 @@ public class BeforeUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.populate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in populateOnBeforeUpdateWhen or populateOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -142,10 +153,21 @@ public class BeforeUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in addErrorOnBeforeUpdateWhen or addErrorOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); diff --git a/force-app/main/default/classes/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index 62f9fd8..c689bd3 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -680,6 +680,98 @@ private class BeforeUpdateTest { ); } + @IsTest + static void populatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(SoqlPopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulator performed SOQL in populateOnBeforeUpdateWhen or populateOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(SoqlValidator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlValidator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlValidator performed SOQL in addErrorOnBeforeUpdateWhen or addErrorOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TitlePopulatorWithSoqlFinalizer populator = new TitlePopulatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulatorWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(populator); + + // Verify + Assert.areEqual(0, populator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DoeValidatorWithSoqlFinalizer validator = new DoeValidatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeUpdateFor(DoeValidatorWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(validator); + + // Verify + Assert.areEqual(0, validator.accounts, 'The finalizer should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -966,6 +1058,42 @@ private class BeforeUpdateTest { private class FailingDmlValidatorWithContinueOnError extends FailingDmlValidator implements BeforeUpdate.ContinueOnError { } + private class SoqlPopulator implements BeforeUpdate.Populator { + public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { + record.put(Contact.Description, 'Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlValidator implements BeforeUpdate.Validator { + public Boolean addErrorOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class TitlePopulatorWithSoqlFinalizer extends TitlePopulator implements BeforeUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class DoeValidatorWithSoqlFinalizer extends DoeValidator implements BeforeUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class TitlePopulatorWithRecursionGuard extends TitlePopulator implements BeforeUpdate.RecursionGuard { public Integer maxRunsPerRecordOnBeforeUpdate() { return 1; diff --git a/force-app/main/default/classes/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index b167c05..a86b323 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -669,6 +669,10 @@ public with sharing class TriggerOrchestrator { protected void logError(Exception e) { TriggerOrchestrator.triggerLogger.log(new TriggerError().withException(e).withHandlerName(this.handlerName).withContext(this.orchestrator.context)); } + + protected Integer getUndeclaredQueries() { + return Limits.getQueries() - this.orchestrator.parentLoader.queries - this.relatedLoader.queries; + } } public abstract class ParentDeclaration { @@ -682,6 +686,8 @@ public with sharing class TriggerOrchestrator { } public class ParentLoader implements TriggerTypes.ParentLoader { + public Integer queries = 0; + private Set lookupsForNewParents = new Set(); private Set lookupsForOldParents = new Set(); private Set loadedLookups = new Set(); @@ -716,12 +722,16 @@ public with sharing class TriggerOrchestrator { return; } + Integer queriesBefore = Limits.getQueries(); + if (this.lookupsForNewParents.contains(lookupField)) { this.queryNewParentsFromSavedRecords(); } this.queryAndAttachParents(lookupField); this.loadedLookups.add(lookupField); + + this.queries += Limits.getQueries() - queriesBefore; } public void refreshNewParents(List records) { @@ -910,6 +920,8 @@ public with sharing class TriggerOrchestrator { } public abstract class RelatedLoader implements TriggerTypes.RelatedLoader { + public Integer queries = 0; + protected List records = new List(); private Map relatedByProviderName = new Map(); @@ -928,8 +940,12 @@ public with sharing class TriggerOrchestrator { return; } + Integer queriesBefore = Limits.getQueries(); + TriggerTypes.RelatedRecords related = this.queryRelated(providerName); + this.queries += Limits.getQueries() - queriesBefore; + if (related != null) { this.relatedByProviderName.put(providerName, related); } diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 9d304bd..9c16ea6 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.3.0 + * v0.4.0 * * 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 90493a5..4bd03ee 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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index cb92097..caa75f4 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.3.0 + * v0.4.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/AfterDelete.cls b/package/main/default/classes/AfterDelete.cls index 5139757..d64d90f 100644 --- a/package/main/default/classes/AfterDelete.cls +++ b/package/main/default/classes/AfterDelete.cls @@ -82,10 +82,30 @@ global class AfterDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterDeleteWhen or writeOnAfterDelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterDeleteWhen or writeOnAfterDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ global class AfterDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterDeleteWhen. Move that work to an after delete Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterDeleteWhen. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/package/main/default/classes/AfterDeleteTest.cls b/package/main/default/classes/AfterDeleteTest.cls index 34eb438..2d3788a 100644 --- a/package/main/default/classes/AfterDeleteTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -368,6 +368,192 @@ private class AfterDeleteTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(DmlAccountWriter.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterDeleteWhen or writeOnAfterDelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(DmlQualificationDispatcher.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterDeleteWhen. Move that work to an after delete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(AccountWriterWithDmlFinalizer.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterDeleteFor(DmlDispatcher.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlAccountWriter.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterDeleteWhen or writeOnAfterDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlQualificationDispatcher.class).with(doe); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterDeleteWhen. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterDeleteFor(AccountWriterWithSoqlFinalizer.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterDeleteFor(SoqlDispatcher.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void priorParentQuery() { // Setup @@ -605,6 +791,85 @@ private class AfterDeleteTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterDelete.ContinueOnError { } + private class DmlAccountWriter implements AfterDelete.Writer { + public Boolean writeOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterDelete.Finalizer { + public void finalizeOnAfterDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterDelete.Dispatcher { + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + } + } + + private class DmlDispatcher implements AfterDelete.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterDelete.Writer { + public Boolean writeOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterDelete.Dispatcher { + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + } + } + + private class SoqlDispatcher implements AfterDelete.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterDelete.Finalizer { + public Integer accounts; + + public void finalizeOnAfterDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class PriorAccountNameWriter implements AfterDelete.Writer, AfterDelete.PriorParentQuery { public Map queryPriorParentsOnAfterDelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/AfterInsert.cls b/package/main/default/classes/AfterInsert.cls index 0be87e3..15f4792 100644 --- a/package/main/default/classes/AfterInsert.cls +++ b/package/main/default/classes/AfterInsert.cls @@ -82,10 +82,30 @@ global class AfterInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterInsertWhen or writeOnAfterInsert. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterInsertWhen or writeOnAfterInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ global class AfterInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterInsertWhen. Move that work to an after insert Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterInsertWhen. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/package/main/default/classes/AfterInsertTest.cls b/package/main/default/classes/AfterInsertTest.cls index 6fab566..b18f065 100644 --- a/package/main/default/classes/AfterInsertTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -374,11 +374,244 @@ private class AfterInsertTest { TriggerOrchestrator.mock().afterInsertFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterInsertWhen or writeOnAfterInsert. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(DmlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterInsertWhen. Move that work to an after insert Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(DmlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWithLoggerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + TriggerOrchestrator.mock().afterInsertFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + // Test - TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } // Verify - Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records().size(), 'The writer should register the account.'); + Assert.areEqual(expectedException, logger.errors[0].getException(), 'The guard should log the exception it throws.'); + } + + @IsTest + static void writerWhenDmlIsPerformedBeforeRun() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Database.setSavepoint(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriter.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriter()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The writer should register the account.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterInsertFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterInsertWhen or writeOnAfterInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(SoqlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterInsertWhen. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterInsertFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(SoqlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + + @IsTest + static void writerWhenRelatedQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock() + .afterInsertFor(RelatedAccountNameWriter.class) + .with(new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType))); + + // Test + TriggerOrchestrator.runTestFor(new RelatedAccountNameWriter()); + + // Verify + Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records().size(), 'The writer should read what its provider queried.'); + } + + @IsTest + static void writerWhenParentQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock() + .afterInsertFor(AccountNameWriter.class) + .with(new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType))); + + // Test + TriggerOrchestrator.runTestFor(new AccountNameWriter()); + + // Verify + Assert.areEqual(1, DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records().size(), 'The writer should read the parent the library queried.'); } @IsTest @@ -764,6 +997,74 @@ private class AfterInsertTest { } } + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterInsert.Finalizer { + public void finalizeOnAfterInsert(TriggerTypes.InsertRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterInsert.Dispatcher { + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + } + } + + private class DmlDispatcher implements AfterInsert.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterInsert.Writer { + public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterInsert.Dispatcher { + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + } + } + + private class SoqlDispatcher implements AfterInsert.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterInsert.Finalizer { + public Integer accounts; + + public void finalizeOnAfterInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterInsert.Writer, AfterInsert.ParentQuery { public Map queryParentsOnAfterInsert() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/AfterUndelete.cls b/package/main/default/classes/AfterUndelete.cls index 8d4f1a3..92b049c 100644 --- a/package/main/default/classes/AfterUndelete.cls +++ b/package/main/default/classes/AfterUndelete.cls @@ -82,10 +82,30 @@ global class AfterUndelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -125,10 +145,28 @@ global class AfterUndelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterUndeleteWhen. Move that work to an after undelete Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterUndeleteWhen. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/package/main/default/classes/AfterUndeleteTest.cls b/package/main/default/classes/AfterUndeleteTest.cls index 61678ae..b25de31 100644 --- a/package/main/default/classes/AfterUndeleteTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -360,6 +360,176 @@ private class AfterUndeleteTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterUndeleteWhen. Move that work to an after undelete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterUndeleteFor(DmlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterUndeleteWhen or writeOnAfterUndelete. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlQualificationDispatcher.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterUndeleteWhen. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterUndeleteFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterUndeleteFor(SoqlDispatcher.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -600,6 +770,85 @@ private class AfterUndeleteTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterUndelete.ContinueOnError { } + private class DmlAccountWriter implements AfterUndelete.Writer { + public Boolean writeOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUndelete(TriggerTypes.UndeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterUndelete.Finalizer { + public void finalizeOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterUndelete.Dispatcher { + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + } + } + + private class DmlDispatcher implements AfterUndelete.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterUndelete.Writer { + public Boolean writeOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUndelete(TriggerTypes.UndeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterUndelete.Dispatcher { + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + } + } + + private class SoqlDispatcher implements AfterUndelete.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterUndeleteWhen(TriggerTypes.UndeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterUndelete.Finalizer { + public Integer accounts; + + public void finalizeOnAfterUndelete(TriggerTypes.UndeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterUndelete.Writer, AfterUndelete.ParentQuery { public Map queryParentsOnAfterUndelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/AfterUpdate.cls b/package/main/default/classes/AfterUpdate.cls index 1623967..6bf59f7 100644 --- a/package/main/default/classes/AfterUpdate.cls +++ b/package/main/default/classes/AfterUpdate.cls @@ -92,10 +92,31 @@ global class AfterUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnAfterUpdateWhen or writeOnAfterUpdate. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in writeOnAfterUpdateWhen or writeOnAfterUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { @@ -138,10 +159,28 @@ global class AfterUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.qualify(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException(this.handlerName + ' performed DML in dispatchOnAfterUpdateWhen. Move that work to an after update Writer.'); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in dispatchOnAfterUpdateWhen. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.dispatch(); this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { diff --git a/package/main/default/classes/AfterUpdateTest.cls b/package/main/default/classes/AfterUpdateTest.cls index a3a9da0..2a8f80c 100644 --- a/package/main/default/classes/AfterUpdateTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -583,6 +583,192 @@ private class AfterUpdateTest { Assert.areEqual(3, writer.finalizeCalls, 'The finalizer should not run beyond the recursion depth.'); } + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(DmlAccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnAfterUpdateWhen or writeOnAfterUpdate. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(DmlQualificationDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlQualificationDispatcher performed DML in dispatchOnAfterUpdateWhen. Move that work to an after update Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithDmlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DmlDispatcher dispatcher = new DmlDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(DmlDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedIds.size(), 'The dispatcher should be allowed to run DML.'); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlAccountWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnAfterUpdateWhen or writeOnAfterUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void dispatcherWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlQualificationDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationDispatcher()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationDispatcher performed SOQL in dispatchOnAfterUpdateWhen. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().afterUpdateFor(AccountWriterWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void dispatcherWhenDispatchPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + SoqlDispatcher dispatcher = new SoqlDispatcher(); + + TriggerOrchestrator.mock().afterUpdateFor(SoqlDispatcher.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(0, dispatcher.accounts, 'The dispatcher should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -914,6 +1100,85 @@ private class AfterUpdateTest { } } + private class DmlAccountWriter implements AfterUpdate.Writer { + public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements AfterUpdate.Finalizer { + public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { + Database.setSavepoint(); + } + } + + private class DmlQualificationDispatcher implements AfterUpdate.Dispatcher { + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + Database.setSavepoint(); + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + } + } + + private class DmlDispatcher implements AfterUpdate.Dispatcher { + public Set dispatchedIds; + + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + Database.setSavepoint(); + this.dispatchedIds = records.getIds(); + } + } + + private class SoqlAccountWriter implements AfterUpdate.Writer { + public Boolean writeOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterUpdate(TriggerTypes.UpdateRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class SoqlQualificationDispatcher implements AfterUpdate.Dispatcher { + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + } + } + + private class SoqlDispatcher implements AfterUpdate.Dispatcher { + public Integer accounts; + + public Boolean dispatchOnAfterUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void dispatchOnAfterUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements AfterUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnAfterUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private virtual class AccountNameWriter implements AfterUpdate.Writer, AfterUpdate.ParentQuery { public Map queryParentsOnAfterUpdate() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/BeforeDelete.cls b/package/main/default/classes/BeforeDelete.cls index 0562fee..43708c4 100644 --- a/package/main/default/classes/BeforeDelete.cls +++ b/package/main/default/classes/BeforeDelete.cls @@ -80,10 +80,30 @@ global class BeforeDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that work to a before delete Writer.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -125,10 +145,30 @@ global class BeforeDelete { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + Integer dmlStatementsBefore = Limits.getDmlStatements(); + Integer immediateEventsBefore = Limits.getPublishImmediateDML(); + for (TriggerTypes.TriggerRecord record : records) { this.write(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + Integer dmlStatementsAfter = Limits.getDmlStatements(); + Integer immediateEventsAfter = Limits.getPublishImmediateDML(); + + if (dmlStatementsAfter > dmlStatementsBefore || immediateEventsAfter > immediateEventsBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed DML in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Register that work in the unit of work.' + ); + } + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); this.commitOwnUnitOfWork(); } catch (Exception e) { diff --git a/package/main/default/classes/BeforeDeleteTest.cls b/package/main/default/classes/BeforeDeleteTest.cls index 9e87da8..a086526 100644 --- a/package/main/default/classes/BeforeDeleteTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -233,20 +233,156 @@ private class BeforeDeleteTest { } @IsTest - static void handlerWhenHandlerPerformsDml() { + static void validatorWhenHandlerPerformsDml() { // Setup SOQL.mock('TriggerObject__mdt').thenReturn(new List()); SOQL.mock('ApexTypeImplementor').thenReturn(new List()); - Contact doe = new Contact(LastName = 'Doe'); + TriggerOrchestrator.mock().beforeDeleteFor(DmlDeletionBlocker.class).with(new Contact(LastName = 'Doe')); - TriggerOrchestrator.mock().beforeDeleteFor(DmlDeletionBlocker.class).with(doe); + Exception expectedException = null; // Test - TriggerOrchestrator.runTestFor(new DmlDeletionBlocker()); + try { + TriggerOrchestrator.runTestFor(new DmlDeletionBlocker()); + } catch (Exception e) { + expectedException = e; + } // Verify - Assert.areEqual('Doe cannot be deleted.', doe.getErrors()[0].getMessage(), 'The handler should block the deletion.'); + Assert.areEqual( + 'DmlDeletionBlocker performed DML in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that work to a before delete Writer.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenHandlerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(DmlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new DmlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'DmlAccountWriter performed DML in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Register that work in the unit of work.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenFinalizerPerformsDml() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(AccountWriterWithDmlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(new AccountWriterWithDmlFinalizer()); + + // Verify + Assert.areEqual('Doe', DML.retrieveResultFor('triggerUow').insertsOf(Account.SObjectType).records()[0].get(Account.Name), 'The Finalizer should be allowed to run DML.'); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeDeleteFor(SoqlDeletionBlocker.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlDeletionBlocker()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlDeletionBlocker performed SOQL in addErrorOnBeforeDeleteWhen or addErrorOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void writerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + TriggerOrchestrator.mock().beforeDeleteFor(SoqlAccountWriter.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlAccountWriter()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlAccountWriter performed SOQL in writeOnBeforeDeleteWhen or writeOnBeforeDelete. Move that query to a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DeletionBlockerWithSoqlFinalizer blocker = new DeletionBlockerWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeDeleteFor(DeletionBlockerWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(blocker); + + // Verify + Assert.areEqual(0, blocker.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void writerWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + AccountWriterWithSoqlFinalizer writer = new AccountWriterWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeDeleteFor(AccountWriterWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(writer); + + // Verify + Assert.areEqual(0, writer.accounts, 'The finalizer should be allowed to query.'); } @IsTest @@ -376,6 +512,59 @@ private class BeforeDeleteTest { } } + private class DmlAccountWriter implements BeforeDelete.Writer { + public Boolean writeOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnBeforeDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + Database.setSavepoint(); + unitOfWork.toInsert(new Account(Name = 'Doe')); + } + } + + private class AccountWriterWithDmlFinalizer extends AccountWriter implements BeforeDelete.Finalizer { + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + Database.setSavepoint(); + } + } + + private class SoqlDeletionBlocker implements BeforeDelete.Validator { + public Boolean addErrorOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeDelete(TriggerTypes.RejectableDeleteRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlAccountWriter implements BeforeDelete.Writer { + public Boolean writeOnBeforeDeleteWhen(TriggerTypes.DeleteRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnBeforeDelete(TriggerTypes.DeleteRecord record, TriggerTypes.UnitOfWork unitOfWork) { + unitOfWork.toInsert(new Account(Name = 'Accounts: ' + [SELECT COUNT() FROM Account])); + } + } + + private class DeletionBlockerWithSoqlFinalizer extends DeletionBlocker implements BeforeDelete.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class AccountWriterWithSoqlFinalizer extends AccountWriter implements BeforeDelete.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeDelete(TriggerTypes.DeleteRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class PriorAccountDeletionBlocker implements BeforeDelete.Validator, BeforeDelete.PriorParentQuery { public Map queryPriorParentsOnBeforeDelete() { return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; diff --git a/package/main/default/classes/BeforeInsert.cls b/package/main/default/classes/BeforeInsert.cls index 58bdfb4..7ed4b21 100644 --- a/package/main/default/classes/BeforeInsert.cls +++ b/package/main/default/classes/BeforeInsert.cls @@ -79,10 +79,20 @@ global class BeforeInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.populate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -131,10 +141,20 @@ global class BeforeInsert { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + ' performed SOQL in addErrorOnBeforeInsertWhen or addErrorOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); diff --git a/package/main/default/classes/BeforeInsertTest.cls b/package/main/default/classes/BeforeInsertTest.cls index 5505361..9763789 100644 --- a/package/main/default/classes/BeforeInsertTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -840,6 +840,198 @@ private class BeforeInsertTest { ); } + @IsTest + static void populatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulator performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlValidator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlValidator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlValidator performed SOQL in addErrorOnBeforeInsertWhen or addErrorOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenQualificationPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlQualificationPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlQualificationPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlQualificationPopulator performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWithLoggerWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TestLogger logger = new TestLogger(); + TriggerOrchestrator.triggerLogger.logger = logger; + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulator.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual(expectedException, logger.errors[0].getException(), 'The guard should log the exception it throws.'); + } + + @IsTest + static void populatorWithContinueOnErrorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().beforeInsertFor(SoqlPopulatorWithContinueOnError.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulatorWithContinueOnError()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulatorWithContinueOnError performed SOQL in populateOnBeforeInsertWhen or populateOnBeforeInsert. Move that query to a ParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TitlePopulatorWithSoqlFinalizer populator = new TitlePopulatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(populator); + + // Verify + Assert.areEqual(0, populator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + DoeValidatorWithSoqlFinalizer validator = new DoeValidatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeInsertFor(DoeValidatorWithSoqlFinalizer.class).with(new Contact(LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(validator); + + // Verify + Assert.areEqual(0, validator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void populatorWhenRelatedQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType)); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithRelatedQuery.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithRelatedQuery()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The populator should read what its provider queried.'); + } + + @IsTest + static void populatorWhenParentQueryPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(LastName = 'Doe', AccountId = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType)); + + TriggerOrchestrator.mock().beforeInsertFor(TitlePopulatorWithParentQuery.class).with(doe); + + // Test + TriggerOrchestrator.runTestFor(new TitlePopulatorWithParentQuery()); + + // Verify + Assert.areEqual('CEO', doe.Title, 'The populator should read the parent the library queried.'); + } + @IsTest static void parentQuery() { // Setup @@ -1224,6 +1416,83 @@ private class BeforeInsertTest { private class FailingDmlValidatorWithContinueOnError extends FailingDmlValidator implements BeforeInsert.ContinueOnError { } + private virtual class SoqlPopulator implements BeforeInsert.Populator { + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Description, 'Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlPopulatorWithContinueOnError extends SoqlPopulator implements BeforeInsert.ContinueOnError { + } + + private class SoqlValidator implements BeforeInsert.Validator { + public Boolean addErrorOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeInsert(TriggerTypes.RejectableInsertRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlQualificationPopulator implements BeforeInsert.Populator { + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return [SELECT COUNT() FROM Account] == 0; + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, 'CEO'); + } + } + + private class TitlePopulatorWithSoqlFinalizer extends TitlePopulator implements BeforeInsert.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class DoeValidatorWithSoqlFinalizer extends DoeValidator implements BeforeInsert.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeInsert(TriggerTypes.InsertRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class TitlePopulatorWithRelatedQuery implements BeforeInsert.Populator, BeforeInsert.RelatedQuery { + public Map queryRelatedOnBeforeInsert() { + 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'); + } + } + + private class TitlePopulatorWithParentQuery implements BeforeInsert.Populator, BeforeInsert.ParentQuery { + public Map queryParentsOnBeforeInsert() { + return new Map{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) }; + } + + public Boolean populateOnBeforeInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeInsert(TriggerTypes.InsertRecord record) { + record.put(Contact.Title, record.getNewParent(Contact.AccountId) == null ? 'CEO' : 'CFO'); + } + } + private class AccountRepointingPopulator implements BeforeInsert.Populator { private Id accountId; diff --git a/package/main/default/classes/BeforeUpdate.cls b/package/main/default/classes/BeforeUpdate.cls index 3ee011d..bdbfebe 100644 --- a/package/main/default/classes/BeforeUpdate.cls +++ b/package/main/default/classes/BeforeUpdate.cls @@ -89,10 +89,21 @@ global class BeforeUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.populate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in populateOnBeforeUpdateWhen or populateOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); @@ -142,10 +153,21 @@ global class BeforeUpdate { try { this.relatedLoader.attachTo(records); + Integer queriesBefore = this.getUndeclaredQueries(); + for (TriggerTypes.TriggerRecord record : records) { this.validate(record); } + Integer queriesAfter = this.getUndeclaredQueries(); + + if (queriesAfter > queriesBefore) { + throw new TriggerTypes.TriggerLibException( + this.handlerName + + ' performed SOQL in addErrorOnBeforeUpdateWhen or addErrorOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.' + ); + } + this.handlerHooks.finalize(this.qualifiedRecords); } catch (Exception e) { this.handleException(e); diff --git a/package/main/default/classes/BeforeUpdateTest.cls b/package/main/default/classes/BeforeUpdateTest.cls index 62f9fd8..c689bd3 100644 --- a/package/main/default/classes/BeforeUpdateTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -680,6 +680,98 @@ private class BeforeUpdateTest { ); } + @IsTest + static void populatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(SoqlPopulator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlPopulator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlPopulator performed SOQL in populateOnBeforeUpdateWhen or populateOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void validatorWhenHandlerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TriggerOrchestrator.mock().beforeUpdateFor(SoqlValidator.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new SoqlValidator()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.areEqual( + 'SoqlValidator performed SOQL in addErrorOnBeforeUpdateWhen or addErrorOnBeforeUpdate. Move that query to a ParentQuery, a PriorParentQuery or a RelatedQuery.', + expectedException?.getMessage(), + 'The exception message should match the expected one.' + ); + } + + @IsTest + static void populatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + TitlePopulatorWithSoqlFinalizer populator = new TitlePopulatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeUpdateFor(TitlePopulatorWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(populator); + + // Verify + Assert.areEqual(0, populator.accounts, 'The finalizer should be allowed to query.'); + } + + @IsTest + static void validatorWhenFinalizerPerformsSoql() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe'); + + DoeValidatorWithSoqlFinalizer validator = new DoeValidatorWithSoqlFinalizer(); + + TriggerOrchestrator.mock().beforeUpdateFor(DoeValidatorWithSoqlFinalizer.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); + + // Test + TriggerOrchestrator.runTestFor(validator); + + // Verify + Assert.areEqual(0, validator.accounts, 'The finalizer should be allowed to query.'); + } + @IsTest static void parentQuery() { // Setup @@ -966,6 +1058,42 @@ private class BeforeUpdateTest { private class FailingDmlValidatorWithContinueOnError extends FailingDmlValidator implements BeforeUpdate.ContinueOnError { } + private class SoqlPopulator implements BeforeUpdate.Populator { + public Boolean populateOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void populateOnBeforeUpdate(TriggerTypes.UpdateRecord record) { + record.put(Contact.Description, 'Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class SoqlValidator implements BeforeUpdate.Validator { + public Boolean addErrorOnBeforeUpdateWhen(TriggerTypes.UpdateRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void addErrorOnBeforeUpdate(TriggerTypes.RejectableUpdateRecord record) { + record.addError('Accounts: ' + [SELECT COUNT() FROM Account]); + } + } + + private class TitlePopulatorWithSoqlFinalizer extends TitlePopulator implements BeforeUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + + private class DoeValidatorWithSoqlFinalizer extends DoeValidator implements BeforeUpdate.Finalizer { + public Integer accounts; + + public void finalizeOnBeforeUpdate(TriggerTypes.UpdateRecords records) { + this.accounts = [SELECT COUNT() FROM Account]; + } + } + private class TitlePopulatorWithRecursionGuard extends TitlePopulator implements BeforeUpdate.RecursionGuard { public Integer maxRunsPerRecordOnBeforeUpdate() { return 1; diff --git a/package/main/default/classes/TriggerOrchestrator.cls b/package/main/default/classes/TriggerOrchestrator.cls index e752579..54bc215 100644 --- a/package/main/default/classes/TriggerOrchestrator.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -666,6 +666,10 @@ global with sharing class TriggerOrchestrator { protected void logError(Exception e) { TriggerOrchestrator.triggerLogger.log(new TriggerError().withException(e).withHandlerName(this.handlerName).withContext(this.orchestrator.context)); } + + protected Integer getUndeclaredQueries() { + return Limits.getQueries() - this.orchestrator.parentLoader.queries - this.relatedLoader.queries; + } } public abstract class ParentDeclaration { @@ -679,6 +683,8 @@ global with sharing class TriggerOrchestrator { } public class ParentLoader implements TriggerTypes.ParentLoader { + public Integer queries = 0; + private Set lookupsForNewParents = new Set(); private Set lookupsForOldParents = new Set(); private Set loadedLookups = new Set(); @@ -713,12 +719,16 @@ global with sharing class TriggerOrchestrator { return; } + Integer queriesBefore = Limits.getQueries(); + if (this.lookupsForNewParents.contains(lookupField)) { this.queryNewParentsFromSavedRecords(); } this.queryAndAttachParents(lookupField); this.loadedLookups.add(lookupField); + + this.queries += Limits.getQueries() - queriesBefore; } public void refreshNewParents(List records) { @@ -907,6 +917,8 @@ global with sharing class TriggerOrchestrator { } public abstract class RelatedLoader implements TriggerTypes.RelatedLoader { + public Integer queries = 0; + protected List records = new List(); private Map relatedByProviderName = new Map(); @@ -925,8 +937,12 @@ global with sharing class TriggerOrchestrator { return; } + Integer queriesBefore = Limits.getQueries(); + TriggerTypes.RelatedRecords related = this.queryRelated(providerName); + this.queries += Limits.getQueries() - queriesBefore; + if (related != null) { this.relatedByProviderName.put(providerName, related); } diff --git a/package/main/default/classes/TriggerOrchestratorTest.cls b/package/main/default/classes/TriggerOrchestratorTest.cls index 9d304bd..9c16ea6 100644 --- a/package/main/default/classes/TriggerOrchestratorTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class diff --git a/package/main/default/classes/TriggerTypes.cls b/package/main/default/classes/TriggerTypes.cls index 1da7872..7c470fb 100644 --- a/package/main/default/classes/TriggerTypes.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record diff --git a/package/main/default/classes/TriggerTypesTest.cls b/package/main/default/classes/TriggerTypesTest.cls index cb92097..caa75f4 100644 --- a/package/main/default/classes/TriggerTypesTest.cls +++ b/package/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.3.0 + * v0.4.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class diff --git a/website/after-delete/dispatcher.md b/website/after-delete/dispatcher.md index a915a62..9e3fd9e 100644 --- a/website/after-delete/dispatcher.md +++ b/website/after-delete/dispatcher.md @@ -47,7 +47,7 @@ public with sharing class ContactRemovalSyncDispatcher implements AfterDelete.Di ## Rules {#rules} - **No synchronous callouts.** A callout from a trigger throws a `System.CalloutException`. Call out from a Queueable that implements `Database.AllowsCallouts`. -- **No unit of work.** DML here runs at once and is not merged with the Writers' unit. Use a [Writer](/after-delete/writer) for record writes. +- **No unit of work.** DML in `dispatchOnAfterDelete` runs at once and is not merged with the Writers' unit. DML in the predicate throws. Use a [Writer](/after-delete/writer) for record writes. - **Merge losers arrive here too.** Skip them with `record.isNull(Contact.MasterRecordId)` when a merge should not count as a removal. - **Writer wins.** A class that also implements `AfterDelete.Writer` runs only as a Writer. diff --git a/website/after-delete/writer.md b/website/after-delete/writer.md index ccd2513..2247a29 100644 --- a/website/after-delete/writer.md +++ b/website/after-delete/writer.md @@ -44,7 +44,7 @@ public with sharing class ContactRemovalTaskWriter implements AfterDelete.Writer - **Read the old row, not SOQL.** SOQL no longer finds the deleted row. Read its values from `getOldSObject()`, which is read-only. - **Merge losers qualify.** Skip them as `[Skip merge losers]` does. -- **Register with `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events).** They commit after the last handler, or right after this Writer with [OwnUnitOfWork](/after-delete/add-ons/own-unit-of-work) or [ContinueOnError](/after-delete/add-ons/continue-on-error). +- **Register with `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events).** They commit after the last handler, or right after this Writer with [OwnUnitOfWork](/after-delete/add-ons/own-unit-of-work) or [ContinueOnError](/after-delete/add-ons/continue-on-error). DML in the predicate or the action throws. - **Writer wins.** A class that also implements `AfterDelete.Dispatcher` runs only as a Writer. ::: warning diff --git a/website/after-insert/dispatcher.md b/website/after-insert/dispatcher.md index 3265a48..0b44543 100644 --- a/website/after-insert/dispatcher.md +++ b/website/after-insert/dispatcher.md @@ -24,7 +24,7 @@ Make one bulk call per chunk with the records that qualify, such as enqueueing a ## Rules {#rules} - **Pass Ids to async work.** Let the job query what it needs. -- **No unit of work.** A Dispatcher never gets one, and OwnUnitOfWork is ignored. DML here runs at once and is not merged. Use a [Writer](/after-insert/writer) for record writes. +- **No unit of work.** A Dispatcher never gets one, and OwnUnitOfWork is ignored. DML in `dispatchOnAfterInsert` runs at once and is not merged. DML in the predicate throws. Use a [Writer](/after-insert/writer) for record writes. - **Publish with `EventBus.publish(events)`.** It runs at once, before the shared unit of work commits. - **Writer wins.** A class that also implements `AfterInsert.Writer` runs only as a Writer. diff --git a/website/after-insert/writer.md b/website/after-insert/writer.md index 14106c6..05ce059 100644 --- a/website/after-insert/writer.md +++ b/website/after-insert/writer.md @@ -27,7 +27,7 @@ Change other records or publish platform events after the insert, through a unit ## Rules {#rules} -- **Register, don't run DML.** Call `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events) on `unitOfWork`. Direct DML runs at once and skips the unit. +- **Register, don't run DML.** Call `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events) on `unitOfWork`. DML in the predicate or the action throws. - **By default, later handlers don't see your writes.** The shared unit of work commits after the last handler. With [OwnUnitOfWork](/after-insert/add-ons/own-unit-of-work) or [ContinueOnError](/after-insert/add-ons/continue-on-error), it commits right after this Writer. - **Inserting the same object runs after insert again.** Insert contexts have no RecursionGuard. Only your predicate stops the chain. - **Writer wins.** A class that also implements `AfterInsert.Dispatcher` runs only as a Writer. diff --git a/website/after-undelete/dispatcher.md b/website/after-undelete/dispatcher.md index 290fec5..35b5379 100644 --- a/website/after-undelete/dispatcher.md +++ b/website/after-undelete/dispatcher.md @@ -25,7 +25,7 @@ Make one bulk call per chunk with the records that qualify, such as enqueueing a - **Pass Ids, not rows.** Hand `records.getIds()` to async work and query the records there. - **Call out from a Queueable.** Give the job `Database.AllowsCallouts` and make the callout in `execute`. -- **No unit of work.** DML here runs at once and is not merged. Change records from a [Writer](/after-undelete/writer). +- **No unit of work.** DML in `dispatchOnAfterUndelete` runs at once and is not merged. DML in the predicate throws. Change records from a [Writer](/after-undelete/writer). - **Writer wins.** A class that also implements `AfterUndelete.Writer` runs only as a Writer. ::: warning diff --git a/website/after-undelete/writer.md b/website/after-undelete/writer.md index 01bb1c0..d6bd59f 100644 --- a/website/after-undelete/writer.md +++ b/website/after-undelete/writer.md @@ -35,7 +35,7 @@ public with sharing class AccountRestoreGuardWriter implements AfterUndelete.Wri ## Rules {#rules} -- **Register, don't run DML.** Call `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events) on `unitOfWork`. Direct DML runs at once, outside the unit. +- **Register, don't run DML.** Call `toInsert`, `toUpdate`, `toUpsert`, `toDelete` or `toPublish` (platform events) on `unitOfWork`. DML in the predicate or the action throws. - **A self-update is a second save.** `unitOfWork.toUpdate(new Account(Id = record.getId(), …))` fires BeforeUpdate and AfterUpdate for the restored record. - **Block a restore with `addError`.** There is no Validator here. List the blocking Writer first. The record stays in the Recycle Bin. - **Writer wins.** A class that also implements `AfterUndelete.Dispatcher` runs only as a Writer. diff --git a/website/after-update/dispatcher.md b/website/after-update/dispatcher.md index de35ff1..530b2c9 100644 --- a/website/after-update/dispatcher.md +++ b/website/after-update/dispatcher.md @@ -52,7 +52,7 @@ public with sharing class OpportunityWonSyncDispatcher implements AfterUpdate.Di ## Rules {#rules} - **One dispatch per chunk.** An update of 1,000 records dispatches 5 times, so the example enqueues 5 jobs. A synchronous transaction can enqueue at most 50 Queueable jobs. -- **No unit of work.** DML here runs at once and is not merged with the Writers' changes. Use a [Writer](/after-update/writer) for record changes that commit with the save. +- **No unit of work.** DML in `dispatchOnAfterUpdate` runs at once and is not merged with the Writers' changes. DML in the predicate throws. Use a [Writer](/after-update/writer) for record changes that commit with the save. - **Writers' changes may not be saved yet.** By default, their registrations commit after the last handler, so a query here does not see them. - **Writer wins.** A class that also implements `AfterUpdate.Writer` runs only as a Writer. diff --git a/website/after-update/writer.md b/website/after-update/writer.md index fd761f5..b2bf92c 100644 --- a/website/after-update/writer.md +++ b/website/after-update/writer.md @@ -27,7 +27,7 @@ Change other records or publish platform events after the update, through a unit ## Rules {#rules} -- **Register, do not run DML.** `toInsert`, `toUpdate`, `toUpsert`, `toDelete` and `toPublish` (platform events) take one record each. The shared unit runs in system mode without sharing. Direct DML runs at once and bypasses the unit. +- **Register, do not run DML.** `toInsert`, `toUpdate`, `toUpsert`, `toDelete` and `toPublish` (platform events) take one record each. The shared unit runs in system mode without sharing. DML in the predicate or the action throws. - **Gate on a change.** A `toUpdate` of records of this object runs the update triggers again. Qualify with `isChanged` or `isChangedTo`, so the nested run skips the record. - **Nothing is saved inside the handler.** A `toInsert` record has no Id yet in the action or the Finalizer. For user mode, partial success or commit results, add [OwnUnitOfWork](/after-update/add-ons/own-unit-of-work). - **Writer wins.** A class that also implements `AfterUpdate.Dispatcher` runs only as a Writer. diff --git a/website/api/trigger-orchestrator.md b/website/api/trigger-orchestrator.md index f0b6ba9..69db5f5 100644 --- a/website/api/trigger-orchestrator.md +++ b/website/api/trigger-orchestrator.md @@ -179,6 +179,8 @@ public interface Error { | `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 | +| ` performed DML in or . Register that work in the unit of work.` | in before delete or an after context, a Writer's predicate or action ran DML. A Validator or a Dispatcher gets `Move that work to a Writer.` instead, and a Dispatcher's message names only its predicate | +| ` performed SOQL in or . Move that query to a ParentQuery or a RelatedQuery.` | a predicate or a per-record action ran its own SOQL, in any context. A Dispatcher's message names only its predicate. Update contexts add PriorParentQuery, and delete contexts name it instead of ParentQuery. Parents and providers the library loads do not count | | ` qualified a record in addErrorOnWhen but attached no error in addErrorOn.` | a Validator's predicate returned true and its error method attached no error | | ` has no record types, so isRecordType cannot be used on it.` | `isRecordType` or `isNotRecordType` on an object without record types; the message names the method | | `No related records provider named is declared. Return it from the queryRelatedOn method of the handler first.` | `getRelated` with a name the handler's RelatedQuery did not return | diff --git a/website/before-delete/validator.md b/website/before-delete/validator.md index 37f457d..94f4154 100644 --- a/website/before-delete/validator.md +++ b/website/before-delete/validator.md @@ -49,7 +49,7 @@ public with sharing class AccountDeletionGuardValidator implements BeforeDelete. ## Rules {#rules} -- **Block with `record.addError`.** `addError(message)` or `addError(field, message)` attaches the error to the row being deleted, and its delete fails. +- **Block with `record.addError`.** `addError(message)` or `addError(field, message)` attaches the error to the row being deleted, and its delete fails. DML in the predicate or the action throws. - **Every qualified record must get an error.** If `addErrorOnBeforeDelete` attaches none, the library throws a [`TriggerTypes.TriggerLibException`](/api/trigger-orchestrator#triggerlibexception), even with ContinueOnError. - **Errors add up.** A record with an error still reaches every later handler in the list. - **Validator wins.** A class that also implements `BeforeDelete.Writer` runs only as a Validator. diff --git a/website/before-delete/writer.md b/website/before-delete/writer.md index c3bcfbf..b934c99 100644 --- a/website/before-delete/writer.md +++ b/website/before-delete/writer.md @@ -53,7 +53,7 @@ public with sharing class ContactReportsReassignWriter implements BeforeDelete.W ## Rules {#rules} -- **Register, do not run DML.** `toInsert`, `toUpdate`, `toUpsert`, `toDelete` and `toPublish` (platform events) take one record each. +- **Register, do not run DML.** `toInsert`, `toUpdate`, `toUpsert`, `toDelete` and `toPublish` (platform events) take one record each. DML in the predicate or the action throws. - **Commits before the delete.** The shared unit of work commits once, after the last handler of the run, while the records still exist. A Writer with [ContinueOnError](/before-delete/add-ons/continue-on-error) gets an automatic unit, committed right after it. [OwnUnitOfWork](/before-delete/add-ons/own-unit-of-work) supplies your own. - **Links still point at the rows.** Records that look up to the deleted rows still hold the Ids here, so a query by `records.getIds()` finds them. - **Validator wins.** A class that also implements `BeforeDelete.Validator` runs only as a Validator. diff --git a/website/before-insert/populator.md b/website/before-insert/populator.md index 8bc7f14..c5cd4bd 100644 --- a/website/before-insert/populator.md +++ b/website/before-insert/populator.md @@ -28,7 +28,7 @@ Set fields on new records before they are saved. ## Rules {#rules} - **`put` writes to the row being saved.** Handlers listed later see the value. -- **No SOQL in the loop.** The predicate and the action run once per record. Read parents with a [ParentQuery](/before-insert/add-ons/parent-query) and other records with a [RelatedQuery](/before-insert/add-ons/related-query). +- **No SOQL in the loop.** The predicate and the action run once per record, so a query there throws. Read parents with a [ParentQuery](/before-insert/add-ons/parent-query) and other records with a [RelatedQuery](/before-insert/add-ons/related-query). - **Populator wins.** A class that also implements `BeforeInsert.Validator` runs only as a Populator. - **Exceptions fail the save.** Without [ContinueOnError](/before-insert/add-ons/continue-on-error), an exception fails every record in the chunk. With it, the exception is logged and the save goes on. diff --git a/website/guide/execution-order.md b/website/guide/execution-order.md index 34c16cb..9c4b4e4 100644 --- a/website/guide/execution-order.md +++ b/website/guide/execution-order.md @@ -40,14 +40,14 @@ SOQL queries a run spends outside your code: - **Declarations merge.** Handlers that declare the same lookup share one query. - **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. +- **Never query in a predicate or an action.** They run once per record, so a query there throws. Declare a ParentQuery or a RelatedQuery, or query once in a Finalizer. The parents and providers the library loads for them do not count. ## DML Cost {#dml-cost} - **Before insert and before update:** none. DML or an event publish throws. - **Shared unit of work:** one commit per run. It costs one statement per operation and object type. An empty unit costs nothing. - **A Writer with OwnUnitOfWork or ContinueOnError:** one more commit per run, when a record qualified. -- **Direct DML** in a Dispatcher or a Finalizer costs what it runs. +- **Direct DML** in a Dispatcher's dispatch method or a Finalizer costs what it runs. In a predicate or an action it throws. - **Events:** a Publish After Commit event counts as a DML statement. A Publish Immediately event counts toward `Limits.getPublishImmediateDML()`. ## Nested Runs {#nested} diff --git a/website/guide/unit-of-work.md b/website/guide/unit-of-work.md index e2c0bab..7818b03 100644 --- a/website/guide/unit-of-work.md +++ b/website/guide/unit-of-work.md @@ -62,4 +62,5 @@ All settings: [DML Lib](https://dml.beyondthecloud.dev). ## Without a Unit {#direct-dml} - **Before insert and before update** allow no DML. Set fields with `put` in a Populator. -- **A Dispatcher** runs direct DML at its turn, before the commit after the last handler. +- **Predicates and actions** in before delete and the after contexts throw on DML. Register the work instead. +- **A Dispatcher** runs direct DML in its dispatch method, at its turn, before the commit after the last handler. A Finalizer may run DML too. diff --git a/website/introduction/design-principles.md b/website/introduction/design-principles.md index de268b2..a276caf 100644 --- a/website/introduction/design-principles.md +++ b/website/introduction/design-principles.md @@ -18,11 +18,11 @@ Handlers run in list order, or in `Order__c` order under `runFromMetadata()`. Ea ## 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 in bulk, never per record. +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. A predicate or an action that runs its own SOQL throws. ## Before Contexts Populate, After Contexts Act {#before-populates-after-acts} -In before insert and before update, DML or an event publish fails the save. Change other records from a Writer in before delete or an after context. +In before insert and before update, DML or an event publish fails the save. Change other records from a Writer in before delete or an after context, through its unit of work: DML in a predicate or an action fails there too. ## Qualification Is Explicit {#qualification-is-explicit}