Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions force-app/main/default/classes/AfterDelete.cls
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
267 changes: 266 additions & 1 deletion force-app/main/default/classes/AfterDeleteTest.cls
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());
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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());

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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());
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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());

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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());
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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());

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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());
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<TriggerObject__mdt>());
SOQL.mock('ApexTypeImplementor').thenReturn(new List<ApexTypeImplementor>());

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
Expand Down Expand Up @@ -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<Id> 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<SObjectField, TriggerTypes.ParentFields> queryPriorParentsOnAfterDelete() {
return new Map<SObjectField, TriggerTypes.ParentFields>{ Contact.AccountId => TriggerTypes.ParentFields.with(Account.Name) };
Expand Down
Loading
Loading