From dd931ca76a97fc2a33e967a04ae10d9d84142ecb Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 26 Sep 2026 11:24:13 +0200 Subject: [PATCH] Refactoring --- .../main/default/classes/AfterDeleteTest.cls | 2 +- .../main/default/classes/AfterInsertTest.cls | 80 ++++++++++- .../default/classes/AfterUndeleteTest.cls | 4 +- .../main/default/classes/AfterUpdateTest.cls | 6 +- .../main/default/classes/BeforeDeleteTest.cls | 2 +- .../main/default/classes/BeforeInsertTest.cls | 2 +- .../main/default/classes/BeforeUpdateTest.cls | 2 +- .../default/classes/TriggerOrchestrator.cls | 18 ++- .../classes/TriggerOrchestratorTest.cls | 2 +- .../main/default/classes/TriggerTypes.cls | 43 +++++- .../main/default/classes/TriggerTypesTest.cls | 134 +++++++++++++++++- .../main/default/classes/AfterDeleteTest.cls | 2 +- .../main/default/classes/AfterInsertTest.cls | 80 ++++++++++- .../default/classes/AfterUndeleteTest.cls | 4 +- .../main/default/classes/AfterUpdateTest.cls | 6 +- .../main/default/classes/BeforeDeleteTest.cls | 2 +- .../main/default/classes/BeforeInsertTest.cls | 2 +- .../main/default/classes/BeforeUpdateTest.cls | 2 +- .../default/classes/TriggerOrchestrator.cls | 18 ++- .../classes/TriggerOrchestratorTest.cls | 2 +- package/main/default/classes/TriggerTypes.cls | 43 +++++- .../main/default/classes/TriggerTypesTest.cls | 134 +++++++++++++++++- website/.vitepress/scripts/generate.mjs | 8 ++ .../after-insert/add-ons/continue-on-error.md | 2 +- website/api/record.md | 6 +- website/api/trigger-orchestrator.md | 2 +- website/guide/testing.md | 3 +- website/installation.md | 6 +- 28 files changed, 574 insertions(+), 43 deletions(-) diff --git a/force-app/main/default/classes/AfterDeleteTest.cls b/force-app/main/default/classes/AfterDeleteTest.cls index 2921440..05e3160 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) * - * v.0.1.0 + * v0.2.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/AfterInsertTest.cls b/force-app/main/default/classes/AfterInsertTest.cls index 4d25ab8..eed6bc4 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -344,6 +344,27 @@ private class AfterInsertTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWithContinueOnErrorWhenRetryIsRequested() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(RetryingWriterWithContinueOnError.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new RetryingWriterWithContinueOnError()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isInstanceOfType(expectedException, EventBus.RetryableException.class, 'The retry request should not be swallowed.'); + } + @IsTest static void writerWhenHandlerPerformsDml() { // Setup @@ -369,7 +390,7 @@ private class AfterInsertTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterInsertFor(AccountNameWriter.class).with(doe); @@ -562,6 +583,20 @@ private class AfterInsertTest { Assert.areEqual('AFTER_INSERT', doe.Description, 'The mocked context should run.'); } + @IsTest + static void mockWithPlatformEvent() { + // Setup + EventCountingDispatcher dispatcher = new EventCountingDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(EventCountingDispatcher.class).with(new BatchApexErrorEvent()); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedEvents, 'The mocked event should be dispatched.'); + } + @IsTest static void mockWithParent() { // Setup @@ -579,6 +614,25 @@ private class AfterInsertTest { Assert.areEqual('Acme', DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records()[0].get(Task.Subject), 'The mocked parent should be served.'); } + @IsTest + static void mockWithParentWithoutRealQuery() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); + Contact doe = new Contact(LastName = 'Doe', AccountId = acme.Id); + + TriggerOrchestrator.mock().afterInsertFor(AccountNameWriter.class).with(doe).withParent(Contact.AccountId, acme); + + // Test + TriggerOrchestrator.runTestFor(new AccountNameWriter()); + + // Verify + Assert.areEqual(0, Limits.getQueries(), 'The mocked context should not query the org.'); + } + @IsTest static void mockWithFakeId() { // Setup @@ -603,6 +657,18 @@ private class AfterInsertTest { } } + private class EventCountingDispatcher implements AfterInsert.Dispatcher { + public Integer dispatchedEvents = 0; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return true; + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + this.dispatchedEvents += records.size(); + } + } + private class AccountWriterWithBypassable extends AccountWriter implements AfterInsert.Bypassable { public Boolean bypassOnAfterInsertWhen() { return true; @@ -677,6 +743,16 @@ private class AfterInsertTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterInsert.ContinueOnError { } + private class RetryingWriterWithContinueOnError implements AfterInsert.Writer, AfterInsert.ContinueOnError { + public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { + throw new EventBus.RetryableException('The writer asked for a retry.'); + } + } + private class DmlAccountWriter implements AfterInsert.Writer { public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); diff --git a/force-app/main/default/classes/AfterUndeleteTest.cls b/force-app/main/default/classes/AfterUndeleteTest.cls index 50f23c0..7c166d1 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -369,7 +369,7 @@ private class AfterUndeleteTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUndeleteFor(AccountNameWriter.class).with(doe); diff --git a/force-app/main/default/classes/AfterUpdateTest.cls b/force-app/main/default/classes/AfterUpdateTest.cls index 21e0109..986fc3d 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -592,7 +592,7 @@ private class AfterUpdateTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUpdateFor(AccountNameWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); @@ -634,7 +634,7 @@ private class AfterUpdateTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUpdateFor(AccountNameWriterWithPriorParentQuery.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe', AccountId = acme.Id)); diff --git a/force-app/main/default/classes/BeforeDeleteTest.cls b/force-app/main/default/classes/BeforeDeleteTest.cls index af4d81c..c8d3324 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) * - * v.0.1.0 + * v0.2.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/BeforeInsertTest.cls b/force-app/main/default/classes/BeforeInsertTest.cls index 4b3bc1e..955164d 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) * - * v.0.1.0 + * v0.2.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/BeforeUpdateTest.cls b/force-app/main/default/classes/BeforeUpdateTest.cls index 802ed6a..9c8b272 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) * - * v.0.1.0 + * v0.2.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/TriggerOrchestrator.cls b/force-app/main/default/classes/TriggerOrchestrator.cls index 057110b..a38e59e 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -508,7 +508,7 @@ public with sharing class TriggerOrchestrator { protected void handleException(Exception e) { this.logError(e); - if (e instanceof TriggerTypes.TriggerLibException || !this.handlerHooks.continuesOnError()) { + if (e instanceof TriggerTypes.TriggerLibException || e instanceof EventBus.RetryableException || !this.handlerHooks.continuesOnError()) { throw e; } } @@ -617,7 +617,13 @@ public with sharing class TriggerOrchestrator { fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); } - List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); + List savedRecords = SOQL.of(this.context.sObjectType) + .with(fieldsToQuery) + .byIds(this.context.newRecords) + .systemMode() + .withoutSharing() + .mockId('SavedTriggerRecords') + .toList(); for (SObjectField lookupField : this.lookupsForNewParents) { Map cachedParentById = this.cachedParentsByLookup.get(lookupField); @@ -958,6 +964,8 @@ public with sharing class TriggerOrchestrator { SOQL.mock(parentType).thenReturn(this.typed(this.parentsByType.get(parentType))); } + SOQL.mock('SavedTriggerRecords').thenReturn(new List()); + TriggerOrchestrator orchestrator = new TriggerOrchestrator(this); orchestrator.context.triggerOperation = this.triggerOperation; @@ -976,6 +984,10 @@ public with sharing class TriggerOrchestrator { } protected List assignIds(List records) { + if (records.isEmpty() || !records[0].getSObjectType().getDescribe().fields.getMap().containsKey('Id')) { + return records; + } + for (SObject record : records) { record.put('Id', record.Id ?? new TriggerTypes.RandomIdGenerator().get(record.getSObjectType())); } diff --git a/force-app/main/default/classes/TriggerOrchestratorTest.cls b/force-app/main/default/classes/TriggerOrchestratorTest.cls index 4fd20b4..729d4c2 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) * - * v.0.1.0 + * v0.2.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 f1e4073..058e53b 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record @@ -30,6 +30,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -93,6 +96,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -157,6 +163,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -186,6 +195,7 @@ public with sharing class TriggerTypes { Boolean areAllChanged(Iterable fields); Boolean isChangedTo(SObjectField field, Object expectedValue); + Boolean isChangedToNotNull(SObjectField field); Boolean isChangedFrom(SObjectField field, Object priorValue); Boolean isChangedFromTo(SObjectField field, Object fromValue, Object toValue); @@ -240,6 +250,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -269,6 +282,7 @@ public with sharing class TriggerTypes { Boolean areAllChanged(Iterable fields); Boolean isChangedTo(SObjectField field, Object expectedValue); + Boolean isChangedToNotNull(SObjectField field); Boolean isChangedFrom(SObjectField field, Object priorValue); Boolean isChangedFromTo(SObjectField field, Object fromValue, Object toValue); @@ -324,6 +338,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -385,6 +402,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -449,6 +469,9 @@ public with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -674,6 +697,20 @@ public with sharing class TriggerTypes { return !this.equals(field, value); } + public Boolean isIn(SObjectField field, Iterable values) { + for (Object value : values) { + if (this.equals(field, value)) { + return true; + } + } + + return false; + } + + public Boolean isNotIn(SObjectField field, Iterable values) { + return !this.isIn(field, values); + } + public Boolean contains(SObjectField field, String value) { return this.isNotNull(field) && this.getFieldValue(field).toString().contains(value); } @@ -774,6 +811,10 @@ public with sharing class TriggerTypes { return this.getFieldValue(field) == value && this.getOldFieldValue(field) != value; } + public Boolean isChangedToNotNull(SObjectField field) { + return this.isNotNull(field) && this.isChanged(field); + } + public Boolean isChangedFrom(SObjectField field, Object value) { return this.getFieldValue(field) != value && this.getOldFieldValue(field) == value; } diff --git a/force-app/main/default/classes/TriggerTypesTest.cls b/force-app/main/default/classes/TriggerTypesTest.cls index 1fefba4..a651e77 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class @@ -146,6 +146,54 @@ private class TriggerTypesTest { Assert.isTrue(result, 'The field should be reported as changed to the value.'); } + @IsTest + static void isChangedToNotNullWhenValueWasSet() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Manager'), new Contact(LastName = 'Doe', Title = null)); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isTrue(result, 'The field should be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueWasReplaced() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Director'), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isTrue(result, 'The field should be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueWasCleared() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = null), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isFalse(result, 'The field should not be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueIsUnchanged() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Manager'), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isFalse(result, 'The field should not be reported as changed to a value.'); + } + @IsTest static void isChangedFromWhenOldValueMatchesAndNewDiffers() { // Setup @@ -1325,6 +1373,90 @@ private class TriggerTypesTest { Assert.isFalse(result, 'The field should equal the value.'); } + @IsTest + static void isInWhenValueIsInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'Doe', 'Roe' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWhenValueIsNotInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'Roe', 'Smith' }); + + // Verify + Assert.isFalse(result, 'The field should not be in the values.'); + } + + @IsTest + static void isInWhenOnlyLetterCaseDiffers() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'DOE' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWithList() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new List{ 'Roe', 'Doe' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWhenFieldIsNull() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.Description, new Set{ 'Anything' }); + + // Verify + Assert.isFalse(result, 'The field should not be in the values.'); + } + + @IsTest + static void isNotInWhenValueIsNotInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isNotIn(Contact.LastName, new Set{ 'Roe', 'Smith' }); + + // Verify + Assert.isTrue(result, 'The field should not be in the values.'); + } + + @IsTest + static void isNotInWhenValueIsInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isNotIn(Contact.LastName, new Set{ 'Doe', 'Roe' }); + + // Verify + Assert.isFalse(result, 'The field should be in the values.'); + } + @IsTest static void containsWhenValueIsPresent() { // Setup diff --git a/package/main/default/classes/AfterDeleteTest.cls b/package/main/default/classes/AfterDeleteTest.cls index 2921440..05e3160 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) * - * v.0.1.0 + * v0.2.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/AfterInsertTest.cls b/package/main/default/classes/AfterInsertTest.cls index 4d25ab8..eed6bc4 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -344,6 +344,27 @@ private class AfterInsertTest { Assert.areEqual('The dispatcher failed.', logger.errors[0].getException().getMessage(), 'The logged exception message should match the expected one.'); } + @IsTest + static void writerWithContinueOnErrorWhenRetryIsRequested() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + + TriggerOrchestrator.mock().afterInsertFor(RetryingWriterWithContinueOnError.class).with(new Contact(LastName = 'Doe')); + + Exception expectedException = null; + + // Test + try { + TriggerOrchestrator.runTestFor(new RetryingWriterWithContinueOnError()); + } catch (Exception e) { + expectedException = e; + } + + // Verify + Assert.isInstanceOfType(expectedException, EventBus.RetryableException.class, 'The retry request should not be swallowed.'); + } + @IsTest static void writerWhenHandlerPerformsDml() { // Setup @@ -369,7 +390,7 @@ private class AfterInsertTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterInsertFor(AccountNameWriter.class).with(doe); @@ -562,6 +583,20 @@ private class AfterInsertTest { Assert.areEqual('AFTER_INSERT', doe.Description, 'The mocked context should run.'); } + @IsTest + static void mockWithPlatformEvent() { + // Setup + EventCountingDispatcher dispatcher = new EventCountingDispatcher(); + + TriggerOrchestrator.mock().afterInsertFor(EventCountingDispatcher.class).with(new BatchApexErrorEvent()); + + // Test + TriggerOrchestrator.runTestFor(dispatcher); + + // Verify + Assert.areEqual(1, dispatcher.dispatchedEvents, 'The mocked event should be dispatched.'); + } + @IsTest static void mockWithParent() { // Setup @@ -579,6 +614,25 @@ private class AfterInsertTest { Assert.areEqual('Acme', DML.retrieveResultFor('triggerUow').insertsOf(Task.SObjectType).records()[0].get(Task.Subject), 'The mocked parent should be served.'); } + @IsTest + static void mockWithParentWithoutRealQuery() { + // Setup + SOQL.mock('TriggerObject__mdt').thenReturn(new List()); + SOQL.mock('ApexTypeImplementor').thenReturn(new List()); + DML.mock('triggerUow').allDmls(); + + Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); + Contact doe = new Contact(LastName = 'Doe', AccountId = acme.Id); + + TriggerOrchestrator.mock().afterInsertFor(AccountNameWriter.class).with(doe).withParent(Contact.AccountId, acme); + + // Test + TriggerOrchestrator.runTestFor(new AccountNameWriter()); + + // Verify + Assert.areEqual(0, Limits.getQueries(), 'The mocked context should not query the org.'); + } + @IsTest static void mockWithFakeId() { // Setup @@ -603,6 +657,18 @@ private class AfterInsertTest { } } + private class EventCountingDispatcher implements AfterInsert.Dispatcher { + public Integer dispatchedEvents = 0; + + public Boolean dispatchOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return true; + } + + public void dispatchOnAfterInsert(TriggerTypes.InsertRecords records) { + this.dispatchedEvents += records.size(); + } + } + private class AccountWriterWithBypassable extends AccountWriter implements AfterInsert.Bypassable { public Boolean bypassOnAfterInsertWhen() { return true; @@ -677,6 +743,16 @@ private class AfterInsertTest { private class FailingDispatcherWithContinueOnError extends FailingDispatcher implements AfterInsert.ContinueOnError { } + private class RetryingWriterWithContinueOnError implements AfterInsert.Writer, AfterInsert.ContinueOnError { + public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { + return record.equals(Contact.LastName, 'Doe'); + } + + public void writeOnAfterInsert(TriggerTypes.InsertRecord record, TriggerTypes.UnitOfWork unitOfWork) { + throw new EventBus.RetryableException('The writer asked for a retry.'); + } + } + private class DmlAccountWriter implements AfterInsert.Writer { public Boolean writeOnAfterInsertWhen(TriggerTypes.InsertRecord record) { return record.equals(Contact.LastName, 'Doe'); diff --git a/package/main/default/classes/AfterUndeleteTest.cls b/package/main/default/classes/AfterUndeleteTest.cls index 50f23c0..7c166d1 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -369,7 +369,7 @@ private class AfterUndeleteTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUndeleteFor(AccountNameWriter.class).with(doe); diff --git a/package/main/default/classes/AfterUpdateTest.cls b/package/main/default/classes/AfterUpdateTest.cls index 21e0109..986fc3d 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE test class @@ -592,7 +592,7 @@ private class AfterUpdateTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUpdateFor(AccountNameWriter.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe')); @@ -634,7 +634,7 @@ private class AfterUpdateTest { Account acme = new Account(Id = new TriggerTypes.RandomIdGenerator().get(Account.SObjectType), Name = 'Acme'); Contact doe = new Contact(Id = new TriggerTypes.RandomIdGenerator().get(Contact.SObjectType), LastName = 'Doe', AccountId = acme.Id); - SOQL.mock(Contact.SObjectType).thenReturn(new Contact(Id = doe.Id, Account = acme)); + SOQL.mock('SavedTriggerRecords').thenReturn(new Contact(Id = doe.Id, Account = acme)); TriggerOrchestrator.mock().afterUpdateFor(AccountNameWriterWithPriorParentQuery.class).with(doe, new Contact(Id = doe.Id, LastName = 'Doe', AccountId = acme.Id)); diff --git a/package/main/default/classes/BeforeDeleteTest.cls b/package/main/default/classes/BeforeDeleteTest.cls index af4d81c..c8d3324 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) * - * v.0.1.0 + * v0.2.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/BeforeInsertTest.cls b/package/main/default/classes/BeforeInsertTest.cls index 4b3bc1e..955164d 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) * - * v.0.1.0 + * v0.2.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/BeforeUpdateTest.cls b/package/main/default/classes/BeforeUpdateTest.cls index 802ed6a..9c8b272 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) * - * v.0.1.0 + * v0.2.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/TriggerOrchestrator.cls b/package/main/default/classes/TriggerOrchestrator.cls index 6d976c2..fc6a098 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessiveClassLength: It is a library and we tried to put everything into ONE class @@ -505,7 +505,7 @@ global with sharing class TriggerOrchestrator { protected void handleException(Exception e) { this.logError(e); - if (e instanceof TriggerTypes.TriggerLibException || !this.handlerHooks.continuesOnError()) { + if (e instanceof TriggerTypes.TriggerLibException || e instanceof EventBus.RetryableException || !this.handlerHooks.continuesOnError()) { throw e; } } @@ -614,7 +614,13 @@ global with sharing class TriggerOrchestrator { fieldsToQuery.addAll(this.getFieldPathsForLookup(lookupField)); } - List savedRecords = SOQL.of(this.context.sObjectType).with(fieldsToQuery).byIds(this.context.newRecords).systemMode().withoutSharing().toList(); + List savedRecords = SOQL.of(this.context.sObjectType) + .with(fieldsToQuery) + .byIds(this.context.newRecords) + .systemMode() + .withoutSharing() + .mockId('SavedTriggerRecords') + .toList(); for (SObjectField lookupField : this.lookupsForNewParents) { Map cachedParentById = this.cachedParentsByLookup.get(lookupField); @@ -955,6 +961,8 @@ global with sharing class TriggerOrchestrator { SOQL.mock(parentType).thenReturn(this.typed(this.parentsByType.get(parentType))); } + SOQL.mock('SavedTriggerRecords').thenReturn(new List()); + TriggerOrchestrator orchestrator = new TriggerOrchestrator(this); orchestrator.context.triggerOperation = this.triggerOperation; @@ -973,6 +981,10 @@ global with sharing class TriggerOrchestrator { } protected List assignIds(List records) { + if (records.isEmpty() || !records[0].getSObjectType().getDescribe().fields.getMap().containsKey('Id')) { + return records; + } + for (SObject record : records) { record.put('Id', record.Id ?? new TriggerTypes.RandomIdGenerator().get(record.getSObjectType())); } diff --git a/package/main/default/classes/TriggerOrchestratorTest.cls b/package/main/default/classes/TriggerOrchestratorTest.cls index 4fd20b4..729d4c2 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) * - * v.0.1.0 + * v0.2.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 79b0097..82ed805 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - ExcessivePublicCount: It is a library class and exposes all necessary methods to evaluate a trigger record @@ -30,6 +30,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -93,6 +96,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -157,6 +163,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -186,6 +195,7 @@ global with sharing class TriggerTypes { Boolean areAllChanged(Iterable fields); Boolean isChangedTo(SObjectField field, Object expectedValue); + Boolean isChangedToNotNull(SObjectField field); Boolean isChangedFrom(SObjectField field, Object priorValue); Boolean isChangedFromTo(SObjectField field, Object fromValue, Object toValue); @@ -240,6 +250,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -269,6 +282,7 @@ global with sharing class TriggerTypes { Boolean areAllChanged(Iterable fields); Boolean isChangedTo(SObjectField field, Object expectedValue); + Boolean isChangedToNotNull(SObjectField field); Boolean isChangedFrom(SObjectField field, Object priorValue); Boolean isChangedFromTo(SObjectField field, Object fromValue, Object toValue); @@ -324,6 +338,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -385,6 +402,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -449,6 +469,9 @@ global with sharing class TriggerTypes { Boolean equals(SObjectField field, Object value); Boolean doesNotEqual(SObjectField field, Object value); + Boolean isIn(SObjectField field, Iterable values); + Boolean isNotIn(SObjectField field, Iterable values); + Boolean contains(SObjectField field, String value); Boolean doesNotContain(SObjectField field, String value); Boolean startsWith(SObjectField field, String value); @@ -674,6 +697,20 @@ global with sharing class TriggerTypes { return !this.equals(field, value); } + public Boolean isIn(SObjectField field, Iterable values) { + for (Object value : values) { + if (this.equals(field, value)) { + return true; + } + } + + return false; + } + + public Boolean isNotIn(SObjectField field, Iterable values) { + return !this.isIn(field, values); + } + public Boolean contains(SObjectField field, String value) { return this.isNotNull(field) && this.getFieldValue(field).toString().contains(value); } @@ -774,6 +811,10 @@ global with sharing class TriggerTypes { return this.getFieldValue(field) == value && this.getOldFieldValue(field) != value; } + public Boolean isChangedToNotNull(SObjectField field) { + return this.isNotNull(field) && this.isChanged(field); + } + public Boolean isChangedFrom(SObjectField field, Object value) { return this.getFieldValue(field) != value && this.getOldFieldValue(field) == value; } diff --git a/package/main/default/classes/TriggerTypesTest.cls b/package/main/default/classes/TriggerTypesTest.cls index 1fefba4..a651e77 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) * - * v.0.1.0 + * v0.2.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE test class @@ -146,6 +146,54 @@ private class TriggerTypesTest { Assert.isTrue(result, 'The field should be reported as changed to the value.'); } + @IsTest + static void isChangedToNotNullWhenValueWasSet() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Manager'), new Contact(LastName = 'Doe', Title = null)); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isTrue(result, 'The field should be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueWasReplaced() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Director'), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isTrue(result, 'The field should be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueWasCleared() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = null), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isFalse(result, 'The field should not be reported as changed to a value.'); + } + + @IsTest + static void isChangedToNotNullWhenValueIsUnchanged() { + // Setup + TriggerTypes.UpdateRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe', Title = 'Manager'), new Contact(LastName = 'Doe', Title = 'Manager')); + + // Test + Boolean result = record.isChangedToNotNull(Contact.Title); + + // Verify + Assert.isFalse(result, 'The field should not be reported as changed to a value.'); + } + @IsTest static void isChangedFromWhenOldValueMatchesAndNewDiffers() { // Setup @@ -1325,6 +1373,90 @@ private class TriggerTypesTest { Assert.isFalse(result, 'The field should equal the value.'); } + @IsTest + static void isInWhenValueIsInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'Doe', 'Roe' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWhenValueIsNotInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'Roe', 'Smith' }); + + // Verify + Assert.isFalse(result, 'The field should not be in the values.'); + } + + @IsTest + static void isInWhenOnlyLetterCaseDiffers() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new Set{ 'DOE' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWithList() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.LastName, new List{ 'Roe', 'Doe' }); + + // Verify + Assert.isTrue(result, 'The field should be in the values.'); + } + + @IsTest + static void isInWhenFieldIsNull() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isIn(Contact.Description, new Set{ 'Anything' }); + + // Verify + Assert.isFalse(result, 'The field should not be in the values.'); + } + + @IsTest + static void isNotInWhenValueIsNotInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isNotIn(Contact.LastName, new Set{ 'Roe', 'Smith' }); + + // Verify + Assert.isTrue(result, 'The field should not be in the values.'); + } + + @IsTest + static void isNotInWhenValueIsInSet() { + // Setup + TriggerTypes.InsertRecord record = new TriggerTypes.TriggerRecord(new Contact(LastName = 'Doe'), null); + + // Test + Boolean result = record.isNotIn(Contact.LastName, new Set{ 'Doe', 'Roe' }); + + // Verify + Assert.isFalse(result, 'The field should be in the values.'); + } + @IsTest static void containsWhenValueIsPresent() { // Setup diff --git a/website/.vitepress/scripts/generate.mjs b/website/.vitepress/scripts/generate.mjs index c8b1876..4624bad 100644 --- a/website/.vitepress/scripts/generate.mjs +++ b/website/.vitepress/scripts/generate.mjs @@ -613,6 +613,10 @@ const recordMethodGroups = [ names: ['equals', 'doesNotEqual'], note: () => 'equal / different; text ignores case' }, + { + names: ['isIn', 'isNotIn'], + note: () => 'equal to one of the values / to none; text ignores case' + }, { names: ['contains', 'doesNotContain', 'startsWith', 'endsWith'], note: () => 'text match; case-sensitive' @@ -656,6 +660,10 @@ const recordMethodGroups = [ names: ['isChangedTo', 'isChangedFrom'], note: () => 'changed to / from the value' }, + { + names: ['isChangedToNotNull'], + note: () => 'changed, and the new value is not null' + }, { names: ['isChangedFromTo'], note: () => 'the old value is `fromValue` and the new one is `toValue`' diff --git a/website/after-insert/add-ons/continue-on-error.md b/website/after-insert/add-ons/continue-on-error.md index b8ad65e..1a6abc6 100644 --- a/website/after-insert/add-ons/continue-on-error.md +++ b/website/after-insert/add-ons/continue-on-error.md @@ -30,5 +30,5 @@ Log and swallow the handler's exceptions, so later handlers still run and the in - **Add a [Logger](/guide/error-handling#logger).** Without one, a swallowed exception leaves no trace. ::: warning -Some errors still fail the insert. [Library exceptions](/api/trigger-orchestrator#triggerlibexception), `System.LimitException`, the `FinalException` from writing a trigger row and exceptions outside the handler's own methods, such as `bypassOnAfterInsertWhen()` or the final commit, are never swallowed. +Some errors still fail the insert. [Library exceptions](/api/trigger-orchestrator#triggerlibexception), `System.LimitException`, `EventBus.RetryableException` (a platform event subscriber asking for redelivery), the `FinalException` from writing a trigger row and exceptions outside the handler's own methods, such as `bypassOnAfterInsertWhen()` or the final commit, are never swallowed. ::: diff --git a/website/api/record.md b/website/api/record.md index f40d0ee..2f4a6f7 100644 --- a/website/api/record.md +++ b/website/api/record.md @@ -43,17 +43,17 @@ A missing member is a compile error, such as `isChanged` in an insert handler. ## Value Checks {#value-checks} -`equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWith`, `isNull`, `isNotNull`, `isEmpty`, `isNotEmpty`, `isBlank`, `isNotBlank`, `isTrue`, `isFalse`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`. +`equals`, `doesNotEqual`, `isIn`, `isNotIn`, `contains`, `doesNotContain`, `startsWith`, `endsWith`, `isNull`, `isNotNull`, `isEmpty`, `isNotEmpty`, `isBlank`, `isNotBlank`, `isTrue`, `isFalse`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`. - **They read the new row.** In the delete contexts they read the old row. - **They see earlier changes.** In before contexts, a value set by an earlier handler with `put` counts. - **They never throw on null.** Comparisons such as `lessThan` are false for null. -- **Case.** `equals` ignores case for text. `contains`, `startsWith` and `endsWith` are case-sensitive. +- **Case.** `equals` and `isIn` ignore case for text. `contains`, `startsWith` and `endsWith` are case-sensitive. - **`0` and `false` are values.** They are not null, empty or blank. ## Change Detection {#change-detection} -Update contexts only: `isChanged`, `isAnyChanged`, `areAllChanged`, `isChangedTo`, `isChangedFrom`, `isChangedFromTo`. +Update contexts only: `isChanged`, `isAnyChanged`, `areAllChanged`, `isChangedTo`, `isChangedToNotNull`, `isChangedFrom`, `isChangedFromTo`. - **`isAnyChanged` and `areAllChanged`** take 2 to 5 fields or an `Iterable`. - **Text comparison ignores case.** `'Doe'` to `'DOE'` is not a change. diff --git a/website/api/trigger-orchestrator.md b/website/api/trigger-orchestrator.md index 9cd4884..1381115 100644 --- a/website/api/trigger-orchestrator.md +++ b/website/api/trigger-orchestrator.md @@ -139,7 +139,7 @@ public interface Error { ``` - **`getHandlerName()`** is the handler's class name without the outer class. -- **`getRecordIds()`** holds every record of the chunk, not only the qualified ones. It is empty in before insert. +- **`getRecordIds()`** holds every record of the chunk, not only the qualified ones. It is empty in before insert and for platform events. ## TriggerLibException {#triggerlibexception} diff --git a/website/guide/testing.md b/website/guide/testing.md index fa4b3f2..df026ec 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -77,7 +77,7 @@ static void populateOnBeforeUpdateWithSmallAmount() { - **Assert on your own rows.** The handler gets the same instances, so the fields a Populator set and the errors a Validator added are on them after the run. - **The predicate, the add-ons and the Finalizer run too.** A row the predicate rejects is skipped, as in a real save. - **Mock the class you run.** `beforeInsertFor(X.class)` pairs with `runTestFor(new X())` by class name. Without a mock, `runTestFor` throws a `TriggerTypes.TriggerLibException`. -- **Rows get Ids where the context has them.** In after insert, after undelete and both delete contexts, a row without an Id gets a fake one. In the update contexts, the new and the old row share one Id. +- **Rows get Ids where the context has them.** In after insert, after undelete and both delete contexts, a row without an Id gets a fake one. In the update contexts, the new and the old row share one Id. Objects without an `Id` field, such as platform events, keep their rows as they are. - **Queue several contexts before the first run.** Each `runTestFor` takes the next context queued for the class, and the last one stays for every later call. ## Parents {#parents} @@ -109,6 +109,7 @@ static void writeOnAfterInsertWithActiveAccountOwner() { - **Nest a grandparent** inside the parent, as `Owner` above. - **Pass the old parents too for a PriorParentQuery.** Set the lookup on the old row and pass both parents, such as `withParent(Account.OwnerId, new List{ newOwner, oldOwner })`. - **Parents of one type share one mock.** Each lookup still picks its parent by Id, so two lookups to Account work side by side. +- **No query reaches the org.** In after insert, after update and after undelete, parents are normally read through one query on the saved rows. `runTestFor` mocks that query too, so every parent comes from `withParent`. ## Related Records {#related} diff --git a/website/installation.md b/website/installation.md index 8dac7ca..c8b2373 100644 --- a/website/installation.md +++ b/website/installation.md @@ -10,9 +10,9 @@ description: Install Trigger Lib in a Salesforce org - deploy via button, or cop sf package version promote --package "Trigger Lib@1.0.0-1" --target-dev-hub beyondthecloud-prod --> -## Install via Unlocked Package +## Install via Unlocked Package {#install-via-unlocked-package} -Install the SOQL Lib unlocked package with `btcdev` namespace to your Salesforce environment: +Install the Trigger Lib unlocked package with `btcdev` namespace to your Salesforce environment: `/packaging/installPackage.apexp?p0=04tP6000003jdrVIAQ` @@ -34,7 +34,7 @@ Click the button below to deploy Trigger Lib to your environment. ::: warning -The button also deploys [SOQL Lib](https://soql.beyondthecloud.dev) 6.11.0 and [DML Lib](https://dml.beyondthecloud.dev) 3.2.0, replacing the org's `SOQL` and `DML` classes. If the org has the same or a newer version, use [Copy and Deploy](#copy-and-deploy) and skip them. +The button also deploys [SOQL Lib](https://soql.beyondthecloud.dev) 6.12.0 and [DML Lib](https://dml.beyondthecloud.dev) 4.0.0, replacing the org's `SOQL` and `DML` classes. If the org has the same or a newer version, use [Copy and Deploy](#copy-and-deploy) and skip them. ::: ## Copy and Deploy {#copy-and-deploy}