From 4d03a3181b74261bb6ee58c2aa2d428366f1cf0e Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 13 Dec 2025 18:31:10 +0100 Subject: [PATCH 01/22] Error mocking --- force-app/main/default/classes/DML.cls | 161 +++- force-app/main/default/classes/DML_Test.cls | 844 ++++++++++++++++++-- todo | 8 +- 3 files changed, 945 insertions(+), 68 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 81b3f57..6ed63a8 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -2,7 +2,7 @@ * Copyright (c) 2025 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/dml-lib/blob/main/LICENSE) * - * v1.9.0 + * v2.0.0 * * PMD False Positives: * - MethodNamingConventions - Some methods are uppercase to indicate that they are "constructors" of othere internal classes @@ -222,6 +222,20 @@ global inherited sharing class DML implements Commitable { Mockable deletesFor(SObjectType objectType); Mockable undeletesFor(SObjectType objectType); Mockable publishesFor(SObjectType objectType); + // Errors + Mockable exceptionOnInserts(); + Mockable exceptionOnUpdates(); + Mockable exceptionOnUpserts(); + Mockable exceptionOnDeletes(); + Mockable exceptionOnUndeletes(); + Mockable exceptionOnPublishes(); + // Per Operation Type Per Object Type + Mockable exceptionOnInsertsFor(SObjectType objectType); + Mockable exceptionOnUpdatesFor(SObjectType objectType); + Mockable exceptionOnUpsertsFor(SObjectType objectType); + Mockable exceptionOnDeletesFor(SObjectType objectType); + Mockable exceptionOnUndeletesFor(SObjectType objectType); + Mockable exceptionOnPublishesFor(SObjectType objectType); } // Hooks @@ -839,12 +853,10 @@ global inherited sharing class DML implements Commitable { List recordsToProcess = this.recordsProcessContainerByType.get(objectTypeName).getRecordsToProcess(); - DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); - List recordResults; - if (dmlMock != null && dmlMock.shouldBeMocked(this.getType(), objectTypeName)) { - recordResults = this.getMockedRecordResults(recordsToProcess); + if (this.shouldBeMocked(objectTypeName)) { + recordResults = this.getMockedRecordResults(recordsToProcess, objectTypeName); } else { recordResults = getAdapter().getStandardizedResults(this.executeDml(recordsToProcess), recordsToProcess); } @@ -859,11 +871,37 @@ global inherited sharing class DML implements Commitable { return operationResults; } - private List getMockedRecordResults(List recordsToProcess) { + private Boolean shouldBeMocked(SObjectType objectType) { + DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); + + return dmlMock != null && (dmlMock.shouldBeMocked(this.getType(), objectType) || dmlMock.shouldThrowException(this.getType(), objectType)); + } + + private List getMockedRecordResults(List recordsToProcess, SObjectType objectType) { + DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); + + Boolean shouldThrowException = dmlMock.shouldThrowException(this.getType(), objectType); + + if (shouldThrowException && this.globalConfiguration.allOrNone) { + throw new DmlException('Exception thrown for ' + this.getType() + ' operation.'); + } + List recordResults = new List(); for (SObject record : recordsToProcess) { - recordResults.add(this.prepareMockedDml(record)); + RecordSummary recordSummary = this.prepareMockedDml(record); + + if (shouldThrowException) { + RecordProcessingError error = new RecordProcessingError() + .setMessage('Exception thrown for ' + this.getType() + ' operation.') + .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) + .setFields(new List { 'Id' }); + + recordSummary.isSuccess(false); + recordSummary.error(error); + } + + recordResults.add(recordSummary); } return recordResults; @@ -871,7 +909,7 @@ global inherited sharing class DML implements Commitable { public abstract OperationType getType(); public abstract List executeDml(List recordsToProcess); - public abstract RecordResult prepareMockedDml(SObject record); + public abstract RecordSummary prepareMockedDml(SObject record); public abstract DmlResultAdapter getAdapter(); public virtual void validate(Record record) { @@ -917,7 +955,7 @@ global inherited sharing class DML implements Commitable { return Database.insert(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { record.put('Id', randomIdGenerator.get(record.getSObjectType())); return new RecordSummary().isSuccess(true).recordId(record.Id); } @@ -946,7 +984,7 @@ global inherited sharing class DML implements Commitable { return Database.update(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } @@ -968,7 +1006,7 @@ global inherited sharing class DML implements Commitable { return Database.upsert(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { if (record.Id == null) { record.put('Id', randomIdGenerator.get(record.getSObjectType())); } @@ -999,7 +1037,7 @@ global inherited sharing class DML implements Commitable { return Database.delete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } @@ -1027,7 +1065,7 @@ global inherited sharing class DML implements Commitable { return Database.undelete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } @@ -1049,7 +1087,7 @@ global inherited sharing class DML implements Commitable { return EventBus.publish(recordsToProcess); } - public override RecordResult prepareMockedDml(SObject record) { + public override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(randomIdGenerator.get(record.getSObjectType())); } @@ -1263,6 +1301,11 @@ global inherited sharing class DML implements Commitable { return this; } + public RecordSummary error(Error error) { + this.errors.add(error); + return this; + } + public RecordSummary errors(List errors) { for (Database.Error error : errors ?? new List()) { this.errors.add(new RecordProcessingError(error)); @@ -1276,12 +1319,29 @@ global inherited sharing class DML implements Commitable { private System.StatusCode statusCode; private List fields; + private RecordProcessingError() {} + private RecordProcessingError(Database.Error error) { this.message = error.getMessage(); this.statusCode = error.getStatusCode(); this.fields = error.getFields(); } + public RecordProcessingError setMessage(String message) { + this.message = message; + return this; + } + + public RecordProcessingError setStatusCode(System.StatusCode statusCode) { + this.statusCode = statusCode; + return this; + } + + public RecordProcessingError setFields(List fields) { + this.fields = fields; + return this; + } + public String message() { return this.message; } @@ -1297,6 +1357,8 @@ global inherited sharing class DML implements Commitable { private class DmlMock implements Mockable { private Set mockedDmlTypes = new Set(); + private Set thrownExceptionDmlTypes = new Set(); + private Map> mockedObjectTypesByDmlType = new Map>{ OperationType.INSERT_DML => new Set(), OperationType.UPDATE_DML => new Set(), @@ -1306,6 +1368,15 @@ global inherited sharing class DML implements Commitable { OperationType.PUBLISH_DML => new Set() }; + private Map> thrownExceptionDmlTypesByObjectTypes = new Map>{ + OperationType.INSERT_DML => new Set(), + OperationType.UPDATE_DML => new Set(), + OperationType.UPSERT_DML => new Set(), + OperationType.DELETE_DML => new Set(), + OperationType.UNDELETE_DML => new Set(), + OperationType.PUBLISH_DML => new Set() + }; + public Mockable allDmls() { this.allInserts(); this.allUpdates(); @@ -1369,6 +1440,64 @@ global inherited sharing class DML implements Commitable { return this.thenMockDmlFor(OperationType.PUBLISH_DML, objectType); } + public Mockable exceptionOnInserts() { + return this.thenExceptionOn(OperationType.INSERT_DML); + } + + public Mockable exceptionOnUpdates() { + return this.thenExceptionOn(OperationType.UPDATE_DML); + } + + public Mockable exceptionOnUpserts() { + return this.thenExceptionOn(OperationType.UPSERT_DML); + } + + public Mockable exceptionOnDeletes() { + return this.thenExceptionOn(OperationType.DELETE_DML); + } + + public Mockable exceptionOnUndeletes() { + return this.thenExceptionOn(OperationType.UNDELETE_DML); + } + + public Mockable exceptionOnPublishes() { + return this.thenExceptionOn(OperationType.PUBLISH_DML); + } + + private Mockable thenExceptionOn(OperationType dmlType) { + this.thrownExceptionDmlTypes.add(dmlType); + return this; + } + + public Mockable exceptionOnInsertsFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.INSERT_DML, objectType); + } + + public Mockable exceptionOnUpdatesFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.UPDATE_DML, objectType); + } + + public Mockable exceptionOnUpsertsFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.UPSERT_DML, objectType); + } + + public Mockable exceptionOnDeletesFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.DELETE_DML, objectType); + } + + public Mockable exceptionOnUndeletesFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.UNDELETE_DML, objectType); + } + + public Mockable exceptionOnPublishesFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.PUBLISH_DML, objectType); + } + + private Mockable thenExceptionOnFor(OperationType dmlType, SObjectType objectType) { + this.thrownExceptionDmlTypesByObjectTypes.get(dmlType).add(objectType); + return this; + } + private Mockable thenMockDmlFor(OperationType dmlType, SObjectType objectType) { this.mockedObjectTypesByDmlType.get(dmlType).add(objectType); return this; @@ -1377,6 +1506,10 @@ global inherited sharing class DML implements Commitable { private Boolean shouldBeMocked(OperationType dmlType, SObjectType objectType) { return this.mockedDmlTypes.contains(dmlType) || this.mockedObjectTypesByDmlType.get(dmlType).contains(objectType); } + + private Boolean shouldThrowException(OperationType dmlType, SObjectType objectType) { + return this.thrownExceptionDmlTypes.contains(dmlType) || this.thrownExceptionDmlTypesByObjectTypes.get(dmlType).contains(objectType); + } } private class DmlRecords implements Records { diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index e4165e5..c51a929 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -2,7 +2,7 @@ * Copyright (c) 2025 Beyond The Cloud Sp. z o.o. (BeyondTheCloud.Dev) * Licensed under the MIT License (https://github.com/beyond-the-cloud-dev/dml-lib/blob/main/LICENSE) * - * v1.9.0 + * v2.0.0 * * PMD False Positives: * - CyclomaticComplexity: It is a library and we tried to put everything into ONE class @@ -675,6 +675,128 @@ private class DML_Test { Assert.isNotNull(contact1.Id, 'Contact should have mocked Id.'); } + @IsTest + static void toInsertWithMockingException() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnInserts(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toInsert(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toInsertWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnInserts(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toInsert(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Result should contain insert type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toInsertWithMockingExceptionForSpecificSObjectType() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnInsertsFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toInsert(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toInsertWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnInsertsFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toInsert(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Result should contain insert type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + // UPDATE @IsTest @@ -1269,6 +1391,132 @@ private class DML_Test { Assert.isNotNull(result.updatesOf(Contact.SObjectType).recordResults()[0].id(), 'Updated operation result should contain a mocked record Id.'); } + @IsTest + static void toUpdateWithMockingException() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUpdates(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUpdate(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toUpdateWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUpdates(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toUpdate(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Result should contain update type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toUpdateWithMockingExceptionForSpecificSObjectType() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUpdatesFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUpdate(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toUpdateWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUpdatesFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toUpdate(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Result should contain update type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + // UPSERT @IsTest @@ -1848,66 +2096,188 @@ private class DML_Test { Assert.isNotNull(result.upsertsOf(Contact.SObjectType).recordResults()[0].id(), 'Upserted operation result should contain a mocked record Id.'); } - // DELETE - - @IsTest - static void toDeleteSingleRecordById() { - // Setup - Account account = insertAccount(); - - // Test - Test.startTest(); - new DML() - .toDelete(account.Id) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); - } - - @IsTest - static void toDeleteSingleRecord() { - // Setup - Account account1 = insertAccount(); - - // Test - Test.startTest(); - DML.Result result = new DML() - .toDelete(account1) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + @IsTest + static void toUpsertWithMockingException() { + // Setup + Account account1 = getAccount(1); - Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); - Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); - Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); - Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); - Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); - Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + DML.mock('dmlMockId').exceptionOnUpserts(); + Exception expectedException = null; - DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + // Test + Test.startTest(); + try { + new DML() + .toUpsert(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); - Assert.areEqual(1, operationResult.records().size(), 'Deleted operation result should contain the deleted record.'); - Assert.areEqual(1, operationResult.recordResults().size(), 'Deleted operation result should contain the deleted record results.'); - Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); - Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); - Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); - } + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } @IsTest - static void deleteImmediatelySingleRecord() { + static void toUpsertWithMockingExceptionWhenAllOrNoneIsSet() { // Setup Account account1 = getAccount(1); - insert account1; + + DML.mock('dmlMockId').exceptionOnUpserts(); + + Exception expectedException = null; + DML.Result result = null; // Test Test.startTest(); - DML.OperationResult result = new DML().deleteImmediately(account1); - Test.stopTest(); + try { + result = new DML() + .toUpsert(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Result should contain upsert type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toUpsertWithMockingExceptionForSpecificSObjectType() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnUpsertsFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUpsert(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toUpsertWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + + DML.mock('dmlMockId').exceptionOnUpsertsFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toUpsert(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Result should contain upsert type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + // DELETE + + @IsTest + static void toDeleteSingleRecordById() { + // Setup + Account account = insertAccount(); + + // Test + Test.startTest(); + new DML() + .toDelete(account.Id) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + } + + @IsTest + static void toDeleteSingleRecord() { + // Setup + Account account1 = insertAccount(); + + // Test + Test.startTest(); + DML.Result result = new DML() + .toDelete(account1) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Deleted operation result should contain the deleted record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Deleted operation result should contain the deleted record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); + } + + @IsTest + static void deleteImmediatelySingleRecord() { + // Setup + Account account1 = getAccount(1); + insert account1; + + // Test + Test.startTest(); + DML.OperationResult result = new DML().deleteImmediately(account1); + Test.stopTest(); // Verify Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Single record should be deleted.'); @@ -2406,6 +2776,132 @@ private class DML_Test { Assert.isNotNull(result.deletesOf(Contact.SObjectType).recordResults()[0].id(), 'Deleted operation result should contain a mocked record Id.'); } + @IsTest + static void toDeleteWithMockingException() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnDeletes(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toDelete(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toDeleteWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnDeletes(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toDelete(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Result should contain delete type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toDeleteWithMockingExceptionForSpecificSObjectType() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnDeletesFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toDelete(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toDeleteWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnDeletesFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toDelete(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Result should contain delete type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + // UNDELETE @IsTest @@ -2965,6 +3461,132 @@ private class DML_Test { Assert.isNotNull(result.undeletesOf(Contact.SObjectType).recordResults()[0].id(), 'Undeleted operation result should contain a mocked record Id.'); } + @IsTest + static void toUndeleteWithMockingException() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUndeletes(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUndelete(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toUndeleteWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUndeletes(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toUndelete(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Result should contain undelete type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toUndeleteWithMockingExceptionForSpecificSObjectType() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUndeletesFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUndelete(account1) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toUndeleteWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account account1 = getAccount(1); + account1.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnUndeletesFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toUndelete(account1) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Result should contain undelete type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + // PLATFORM EVENT @IsTest @@ -3163,6 +3785,128 @@ private class DML_Test { Assert.isNotNull(result.eventsOf(FlowOrchestrationEvent.SObjectType).recordResults()[1].id(), 'Published operation result should contain a mocked record Id.'); } + @IsTest + static void toPublishWithMockingException() { + // Setup + FlowOrchestrationEvent event = new FlowOrchestrationEvent(); + + DML.mock('dmlMockId').exceptionOnPublishes(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toPublish(event) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toPublishWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + FlowOrchestrationEvent event = new FlowOrchestrationEvent(); + + DML.mock('dmlMockId').exceptionOnPublishes(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toPublish(event) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(FlowOrchestrationEvent.SObjectType, operationResult.objectType(), 'Result should contain FlowOrchestrationEvent object type.'); + Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Result should contain publish type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toPublishWithMockingExceptionForSpecificSObjectType() { + // Setup + FlowOrchestrationEvent event = new FlowOrchestrationEvent(); + + DML.mock('dmlMockId').exceptionOnPublishesFor(FlowOrchestrationEvent.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toPublish(event) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toPublishWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + FlowOrchestrationEvent event = new FlowOrchestrationEvent(); + + DML.mock('dmlMockId').exceptionOnPublishesFor(FlowOrchestrationEvent.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toPublish(event) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(FlowOrchestrationEvent.SObjectType, operationResult.objectType(), 'Result should contain FlowOrchestrationEvent object type.'); + Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Result should contain publish type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + // DEBUG @IsTest diff --git a/todo b/todo index 779a4e7..e8a1998 100644 --- a/todo +++ b/todo @@ -21,7 +21,7 @@ - [x] with sharing - [x] without sharing - [ ] Sharing mode configurable per operation (?) -- [ ] Relationships +- [x] Relationships - [x] Deferred relationship resolution — resolve lookups just-in-time during commit (like fflib), not at registration time. - [x] Automatic dependency ordering — parents insert before children; deletes child-first; derive order from relationships if not explicit. - [ ] Preview mode or debug mode (?) - dry-run uow.preview() shows per-type op counts, fields to mutate, effective chunk size/allOrNone/security mode, and pending relationships; no DML. @@ -96,18 +96,18 @@ class UowResult { } ``` - [x] discardWork() — clear all pending operations without committing. -- [ ] Clear internal state after commitWork() — prevent re-commit of same operations. +- [x] Clear internal state after commitWork() — prevent re-commit of same operations. - [ ] Query operations — query(), countQuery(), getQueryLocator(), getCursor() with same access/sharing mode. - [ ] Connector to SOQL Lib — inject selectors for reads; honor chosen sharing mode. - [ ] Dedupling registration — when records with the same Id are registered and modify the same field: policy LAST_WINS | THROW; track warnings when deduping occurs. - [ ] Observability hooks — uow.withLogger(ILogger) to emit timings, limits, retries, per-op summaries; keep lightweight to avoid CPU spikes. - [ ] DML optimization - [ ] Minimize DML statements — batch same-type operations. - - [ ] Build dependency graph — derive parent/child order from registered relationships. + - [x] Build dependency graph — derive parent/child order from registered relationships. - [x] insertImmediately, updateImmediately, etc. - [ ] Email integration — toSendEmail(Messaging.Email) sent during commit. - [ ] Custom work (IDoWork) — registerWork(DML.Work) for custom logic during commit. -- [ ] Lifecycle hooks — onCommitWorkStarting(), onDMLFinished(), onCommitWorkFinished(success) for subclass overrides. + --- ```java From dcdc2f8e15c0f13e00381626674244ec91aa6fcc Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 13 Dec 2025 20:51:35 +0100 Subject: [PATCH 02/22] Refactoring --- force-app/main/default/classes/DML.cls | 200 +++++++++++++------------ 1 file changed, 104 insertions(+), 96 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 6ed63a8..248276a 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -285,8 +285,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toInsert(Record record) { - this.operationsByType.get(OperationType.INSERT_DML).register(record); - return this; + return this.registerOperation(OperationType.INSERT_DML, record); } public Commitable toInsert(List records) { @@ -294,8 +293,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toInsert(Records records) { - this.operationsByType.get(OperationType.INSERT_DML).register(records); - return this; + return this.registerOperation(OperationType.INSERT_DML, records); } public OperationResult insertImmediately(SObject record) { @@ -303,7 +301,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult insertImmediately(Record record) { - return new InsertOperation(this.configuration).register(record).commitWork().get(0); + return this.executeImmediately(new InsertOperation(this.configuration), record); } public OperationResult insertImmediately(List records) { @@ -311,7 +309,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult insertImmediately(Records records) { - return new InsertOperation(this.configuration).register(records).commitWork().get(0); + return this.executeImmediately(new InsertOperation(this.configuration), records); } // Update @@ -321,8 +319,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpdate(Record record) { - this.operationsByType.get(OperationType.UPDATE_DML).register(record); - return this; + return this.registerOperation(OperationType.UPDATE_DML, record); } public Commitable toUpdate(List records) { @@ -330,8 +327,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpdate(Records records) { - this.operationsByType.get(OperationType.UPDATE_DML).register(records); - return this; + return this.registerOperation(OperationType.UPDATE_DML, records); } public OperationResult updateImmediately(SObject record) { @@ -339,7 +335,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult updateImmediately(Record record) { - return new UpdateOperation(this.configuration).register(record).commitWork().get(0); + return this.executeImmediately(new UpdateOperation(this.configuration), record); } public OperationResult updateImmediately(List records) { @@ -347,7 +343,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult updateImmediately(Records records) { - return new UpdateOperation(this.configuration).register(records).commitWork().get(0); + return this.executeImmediately(new UpdateOperation(this.configuration), records); } // Upsert @@ -357,8 +353,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpsert(Record record) { - this.operationsByType.get(OperationType.UPSERT_DML).register(record); - return this; + return this.registerOperation(OperationType.UPSERT_DML, record); } public Commitable toUpsert(List records) { @@ -366,8 +361,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpsert(Records records) { - this.operationsByType.get(OperationType.UPSERT_DML).register(records); - return this; + return this.registerOperation(OperationType.UPSERT_DML, records); } public OperationResult upsertImmediately(SObject record) { @@ -375,7 +369,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult upsertImmediately(Record record) { - return new UpsertOperation(this.configuration).register(record).commitWork().get(0); + return this.executeImmediately(new UpsertOperation(this.configuration), record); } public OperationResult upsertImmediately(List records) { @@ -383,14 +377,13 @@ global inherited sharing class DML implements Commitable { } public OperationResult upsertImmediately(Records records) { - return new UpsertOperation(this.configuration).register(records).commitWork().get(0); + return this.executeImmediately(new UpsertOperation(this.configuration), records); } // Delete public Commitable toDelete(Id recordId) { - this.operationsByType.get(OperationType.DELETE_DML).register(Record(recordId)); - return this; + return this.registerOperation(OperationType.DELETE_DML, Record(recordId)); } public Commitable toDelete(SObject record) { @@ -398,36 +391,33 @@ global inherited sharing class DML implements Commitable { } public Commitable toDelete(Iterable recordIds) { - this.operationsByType.get(OperationType.DELETE_DML).register(Records(recordIds)); - return this; + return this.registerOperation(OperationType.DELETE_DML, Records(recordIds)); } public Commitable toDelete(List records) { - this.operationsByType.get(OperationType.DELETE_DML).register(Records(records)); - return this; + return this.registerOperation(OperationType.DELETE_DML, Records(records)); } public OperationResult deleteImmediately(Id recordId) { - return new DeleteOperation(this.configuration).register(Record(recordId)).commitWork().get(0); + return this.executeImmediately(new DeleteOperation(this.configuration), Record(recordId)); } public OperationResult deleteImmediately(SObject record) { - return new DeleteOperation(this.configuration).register(Record(record)).commitWork().get(0); + return this.executeImmediately(new DeleteOperation(this.configuration), Record(record)); } public OperationResult deleteImmediately(Iterable recordIds) { - return new DeleteOperation(this.configuration).register(Records(recordIds)).commitWork().get(0); + return this.executeImmediately(new DeleteOperation(this.configuration), Records(recordIds)); } public OperationResult deleteImmediately(List records) { - return new DeleteOperation(this.configuration).register(Records(records)).commitWork().get(0); + return this.executeImmediately(new DeleteOperation(this.configuration), Records(records)); } // Undelete public Commitable toUndelete(Id recordId) { - this.operationsByType.get(OperationType.UNDELETE_DML).register(Record(recordId)); - return this; + return this.registerOperation(OperationType.UNDELETE_DML, Record(recordId)); } public Commitable toUndelete(SObject record) { @@ -435,49 +425,63 @@ global inherited sharing class DML implements Commitable { } public Commitable toUndelete(Iterable recordIds) { - this.operationsByType.get(OperationType.UNDELETE_DML).register(Records(recordIds)); - return this; + return this.registerOperation(OperationType.UNDELETE_DML, Records(recordIds)); } public Commitable toUndelete(List records) { - this.operationsByType.get(OperationType.UNDELETE_DML).register(Records(records)); - return this; + return this.registerOperation(OperationType.UNDELETE_DML, Records(records)); } public OperationResult undeleteImmediately(Id recordId) { - return new UndeleteOperation(this.configuration).register(Record(recordId)).commitWork().get(0); + return this.executeImmediately(new UndeleteOperation(this.configuration), Record(recordId)); } public OperationResult undeleteImmediately(SObject record) { - return new UndeleteOperation(this.configuration).register(Record(record)).commitWork().get(0); + return this.executeImmediately(new UndeleteOperation(this.configuration), Record(record)); } public OperationResult undeleteImmediately(Iterable recordIds) { - return new UndeleteOperation(this.configuration).register(Records(recordIds)).commitWork().get(0); + return this.executeImmediately(new UndeleteOperation(this.configuration), Records(recordIds)); } public OperationResult undeleteImmediately(List records) { - return new UndeleteOperation(this.configuration).register(Records(records)).commitWork().get(0); + return this.executeImmediately(new UndeleteOperation(this.configuration), Records(records)); } // Platform Event public Commitable toPublish(SObject record) { - this.operationsByType.get(OperationType.PUBLISH_DML).register(Record(record)); - return this; + return this.registerOperation(OperationType.PUBLISH_DML, Record(record)); } public Commitable toPublish(List records) { - this.operationsByType.get(OperationType.PUBLISH_DML).register(Records(records)); - return this; + return this.registerOperation(OperationType.PUBLISH_DML, Records(records)); } public OperationResult publishImmediately(SObject record) { - return new PlatformEventOperation(this.configuration).register(Record(record)).commitWork().get(0); + return this.executeImmediately(new PlatformEventOperation(this.configuration), Record(record)); } public OperationResult publishImmediately(List records) { - return new PlatformEventOperation(this.configuration).register(Records(records)).commitWork().get(0); + return this.executeImmediately(new PlatformEventOperation(this.configuration), Records(records)); + } + + private Commitable registerOperation(OperationType type, Record record) { + this.operationsByType.get(type).register(record); + return this; + } + + private Commitable registerOperation(OperationType type, Records records) { + this.operationsByType.get(type).register(records); + return this; + } + + private OperationResult executeImmediately(DmlOperation operation, Record record) { + return operation.register(record).commitWork().get(0); + } + + private OperationResult executeImmediately(DmlOperation operation, Records records) { + return operation.register(records).commitWork().get(0); } // Identifier @@ -567,8 +571,6 @@ global inherited sharing class DML implements Commitable { this.hook?.after(result); return result; - } catch (Exception e) { - throw e; } finally { Database.rollback(savePoint); Database.releaseSavepoint(savePoint); @@ -583,7 +585,7 @@ global inherited sharing class DML implements Commitable { result = this.executeOperations(); - dmlIdentifierToResult.put(this.configuration.dmlIdentifier, result); + this.storeResult(result); this.hook?.after(result); } finally { @@ -593,6 +595,14 @@ global inherited sharing class DML implements Commitable { return result; } + private void storeResult(DMLResult result) { + if (String.isBlank(this.configuration.dmlIdentifier)) { + return; + } + + dmlIdentifierToResult.put(this.configuration.dmlIdentifier, result); + } + public Result commitTransaction() { if (!this.configuration.allOrNone) { throw new DmlException('commitTransaction() is not supported when allOrNone=false'); @@ -628,7 +638,7 @@ global inherited sharing class DML implements Commitable { this.initializeOperations(); } - public class DmlResult implements Result { + private class DmlResult implements Result { private Map> operationResultsByObjectType = new Map>{ OperationType.INSERT_DML => new Map(), OperationType.UPDATE_DML => new Map(), @@ -846,6 +856,8 @@ global inherited sharing class DML implements Commitable { private List process(Iterable objectTypesOrder) { List operationResults = new List(); + DmlResultAdapter adapter = this.getAdapter(); + for (SObjectType objectTypeName : objectTypesOrder) { if (!this.recordsProcessContainerByType.containsKey(objectTypeName)) { continue; @@ -858,7 +870,7 @@ global inherited sharing class DML implements Commitable { if (this.shouldBeMocked(objectTypeName)) { recordResults = this.getMockedRecordResults(recordsToProcess, objectTypeName); } else { - recordResults = getAdapter().getStandardizedResults(this.executeDml(recordsToProcess), recordsToProcess); + recordResults = adapter.getStandardizedResults(this.executeDml(recordsToProcess), recordsToProcess); } operationResults.add( @@ -907,12 +919,12 @@ global inherited sharing class DML implements Commitable { return recordResults; } - public abstract OperationType getType(); - public abstract List executeDml(List recordsToProcess); - public abstract RecordSummary prepareMockedDml(SObject record); - public abstract DmlResultAdapter getAdapter(); + protected abstract OperationType getType(); + protected abstract List executeDml(List recordsToProcess); + protected abstract RecordSummary prepareMockedDml(SObject record); + protected abstract DmlResultAdapter getAdapter(); - public virtual void validate(Record record) { + protected virtual void validate(Record record) { return; } } @@ -920,11 +932,11 @@ global inherited sharing class DML implements Commitable { private class RecordsContainer { private List enhancedRecords = new List(); - public void addNewRecordToProcess(EnhancedRecord enhancedRecord) { + private void addNewRecordToProcess(EnhancedRecord enhancedRecord) { this.enhancedRecords.add(enhancedRecord); } - public List getRecordsToProcess() { + private List getRecordsToProcess() { List recordsToProcess = new List(); for (EnhancedRecord enhancedRecord : this.enhancedRecords) { @@ -936,162 +948,162 @@ global inherited sharing class DML implements Commitable { } } - public inherited sharing class InsertOperation extends DmlOperation { + private inherited sharing class InsertOperation extends DmlOperation { public InsertOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.INSERT_DML; } - public override void validate(Record record) { + protected override void validate(Record record) { if (record.get().doesRecordHaveIdSpecified()) { throw new DmlException('Only new records can be registered as new.'); } } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return Database.insert(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { record.put('Id', randomIdGenerator.get(record.getSObjectType())); return new RecordSummary().isSuccess(true).recordId(record.Id); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new SaveResultAdapter(); } } - public inherited sharing class UpdateOperation extends DmlOperation { + private inherited sharing class UpdateOperation extends DmlOperation { public UpdateOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.UPDATE_DML; } - public override void validate(Record record) { + protected override void validate(Record record) { if (!record.get().doesRecordHaveIdSpecified()) { throw new DmlException('Only existing records can be updated.'); } } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return Database.update(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new SaveResultAdapter(); } } - public inherited sharing class UpsertOperation extends DmlOperation { + private inherited sharing class UpsertOperation extends DmlOperation { public UpsertOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.UPSERT_DML; } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return Database.upsert(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { if (record.Id == null) { record.put('Id', randomIdGenerator.get(record.getSObjectType())); } return new RecordSummary().isSuccess(true).recordId(record.Id); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new UpsertResultAdapter(); } } - public inherited sharing class DeleteOperation extends DmlOperation { + private inherited sharing class DeleteOperation extends DmlOperation { public DeleteOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.DELETE_DML; } - public override void validate(Record record) { + protected override void validate(Record record) { if (!record.get().doesRecordHaveIdSpecified()) { throw new DmlException('Only existing records can be registered as deleted.'); } } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return Database.delete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new DeleteResultAdapter(); } } - public inherited sharing class UndeleteOperation extends DmlOperation { + private inherited sharing class UndeleteOperation extends DmlOperation { public UndeleteOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.UNDELETE_DML; } - public override void validate(Record record) { + protected override void validate(Record record) { if (!record.get().doesRecordHaveIdSpecified()) { throw new DmlException('Only deleted records can be undeleted.'); } } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return Database.undelete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new UndeleteResultAdapter(); } } - public inherited sharing class PlatformEventOperation extends DmlOperation { + private inherited sharing class PlatformEventOperation extends DmlOperation { public PlatformEventOperation(Configuration configuration) { super(configuration); } - public override OperationType getType() { + protected override OperationType getType() { return OperationType.PUBLISH_DML; } - public override List executeDml(List recordsToProcess) { + protected override List executeDml(List recordsToProcess) { return EventBus.publish(recordsToProcess); } - public override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary prepareMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(randomIdGenerator.get(record.getSObjectType())); } - public override DmlResultAdapter getAdapter() { + protected override DmlResultAdapter getAdapter() { return new SaveResultAdapter(); } } @@ -1624,11 +1636,7 @@ global inherited sharing class DML implements Commitable { } private EnhancedRecord(Id currentRecordId) { - if (currentRecordId == null) { - return; - } - - this.currentRecord = currentRecordId.getSObjectType().newSObject(currentRecordId); + this.currentRecord = currentRecordId?.getSObjectType()?.newSObject(currentRecordId); } private void with(SObjectField field, Object value) { @@ -1811,7 +1819,7 @@ global inherited sharing class DML implements Commitable { } if (typesExecutionOrder.size() < this.parentsRemainingByType.size()) { - throw new DmlException('Cyclic type dependencies detected during rgistration graph.'); + throw new DmlException('Cyclic type dependencies detected during registration graph.'); } return typesExecutionOrder; From 7e5fa047f8edb06a07bb7b8f1618229232b28580 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 13 Dec 2025 21:00:00 +0100 Subject: [PATCH 03/22] Refactoring --- force-app/main/default/classes/DML.cls | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 248276a..fb64819 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -788,7 +788,7 @@ global inherited sharing class DML implements Commitable { } } - public abstract class DmlOperation { + private abstract class DmlOperation { protected Map recordsProcessContainerByType = new Map(); protected Configuration globalConfiguration; protected OrderDependencyGraph orderDependencyGraph = new OrderDependencyGraph(); @@ -797,7 +797,7 @@ global inherited sharing class DML implements Commitable { this.globalConfiguration = configuration; } - public DmlOperation register(Records records) { + private DmlOperation register(Records records) { for (Record record : records.get()) { this.register(record); } @@ -805,7 +805,7 @@ global inherited sharing class DML implements Commitable { return this; } - public DmlOperation register(Record record) { + private DmlOperation register(Record record) { EnhancedRecord enhancedRecord = record.get(); SObjectType typeName = enhancedRecord.getSObjectType(); @@ -833,7 +833,7 @@ global inherited sharing class DML implements Commitable { this.recordsProcessContainerByType.get(typeName).addNewRecordToProcess(enhancedRecord); } - public void preview() { + private void preview() { System.debug(LoggingLevel.ERROR, '\n\n============ ' + this.getType() + ' Preview ============' + '\nRecords Process Container: ' + JSON.serializePretty(this.recordsProcessContainerByType) @@ -841,11 +841,11 @@ global inherited sharing class DML implements Commitable { ); } - public List commitWork() { + private List commitWork() { return this.globalConfiguration.sharingExecutor.execute(this); } - public List execute() { + private List execute() { if (!this.globalConfiguration.customExecutionOrder.isEmpty()) { return this.process(this.globalConfiguration.customExecutionOrder); } @@ -1826,7 +1826,8 @@ global inherited sharing class DML implements Commitable { } } - public class RandomIdGenerator { + @TestVisible + private class RandomIdGenerator { public Id get(SObjectType objectType) { return get(objectType.getDescribe().getKeyPrefix()); } From 709787257cf49b7f8f02c5d86afe9e669fe41234 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 13 Dec 2025 21:45:24 +0100 Subject: [PATCH 04/22] Refactoring --- force-app/main/default/classes/DML.cls | 110 +++++++++++++------------ 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index fb64819..14da3ca 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -856,7 +856,7 @@ global inherited sharing class DML implements Commitable { private List process(Iterable objectTypesOrder) { List operationResults = new List(); - DmlResultAdapter adapter = this.getAdapter(); + DmlResultAdapter operationResultAdapter = this.getAdapter(); for (SObjectType objectTypeName : objectTypesOrder) { if (!this.recordsProcessContainerByType.containsKey(objectTypeName)) { @@ -867,11 +867,13 @@ global inherited sharing class DML implements Commitable { List recordResults; - if (this.shouldBeMocked(objectTypeName)) { - recordResults = this.getMockedRecordResults(recordsToProcess, objectTypeName); + DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); + + if (dmlMock != null && dmlMock.shouldBeMocked(this.getType(), objectTypeName)) { + recordResults = dmlMock.getMockedRecordResults(this, objectTypeName, recordsToProcess); } else { - recordResults = adapter.getStandardizedResults(this.executeDml(recordsToProcess), recordsToProcess); - } + recordResults = operationResultAdapter.get(this.executeDml(recordsToProcess), recordsToProcess); + } operationResults.add( new OperationSummary(objectTypeName, this.getType()) @@ -883,42 +885,6 @@ global inherited sharing class DML implements Commitable { return operationResults; } - private Boolean shouldBeMocked(SObjectType objectType) { - DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); - - return dmlMock != null && (dmlMock.shouldBeMocked(this.getType(), objectType) || dmlMock.shouldThrowException(this.getType(), objectType)); - } - - private List getMockedRecordResults(List recordsToProcess, SObjectType objectType) { - DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); - - Boolean shouldThrowException = dmlMock.shouldThrowException(this.getType(), objectType); - - if (shouldThrowException && this.globalConfiguration.allOrNone) { - throw new DmlException('Exception thrown for ' + this.getType() + ' operation.'); - } - - List recordResults = new List(); - - for (SObject record : recordsToProcess) { - RecordSummary recordSummary = this.prepareMockedDml(record); - - if (shouldThrowException) { - RecordProcessingError error = new RecordProcessingError() - .setMessage('Exception thrown for ' + this.getType() + ' operation.') - .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) - .setFields(new List { 'Id' }); - - recordSummary.isSuccess(false); - recordSummary.error(error); - } - - recordResults.add(recordSummary); - } - - return recordResults; - } - protected abstract OperationType getType(); protected abstract List executeDml(List recordsToProcess); protected abstract RecordSummary prepareMockedDml(SObject record); @@ -1131,7 +1097,7 @@ global inherited sharing class DML implements Commitable { } private abstract class DmlResultAdapter { - public List getStandardizedResults(List dmlResults, List processedRecords) { + public List get(List dmlResults, List processedRecords) { List recordResults = new List(); for (Integer i = 0; i < dmlResults.size(); i++) { @@ -1297,28 +1263,28 @@ global inherited sharing class DML implements Commitable { return this.errors; } - public RecordSummary recordId(Id recordId) { + private RecordSummary recordId(Id recordId) { this.recordId = recordId; return this; } - public RecordSummary record(SObject record) { + private RecordSummary record(SObject record) { this.record = record; return this; } @SuppressWarnings('PMD.AvoidBooleanMethodParameters') - public RecordSummary isSuccess(Boolean isSuccess) { + private RecordSummary isSuccess(Boolean isSuccess) { this.isSuccess = isSuccess; return this; } - public RecordSummary error(Error error) { + private RecordSummary error(Error error) { this.errors.add(error); return this; } - public RecordSummary errors(List errors) { + private RecordSummary errors(List errors) { for (Database.Error error : errors ?? new List()) { this.errors.add(new RecordProcessingError(error)); } @@ -1339,17 +1305,17 @@ global inherited sharing class DML implements Commitable { this.fields = error.getFields(); } - public RecordProcessingError setMessage(String message) { + private RecordProcessingError setMessage(String message) { this.message = message; return this; } - public RecordProcessingError setStatusCode(System.StatusCode statusCode) { + private RecordProcessingError setStatusCode(System.StatusCode statusCode) { this.statusCode = statusCode; return this; } - public RecordProcessingError setFields(List fields) { + private RecordProcessingError setFields(List fields) { this.fields = fields; return this; } @@ -1516,7 +1482,49 @@ global inherited sharing class DML implements Commitable { } private Boolean shouldBeMocked(OperationType dmlType, SObjectType objectType) { - return this.mockedDmlTypes.contains(dmlType) || this.mockedObjectTypesByDmlType.get(dmlType).contains(objectType); + return this.mockedDmlTypes.contains(dmlType) || this.mockedObjectTypesByDmlType.get(dmlType).contains(objectType) || this.shouldThrowException(dmlType, objectType); + } + + private List getMockedRecordResults(DmlOperation executor, SObjectType objectType, List recordsToProcess) { + if (this.shouldThrowException(executor.getType(), objectType)) { + if (executor.globalConfiguration.allOrNone) { + throw new DmlException('Exception thrown for ' + executor.getType() + ' operation.'); + } + + // all or none is false, so we need to return a list of record results with errors + return this.getMockedRecordErrors(executor, objectType, recordsToProcess); + } + + return this.getMockedRecordSuccesses(executor, objectType, recordsToProcess); + } + + private List getMockedRecordErrors(DmlOperation executor, SObjectType objectType, List recordsToProcess) { + List recordResults = new List(); + + for (SObject record : recordsToProcess) { + RecordProcessingError error = new RecordProcessingError() + .setMessage('Exception thrown for ' + executor.getType() + ' operation.') + .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) + .setFields(new List { 'Id' }); + + RecordSummary recordSummary = new RecordSummary() + .isSuccess(false) + .error(error); + + recordResults.add(recordSummary); + } + + return recordResults; + } + + private List getMockedRecordSuccesses(DmlOperation executor, SObjectType objectType, List recordsToProcess) { + List recordResults = new List(); + + for (SObject record : recordsToProcess) { + recordResults.add(executor.prepareMockedDml(record)); + } + + return recordResults; } private Boolean shouldThrowException(OperationType dmlType, SObjectType objectType) { From e32ec688ad295463540932e91dad2293954184fa Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 13 Dec 2025 21:58:11 +0100 Subject: [PATCH 05/22] Refactoring --- force-app/main/default/classes/DML.cls | 76 +++++++++----------------- 1 file changed, 25 insertions(+), 51 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 14da3ca..a912a02 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -271,8 +271,8 @@ global inherited sharing class DML implements Commitable { private void initializeOperations() { this.operationsByType.put(OperationType.INSERT_DML, new InsertOperation(this.configuration)); - this.operationsByType.put(OperationType.UPDATE_DML, new UpdateOperation(this.configuration)); this.operationsByType.put(OperationType.UPSERT_DML, new UpsertOperation(this.configuration)); + this.operationsByType.put(OperationType.UPDATE_DML, new UpdateOperation(this.configuration)); this.operationsByType.put(OperationType.DELETE_DML, new DeleteOperation(this.configuration)); this.operationsByType.put(OperationType.UNDELETE_DML, new UndeleteOperation(this.configuration)); this.operationsByType.put(OperationType.PUBLISH_DML, new PlatformEventOperation(this.configuration)); @@ -496,12 +496,9 @@ global inherited sharing class DML implements Commitable { public void preview() { this.configuration.preview(); - this.operationsByType.get(OperationType.INSERT_DML).preview(); - this.operationsByType.get(OperationType.UPDATE_DML).preview(); - this.operationsByType.get(OperationType.UPSERT_DML).preview(); - this.operationsByType.get(OperationType.DELETE_DML).preview(); - this.operationsByType.get(OperationType.UNDELETE_DML).preview(); - this.operationsByType.get(OperationType.PUBLISH_DML).preview(); + for (OperationType operationType : this.operationsByType.keySet()) { + this.operationsByType.get(operationType).preview(); + } } // Field Level Security @@ -623,12 +620,9 @@ global inherited sharing class DML implements Commitable { private DMLResult executeOperations() { DMLResult result = new DmlResult(); - result.add(OperationType.INSERT_DML, this.operationsByType.get(OperationType.INSERT_DML).commitWork()); - result.add(OperationType.UPSERT_DML, this.operationsByType.get(OperationType.UPSERT_DML).commitWork()); - result.add(OperationType.UPDATE_DML, this.operationsByType.get(OperationType.UPDATE_DML).commitWork()); - result.add(OperationType.DELETE_DML, this.operationsByType.get(OperationType.DELETE_DML).commitWork()); - result.add(OperationType.UNDELETE_DML, this.operationsByType.get(OperationType.UNDELETE_DML).commitWork()); - result.add(OperationType.PUBLISH_DML, this.operationsByType.get(OperationType.PUBLISH_DML).commitWork()); + for (OperationType operationType : this.operationsByType.keySet()) { + result.add(operationType, this.operationsByType.get(operationType).commitWork()); + } return result; } @@ -705,13 +699,7 @@ global inherited sharing class DML implements Commitable { } private List getOperationResults(OperationType operationType) { - List operationResults = new List(); - - for (SObjectType objectType : this.operationResultsByObjectType.get(operationType).keySet()) { - operationResults.add(this.operationResultsByObjectType.get(operationType).get(objectType)); - } - - return operationResults; + return this.operationResultsByObjectType.get(operationType).values(); } private OperationResult getOperationResult(OperationType operationType, SObjectType objectType) { @@ -1337,33 +1325,13 @@ global inherited sharing class DML implements Commitable { private Set mockedDmlTypes = new Set(); private Set thrownExceptionDmlTypes = new Set(); - private Map> mockedObjectTypesByDmlType = new Map>{ - OperationType.INSERT_DML => new Set(), - OperationType.UPDATE_DML => new Set(), - OperationType.UPSERT_DML => new Set(), - OperationType.DELETE_DML => new Set(), - OperationType.UNDELETE_DML => new Set(), - OperationType.PUBLISH_DML => new Set() - }; - - private Map> thrownExceptionDmlTypesByObjectTypes = new Map>{ - OperationType.INSERT_DML => new Set(), - OperationType.UPDATE_DML => new Set(), - OperationType.UPSERT_DML => new Set(), - OperationType.DELETE_DML => new Set(), - OperationType.UNDELETE_DML => new Set(), - OperationType.PUBLISH_DML => new Set() - }; + private Map> mockedObjectTypesByDmlType = new Map>(); + private Map> thrownExceptionDmlTypesByObjectTypes = new Map>(); public Mockable allDmls() { - this.allInserts(); - this.allUpdates(); - this.allUpserts(); - this.allDeletes(); - this.allUndeletes(); - this.allPublishes(); + this.mockedDmlTypes.addAll(OperationType.values()); return this; - } + } public Mockable allInserts() { return this.thenMockDml(OperationType.INSERT_DML); @@ -1418,6 +1386,14 @@ global inherited sharing class DML implements Commitable { return this.thenMockDmlFor(OperationType.PUBLISH_DML, objectType); } + private Mockable thenMockDmlFor(OperationType dmlType, SObjectType objectType) { + if (!this.mockedObjectTypesByDmlType.containsKey(dmlType)) { + this.mockedObjectTypesByDmlType.put(dmlType, new Set()); + } + this.mockedObjectTypesByDmlType.get(dmlType).add(objectType); + return this; + } + public Mockable exceptionOnInserts() { return this.thenExceptionOn(OperationType.INSERT_DML); } @@ -1472,17 +1448,15 @@ global inherited sharing class DML implements Commitable { } private Mockable thenExceptionOnFor(OperationType dmlType, SObjectType objectType) { + if (!this.thrownExceptionDmlTypesByObjectTypes.containsKey(dmlType)) { + this.thrownExceptionDmlTypesByObjectTypes.put(dmlType, new Set()); + } this.thrownExceptionDmlTypesByObjectTypes.get(dmlType).add(objectType); return this; } - private Mockable thenMockDmlFor(OperationType dmlType, SObjectType objectType) { - this.mockedObjectTypesByDmlType.get(dmlType).add(objectType); - return this; - } - private Boolean shouldBeMocked(OperationType dmlType, SObjectType objectType) { - return this.mockedDmlTypes.contains(dmlType) || this.mockedObjectTypesByDmlType.get(dmlType).contains(objectType) || this.shouldThrowException(dmlType, objectType); + return this.mockedDmlTypes.contains(dmlType) || (this.mockedObjectTypesByDmlType.get(dmlType) ?? new Set()).contains(objectType) || this.shouldThrowException(dmlType, objectType); } private List getMockedRecordResults(DmlOperation executor, SObjectType objectType, List recordsToProcess) { @@ -1528,7 +1502,7 @@ global inherited sharing class DML implements Commitable { } private Boolean shouldThrowException(OperationType dmlType, SObjectType objectType) { - return this.thrownExceptionDmlTypes.contains(dmlType) || this.thrownExceptionDmlTypesByObjectTypes.get(dmlType).contains(objectType); + return this.thrownExceptionDmlTypes.contains(dmlType) || (this.thrownExceptionDmlTypesByObjectTypes.get(dmlType) ?? new Set()).contains(objectType); } } From da8d4a0a6e581fe1f211d2c1316c940f52aebaea Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 14 Dec 2025 13:17:53 +0100 Subject: [PATCH 06/22] Refactoring --- force-app/main/default/classes/DML.cls | 77 +++++++++++--------------- 1 file changed, 33 insertions(+), 44 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index a912a02..3e6ea06 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -1134,7 +1134,7 @@ global inherited sharing class DML implements Commitable { private class UndeleteResultAdapter extends DmlResultAdapter { public override RecordSummary transform(Object result) { Database.UndeleteResult undeleteResult = (Database.UndeleteResult) result; - + return new RecordSummary() .isSuccess(undeleteResult.isSuccess()) .recordId(undeleteResult.getId()) @@ -1148,8 +1148,9 @@ global inherited sharing class DML implements Commitable { private List recordResults = new List(); private List records = new List(); - private List cachedSuccesses; - private List cachedFailures; + private List cachedSuccesses = new List(); + private List cachedFailures = new List(); + private List cachedErrors = new List(); private OperationSummary(SObjectType objectType, OperationType type) { this.objectType = objectType; @@ -1163,9 +1164,21 @@ global inherited sharing class DML implements Commitable { private OperationSummary setRecordResults(List recordResults) { this.recordResults = recordResults; + this.flatResults(); return this; } + private void flatResults() { + for (RecordResult recordResult : this.recordResults) { + if (recordResult.isSuccess()) { + this.cachedSuccesses.add(recordResult.record()); + } else { + this.cachedErrors.addAll(recordResult.errors()); + this.cachedFailures.add(recordResult.record()); + } + } + } + public SObjectType objectType() { return this.objectType; } @@ -1179,13 +1192,7 @@ global inherited sharing class DML implements Commitable { } public List errors() { - List errors = new List(); - - for (RecordResult recordResult : this.recordResults) { - errors.addAll(recordResult.errors()); - } - - return errors; + return this.cachedErrors; } public List records() { @@ -1193,34 +1200,10 @@ global inherited sharing class DML implements Commitable { } public List successes() { - if (this.cachedSuccesses != null) { - return this.cachedSuccesses; - } - - this.cachedSuccesses = new List(); - - for (RecordResult recordResult : this.recordResults) { - if (recordResult.isSuccess()) { - this.cachedSuccesses.add(recordResult.record()); - } - } - return this.cachedSuccesses; } public List failures() { - if (this.cachedFailures != null) { - return this.cachedFailures; - } - - this.cachedFailures = new List(); - - for (RecordResult recordResult : this.recordResults) { - if (!recordResult.isSuccess()) { - this.cachedFailures.add(recordResult.record()); - } - } - return this.cachedFailures; } @@ -1752,11 +1735,11 @@ global inherited sharing class DML implements Commitable { private Map> childrenByType = new Map>(); private Map parentsRemainingByType = new Map(); - public void registerType(SObjectType type) { + private void registerType(SObjectType type) { this.ensureTypeRegistered(type); } - public void addDependency(SObjectType parentType, SObjectType childType) { + private void addDependency(SObjectType parentType, SObjectType childType) { this.ensureTypeRegistered(parentType); this.ensureTypeRegistered(childType); @@ -1775,15 +1758,9 @@ global inherited sharing class DML implements Commitable { } // Kahn’s sort algorithm - public List getTopologicalOrder() { + private List getTopologicalOrder() { + List parentsTypesWithoutDependencies = this.getTypesWithNoDependencies(); List typesExecutionOrder = new List(); - List parentsTypesWithoutDependencies = new List(); - - for (SObjectType type : this.parentsRemainingByType.keySet()) { - if (this.parentsRemainingByType.get(type) == 0) { - parentsTypesWithoutDependencies.add(type); - } - } while (!parentsTypesWithoutDependencies.isEmpty()) { SObjectType currentType = parentsTypesWithoutDependencies.remove(0); @@ -1806,6 +1783,18 @@ global inherited sharing class DML implements Commitable { return typesExecutionOrder; } + + private List getTypesWithNoDependencies() { + List typesWithNoDependencies = new List(); + + for (SObjectType type : this.parentsRemainingByType.keySet()) { + if (this.parentsRemainingByType.get(type) == 0) { + typesWithNoDependencies.add(type); + } + } + + return typesWithNoDependencies; + } } @TestVisible From d9a7aaa0ac22414ab9cc4f43a80e292e8e312cd2 Mon Sep 17 00:00:00 2001 From: Piotr Gajek Date: Mon, 15 Dec 2025 19:41:34 +0100 Subject: [PATCH 07/22] Feature/refactoring (#12) * Code structure change * DML Refactoring * command pattern * Refactoring * Refactoring --- force-app/main/default/classes/DML.cls | 735 +++++++++++++------- force-app/main/default/classes/DML_Test.cls | 17 +- sfdx-project.json | 4 + todo | 30 +- 4 files changed, 528 insertions(+), 258 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 3e6ea06..cc9f3e4 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -60,19 +60,31 @@ global inherited sharing class DML implements Commitable { Commitable toUpdate(DML.Records records); // Upsert Commitable toUpsert(SObject record); + Commitable toUpsert(SObject record, SObjectField externalIdField); Commitable toUpsert(DML.Record record); Commitable toUpsert(List records); + Commitable toUpsert(List records, SObjectField externalIdField); Commitable toUpsert(DML.Records records); // Delete Commitable toDelete(Id recordId); Commitable toDelete(SObject record); Commitable toDelete(Iterable recordIds); Commitable toDelete(List records); + // Hard Delete + Commitable toHardDelete(Id recordId); + Commitable toHardDelete(SObject record); + Commitable toHardDelete(Iterable recordIds); + Commitable toHardDelete(List records); // Undelete Commitable toUndelete(Id recordId); Commitable toUndelete(SObject record); Commitable toUndelete(Iterable recordIds); Commitable toUndelete(List records); + // Merge + Commitable toMerge(SObject mergeToRecord, SObject duplicatedRecord); + Commitable toMerge(SObject mergeToRecord, List duplicateRecords); + Commitable toMerge(SObject mergeToRecord, Id duplicatedRecordId); + Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds); // Platform Event Commitable toPublish(SObject record); Commitable toPublish(List records); @@ -247,11 +259,10 @@ global inherited sharing class DML implements Commitable { // Implementation - private Configuration configuration = new Configuration(); + global enum OperationType { INSERT_DML, UPSERT_DML, UPDATE_DML, MERGE_DML, DELETE_DML, UNDELETE_DML, PUBLISH_DML } - global enum OperationType { INSERT_DML, UPDATE_DML, UPSERT_DML, DELETE_DML, UNDELETE_DML, PUBLISH_DML } - - private Map operationsByType = new Map(); + private Orchestrator orchestrator; + private Configuration configuration; private Hook hook = null; @@ -261,23 +272,15 @@ global inherited sharing class DML implements Commitable { private static final Map dmlIdentifierToMock = new Map(); global DML() { - this.initializeOperations(); + this.configuration = new Configuration(); + this.orchestrator = new Orchestrator(this.configuration); } global DML(List customExecutionOrder) { this(); this.configuration.executionOrder(customExecutionOrder); } - - private void initializeOperations() { - this.operationsByType.put(OperationType.INSERT_DML, new InsertOperation(this.configuration)); - this.operationsByType.put(OperationType.UPSERT_DML, new UpsertOperation(this.configuration)); - this.operationsByType.put(OperationType.UPDATE_DML, new UpdateOperation(this.configuration)); - this.operationsByType.put(OperationType.DELETE_DML, new DeleteOperation(this.configuration)); - this.operationsByType.put(OperationType.UNDELETE_DML, new UndeleteOperation(this.configuration)); - this.operationsByType.put(OperationType.PUBLISH_DML, new PlatformEventOperation(this.configuration)); - } - + // Insert public Commitable toInsert(SObject record) { @@ -285,7 +288,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toInsert(Record record) { - return this.registerOperation(OperationType.INSERT_DML, record); + return this.registerOperation(new InsertCommand(record)); } public Commitable toInsert(List records) { @@ -293,7 +296,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toInsert(Records records) { - return this.registerOperation(OperationType.INSERT_DML, records); + return this.registerOperation(new InsertCommand(records)); } public OperationResult insertImmediately(SObject record) { @@ -301,7 +304,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult insertImmediately(Record record) { - return this.executeImmediately(new InsertOperation(this.configuration), record); + return this.executeImmediately(new InsertCommand(record)); } public OperationResult insertImmediately(List records) { @@ -309,9 +312,10 @@ global inherited sharing class DML implements Commitable { } public OperationResult insertImmediately(Records records) { - return this.executeImmediately(new InsertOperation(this.configuration), records); + return this.executeImmediately(new InsertCommand(records)); } + // Update public Commitable toUpdate(SObject record) { @@ -319,7 +323,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpdate(Record record) { - return this.registerOperation(OperationType.UPDATE_DML, record); + return this.registerOperation(new UpdateCommand(record)); } public Commitable toUpdate(List records) { @@ -327,7 +331,7 @@ global inherited sharing class DML implements Commitable { } public Commitable toUpdate(Records records) { - return this.registerOperation(OperationType.UPDATE_DML, records); + return this.registerOperation(new UpdateCommand(records)); } public OperationResult updateImmediately(SObject record) { @@ -335,7 +339,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult updateImmediately(Record record) { - return this.executeImmediately(new UpdateOperation(this.configuration), record); + return this.executeImmediately(new UpdateCommand(record)); } public OperationResult updateImmediately(List records) { @@ -343,7 +347,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult updateImmediately(Records records) { - return this.executeImmediately(new UpdateOperation(this.configuration), records); + return this.executeImmediately(new UpdateCommand(records)); } // Upsert @@ -352,16 +356,24 @@ global inherited sharing class DML implements Commitable { return this.toUpsert(Record(record)); } + public Commitable toUpsert(SObject record, SObjectField externalIdField) { + return this.registerOperation(new UpsertCommand(Record(record)).withExternalIdField(externalIdField)); + } + public Commitable toUpsert(Record record) { - return this.registerOperation(OperationType.UPSERT_DML, record); + return this.registerOperation(new UpsertCommand(record)); } public Commitable toUpsert(List records) { return this.toUpsert(Records(records)); } + public Commitable toUpsert(List records, SObjectField externalIdField) { + return this.registerOperation(new UpsertCommand(Records(records)).withExternalIdField(externalIdField)); + } + public Commitable toUpsert(Records records) { - return this.registerOperation(OperationType.UPSERT_DML, records); + return this.registerOperation(new UpsertCommand(records)); } public OperationResult upsertImmediately(SObject record) { @@ -369,7 +381,7 @@ global inherited sharing class DML implements Commitable { } public OperationResult upsertImmediately(Record record) { - return this.executeImmediately(new UpsertOperation(this.configuration), record); + return this.executeImmediately(new UpsertCommand(record)); } public OperationResult upsertImmediately(List records) { @@ -377,13 +389,13 @@ global inherited sharing class DML implements Commitable { } public OperationResult upsertImmediately(Records records) { - return this.executeImmediately(new UpsertOperation(this.configuration), records); + return this.executeImmediately(new UpsertCommand(records)); } // Delete public Commitable toDelete(Id recordId) { - return this.registerOperation(OperationType.DELETE_DML, Record(recordId)); + return this.registerOperation(new DeleteCommand(Record(recordId))); } public Commitable toDelete(SObject record) { @@ -391,33 +403,51 @@ global inherited sharing class DML implements Commitable { } public Commitable toDelete(Iterable recordIds) { - return this.registerOperation(OperationType.DELETE_DML, Records(recordIds)); + return this.registerOperation(new DeleteCommand(Records(recordIds))); } public Commitable toDelete(List records) { - return this.registerOperation(OperationType.DELETE_DML, Records(records)); + return this.registerOperation(new DeleteCommand(Records(records))); } public OperationResult deleteImmediately(Id recordId) { - return this.executeImmediately(new DeleteOperation(this.configuration), Record(recordId)); + return this.executeImmediately(new DeleteCommand(Record(recordId))); } public OperationResult deleteImmediately(SObject record) { - return this.executeImmediately(new DeleteOperation(this.configuration), Record(record)); + return this.executeImmediately(new DeleteCommand(Record(record))); } public OperationResult deleteImmediately(Iterable recordIds) { - return this.executeImmediately(new DeleteOperation(this.configuration), Records(recordIds)); + return this.executeImmediately(new DeleteCommand(Records(recordIds))); } public OperationResult deleteImmediately(List records) { - return this.executeImmediately(new DeleteOperation(this.configuration), Records(records)); + return this.executeImmediately(new DeleteCommand(Records(records))); + } + + // Hard Delete + + public Commitable toHardDelete(Id recordId) { + return this.registerOperation(new DeleteCommand(Record(recordId)).withHardDelete()); + } + + public Commitable toHardDelete(SObject record) { + return this.toHardDelete(record.Id); + } + + public Commitable toHardDelete(Iterable recordIds) { + return this.registerOperation(new DeleteCommand(Records(recordIds)).withHardDelete()); + } + + public Commitable toHardDelete(List records) { + return this.registerOperation(new DeleteCommand(Records(records)).withHardDelete()); } // Undelete public Commitable toUndelete(Id recordId) { - return this.registerOperation(OperationType.UNDELETE_DML, Record(recordId)); + return this.registerOperation(new UndeleteCommand(Record(recordId))); } public Commitable toUndelete(SObject record) { @@ -425,63 +455,74 @@ global inherited sharing class DML implements Commitable { } public Commitable toUndelete(Iterable recordIds) { - return this.registerOperation(OperationType.UNDELETE_DML, Records(recordIds)); + return this.registerOperation(new UndeleteCommand(Records(recordIds))); } public Commitable toUndelete(List records) { - return this.registerOperation(OperationType.UNDELETE_DML, Records(records)); + return this.registerOperation(new UndeleteCommand(Records(records))); } public OperationResult undeleteImmediately(Id recordId) { - return this.executeImmediately(new UndeleteOperation(this.configuration), Record(recordId)); + return this.executeImmediately(new UndeleteCommand(Record(recordId))); } public OperationResult undeleteImmediately(SObject record) { - return this.executeImmediately(new UndeleteOperation(this.configuration), Record(record)); + return this.executeImmediately(new UndeleteCommand(Record(record))); } public OperationResult undeleteImmediately(Iterable recordIds) { - return this.executeImmediately(new UndeleteOperation(this.configuration), Records(recordIds)); + return this.executeImmediately(new UndeleteCommand(Records(recordIds))); } public OperationResult undeleteImmediately(List records) { - return this.executeImmediately(new UndeleteOperation(this.configuration), Records(records)); + return this.executeImmediately(new UndeleteCommand(Records(records))); + } + + // Merge + + public Commitable toMerge(SObject mergeToRecord, List duplicateRecords) { + return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicateRecords)); + } + + public Commitable toMerge(SObject mergeToRecord, SObject duplicatedRecord) { + return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecord)); + } + + public Commitable toMerge(SObject mergeToRecord, Id duplicatedRecordId) { + return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecordId)); + } + + public Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds) { + return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecordIds)); } // Platform Event public Commitable toPublish(SObject record) { - return this.registerOperation(OperationType.PUBLISH_DML, Record(record)); + return this.registerOperation(new PlatformEventCommand(Record(record))); } public Commitable toPublish(List records) { - return this.registerOperation(OperationType.PUBLISH_DML, Records(records)); + return this.registerOperation(new PlatformEventCommand(Records(records))); } public OperationResult publishImmediately(SObject record) { - return this.executeImmediately(new PlatformEventOperation(this.configuration), Record(record)); + return this.executeImmediately(new PlatformEventCommand(Record(record))); } public OperationResult publishImmediately(List records) { - return this.executeImmediately(new PlatformEventOperation(this.configuration), Records(records)); + return this.executeImmediately(new PlatformEventCommand(Records(records))); } - private Commitable registerOperation(OperationType type, Record record) { - this.operationsByType.get(type).register(record); - return this; - } - - private Commitable registerOperation(OperationType type, Records records) { - this.operationsByType.get(type).register(records); - return this; - } + // Helpers - private OperationResult executeImmediately(DmlOperation operation, Record record) { - return operation.register(record).commitWork().get(0); + private Commitable registerOperation(DmlCommand command) { + this.orchestrator.register(command); + return this; } - private OperationResult executeImmediately(DmlOperation operation, Records records) { - return operation.register(records).commitWork().get(0); + private OperationResult executeImmediately(DmlCommand command) { + return command.setGlobalConfiguration(this.configuration).commitWork(); } // Identifier @@ -495,10 +536,7 @@ global inherited sharing class DML implements Commitable { public void preview() { this.configuration.preview(); - - for (OperationType operationType : this.operationsByType.keySet()) { - this.operationsByType.get(operationType).preview(); - } + this.orchestrator.preview(); } // Field Level Security @@ -563,7 +601,7 @@ global inherited sharing class DML implements Commitable { try { this.hook?.before(); - DMLResult result = this.executeOperations(); + DMLResult result = this.orchestrator.execute(); this.hook?.after(result); @@ -580,7 +618,7 @@ global inherited sharing class DML implements Commitable { try { this.hook?.before(); - result = this.executeOperations(); + result = this.orchestrator.execute(); this.storeResult(result); @@ -617,36 +655,103 @@ global inherited sharing class DML implements Commitable { } } - private DMLResult executeOperations() { - DMLResult result = new DmlResult(); + private void reset() { + this.orchestrator = new Orchestrator(this.configuration); + } + + private class Orchestrator { + private final List EXECUTION_ORDER = new List { + OperationType.INSERT_DML, + OperationType.UPSERT_DML, + OperationType.UPDATE_DML, + OperationType.MERGE_DML, + OperationType.DELETE_DML, + OperationType.UNDELETE_DML, + OperationType.PUBLISH_DML + }; - for (OperationType operationType : this.operationsByType.keySet()) { - result.add(operationType, this.operationsByType.get(operationType).commitWork()); + private Map commandsByHashCode = new Map(); + private Map> commandsByOperationType = new Map>(); + private Map dependencyGraphsByOperationType = new Map(); + + private Configuration configuration; + + private Orchestrator(Configuration configuration) { + this.configuration = configuration; + + for (OperationType operationType : this.EXECUTION_ORDER) { + this.commandsByOperationType.put(operationType, new List()); + this.dependencyGraphsByOperationType.put(operationType, new OrderDependencyGraph()); + } } - return result; - } + private void register(DmlCommand command) { + DmlCommand existingCommand = this.commandsByHashCode.get(command.hashCode()); - private void reset() { - this.operationsByType.clear(); - this.initializeOperations(); + if (existingCommand == null) { + OrderDependencyGraph operationDependencyGraph = this.dependencyGraphsByOperationType.get(command.getOperationType()); + + command + .setGlobalConfiguration(this.configuration) + .setDependencyGraph(operationDependencyGraph) + .recalculate(); + + this.commandsByHashCode.put(command.hashCode(), command); + this.commandsByOperationType.get(command.getOperationType()).add(command); + + return; + } + + existingCommand.mergeWith(command); + } + + private void preview() { + for (OperationType operationType : this.EXECUTION_ORDER) { + for (DmlCommand command : this.getSortedCommands(operationType)) { + command.preview(); + } + } + } + + private DmlResult execute() { + DmlResult result = new DmlResult(); + + for (OperationType operationType : this.EXECUTION_ORDER) { + for (DmlCommand command : this.getSortedCommands(operationType)) { + result.add(operationType, command.commitWork()); + } + } + + return result; + } + + private List getSortedCommands(OperationType operationType) { + List operationCommands = this.commandsByOperationType.get(operationType); + operationCommands.sort(new DmlCommandComperator(this.getSObjectTypesExecutionOrder(operationType))); + + return operationCommands; + } + + private List getSObjectTypesExecutionOrder(OperationType operationType) { + if (!this.configuration.customExecutionOrder.isEmpty()) { + return this.configuration.customExecutionOrder; + } + + return this.dependencyGraphsByOperationType.get(operationType).getTopologicalOrder(); + } } private class DmlResult implements Result { - private Map> operationResultsByObjectType = new Map>{ - OperationType.INSERT_DML => new Map(), - OperationType.UPDATE_DML => new Map(), - OperationType.UPSERT_DML => new Map(), - OperationType.DELETE_DML => new Map(), - OperationType.UNDELETE_DML => new Map(), - OperationType.PUBLISH_DML => new Map() - }; + private Map> operationResultsByObjectType = new Map>(); - public DmlResult add(OperationType operationType, List results) { - for (OperationResult result : results) { - this.operationResultsByObjectType.get(operationType).put(result.objectType(), result); + public DmlResult() { + for (OperationType operationType : OperationType.values()) { + this.operationResultsByObjectType.put(operationType, new Map()); } + } + public DmlResult add(OperationType operationType, OperationResult result) { + this.operationResultsByObjectType.get(operationType).put(result.objectType(), result); return this; } @@ -776,121 +881,113 @@ global inherited sharing class DML implements Commitable { } } - private abstract class DmlOperation { - protected Map recordsProcessContainerByType = new Map(); - protected Configuration globalConfiguration; - protected OrderDependencyGraph orderDependencyGraph = new OrderDependencyGraph(); + private class DmlCommandComperator implements System.Comparator { + private List sobjectExecutionOrder; + + public DmlCommandComperator(List sobjectExecutionOrder) { + this.sobjectExecutionOrder = sobjectExecutionOrder; + } + + public Integer compare(DmlCommand a, DmlCommand b) { + SObjectType aObjectType = a.getObjectType(); + SObjectType bObjectType = b.getObjectType(); - public DmlOperation(Configuration configuration) { - this.globalConfiguration = configuration; + return this.sobjectExecutionOrder.indexOf(aObjectType) - this.sobjectExecutionOrder.indexOf(bObjectType); } + } - private DmlOperation register(Records records) { + private abstract class DmlCommand { + private SObjectType objectType; + private Configuration globalConfiguration; + private OrderDependencyGraph orderDependencyGraph; + private List enhancedRecords = new List(); + + public DmlCommand(Record record) { + this.register(record); + } + + public DmlCommand(Records records) { for (Record record : records.get()) { this.register(record); } - - return this; } - private DmlOperation register(Record record) { + private void register(Record record) { EnhancedRecord enhancedRecord = record.get(); - SObjectType typeName = enhancedRecord.getSObjectType(); + this.setObjectType(enhancedRecord); - this.validate(record); - this.validateCustomExecutionOrder(typeName); - - this.addNewRecordToProcess(typeName, enhancedRecord); - - enhancedRecord.addToDependencyGraph(this.orderDependencyGraph); - - return this; + this.enhancedRecords.add(enhancedRecord); } - private void validateCustomExecutionOrder(SObjectType typeName) { - if (!this.globalConfiguration.customExecutionOrder.isEmpty() && !this.globalConfiguration.customExecutionOrder.contains(typeName)) { - throw new DmlException('Only the following types can be registered: ' + this.globalConfiguration.customExecutionOrder); + private void setObjectType(EnhancedRecord enhancedRecord) { + if (this.objectType == null) { + this.objectType = enhancedRecord.getSObjectType(); } } - private void addNewRecordToProcess(SObjectType typeName, EnhancedRecord enhancedRecord) { - if (!this.recordsProcessContainerByType.containsKey(typeName)) { - this.recordsProcessContainerByType.put(typeName, new RecordsContainer()); - } + private DmlCommand setGlobalConfiguration(Configuration globalConfiguration) { + this.globalConfiguration = globalConfiguration; + return this; + } - this.recordsProcessContainerByType.get(typeName).addNewRecordToProcess(enhancedRecord); + private DmlCommand setDependencyGraph(OrderDependencyGraph orderDependencyGraph) { + this.orderDependencyGraph = orderDependencyGraph; + return this; } private void preview() { System.debug(LoggingLevel.ERROR, - '\n\n============ ' + this.getType() + ' Preview ============' - + '\nRecords Process Container: ' + JSON.serializePretty(this.recordsProcessContainerByType) + '\n\n============ ' + this.getOperationType() + ' Preview ============' + + '\nObject Type: ' + this.objectType + + '\nRecords: ' + JSON.serializePretty(this.enhancedRecords) + '\n=======================================\n' ); } - private List commitWork() { - return this.globalConfiguration.sharingExecutor.execute(this); - } + private DmlCommand recalculate() { + this.validateCustomExecutionOrder(); - private List execute() { - if (!this.globalConfiguration.customExecutionOrder.isEmpty()) { - return this.process(this.globalConfiguration.customExecutionOrder); - } + for (EnhancedRecord enhancedRecord : this.enhancedRecords) { + this.validate(enhancedRecord); + enhancedRecord.addToDependencyGraph(this.orderDependencyGraph); + } - return this.process(this.orderDependencyGraph.getTopologicalOrder()); + return this; } - private List process(Iterable objectTypesOrder) { - List operationResults = new List(); - - DmlResultAdapter operationResultAdapter = this.getAdapter(); - - for (SObjectType objectTypeName : objectTypesOrder) { - if (!this.recordsProcessContainerByType.containsKey(objectTypeName)) { - continue; - } - - List recordsToProcess = this.recordsProcessContainerByType.get(objectTypeName).getRecordsToProcess(); - - List recordResults; - - DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); - - if (dmlMock != null && dmlMock.shouldBeMocked(this.getType(), objectTypeName)) { - recordResults = dmlMock.getMockedRecordResults(this, objectTypeName, recordsToProcess); - } else { - recordResults = operationResultAdapter.get(this.executeDml(recordsToProcess), recordsToProcess); - } - - operationResults.add( - new OperationSummary(objectTypeName, this.getType()) - .setRecords(recordsToProcess) - .setRecordResults(recordResults) - ); + private void validateCustomExecutionOrder() { + if (this.globalConfiguration.customExecutionOrder.isEmpty()) { + return; } - return operationResults; + if (!this.globalConfiguration.customExecutionOrder.contains(this.getObjectType())) { + throw new DmlException('Only the following types can be registered: ' + this.globalConfiguration.customExecutionOrder); + } } - protected abstract OperationType getType(); - protected abstract List executeDml(List recordsToProcess); - protected abstract RecordSummary prepareMockedDml(SObject record); - protected abstract DmlResultAdapter getAdapter(); + private SObjectType getObjectType() { + return this.objectType; + } - protected virtual void validate(Record record) { - return; + private OperationResult commitWork() { + return this.globalConfiguration.sharingExecutor.execute(this); } - } + + private virtual OperationResult execute() { + List recordsToProcess = this.getRecordsToProcess(); - private class RecordsContainer { - private List enhancedRecords = new List(); + OperationSummary operationSummary = new OperationSummary(this.objectType, this.getOperationType()).setRecords(recordsToProcess); - private void addNewRecordToProcess(EnhancedRecord enhancedRecord) { - this.enhancedRecords.add(enhancedRecord); + DmlMock dmlMock = dmlIdentifierToMock.get(this.globalConfiguration.dmlIdentifier); + + if (dmlMock != null && dmlMock.shouldBeMocked(this.getOperationType(), this.objectType)) { + return operationSummary.setRecordResults(dmlMock.getMockedRecordResults(this, recordsToProcess)); + } + + return operationSummary.setRecordResults(this.getAdapter().get(this.executeDml(recordsToProcess), recordsToProcess)); } - private List getRecordsToProcess() { + private List getRecordsToProcess() { List recordsToProcess = new List(); for (EnhancedRecord enhancedRecord : this.enhancedRecords) { @@ -900,19 +997,48 @@ global inherited sharing class DML implements Commitable { return recordsToProcess; } + + private virtual void mergeWith(DmlCommand command) { + // TODO: Avoid duplicate records based on Id when specified + this.enhancedRecords.addAll(command.enhancedRecords); + this.recalculate(); + } + + protected abstract OperationType getOperationType(); + protected abstract DmlResultAdapter getAdapter(); + protected abstract List executeDml(List recordsToProcess); + protected abstract RecordSummary executeMockedDml(SObject record); + + protected virtual void validate(EnhancedRecord enhancedRecord) { + return; + } + + protected virtual String hashCode() { + return this.getOperationType() + '_' + this.objectType; + } } - private inherited sharing class InsertOperation extends DmlOperation { - public InsertOperation(Configuration configuration) { - super(configuration); + // Commands + + private inherited sharing class InsertCommand extends DmlCommand { + public InsertCommand(Record record) { + super(record); } - protected override OperationType getType() { + public InsertCommand(Records records) { + super(records); + } + + protected override OperationType getOperationType() { return OperationType.INSERT_DML; } - protected override void validate(Record record) { - if (record.get().doesRecordHaveIdSpecified()) { + public override DmlResultAdapter getAdapter() { + return new SaveResultAdapter(); + } + + protected override void validate(EnhancedRecord enhancedRecord) { + if (String.isNotBlank(enhancedRecord.getRecordId())) { throw new DmlException('Only new records can be registered as new.'); } } @@ -921,27 +1047,74 @@ global inherited sharing class DML implements Commitable { return Database.insert(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - protected override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary executeMockedDml(SObject record) { record.put('Id', randomIdGenerator.get(record.getSObjectType())); return new RecordSummary().isSuccess(true).recordId(record.Id); } + } + + private inherited sharing class UpsertCommand extends DmlCommand { + private SObjectField externalIdField; + public UpsertCommand(Record record) { + super(record); + } + + public UpsertCommand(Records records) { + super(records); + } + + private UpsertCommand withExternalIdField(SObjectField externalIdField) { + this.externalIdField = externalIdField; + return this; + } + + protected override String hashCode() { + return super.hashCode() + '_' + this.externalIdField ?? 'Id'; + } + + protected override OperationType getOperationType() { + return OperationType.UPSERT_DML; + } protected override DmlResultAdapter getAdapter() { - return new SaveResultAdapter(); + return new UpsertResultAdapter(); + } + + protected override List executeDml(List recordsToProcess) { + if (this.externalIdField == null) { + return Database.upsert(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + } + + return Database.upsert(recordsToProcess, this.externalIdField, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + } + + protected override RecordSummary executeMockedDml(SObject record) { + if (record.Id == null) { + record.put('Id', randomIdGenerator.get(record.getSObjectType())); + } + return new RecordSummary().isSuccess(true).recordId(record.Id); } } - private inherited sharing class UpdateOperation extends DmlOperation { - public UpdateOperation(Configuration configuration) { - super(configuration); + private inherited sharing class UpdateCommand extends DmlCommand { + public UpdateCommand(Record record) { + super(record); + } + + public UpdateCommand(Records records) { + super(records); } - protected override OperationType getType() { + protected override OperationType getOperationType() { return OperationType.UPDATE_DML; } - protected override void validate(Record record) { - if (!record.get().doesRecordHaveIdSpecified()) { + protected override DmlResultAdapter getAdapter() { + return new SaveResultAdapter(); + } + + protected override void validate(EnhancedRecord enhancedRecord) { + if (String.isBlank(enhancedRecord.getRecordId())) { throw new DmlException('Only existing records can be updated.'); } } @@ -950,79 +1123,134 @@ global inherited sharing class DML implements Commitable { return Database.update(recordsToProcess, this.globalConfiguration.options, this.globalConfiguration.accessMode); } - protected override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary executeMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } - - protected override DmlResultAdapter getAdapter() { - return new SaveResultAdapter(); - } } - private inherited sharing class UpsertOperation extends DmlOperation { - public UpsertOperation(Configuration configuration) { - super(configuration); + private inherited sharing class MergeCommand extends DmlCommand { + private SObject mergeToRecord; + private List duplicateRecords = new List(); + + public MergeCommand(Record mergeToRecord, SObject duplicateRecord) { + super(mergeToRecord); + this.duplicateRecords.add(duplicateRecord); } - protected override OperationType getType() { - return OperationType.UPSERT_DML; + public MergeCommand(Record mergeToRecord, List duplicateRecords) { + super(mergeToRecord); + this.duplicateRecords.addAll(duplicateRecords); } - protected override List executeDml(List recordsToProcess) { - return Database.upsert(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + public MergeCommand(Record mergeToRecord, Id duplicateRecordId) { + this(mergeToRecord, mergeToRecord.get().getSObjectType().newSObject(duplicateRecordId)); } - protected override RecordSummary prepareMockedDml(SObject record) { - if (record.Id == null) { - record.put('Id', randomIdGenerator.get(record.getSObjectType())); + public MergeCommand(Record mergeToRecord, Iterable duplicateRecordIds) { + super(mergeToRecord); + + SObjectType objectType = mergeToRecord.get().getSObjectType(); + + for (Id duplicateRecordId : duplicateRecordIds) { + this.duplicateRecords.add(objectType.newSObject(duplicateRecordId)); } - return new RecordSummary().isSuccess(true).recordId(record.Id); + } + + protected override String hashCode() { + return super.hashCode() + '_' + this.mergeToRecord.Id; + } + + protected override OperationType getOperationType() { + return OperationType.MERGE_DML; } protected override DmlResultAdapter getAdapter() { - return new UpsertResultAdapter(); + return new MergeResultAdapter(); + } + + protected override void validate(EnhancedRecord enhancedRecord) { + if (String.isBlank(enhancedRecord.getRecordId())) { + throw new DmlException('Only existing records can be merged.'); + } + } + + protected override List executeDml(List recordsToProcess) { + return Database.merge(this.mergeToRecord, recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + } + + protected override RecordSummary executeMockedDml(SObject record) { + return new RecordSummary().isSuccess(true).recordId(record.Id); } } - private inherited sharing class DeleteOperation extends DmlOperation { - public DeleteOperation(Configuration configuration) { - super(configuration); + private inherited sharing class DeleteCommand extends DmlCommand { + private Boolean makeHardDelete = false; + + public DeleteCommand(Record record) { + super(record); + } + + public DeleteCommand(Records records) { + super(records); } - protected override OperationType getType() { + private DeleteCommand withHardDelete() { + this.makeHardDelete = true; + return this; + } + + protected override String hashCode() { + return super.hashCode() + '_' + this.makeHardDelete; + } + + protected override OperationType getOperationType() { return OperationType.DELETE_DML; } - protected override void validate(Record record) { - if (!record.get().doesRecordHaveIdSpecified()) { + protected override DmlResultAdapter getAdapter() { + return new DeleteResultAdapter(); + } + + protected override void validate(EnhancedRecord enhancedRecord) { + if (String.isBlank(enhancedRecord.getRecordId())) { throw new DmlException('Only existing records can be registered as deleted.'); } } protected override List executeDml(List recordsToProcess) { - return Database.delete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + List dmlResults = Database.delete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + + if (this.makeHardDelete) { + System.Database.emptyRecycleBin(recordsToProcess); + } + + return dmlResults; } - protected override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary executeMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } + } - protected override DmlResultAdapter getAdapter() { - return new DeleteResultAdapter(); + private inherited sharing class UndeleteCommand extends DmlCommand { + public UndeleteCommand(Record record) { + super(record); } - } - private inherited sharing class UndeleteOperation extends DmlOperation { - public UndeleteOperation(Configuration configuration) { - super(configuration); + public UndeleteCommand(Records records) { + super(records); } - protected override OperationType getType() { + protected override OperationType getOperationType() { return OperationType.UNDELETE_DML; } - protected override void validate(Record record) { - if (!record.get().doesRecordHaveIdSpecified()) { + protected override DmlResultAdapter getAdapter() { + return new UndeleteResultAdapter(); + } + + protected override void validate(EnhancedRecord enhancedRecord) { + if (String.isBlank(enhancedRecord.getRecordId())) { throw new DmlException('Only deleted records can be undeleted.'); } } @@ -1031,59 +1259,63 @@ global inherited sharing class DML implements Commitable { return Database.undelete(recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } - protected override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary executeMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(record.Id); } + } - protected override DmlResultAdapter getAdapter() { - return new UndeleteResultAdapter(); + private inherited sharing class PlatformEventCommand extends DmlCommand { + public PlatformEventCommand(Record record) { + super(record); } - } - private inherited sharing class PlatformEventOperation extends DmlOperation { - public PlatformEventOperation(Configuration configuration) { - super(configuration); + public PlatformEventCommand(Records records) { + super(records); } - protected override OperationType getType() { + protected override OperationType getOperationType() { return OperationType.PUBLISH_DML; } + protected override DmlResultAdapter getAdapter() { + return new SaveResultAdapter(); + } + protected override List executeDml(List recordsToProcess) { return EventBus.publish(recordsToProcess); } - protected override RecordSummary prepareMockedDml(SObject record) { + protected override RecordSummary executeMockedDml(SObject record) { return new RecordSummary().isSuccess(true).recordId(randomIdGenerator.get(record.getSObjectType())); } - - protected override DmlResultAdapter getAdapter() { - return new SaveResultAdapter(); - } } - + + // Issuers + private interface DmlSharing { - List execute(DmlOperation executor); + OperationResult execute(DmlCommand command); } private inherited sharing class InheritedSharing implements DmlSharing { - public List execute(DmlOperation executor) { - return executor.execute(); + public OperationResult execute(DmlCommand command) { + return command.execute(); } } private without sharing class WithoutSharing implements DmlSharing { - public List execute(DmlOperation executor) { - return executor.execute(); + public OperationResult execute(DmlCommand command) { + return command.execute(); } } private with sharing class WithSharing implements DmlSharing { - public List execute(DmlOperation executor) { - return executor.execute(); + public OperationResult execute(DmlCommand command) { + return command.execute(); } } + // Result Adapters + private abstract class DmlResultAdapter { public List get(List dmlResults, List processedRecords) { List recordResults = new List(); @@ -1120,6 +1352,17 @@ global inherited sharing class DML implements Commitable { } } + private class MergeResultAdapter extends DmlResultAdapter { + public override RecordSummary transform(Object result) { + Database.MergeResult mergeResult = (Database.MergeResult) result; + + return new RecordSummary() + .isSuccess(mergeResult.isSuccess()) + .recordId(mergeResult.getId()) + .errors(mergeResult.getErrors()); + } + } + private class DeleteResultAdapter extends DmlResultAdapter { public override RecordSummary transform(Object result) { Database.DeleteResult deleteResult = (Database.DeleteResult) result; @@ -1442,25 +1685,25 @@ global inherited sharing class DML implements Commitable { return this.mockedDmlTypes.contains(dmlType) || (this.mockedObjectTypesByDmlType.get(dmlType) ?? new Set()).contains(objectType) || this.shouldThrowException(dmlType, objectType); } - private List getMockedRecordResults(DmlOperation executor, SObjectType objectType, List recordsToProcess) { - if (this.shouldThrowException(executor.getType(), objectType)) { - if (executor.globalConfiguration.allOrNone) { - throw new DmlException('Exception thrown for ' + executor.getType() + ' operation.'); + private List getMockedRecordResults(DmlCommand command, List recordsToProcess) { + if (this.shouldThrowException(command.getOperationType(), command.getObjectType())) { + if (command.globalConfiguration.allOrNone) { + throw new DmlException('Exception thrown for ' + command.getOperationType() + ' operation.'); } // all or none is false, so we need to return a list of record results with errors - return this.getMockedRecordErrors(executor, objectType, recordsToProcess); + return this.getMockedRecordErrors(command, recordsToProcess); } - return this.getMockedRecordSuccesses(executor, objectType, recordsToProcess); + return this.getMockedRecordSuccesses(command, recordsToProcess); } - private List getMockedRecordErrors(DmlOperation executor, SObjectType objectType, List recordsToProcess) { + private List getMockedRecordErrors(DmlCommand command, List recordsToProcess) { List recordResults = new List(); for (SObject record : recordsToProcess) { RecordProcessingError error = new RecordProcessingError() - .setMessage('Exception thrown for ' + executor.getType() + ' operation.') + .setMessage('Exception thrown for ' + command.getOperationType() + ' operation.') .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) .setFields(new List { 'Id' }); @@ -1474,11 +1717,11 @@ global inherited sharing class DML implements Commitable { return recordResults; } - private List getMockedRecordSuccesses(DmlOperation executor, SObjectType objectType, List recordsToProcess) { + private List getMockedRecordSuccesses(DmlCommand command, List recordsToProcess) { List recordResults = new List(); for (SObject record : recordsToProcess) { - recordResults.add(executor.prepareMockedDml(record)); + recordResults.add(command.executeMockedDml(record)); } return recordResults; @@ -1617,6 +1860,10 @@ global inherited sharing class DML implements Commitable { } private void addToDependencyGraph(OrderDependencyGraph orderDependencyGraph) { + if (orderDependencyGraph == null) { + return; + } + orderDependencyGraph.registerType(this.getSObjectType()); for (ParentRelationship parentRelationship : this.parentRelationships) { @@ -1625,17 +1872,9 @@ global inherited sharing class DML implements Commitable { } private void resolveRecordRelationships() { - this.resolveRelationships(); - this.resolveExternalRelationships(); - } - - private void resolveRelationships() { for (ParentRelationship parentRelationship : this.parentRelationships) { parentRelationship.resolve(this.currentRecord); } - } - - private void resolveExternalRelationships() { for (ExternalRelationship externalRelationship : this.externalRelationships) { externalRelationship.resolve(this.currentRecord); } diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index c51a929..e732e7b 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -41,7 +41,6 @@ private class DML_Test { Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); - DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); Assert.areEqual(1, operationResult.records().size(), 'Inserted operation result should contain the inserted record.'); @@ -267,10 +266,10 @@ private class DML_Test { Exception expectedException = null; try { - new DML() + new DML() .toInsert(newAccount) .toInsert(DML.Records(contacts).withRelationship(Contact.LastName, newAccount)) - .commitWork(); + .commitWork(); } catch (Exception e) { expectedException = e; } @@ -1556,7 +1555,6 @@ private class DML_Test { Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); } - @IsTest static void upsertImmediatelySingleRecord() { // Setup @@ -4675,8 +4673,15 @@ private class DML_Test { // Verify Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); - Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Contact should be inserted.'); - Assert.areEqual(1, [SELECT COUNT() FROM Opportunity], 'Opportunity should be inserted.'); + Assert.areEqual(1, [SELECT COUNT() FROM Contact WHERE AccountId != null], 'Contact should be inserted.'); + Assert.areEqual(1, [SELECT COUNT() FROM Opportunity WHERE AccountId != null], 'Opportunity should be inserted.'); + + Assert.isNotNull(contact1.Id, 'Contact should be inserted and have an Id.'); + Assert.isNotNull(opportunity1.Id, 'Opportunity should be inserted and have an Id.'); + Assert.isNotNull(account1.Id, 'Account should be inserted and have an Id.'); + + Assert.areEqual(account1.Id, contact1.AccountId, 'Contact should be related to Account.'); + Assert.areEqual(account1.Id, opportunity1.AccountId, 'Opportunity should be related to Account.'); } // SHARED diff --git a/sfdx-project.json b/sfdx-project.json index c893893..bdf385f 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -7,6 +7,10 @@ "default": true, "package": "DML Lib", "versionDescription": "" + }, + { + "path": "drafts", + "default": false } ], "name": "dml-lib", diff --git a/todo b/todo index e8a1998..e3348ff 100644 --- a/todo +++ b/todo @@ -4,6 +4,7 @@ - [x] Fluent Bulk records update without the loop e.g. - [x] DML.Records(opportunities).with(Opportunity.Status, 'Closed Won') — loop-free builder that batches field patches, applies FLS (per mode), then registers the op. - [ ] Bulk DMLs — group by SObjectType and operation; execute in chunked batches (default 200) to minimize statements and respect limits. +- [ ] when the same record is added with the same id, avoid duplication, apply for update and upser - [ ] Savepoint + rollback — wrap commitWork() in a savepoint; - [x] on error rollback everything; - [ ] clear internal state afterward. @@ -235,13 +236,34 @@ toHardDelete(List records); Account account1 = new Account(Name = 'Account 1'); Contact contact1 = new Contact(LastName = 'Contact 1'); - Opportunity opportunity1 = new Opportunity(Name = 'Opportunity 1'); + Opportunity opportunity1 = new Opportunity(Name = 'Opportunity 1', StageName = 'Discovery', CloseDate = System.today()); new DML() - .toInsert(account1) - .toInsert(DML.Record(contact1).withRelationship(Contact.AccountId, account1)) .toInsert(DML.Record(opportunity1) .withRelationship(Opportunity.AccountId, account1) .withRelationship(Opportunity.Contact__c, contact1) ) - .commitWork(); \ No newline at end of file + .toInsert(DML.Record(contact1).withRelationship(Contact.AccountId, account1)) + .toInsert(DML.Record(account1).withRelationship(Account.Contact__c, contact1)) + .commitWork(); + + +--- + +Status | Priority | Feature | Effort | Impact | +|------|----------|---------|--------|--------| +| [x] | 🔴 Critical | Deferred Relationship Resolution | High | Very High | +| [x] | 🔴 Critical | Automatic Dependency Ordering | High | Very High | +| [x] | 🔴 Critical | Merge Operations | Medium | High | +| [x] | 🔴 Critical | Hard Delete | Low | Medium | +| [x] | 🔴 Critical | Discard Work | Low | Medium | +| [x] | 🟡 Important | Upsert with External ID | Medium | High | +| [ ] | 🟡 Important | Lead Conversion | Medium | Medium | +| [ ] | 🟡 Important | Per-Operation Overrides | High | High | +| [ ] | 🟡 Important | Email Integration | Medium | Medium | +| [ ] | 🟡 Important | Custom Work (IDoWork) | Low | Medium | +| [ ] | 🟢 Nice | Better Mock API with Errors | Medium | Medium | +| [ ] | 🟢 Nice | Retry Strategy | High | Medium | +| [ ] | 🟢 Nice | Chunk Size | Medium | Medium | +| [ ] | 🟢 Nice | Query Integration | Medium | Low | +| [x] | 🟢 Nice | Lifecycle Hooks | Medium | Low | From be54eeb7969ce4c5f4cd8ce4581f23fa0bc3cd55 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Tue, 16 Dec 2025 21:39:31 +0100 Subject: [PATCH 08/22] toHardDelete tests --- force-app/main/default/classes/DML_Test.cls | 272 ++++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index e732e7b..4fd64f9 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -2900,6 +2900,278 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } + // HARD DELETE + + @IsTest + static void toHardDeleteSingleRecordById() { + // Setup + Account account = insertAccount(); + + // Test + Test.startTest(); + new DML() + .toHardDelete(account.Id) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + } + + @IsTest + static void toHardDeleteSingleRecord() { + // Setup + Account account1 = insertAccount(); + + // Test + Test.startTest(); + DML.Result result = new DML() + .toHardDelete(account1) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Hard deleted operation result should contain the deleted record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Hard deleted operation result should contain the deleted record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Hard deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Hard deleted operation result should contain delete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Hard deleted operation result should not have failures.'); + } + + @IsTest + static void toHardDeleteMultipleRecords() { + // Setup + Account account1 = getAccount(1); + Account account2 = getAccount(2); + + insert new List{ account1, account2 }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toHardDelete(account1) + .toHardDelete(account2) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(2, operationResult.records().size(), 'Hard deleted operation result should contain the deleted records.'); + Assert.areEqual(2, operationResult.recordResults().size(), 'Hard deleted operation result should contain the deleted record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Hard deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Hard deleted operation result should contain delete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Hard deleted operation result should not have failures.'); + } + + @IsTest + static void toHardDeleteListOfRecords() { + // Setup + List accounts = insertAccounts(); + + // Test + Test.startTest(); + new DML() + .toHardDelete(accounts) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + } + + @IsTest + static void toHardDeleteMultipleRecordsById() { + // Setup + List accounts = insertAccounts(); + + // Test + Test.startTest(); + Set accountIds = new Map(accounts).keySet(); + + new DML() + .toHardDelete(accountIds) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + } + + @IsTest + static void toHardDeleteMultipleRecordsTypes() { + // Setup + Account account1 = getAccount(1); + Opportunity opportunity1 = getOpportunity(1); + Lead lead1 = getLead(1); + + insert new List{ account1, opportunity1, lead1 }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toHardDelete(account1) + .toHardDelete(opportunity1) + .toHardDelete(lead1) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be hard deleted.'); + Assert.areEqual(0, [SELECT COUNT() FROM Opportunity], 'Opportunity should be hard deleted.'); + Assert.areEqual(0, [SELECT COUNT() FROM Lead], 'Lead should be hard deleted.'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(3, result.deletes().size(), 'Deleted operation result should contain 3 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult accountOperationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(1, accountOperationResult.records().size(), 'Hard deleted operation result should contain the deleted record.'); + Assert.areEqual(1, accountOperationResult.recordResults().size(), 'Hard deleted operation result should contain the deleted record results.'); + Assert.areEqual(Account.SObjectType, accountOperationResult.objectType(), 'Hard deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, accountOperationResult.operationType(), 'Hard deleted operation result should contain delete type.'); + Assert.isFalse(accountOperationResult.hasFailures(), 'Hard deleted operation result should not have failures.'); + + DML.OperationResult opportunityOperationResult = result.deletesOf(Opportunity.SObjectType); + + Assert.areEqual(1, opportunityOperationResult.records().size(), 'Hard deleted operation result should contain the deleted record.'); + Assert.areEqual(1, opportunityOperationResult.recordResults().size(), 'Hard deleted operation result should contain the deleted record results.'); + Assert.areEqual(Opportunity.SObjectType, opportunityOperationResult.objectType(), 'Hard deleted operation result should contain Opportunity object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, opportunityOperationResult.operationType(), 'Hard deleted operation result should contain delete type.'); + Assert.isFalse(opportunityOperationResult.hasFailures(), 'Hard deleted operation result should not have failures.'); + + DML.OperationResult leadOperationResult = result.deletesOf(Lead.SObjectType); + + Assert.areEqual(1, leadOperationResult.records().size(), 'Hard deleted operation result should contain the deleted record.'); + Assert.areEqual(1, leadOperationResult.recordResults().size(), 'Hard deleted operation result should contain the deleted record results.'); + Assert.areEqual(Lead.SObjectType, leadOperationResult.objectType(), 'Hard deleted operation result should contain Lead object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, leadOperationResult.operationType(), 'Hard deleted operation result should contain delete type.'); + Assert.isFalse(leadOperationResult.hasFailures(), 'Hard deleted operation result should not have failures.'); + } + + @IsTest + static void toHardDeleteWithoutExistingIds() { + // Setup + Account account = getAccount(1); + + DmlException expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toHardDelete(account) + .commitWork(); + } catch (DmlException e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Only existing records can be registered as deleted.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toHardDeleteWithPartialSuccess() { + // Setup + List accounts = new List{ + getAccount(1), + getAccount(2) + }; + + insert accounts; + + // Test + Test.startTest(); + new DML() + .toHardDelete(accounts) + .allowPartialSuccess() + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + } + + @IsTest + static void toHardDeleteWithUserMode() { + // Setup + Case newCase = getCase(1); + insert newCase; + + Exception expectedException = null; + + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + try { + new DML() + .toHardDelete(newCase) + .commitWork(); // user mode by default + } catch (Exception e) { + expectedException = e; + } + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Access to entity \'Case\' denied', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toHardDeleteWithSystemMode() { + // Setup + Case newCase = getCase(1); + insert newCase; + + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + new DML() + .toHardDelete(newCase) + .systemMode() + .withoutSharing() + .commitWork(); + } + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Case], 'Cases should be hard deleted.'); + // No assertion with ALL ROWS, because there is Salesforce error + } + // UNDELETE @IsTest From 08b7e3f08e7c9eb0c51fe05c438f576f6c5bcec1 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sat, 20 Dec 2025 22:58:26 +0100 Subject: [PATCH 09/22] DML Tests --- force-app/main/default/classes/DML.cls | 90 +- force-app/main/default/classes/DML_Test.cls | 1069 ++++++++++++++++++- 2 files changed, 1107 insertions(+), 52 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index cc9f3e4..d89974d 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -164,6 +164,7 @@ global inherited sharing class DML implements Commitable { List upserts(); List deletes(); List undeletes(); + List merges(); List events(); // Per Object OperationType OperationResult insertsOf(Schema.SObjectType objectType); @@ -171,6 +172,7 @@ global inherited sharing class DML implements Commitable { OperationResult upsertsOf(Schema.SObjectType objectType); OperationResult deletesOf(Schema.SObjectType objectType); OperationResult undeletesOf(Schema.SObjectType objectType); + OperationResult mergesOf(Schema.SObjectType objectType); OperationResult eventsOf(Schema.SObjectType objectType); } @@ -226,6 +228,7 @@ global inherited sharing class DML implements Commitable { Mockable allUpserts(); Mockable allDeletes(); Mockable allUndeletes(); + Mockable allMerges(); Mockable allPublishes(); // Per Object OperationType Mockable insertsFor(SObjectType objectType); @@ -233,6 +236,7 @@ global inherited sharing class DML implements Commitable { Mockable upsertsFor(SObjectType objectType); Mockable deletesFor(SObjectType objectType); Mockable undeletesFor(SObjectType objectType); + Mockable mergesFor(SObjectType objectType); Mockable publishesFor(SObjectType objectType); // Errors Mockable exceptionOnInserts(); @@ -240,6 +244,7 @@ global inherited sharing class DML implements Commitable { Mockable exceptionOnUpserts(); Mockable exceptionOnDeletes(); Mockable exceptionOnUndeletes(); + Mockable exceptionOnMerges(); Mockable exceptionOnPublishes(); // Per Operation Type Per Object Type Mockable exceptionOnInsertsFor(SObjectType objectType); @@ -247,6 +252,7 @@ global inherited sharing class DML implements Commitable { Mockable exceptionOnUpsertsFor(SObjectType objectType); Mockable exceptionOnDeletesFor(SObjectType objectType); Mockable exceptionOnUndeletesFor(SObjectType objectType); + Mockable exceptionOnMergesFor(SObjectType objectType); Mockable exceptionOnPublishesFor(SObjectType objectType); } @@ -481,19 +487,19 @@ global inherited sharing class DML implements Commitable { // Merge public Commitable toMerge(SObject mergeToRecord, List duplicateRecords) { - return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicateRecords)); + return this.registerOperation(new MergeCommand(Record(mergeToRecord), Records(duplicateRecords))); } public Commitable toMerge(SObject mergeToRecord, SObject duplicatedRecord) { - return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecord)); + return this.registerOperation(new MergeCommand(Record(mergeToRecord), Record(duplicatedRecord))); } public Commitable toMerge(SObject mergeToRecord, Id duplicatedRecordId) { - return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecordId)); + return this.registerOperation(new MergeCommand(Record(mergeToRecord), Record(duplicatedRecordId))); } public Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds) { - return this.registerOperation(new MergeCommand(Record(mergeToRecord), duplicatedRecordIds)); + return this.registerOperation(new MergeCommand(Record(mergeToRecord), Records(duplicatedRecordIds))); } // Platform Event @@ -660,16 +666,6 @@ global inherited sharing class DML implements Commitable { } private class Orchestrator { - private final List EXECUTION_ORDER = new List { - OperationType.INSERT_DML, - OperationType.UPSERT_DML, - OperationType.UPDATE_DML, - OperationType.MERGE_DML, - OperationType.DELETE_DML, - OperationType.UNDELETE_DML, - OperationType.PUBLISH_DML - }; - private Map commandsByHashCode = new Map(); private Map> commandsByOperationType = new Map>(); private Map dependencyGraphsByOperationType = new Map(); @@ -679,7 +675,7 @@ global inherited sharing class DML implements Commitable { private Orchestrator(Configuration configuration) { this.configuration = configuration; - for (OperationType operationType : this.EXECUTION_ORDER) { + for (OperationType operationType : OperationType.values()) { this.commandsByOperationType.put(operationType, new List()); this.dependencyGraphsByOperationType.put(operationType, new OrderDependencyGraph()); } @@ -702,11 +698,11 @@ global inherited sharing class DML implements Commitable { return; } - existingCommand.mergeWith(command); + existingCommand.combine(command); } private void preview() { - for (OperationType operationType : this.EXECUTION_ORDER) { + for (OperationType operationType : OperationType.values()) { for (DmlCommand command : this.getSortedCommands(operationType)) { command.preview(); } @@ -716,7 +712,7 @@ global inherited sharing class DML implements Commitable { private DmlResult execute() { DmlResult result = new DmlResult(); - for (OperationType operationType : this.EXECUTION_ORDER) { + for (OperationType operationType : OperationType.values()) { for (DmlCommand command : this.getSortedCommands(operationType)) { result.add(operationType, command.commitWork()); } @@ -775,6 +771,10 @@ global inherited sharing class DML implements Commitable { return this.getOperationResults(OperationType.UNDELETE_DML); } + public List merges() { + return this.getOperationResults(OperationType.MERGE_DML); + } + public List events() { return this.getOperationResults(OperationType.PUBLISH_DML); } @@ -799,6 +799,10 @@ global inherited sharing class DML implements Commitable { return this.getOperationResult(OperationType.UNDELETE_DML, objectType); } + public OperationResult mergesOf(SObjectType objectType) { + return this.getOperationResult(OperationType.MERGE_DML, objectType); + } + public OperationResult eventsOf(SObjectType objectType) { return this.getOperationResult(OperationType.PUBLISH_DML, objectType); } @@ -998,7 +1002,7 @@ global inherited sharing class DML implements Commitable { return recordsToProcess; } - private virtual void mergeWith(DmlCommand command) { + private virtual void combine(DmlCommand command) { // TODO: Avoid duplicate records based on Id when specified this.enhancedRecords.addAll(command.enhancedRecords); this.recalculate(); @@ -1129,35 +1133,22 @@ global inherited sharing class DML implements Commitable { } private inherited sharing class MergeCommand extends DmlCommand { - private SObject mergeToRecord; private List duplicateRecords = new List(); - public MergeCommand(Record mergeToRecord, SObject duplicateRecord) { - super(mergeToRecord); - this.duplicateRecords.add(duplicateRecord); - } - - public MergeCommand(Record mergeToRecord, List duplicateRecords) { + public MergeCommand(Record mergeToRecord, Record duplicateRecord) { super(mergeToRecord); - this.duplicateRecords.addAll(duplicateRecords); - } - - public MergeCommand(Record mergeToRecord, Id duplicateRecordId) { - this(mergeToRecord, mergeToRecord.get().getSObjectType().newSObject(duplicateRecordId)); + this.duplicateRecords.add(duplicateRecord.get().getRecord()); } - public MergeCommand(Record mergeToRecord, Iterable duplicateRecordIds) { + public MergeCommand(Record mergeToRecord, Records duplicateRecords) { super(mergeToRecord); - - SObjectType objectType = mergeToRecord.get().getSObjectType(); - - for (Id duplicateRecordId : duplicateRecordIds) { - this.duplicateRecords.add(objectType.newSObject(duplicateRecordId)); + for (Record duplicateRecord : duplicateRecords.get()) { + this.duplicateRecords.add(duplicateRecord.get().getRecord()); } } protected override String hashCode() { - return super.hashCode() + '_' + this.mergeToRecord.Id; + return super.hashCode() + '_' + this.enhancedRecords[0].getRecordId(); } protected override OperationType getOperationType() { @@ -1169,16 +1160,19 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (String.isBlank(enhancedRecord.getRecordId())) { + if (enhancedRecord.getRecordId() == null) { throw new DmlException('Only existing records can be merged.'); } } protected override List executeDml(List recordsToProcess) { - return Database.merge(this.mergeToRecord, recordsToProcess, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); + return Database.merge(recordsToProcess[0], this.duplicateRecords, this.globalConfiguration.allOrNone, this.globalConfiguration.accessMode); } protected override RecordSummary executeMockedDml(SObject record) { + if (this.duplicateRecords.isEmpty()) { + throw new DmlException('A single merge request must contain at least 1 records to merge, got none'); + } return new RecordSummary().isSuccess(true).recordId(record.Id); } } @@ -1579,6 +1573,10 @@ global inherited sharing class DML implements Commitable { return this.thenMockDml(OperationType.UNDELETE_DML); } + public Mockable allMerges() { + return this.thenMockDml(OperationType.MERGE_DML); + } + public Mockable allPublishes() { return this.thenMockDml(OperationType.PUBLISH_DML); } @@ -1608,6 +1606,10 @@ global inherited sharing class DML implements Commitable { return this.thenMockDmlFor(OperationType.UNDELETE_DML, objectType); } + public Mockable mergesFor(SObjectType objectType) { + return this.thenMockDmlFor(OperationType.MERGE_DML, objectType); + } + public Mockable publishesFor(SObjectType objectType) { return this.thenMockDmlFor(OperationType.PUBLISH_DML, objectType); } @@ -1640,6 +1642,10 @@ global inherited sharing class DML implements Commitable { return this.thenExceptionOn(OperationType.UNDELETE_DML); } + public Mockable exceptionOnMerges() { + return this.thenExceptionOn(OperationType.MERGE_DML); + } + public Mockable exceptionOnPublishes() { return this.thenExceptionOn(OperationType.PUBLISH_DML); } @@ -1669,6 +1675,10 @@ global inherited sharing class DML implements Commitable { return this.thenExceptionOnFor(OperationType.UNDELETE_DML, objectType); } + public Mockable exceptionOnMergesFor(SObjectType objectType) { + return this.thenExceptionOnFor(OperationType.MERGE_DML, objectType); + } + public Mockable exceptionOnPublishesFor(SObjectType objectType) { return this.thenExceptionOnFor(OperationType.PUBLISH_DML, objectType); } diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index 4fd64f9..4839e53 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -796,6 +796,79 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } + @IsTest + static void toInsertWithEmptyRecords() { + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toInsert(new List()) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be inserted to the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Inserted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Inserted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Inserted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Inserted operation result should contain insert type.'); + Assert.isFalse(operationResult.hasFailures(), 'Inserted operation result should not have failures.'); + } + + @IsTest + static void toInsertWithEmptyRecordWhenMocking() { + // Setup + DML.mock('dmlMockId').allInserts(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toInsert(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be inserted to the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 1 result.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Inserted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Inserted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Inserted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Inserted operation result should contain insert type.'); + Assert.isFalse(operationResult.hasFailures(), 'Inserted operation result should not have failures.'); + } + // UPDATE @IsTest @@ -1516,6 +1589,79 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } + @IsTest + static void toUpdateWithEmptyRecords() { + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toUpdate(new List()) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be updated in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(1, result.updates().size(), 'Updated operation result should contain 1 result.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Updated operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Updated operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Updated operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Updated operation result should contain update type.'); + Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); + } + + @IsTest + static void toUpdateWithEmptyRecordsWhenMocking() { + // Setup + DML.mock('dmlMockId').allUpdates(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toUpdate(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be updated in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(1, result.updates().size(), 'Updated operation result should contain 1 result.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Updated operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Updated operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Updated operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Updated operation result should contain update type.'); + Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); + } + // UPSERT @IsTest @@ -2216,6 +2362,79 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } + @IsTest + static void toUpsertWithEmptyRecords() { + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toUpsert(new List()) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be upserted in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(1, result.upserts().size(), 'Upserted operation result should contain 1 result.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Upserted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Upserted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Upserted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Upserted operation result should contain upsert type.'); + Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); + } + + @IsTest + static void toUpsertWithEmptyRecordsWhenMocking() { + // Setup + DML.mock('dmlMockId').allUpserts(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toUpsert(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be upserted in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(1, result.upserts().size(), 'Upserted operation result should contain 1 result.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Upserted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Upserted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Upserted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Upserted operation result should contain upsert type.'); + Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); + } + // DELETE @IsTest @@ -3172,6 +3391,79 @@ private class DML_Test { // No assertion with ALL ROWS, because there is Salesforce error } + @IsTest + static void toDeleteWithEmptyRecords() { + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toDelete(new List()) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be deleted from the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Deleted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Deleted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); + } + + @IsTest + static void toDeleteWithEmptyRecordsWhenMocking() { + // Setup + DML.mock('dmlMockId').allDeletes(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toDelete(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be deleted from the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(1, result.deletes().size(), 'Deleted operation result should contain 1 result.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Deleted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Deleted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); + } + // UNDELETE @IsTest @@ -3857,32 +4149,714 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } - // PLATFORM EVENT - @IsTest - static void toPublishSingleRecord() { - // Setup - FlowOrchestrationEvent event = new FlowOrchestrationEvent(); - + static void toUndeleteWithEmptyRecords() { // Test Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + DML.Result result = new DML() - .toPublish(event) + .toUndelete(new List()) .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); Test.stopTest(); // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be undeleted in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); - Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); - Assert.areEqual(1, result.events().size(), 'Published operation result should contain 1 result.'); - + Assert.areEqual(1, result.undeletes().size(), 'Undeleted operation result should contain 1 result.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); - DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); - Assert.areEqual(1, operationResult.records().size(), 'Published operation result should contain the published record.'); + Assert.areEqual(0, operationResult.records().size(), 'Undeleted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Undeleted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Undeleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Undeleted operation result should contain undelete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Undeleted operation result should not have failures.'); + } + + @IsTest + static void toUndeleteWithEmptyRecordsWhenMocking() { + // Setup + DML.mock('dmlMockId').allUndeletes(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toUndelete(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be undeleted in the database.'); + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(1, result.undeletes().size(), 'Undeleted operation result should contain 1 result.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Undeleted operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Undeleted operation result should contain 0 record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Undeleted operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Undeleted operation result should contain undelete type.'); + Assert.isFalse(operationResult.hasFailures(), 'Undeleted operation result should not have failures.'); + } + + // MERGE + + @IsTest + static void toMergeSingleDuplicateRecord() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + insert new List{ masterAccount, duplicateAccount }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + Assert.areEqual(masterAccount.Id, [SELECT Id FROM Account LIMIT 1].Id, 'Master account should survive the merge.'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeSingleDuplicateRecordById() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert new List{ masterAccount, duplicateAccount }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterAccount, duplicateAccount.Id) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + Assert.areEqual(masterAccount.Id, [SELECT Id FROM Account LIMIT 1].Id, 'Master account should survive the merge.'); + + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeMultipleDuplicateRecords() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount1 = getAccount(2); + Account duplicateAccount2 = getAccount(3); + insert new List{ masterAccount, duplicateAccount1, duplicateAccount2 }; + + List duplicateAccounts = new List{ duplicateAccount1, duplicateAccount2 }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterAccount, duplicateAccounts) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + Assert.areEqual(masterAccount.Id, [SELECT Id FROM Account LIMIT 1].Id, 'Master account should survive the merge.'); + + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeMultipleDuplicateRecordsById() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount1 = getAccount(2); + Account duplicateAccount2 = getAccount(3); + insert new List{ masterAccount, duplicateAccount1, duplicateAccount2 }; + + Set duplicateAccountIds = new Set{ duplicateAccount1.Id, duplicateAccount2.Id }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterAccount, duplicateAccountIds) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + Assert.areEqual(masterAccount.Id, [SELECT Id FROM Account LIMIT 1].Id, 'Master account should survive the merge.'); + + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeWithRelatedRecords() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert new List{ masterAccount, duplicateAccount }; + + Contact contactOnDuplicate = getContact(1); + contactOnDuplicate.AccountId = duplicateAccount.Id; + insert contactOnDuplicate; + + // Test + Test.startTest(); + new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Contact should still exist.'); + + Contact reparentedContact = [SELECT Id, AccountId FROM Contact WHERE Id = :contactOnDuplicate.Id]; + Assert.areEqual(masterAccount.Id, reparentedContact.AccountId, 'Contact should be reparented to master account.'); + } + + @IsTest + static void toMergeWithoutExistingMasterRecord() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert duplicateAccount; + + DmlException expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); + } catch (DmlException e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Only existing records can be merged.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toMergeWithoutExistingDuplicateRecord() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert masterAccount; + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toMergeWithUserMode() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert new List{ masterAccount, duplicateAccount }; + + Exception expectedException = null; + + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + try { + new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); // user mode by default + } catch (Exception e) { + expectedException = e; + } + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toMergeWithSystemMode() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert new List{ masterAccount, duplicateAccount }; + + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + new DML() + .toMerge(masterAccount, duplicateAccount) + .systemMode() + .withoutSharing() + .commitWork(); + } + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only master account should remain after merge.'); + } + + @IsTest + static void toMergeLeads() { + // Setup + Lead masterLead = getLead(1); + Lead duplicateLead = getLead(2); + insert new List{ masterLead, duplicateLead }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterLead, duplicateLead) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Lead], 'Only master lead should remain after merge.'); + Assert.areEqual(masterLead.Id, [SELECT Id FROM Lead LIMIT 1].Id, 'Master lead should survive the merge.'); + + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.mergesOf(Lead.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Lead.SObjectType, operationResult.objectType(), 'Merged operation result should contain Lead object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeContacts() { + // Setup + Contact masterContact = getContact(1); + Contact duplicateContact = getContact(2); + insert new List{ masterContact, duplicateContact }; + + // Test + Test.startTest(); + DML.Result result = new DML() + .toMerge(masterContact, duplicateContact) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Only master contact should remain after merge.'); + Assert.areEqual(masterContact.Id, [SELECT Id FROM Contact LIMIT 1].Id, 'Master contact should survive the merge.'); + + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.mergesOf(Contact.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(Contact.SObjectType, operationResult.objectType(), 'Merged operation result should contain Contact object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Merged operation result should contain merge type.'); + Assert.isFalse(operationResult.hasFailures(), 'Merged operation result should not have failures.'); + } + + @IsTest + static void toMergeSingleRecordWithMocking() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').allMerges(); + + // Test + Test.startTest(); + new DML() + .toMerge(masterAccount, duplicateAccount) + .identifier('dmlMockId') + .commitWork(); + Test.stopTest(); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be in the database.'); + Assert.areEqual(1, result.merges().size(), 'Merged operation result should contain 1 result.'); + Assert.areEqual(Account.SObjectType, result.mergesOf(Account.SObjectType).objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, result.mergesOf(Account.SObjectType).operationType(), 'Merged operation result should contain merge type.'); + Assert.areEqual(1, result.mergesOf(Account.SObjectType).records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, result.mergesOf(Account.SObjectType).recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.isTrue(result.mergesOf(Account.SObjectType).recordResults()[0].isSuccess(), 'Merged operation result should contain a successful record result.'); + Assert.isNotNull(result.mergesOf(Account.SObjectType).recordResults()[0].id(), 'Merged operation result should contain a mocked record Id.'); + } + + @IsTest + static void toMergeMultipleRecordTypesWithMocking() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + Lead masterLead = getLead(1); + Lead duplicateLead = getLead(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + masterLead.Id = DML.randomIdGenerator.get(Lead.SObjectType); + duplicateLead.Id = DML.randomIdGenerator.get(Lead.SObjectType); + + DML.mock('dmlMockId').allMerges(); + + // Test + Test.startTest(); + new DML() + .toMerge(masterAccount, duplicateAccount) + .toMerge(masterLead, duplicateLead) + .identifier('dmlMockId') + .commitWork(); + Test.stopTest(); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No Account records should be in the database.'); + Assert.areEqual(0, [SELECT COUNT() FROM Lead], 'No Lead records should be in the database.'); + Assert.areEqual(2, result.merges().size(), 'Merged operation result should contain 2 results.'); + Assert.areEqual(Account.SObjectType, result.mergesOf(Account.SObjectType).objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(Lead.SObjectType, result.mergesOf(Lead.SObjectType).objectType(), 'Merged operation result should contain Lead object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, result.mergesOf(Account.SObjectType).operationType(), 'Merged operation result should contain merge type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, result.mergesOf(Lead.SObjectType).operationType(), 'Merged operation result should contain merge type.'); + Assert.areEqual(1, result.mergesOf(Account.SObjectType).records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, result.mergesOf(Account.SObjectType).recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.areEqual(1, result.mergesOf(Lead.SObjectType).records().size(), 'Merged operation result should contain the merged record.'); + Assert.areEqual(1, result.mergesOf(Lead.SObjectType).recordResults().size(), 'Merged operation result should contain the merged record results.'); + Assert.isTrue(result.mergesOf(Account.SObjectType).recordResults()[0].isSuccess(), 'Merged operation result should contain a successful record result.'); + Assert.isTrue(result.mergesOf(Lead.SObjectType).recordResults()[0].isSuccess(), 'Merged operation result should contain a successful record result.'); + } + + @IsTest + static void toMergeWithMockingSpecificSObjectType() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + insert new List{ masterAccount, duplicateAccount }; + + Lead masterLead = getLead(1); + Lead duplicateLead = getLead(2); + + masterLead.Id = DML.randomIdGenerator.get(Lead.SObjectType); + duplicateLead.Id = DML.randomIdGenerator.get(Lead.SObjectType); + + DML.mock('dmlMockId').mergesFor(Lead.SObjectType); + + // Test + Test.startTest(); + new DML() + .toMerge(masterAccount, duplicateAccount) + .toMerge(masterLead, duplicateLead) + .identifier('dmlMockId') + .commitWork(); + Test.stopTest(); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account merge should not be mocked.'); + Assert.areEqual(0, [SELECT COUNT() FROM Lead], 'Lead merge should be mocked.'); + Assert.areEqual(2, result.merges().size(), 'Merged operation result should contain 2 results.'); + Assert.areEqual(Account.SObjectType, result.mergesOf(Account.SObjectType).objectType(), 'Merged operation result should contain Account object type.'); + Assert.areEqual(Lead.SObjectType, result.mergesOf(Lead.SObjectType).objectType(), 'Merged operation result should contain Lead object type.'); + } + + @IsTest + static void toMergeWithMockingException() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnMerges(); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toMerge(masterAccount, duplicateAccount) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toMergeWithMockingExceptionWhenAllOrNoneIsSet() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnMerges(); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toMerge(masterAccount, duplicateAccount) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Result should contain merge type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toMergeWithMockingExceptionForSpecificSObjectType() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnMergesFor(Account.SObjectType); + + Exception expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toMerge(masterAccount, duplicateAccount) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + } + + @IsTest + static void toMergeWithMockingExceptionForSpecificSObjectTypeWhenAllOrNoneIsSet() { + // Setup + Account masterAccount = getAccount(1); + Account duplicateAccount = getAccount(2); + + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + duplicateAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').exceptionOnMergesFor(Account.SObjectType); + + Exception expectedException = null; + DML.Result result = null; + + // Test + Test.startTest(); + try { + result = new DML() + .toMerge(masterAccount, duplicateAccount) + .allowPartialSuccess() + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNull(expectedException, 'Expected exception to not be thrown.'); + + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Result should contain 1 record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Result should contain 1 record result.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Result should contain Account object type.'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Result should contain merge type.'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Result should contain a failed record result.'); + Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); + } + + @IsTest + static void toMergeWithEmptyRecords() { + // Setup + Account masterAccount = getAccount(1); + insert masterAccount; + + // Test + Test.startTest(); + Exception expectedException = null; + + try { + new DML() + .toMerge(masterAccount, new List()) + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least 1 records to merge, got none'), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toMergeWithEmptyRecordsWhenMocking() { + // Setup + Account masterAccount = getAccount(1); + masterAccount.Id = DML.randomIdGenerator.get(Account.SObjectType); + + DML.mock('dmlMockId').allMerges(); + + // Test + Test.startTest(); + Exception expectedException = null; + + try { + new DML() + .toMerge(masterAccount, new List()) + .identifier('dmlMockId') + .commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least 1 records to merge, got none'), 'Expected exception message should be thrown.'); + } + + // PLATFORM EVENT + + @IsTest + static void toPublishSingleRecord() { + // Setup + FlowOrchestrationEvent event = new FlowOrchestrationEvent(); + + // Test + Test.startTest(); + DML.Result result = new DML() + .toPublish(event) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(1, result.events().size(), 'Published operation result should contain 1 result.'); + + + DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Published operation result should contain the published record.'); Assert.areEqual(1, operationResult.recordResults().size(), 'Published operation result should contain the published record results.'); Assert.areEqual(FlowOrchestrationEvent.SObjectType, operationResult.objectType(), 'Published operation result should contain FlowOrchestrationEvent object type.'); Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Published operation result should contain publish type.'); @@ -4177,6 +5151,77 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } + @IsTest + static void toPublishWithEmptyRecords() { + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toPublish(new List()) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(1, result.events().size(), 'Published operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Published operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Published operation result should contain 0 record results.'); + Assert.areEqual(FlowOrchestrationEvent.SObjectType, operationResult.objectType(), 'Published operation result should contain FlowOrchestrationEvent object type.'); + Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Published operation result should contain publish type.'); + Assert.isFalse(operationResult.hasFailures(), 'Published operation result should not have failures.'); + } + + @IsTest + static void toPublishWithEmptyRecordsWhenMocking() { + // Setup + DML.mock('dmlMockId').allPublishes(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + new DML() + .toPublish(new List()) + .identifier('dmlMockId') + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + DML.Result result = DML.retrieveResultFor('dmlMockId'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(1, result.events().size(), 'Published operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.eventsOf(FlowOrchestrationEvent.SObjectType); + + Assert.areEqual(0, operationResult.records().size(), 'Published operation result should contain 0 records.'); + Assert.areEqual(0, operationResult.recordResults().size(), 'Published operation result should contain 0 record results.'); + Assert.areEqual(FlowOrchestrationEvent.SObjectType, operationResult.objectType(), 'Published operation result should contain FlowOrchestrationEvent object type.'); + Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Published operation result should contain publish type.'); + Assert.isFalse(operationResult.hasFailures(), 'Published operation result should not have failures.'); + } + // DEBUG @IsTest From 284186ca64d3087ca71cdc6dc90720dee53e171a Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 11:32:51 +0100 Subject: [PATCH 10/22] Dedupling registration --- force-app/main/default/classes/DML.cls | 65 ++++++++++++++++-- force-app/main/default/classes/DML_Test.cls | 73 +++++++++++++++++++++ todo | 3 +- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index d89974d..92a5495 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -132,6 +132,7 @@ global inherited sharing class DML implements Commitable { Commitable options(Database.DmlOptions options); Commitable discardWork(); Commitable commitHook(DML.Hook callback); + Commitable onDuplicateCombine(); // Save Result dryRun(); Result commitWork(); @@ -584,6 +585,11 @@ global inherited sharing class DML implements Commitable { return this; } + public Commitable onDuplicateCombine() { + this.configuration.onDuplicateCombine(); + return this; + } + public Commitable options(Database.DmlOptions options) { this.configuration.options(options); return this; @@ -698,7 +704,9 @@ global inherited sharing class DML implements Commitable { return; } - existingCommand.combine(command); + this.configuration.duplicateCombineStrategy.combine(existingCommand, command); + + existingCommand.recalculate(); } private void preview() { @@ -831,7 +839,9 @@ global inherited sharing class DML implements Commitable { private System.AccessLevel accessMode = System.AccessLevel.USER_MODE; private Database.DmlOptions options = new Database.DMLOptions(); private DmlSharing sharingExecutor = new InheritedSharing(); + private DuplicateCombineStrategy duplicateCombineStrategy = new ThrownExceptionDuplicateStrategy(); private Boolean allOrNone = true; + private Boolean mergeOnDuplicate = false; private String dmlIdentifier = null; public Configuration() { @@ -863,6 +873,10 @@ global inherited sharing class DML implements Commitable { this.options.optAllOrNone = false; } + public void onDuplicateCombine() { + this.duplicateCombineStrategy = new MergeDuplicateStrategy(); + } + public void options(Database.DmlOptions options) { this.options = options; this.options.optAllOrNone = this.options.optAllOrNone ?? this.allOrNone ?? true; @@ -1002,12 +1016,6 @@ global inherited sharing class DML implements Commitable { return recordsToProcess; } - private virtual void combine(DmlCommand command) { - // TODO: Avoid duplicate records based on Id when specified - this.enhancedRecords.addAll(command.enhancedRecords); - this.recalculate(); - } - protected abstract OperationType getOperationType(); protected abstract DmlResultAdapter getAdapter(); protected abstract List executeDml(List recordsToProcess); @@ -1378,6 +1386,41 @@ global inherited sharing class DML implements Commitable { .errors(undeleteResult.getErrors()); } } + + private interface DuplicateCombineStrategy { + void combine(DmlCommand mergeToCommand, DmlCommand duplicateCommand); + } + + private class ThrownExceptionDuplicateStrategy implements DuplicateCombineStrategy { + public void combine(DmlCommand mergeToCommand, DmlCommand duplicateCommand) { + for (EnhancedRecord newEnhancedRecord : duplicateCommand.enhancedRecords) { + if (mergeToCommand.enhancedRecords.contains(newEnhancedRecord)) { + throw new DmlException('Duplicate records found during registration. Fix the code or use the mergeOnDuplicate() method.'); + } + } + + mergeToCommand.enhancedRecords.addAll(duplicateCommand.enhancedRecords); + } + } + + private class MergeDuplicateStrategy implements DuplicateCombineStrategy { + public void combine(DmlCommand mergeToCommand, DmlCommand duplicateCommand) { + for (EnhancedRecord newEnhancedRecord : duplicateCommand.enhancedRecords) { + if (mergeToCommand.enhancedRecords.contains(newEnhancedRecord)) { + Integer index = mergeToCommand.enhancedRecords.indexOf(newEnhancedRecord); + + SObject existingRecord = mergeToCommand.enhancedRecords[index].getRecord(); + SObject newRecord = newEnhancedRecord.getRecord(); + + for (String field : newRecord.getPopulatedFieldsAsMap().keySet()) { + existingRecord.put(field, newRecord.get(field)); + } + } else { + mergeToCommand.enhancedRecords.add(newEnhancedRecord); + } + } + } + } private class OperationSummary implements OperationResult { private OperationType type; @@ -1905,6 +1948,14 @@ global inherited sharing class DML implements Commitable { private Id getRecordId() { return this.currentRecord?.Id; } + + private Boolean equals(Object other) { + if (this.getRecordId() == null) { + return false; + } + + return this.getRecordId() == ((EnhancedRecord) other)?.getRecordId(); + } } private class ParentRelationship { diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index 4839e53..8a25191 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -908,6 +908,79 @@ private class DML_Test { Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); } + @IsTest + static void toUpdateSingleRecordTwice() { + // Setup + Account account1 = getAccount(1); + insert account1; + + // Test + Test.startTest(); + Exception expectedException = null; + + try { + account1.Name = 'Updated Test Account'; + + DML db = new DML(); + + db.toUpdate(account1); + + Account duplicatedAccount = new Account(Id = account1.Id, Name = 'Updated Test Account 2'); + + db.toUpdate(duplicatedAccount); + + db.commitWork(); + } catch (Exception e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Duplicate records found during registration. Fix the code or use the mergeOnDuplicate() method.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toUpdateSingleRecordTwiceWithMergeOnDuplicate() { + // Setup + Account account1 = getAccount(1); + insert account1; + + // Test + Test.startTest(); + account1.Name = 'Updated Test Account'; + account1.Website = 'https://www.updatedtestaccount.com'; + + DML db = new DML(); + + db.onDuplicateCombine(); + + db.toUpdate(account1); + + Account duplicatedAccount = new Account(Id = account1.Id, Name = 'Updated Test Account 2', Description = 'Updated Test Description'); + + db.toUpdate(duplicatedAccount); + + DML.Result result = db.commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); + + Assert.areEqual(1, result.updates().size(), 'Updated operation result should contain 1 result.'); + + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.areEqual(1, operationResult.records().size(), 'Updated operation result should contain the updated record.'); + Assert.areEqual(1, operationResult.recordResults().size(), 'Updated operation result should contain the updated record results.'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Updated operation result should contain Account object type.'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Updated operation result should contain update type.'); + Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); + Assert.areEqual('Updated Test Account 2', account1.Name, 'Account should be updated.'); + Assert.areEqual('https://www.updatedtestaccount.com', account1.Website, 'Account should be updated.'); + Assert.areEqual('Updated Test Description', account1.Description, 'Account should be updated.'); + } + @IsTest static void updateImmediatelySingleRecord() { // Setup diff --git a/todo b/todo index e3348ff..9777ade 100644 --- a/todo +++ b/todo @@ -100,7 +100,7 @@ class UowResult { - [x] Clear internal state after commitWork() — prevent re-commit of same operations. - [ ] Query operations — query(), countQuery(), getQueryLocator(), getCursor() with same access/sharing mode. - [ ] Connector to SOQL Lib — inject selectors for reads; honor chosen sharing mode. -- [ ] Dedupling registration — when records with the same Id are registered and modify the same field: policy LAST_WINS | THROW; track warnings when deduping occurs. +- [x] Dedupling registration — when records with the same Id are registered and modify the same field: policy LAST_WINS | THROW; track warnings when deduping occurs. - [ ] Observability hooks — uow.withLogger(ILogger) to emit timings, limits, retries, per-op summaries; keep lightweight to avoid CPU spikes. - [ ] DML optimization - [ ] Minimize DML statements — batch same-type operations. @@ -262,6 +262,7 @@ Status | Priority | Feature | Effort | Impact | | [ ] | 🟡 Important | Per-Operation Overrides | High | High | | [ ] | 🟡 Important | Email Integration | Medium | Medium | | [ ] | 🟡 Important | Custom Work (IDoWork) | Low | Medium | +| [x] | 🟢 Nice | Dedupling registration | Medium | Medium | | [ ] | 🟢 Nice | Better Mock API with Errors | Medium | Medium | | [ ] | 🟢 Nice | Retry Strategy | High | Medium | | [ ] | 🟢 Nice | Chunk Size | Medium | Medium | From f28d442c7df2ede0989d5b91aaaee208550e23a5 Mon Sep 17 00:00:00 2001 From: Maciej Ptak <0ptaq0@gmail.com> Date: Sun, 21 Dec 2025 13:25:50 +0100 Subject: [PATCH 11/22] fixed sfdx-project.json Signed-off-by: Maciej Ptak <0ptaq0@gmail.com> --- sfdx-project.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sfdx-project.json b/sfdx-project.json index bdf385f..c893893 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -7,10 +7,6 @@ "default": true, "package": "DML Lib", "versionDescription": "" - }, - { - "path": "drafts", - "default": false } ], "name": "dml-lib", From 7f76559d2dd6a096b09040854dca93f73744abeb Mon Sep 17 00:00:00 2001 From: Maciej Ptak <0ptaq0@gmail.com> Date: Sun, 21 Dec 2025 13:30:21 +0100 Subject: [PATCH 12/22] Show error message for CI pipeline Signed-off-by: Maciej Ptak <0ptaq0@gmail.com> --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0efd31..6b9fa93 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,12 +26,12 @@ jobs: - name: Authorize with the dev hub run: | - echo "${{ secrets.SFDX_AUTH_URL_DEVHUB }}" | sf org login sfdx-url --alias DevHub --set-default-dev-hub --sfdx-url-stdin > /dev/null 2>&1 + echo "${{ secrets.SFDX_AUTH_URL_DEVHUB }}" | sf org login sfdx-url --alias DevHub --set-default-dev-hub --sfdx-url-stdin echo "✓ Successfully authenticated with Dev Hub" - name: Create scratch org run: | - sf org create scratch --definition-file config/project-scratch-def.json --alias ScratchOrg --wait 30 --duration-days 1 --set-default > /dev/null 2>&1 + sf org create scratch --definition-file config/project-scratch-def.json --alias ScratchOrg --wait 30 --duration-days 1 --set-default echo "✓ Successfully created scratch org" - name: Push source @@ -50,5 +50,5 @@ jobs: - name: Delete scratch org if: always() run: | - sf org delete scratch --target-org ScratchOrg --no-prompt > /dev/null 2>&1 + sf org delete scratch --target-org ScratchOrg --no-prompt || true echo "✓ Scratch org cleanup completed" From 29320cd28d93998bbfc37b9fb602590c0121e275 Mon Sep 17 00:00:00 2001 From: Maciej Ptak <0ptaq0@gmail.com> Date: Sun, 21 Dec 2025 15:03:50 +0100 Subject: [PATCH 13/22] Use template ci file Signed-off-by: Maciej Ptak <0ptaq0@gmail.com> --- .github/workflows/ci.yml | 55 ++++++++-------------------------------- 1 file changed, 11 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b9fa93..1ec5b8b 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,50 +5,17 @@ on: branches: [ main ] pull_request: branches: [ main ] - workflow_dispatch: jobs: - deploy-to-scratch-and-run-tests: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install Salesforce CLI with NPM - run: | - npm install @salesforce/cli --global - - - name: Authorize with the dev hub - run: | - echo "${{ secrets.SFDX_AUTH_URL_DEVHUB }}" | sf org login sfdx-url --alias DevHub --set-default-dev-hub --sfdx-url-stdin - echo "✓ Successfully authenticated with Dev Hub" - - - name: Create scratch org - run: | - sf org create scratch --definition-file config/project-scratch-def.json --alias ScratchOrg --wait 30 --duration-days 1 --set-default - echo "✓ Successfully created scratch org" - - - name: Push source - run: sf project deploy start --target-org ScratchOrg --wait 30 - - - name: Run tests - run: sf apex run test --target-org ScratchOrg --code-coverage --result-format human --output-dir ./tests/apex --test-level RunLocalTests --wait 30 - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: beyond-the-cloud-dev/dml-lib - flags: Apex - - - name: Delete scratch org - if: always() - run: | - sf org delete scratch --target-org ScratchOrg --no-prompt || true - echo "✓ Scratch org cleanup completed" + salesforce-ci: + uses: beyond-the-cloud-dev/cicd-template/.github/workflows/salesforce-ci.yml@main + with: + node-version: '20' + scratch-org-duration: 1 + test-level: 'RunLocalTests' + upload-to-codecov: true + codecov-slug: ${{ github.repository }} # Automatically uses current repository + secrets: + SFDX_AUTH_URL_DEVHUB: ${{ secrets.SFDX_AUTH_URL_DEVHUB }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From ecddcbf8d6790418e63e621b482b6f33500c9d4d Mon Sep 17 00:00:00 2001 From: Maciej Ptak <0ptaq0@gmail.com> Date: Sun, 21 Dec 2025 15:41:42 +0100 Subject: [PATCH 14/22] Update CI configuration to specify latest Salesforce CLI version Signed-off-by: Maciej Ptak <0ptaq0@gmail.com> --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ec5b8b..2f16998 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: uses: beyond-the-cloud-dev/cicd-template/.github/workflows/salesforce-ci.yml@main with: node-version: '20' + sf-cli-version: 'latest' scratch-org-duration: 1 test-level: 'RunLocalTests' upload-to-codecov: true From 04d6f675e3e2cb0cfd50dcec59a6148040d0be6b Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 18:30:04 +0100 Subject: [PATCH 15/22] Refactoring --- force-app/main/default/classes/DML.cls | 67 ++++++++++----------- force-app/main/default/classes/DML_Test.cls | 62 ++++++++++--------- 2 files changed, 65 insertions(+), 64 deletions(-) diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index 92a5495..a9ca737 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -5,7 +5,7 @@ * v2.0.0 * * PMD False Positives: - * - MethodNamingConventions - Some methods are uppercase to indicate that they are "constructors" of othere internal classes + * - MethodNamingConventions - Some methods are uppercase to indicate that they are "constructors" of other internal classes * - CyclomaticComplexity: It is a library and we tried to put everything into ONE class * - CognitiveComplexity: It is a library and we tried to put everything into ONE class * - ExcessivePublicCount: It is a library and we tried to put everything into ONE class @@ -731,7 +731,7 @@ global inherited sharing class DML implements Commitable { private List getSortedCommands(OperationType operationType) { List operationCommands = this.commandsByOperationType.get(operationType); - operationCommands.sort(new DmlCommandComperator(this.getSObjectTypesExecutionOrder(operationType))); + operationCommands.sort(new DmlCommandComparator(this.getSObjectTypesExecutionOrder(operationType))); return operationCommands; } @@ -841,7 +841,6 @@ global inherited sharing class DML implements Commitable { private DmlSharing sharingExecutor = new InheritedSharing(); private DuplicateCombineStrategy duplicateCombineStrategy = new ThrownExceptionDuplicateStrategy(); private Boolean allOrNone = true; - private Boolean mergeOnDuplicate = false; private String dmlIdentifier = null; public Configuration() { @@ -899,18 +898,15 @@ global inherited sharing class DML implements Commitable { } } - private class DmlCommandComperator implements System.Comparator { + private class DmlCommandComparator implements System.Comparator { private List sobjectExecutionOrder; - public DmlCommandComperator(List sobjectExecutionOrder) { + public DmlCommandComparator(List sobjectExecutionOrder) { this.sobjectExecutionOrder = sobjectExecutionOrder; } public Integer compare(DmlCommand a, DmlCommand b) { - SObjectType aObjectType = a.getObjectType(); - SObjectType bObjectType = b.getObjectType(); - - return this.sobjectExecutionOrder.indexOf(aObjectType) - this.sobjectExecutionOrder.indexOf(bObjectType); + return this.sobjectExecutionOrder.indexOf(a.getObjectType()) - this.sobjectExecutionOrder.indexOf(b.getObjectType()); } } @@ -1050,7 +1046,7 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (String.isNotBlank(enhancedRecord.getRecordId())) { + if (enhancedRecord.hasId()) { throw new DmlException('Only new records can be registered as new.'); } } @@ -1067,6 +1063,7 @@ global inherited sharing class DML implements Commitable { private inherited sharing class UpsertCommand extends DmlCommand { private SObjectField externalIdField; + public UpsertCommand(Record record) { super(record); } @@ -1081,7 +1078,8 @@ global inherited sharing class DML implements Commitable { } protected override String hashCode() { - return super.hashCode() + '_' + this.externalIdField ?? 'Id'; + String externalId = this.externalIdField == null ? 'Id' : this.externalIdField.toString(); + return super.hashCode() + '_' + externalId; } protected override OperationType getOperationType() { @@ -1126,7 +1124,7 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (String.isBlank(enhancedRecord.getRecordId())) { + if (!enhancedRecord.hasId()) { throw new DmlException('Only existing records can be updated.'); } } @@ -1145,11 +1143,13 @@ global inherited sharing class DML implements Commitable { public MergeCommand(Record mergeToRecord, Record duplicateRecord) { super(mergeToRecord); + this.duplicateRecords.add(duplicateRecord.get().getRecord()); } public MergeCommand(Record mergeToRecord, Records duplicateRecords) { super(mergeToRecord); + for (Record duplicateRecord : duplicateRecords.get()) { this.duplicateRecords.add(duplicateRecord.get().getRecord()); } @@ -1168,7 +1168,7 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (enhancedRecord.getRecordId() == null) { + if (!enhancedRecord.hasId()) { throw new DmlException('Only existing records can be merged.'); } } @@ -1179,7 +1179,7 @@ global inherited sharing class DML implements Commitable { protected override RecordSummary executeMockedDml(SObject record) { if (this.duplicateRecords.isEmpty()) { - throw new DmlException('A single merge request must contain at least 1 records to merge, got none'); + throw new DmlException('A single merge request must contain at least one record to merge, got none.'); } return new RecordSummary().isSuccess(true).recordId(record.Id); } @@ -1214,7 +1214,7 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (String.isBlank(enhancedRecord.getRecordId())) { + if (!enhancedRecord.hasId()) { throw new DmlException('Only existing records can be registered as deleted.'); } } @@ -1252,7 +1252,7 @@ global inherited sharing class DML implements Commitable { } protected override void validate(EnhancedRecord enhancedRecord) { - if (String.isBlank(enhancedRecord.getRecordId())) { + if (!enhancedRecord.hasId()) { throw new DmlException('Only deleted records can be undeleted.'); } } @@ -1395,7 +1395,7 @@ global inherited sharing class DML implements Commitable { public void combine(DmlCommand mergeToCommand, DmlCommand duplicateCommand) { for (EnhancedRecord newEnhancedRecord : duplicateCommand.enhancedRecords) { if (mergeToCommand.enhancedRecords.contains(newEnhancedRecord)) { - throw new DmlException('Duplicate records found during registration. Fix the code or use the mergeOnDuplicate() method.'); + throw new DmlException('Duplicate records found during registration. Fix the code or use the onDuplicateCombine() method.'); } } @@ -1753,16 +1753,17 @@ global inherited sharing class DML implements Commitable { private List getMockedRecordErrors(DmlCommand command, List recordsToProcess) { List recordResults = new List(); + String errorMessage = 'Exception thrown for ' + command.getOperationType() + ' operation.'; for (SObject record : recordsToProcess) { - RecordProcessingError error = new RecordProcessingError() - .setMessage('Exception thrown for ' + command.getOperationType() + ' operation.') - .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) - .setFields(new List { 'Id' }); - RecordSummary recordSummary = new RecordSummary() .isSuccess(false) - .error(error); + .error( + new RecordProcessingError() + .setMessage(errorMessage) + .setStatusCode(System.StatusCode.ALREADY_IN_PROCESS) + .setFields(new List { 'Id' }) + ); recordResults.add(recordSummary); } @@ -1797,10 +1798,8 @@ global inherited sharing class DML implements Commitable { } private DmlRecords(Iterable recordIds) { - SObjectType objectType = recordIds.iterator().next()?.getSObjectType(); - for (Id recordId : recordIds) { - this.recordsToProcess.add(objectType.newSObject(recordId)); + this.recordsToProcess.add(recordId.getSObjectType().newSObject(recordId)); } } @@ -1937,19 +1936,19 @@ global inherited sharing class DML implements Commitable { return this.currentRecord?.getSObjectType(); } - private Boolean doesRecordHaveIdSpecified() { - return String.isNotBlank(this.getRecordId()); - } - private SObject getRecord() { return this.currentRecord; } + private Boolean hasId() { + return this.currentRecord?.Id != null; + } + private Id getRecordId() { return this.currentRecord?.Id; } - private Boolean equals(Object other) { + public Boolean equals(Object other) { if (this.getRecordId() == null) { return false; } @@ -2014,10 +2013,8 @@ global inherited sharing class DML implements Commitable { } private void validateExternalIdField(SObjectField relationshipField, SObjectField externalIdField) { - Boolean externalIdFieldIsValid = externalIdField.getDescribe().isExternalId(); - - if (!externalIdFieldIsValid) { - throw new DmlException('Invalid argument: externalIdField. Field supplied is not a marked as an External Identifier.'); + if (!externalIdField.getDescribe().isExternalId()) { + throw new DmlException('Invalid argument: externalIdField. Field supplied is not marked as an External Identifier.'); } SObjectType relatedObjectType = relationshipField.getDescribe().getReferenceTo()[0]; diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index 8a25191..59d89b1 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -8,7 +8,7 @@ * - CyclomaticComplexity: It is a library and we tried to put everything into ONE class * - CognitiveComplexity: It is a library and we tried to put everything into ONE class * - ApexUnitTestClassShouldHaveRunAs: System.runAs is used to test fls and sharing modes - * - NcssMethodCount: Some methods are longer becasue of amount of assertions + * - NcssMethodCount: Some methods are longer because of amount of assertions * - NcssTypeCount: It is a library and we tried to put everything into ONE class **/ @SuppressWarnings('PMD.CyclomaticComplexity,PMD.CognitiveComplexity,PMD.ApexUnitTestClassShouldHaveRunAs,PMD.NcssMethodCount,PMD.NcssTypeCount') @@ -163,7 +163,7 @@ private class DML_Test { // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Invalid argument: externalIdField. Field supplied is not a marked as an External Identifier.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + Assert.areEqual('Invalid argument: externalIdField. Field supplied is not marked as an External Identifier.', expectedException.getMessage(), 'Expected exception message should be thrown.'); } @IsTest @@ -333,7 +333,7 @@ private class DML_Test { // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Invalid argument: externalIdField. Field supplied is not a marked as an External Identifier.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + Assert.areEqual('Invalid argument: externalIdField. Field supplied is not marked as an External Identifier.', expectedException.getMessage(), 'Expected exception message should be thrown.'); } @IsTest @@ -937,7 +937,7 @@ private class DML_Test { // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Duplicate records found during registration. Fix the code or use the mergeOnDuplicate() method.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + Assert.areEqual('Duplicate records found during registration. Fix the code or use the onDuplicateCombine() method.', expectedException.getMessage(), 'Expected exception message should be thrown.'); } @IsTest @@ -981,6 +981,34 @@ private class DML_Test { Assert.areEqual('Updated Test Description', account1.Description, 'Account should be updated.'); } + @IsTest + static void toUpdateWithEmptyRecordIds() { + // Setup + List recordIds = new List(); + + // Test + Test.startTest(); + Integer dmlStatementsBefore = Limits.getDMLStatements(); + + DML.Result result = new DML() + .toUpdate(DML.Records(recordIds)) + .commitWork(); + + Integer dmlStatementsAfter = Limits.getDMLStatements(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); + + Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); + Assert.areEqual(1, result.updates().size(), 'Updated operation result should contain 1 result.'); + Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); + Assert.areEqual(0, result.undeletes().size(), 'Undeleted operation result should contain 0 results.'); + Assert.areEqual(0, result.merges().size(), 'Merged operation result should contain 0 results.'); + Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); + } + @IsTest static void updateImmediatelySingleRecord() { // Setup @@ -4853,30 +4881,6 @@ private class DML_Test { Assert.isTrue(operationResult.hasFailures(), 'Result should have failures.'); } - @IsTest - static void toMergeWithEmptyRecords() { - // Setup - Account masterAccount = getAccount(1); - insert masterAccount; - - // Test - Test.startTest(); - Exception expectedException = null; - - try { - new DML() - .toMerge(masterAccount, new List()) - .commitWork(); - } catch (Exception e) { - expectedException = e; - } - Test.stopTest(); - - // Verify - Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least 1 records to merge, got none'), 'Expected exception message should be thrown.'); - } - @IsTest static void toMergeWithEmptyRecordsWhenMocking() { // Setup @@ -4901,7 +4905,7 @@ private class DML_Test { // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least 1 records to merge, got none'), 'Expected exception message should be thrown.'); + Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least one record to merge, got none'), 'Expected exception message should be thrown.'); } // PLATFORM EVENT From bd3495903582d7728651b830b16378de8f3e1e38 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 19:22:12 +0100 Subject: [PATCH 16/22] Documentation --- force-app/main/default/classes/DML.cls | 10 +- force-app/main/default/classes/DML_Test.cls | 4 +- website/docs/.vitepress/config.mts | 5 +- website/docs/architecture/registration.md | 105 +++++++++ website/docs/dml/merge.md | 151 +++++++++++++ website/docs/dml/upsert.md | 70 +++++- website/docs/mocking/merge.md | 227 ++++++++++++++++++++ 7 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 website/docs/architecture/registration.md create mode 100644 website/docs/dml/merge.md create mode 100644 website/docs/mocking/merge.md diff --git a/force-app/main/default/classes/DML.cls b/force-app/main/default/classes/DML.cls index a9ca737..ec1488e 100644 --- a/force-app/main/default/classes/DML.cls +++ b/force-app/main/default/classes/DML.cls @@ -132,7 +132,7 @@ global inherited sharing class DML implements Commitable { Commitable options(Database.DmlOptions options); Commitable discardWork(); Commitable commitHook(DML.Hook callback); - Commitable onDuplicateCombine(); + Commitable combineOnDuplicate(); // Save Result dryRun(); Result commitWork(); @@ -585,8 +585,8 @@ global inherited sharing class DML implements Commitable { return this; } - public Commitable onDuplicateCombine() { - this.configuration.onDuplicateCombine(); + public Commitable combineOnDuplicate() { + this.configuration.combineOnDuplicate(); return this; } @@ -872,7 +872,7 @@ global inherited sharing class DML implements Commitable { this.options.optAllOrNone = false; } - public void onDuplicateCombine() { + public void combineOnDuplicate() { this.duplicateCombineStrategy = new MergeDuplicateStrategy(); } @@ -1395,7 +1395,7 @@ global inherited sharing class DML implements Commitable { public void combine(DmlCommand mergeToCommand, DmlCommand duplicateCommand) { for (EnhancedRecord newEnhancedRecord : duplicateCommand.enhancedRecords) { if (mergeToCommand.enhancedRecords.contains(newEnhancedRecord)) { - throw new DmlException('Duplicate records found during registration. Fix the code or use the onDuplicateCombine() method.'); + throw new DmlException('Duplicate records found during registration. Fix the code or use the combineOnDuplicate() method.'); } } diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index 59d89b1..10224e2 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -937,7 +937,7 @@ private class DML_Test { // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Duplicate records found during registration. Fix the code or use the onDuplicateCombine() method.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + Assert.areEqual('Duplicate records found during registration. Fix the code or use the combineOnDuplicate() method.', expectedException.getMessage(), 'Expected exception message should be thrown.'); } @IsTest @@ -953,7 +953,7 @@ private class DML_Test { DML db = new DML(); - db.onDuplicateCombine(); + db.combineOnDuplicate(); db.toUpdate(account1); diff --git a/website/docs/.vitepress/config.mts b/website/docs/.vitepress/config.mts index 0df245a..4c9bd58 100644 --- a/website/docs/.vitepress/config.mts +++ b/website/docs/.vitepress/config.mts @@ -46,6 +46,7 @@ export default defineConfig({ { text: 'Upsert', link: '/dml/upsert' }, { text: 'Delete', link: '/dml/delete' }, { text: 'Undelete', link: '/dml/undelete' }, + { text: 'Merge', link: '/dml/merge' }, { text: 'Publish', link: '/dml/publish' }, { text: 'Result', link: '/result' } ] @@ -59,6 +60,7 @@ export default defineConfig({ { text: 'Upsert', link: '/mocking/upsert' }, { text: 'Delete', link: '/mocking/delete' }, { text: 'Undelete', link: '/mocking/undelete' }, + { text: 'Merge', link: '/mocking/merge' }, { text: 'Publish', link: '/mocking/publish' } ] }, @@ -75,7 +77,8 @@ export default defineConfig({ text: 'Architecture', collapsed: true, items: [ - { text: 'Rollback', link: '/architecture/rollback' } + { text: 'Rollback', link: '/architecture/rollback' }, + { text: 'Registration', link: '/architecture/registration' } ] } ], diff --git a/website/docs/architecture/registration.md b/website/docs/architecture/registration.md new file mode 100644 index 0000000..d5e1854 --- /dev/null +++ b/website/docs/architecture/registration.md @@ -0,0 +1,105 @@ +--- +outline: deep +--- + +# Registration + +## Deduplication Strategy + +When records with the same ID are added to a list and an update is attempted, Salesforce throws the error `System.ListException: Duplicate id in list`. + +**Standard DML** + +```apex +Account account = [SELECT Id, Name FROM Account LIMIT 1]; + +List accountsToUpdate = new List(); + +Account account1 = new Account(Id = account.Id, Name = 'New Account 1', Website = 'mywebsite.com'); +accountsToUpdate.add(account1); + +Account account2 = new Account(Id = account.Id, Name = 'New Account 2'); +accountsToUpdate.add(account2); + +update accountsToUpdate; // Throws: System.ListException: Duplicate id in list +``` + +The same behavior applies in DML Lib. By default, an error will be thrown when the same record is registered multiple times. + +**DML Lib (Default Behavior)** + +```apex +Account account = [SELECT Id, Name FROM Account LIMIT 1]; + +new DML() + .toUpdate(new Account(Id = account.Id, Name = 'New Account 1', Website = 'mywebsite.com')) + .toUpdate(new Account(Id = account.Id, Name = 'New Account 2')) + .commitWork(); // Throws: Duplicate records found during registration +``` + +### combineOnDuplicate + +Use `combineOnDuplicate()` to automatically merge duplicate registrations into a single record. When the same record ID is registered multiple times, field values from later registrations override earlier ones, while preserving fields that are only set in earlier registrations. + +**Signature** + +```apex +Commitable combineOnDuplicate(); +``` + +**Example** + +```apex +Account account = [SELECT Id, Name FROM Account LIMIT 1]; + +new DML() + .combineOnDuplicate() + .toUpdate(new Account(Id = account.Id, Name = 'New Account 1', Website = 'mywebsite.com')) + .toUpdate(new Account(Id = account.Id, Name = 'New Account 2')) + .commitWork(); +``` + +The two records will be merged into one before the DML operation: + +| Field | First Registration | Second Registration | Final Value | +|-------|-------------------|---------------------|-------------| +| Id | account.Id | account.Id | account.Id | +| Name | 'New Account 1' | 'New Account 2' | 'New Account 2' | +| Website | 'mywebsite.com' | - | 'mywebsite.com' | + +Result: `new Account(Id = account.Id, Name = 'New Account 2', Website = 'mywebsite.com')` will be updated. + +## Registration Order + +DML Lib uses Kahn's algorithm (topological sort) to resolve dependencies between records and commit them in the correct order. You can register records in any order — DML Lib will automatically determine the proper execution sequence. + +**Example** + +```apex +Account account = new Account(Name = 'Acme'); +Contact contact = new Contact(LastName = 'Smith'); +Opportunity opportunity = new Opportunity(Name = 'Deal', StageName = 'New', CloseDate = Date.today()); + +new DML() + .toInsert(DML.Record(contact).withRelationship(Contact.AccountId, account)) + .toInsert(DML.Record(opportunity).withRelationship(Opportunity.AccountId, account)) + .toInsert(account) // Registered last, but inserted first + .commitWork(); +``` + +## Minimal DMLs + +DML Lib groups records by SObject type and operation, reducing the number of DML statements to the minimum required. + +**Example** + +```apex +new DML() + .toInsert(new Account(Name = 'Account 1')) + .toInsert(new Account(Name = 'Account 2')) + .toUpsert(new Contact(LastName = 'Smith'), Contact.MyExternalId__c) + .toUpsert(new Contact(LastName = 'Doe'), Contact.MyExternalId__c) + .commitWork(); +``` + +Only 2 DML statements will be executed: one `INSERT` for Accounts, one `UPSERT` for Contacts. Records are automatically bulkified regardless of whether they are registered together or in separate method calls. \ No newline at end of file diff --git a/website/docs/dml/merge.md b/website/docs/dml/merge.md new file mode 100644 index 0000000..30fbb3c --- /dev/null +++ b/website/docs/dml/merge.md @@ -0,0 +1,151 @@ +--- +outline: deep +--- + +# Merge + +Merge duplicate records into a master record. + +**Example** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Account duplicate1 = [SELECT Id FROM Account WHERE ...]; +Account duplicate2 = [SELECT Id FROM Account WHERE ...]; + +new DML() + .toMerge(masterAccount, new List{ duplicate1, duplicate2 }) + .systemMode() + .withoutSharing() + .commitWork(); +``` + +## toMerge + +Register records for merging. The actual DML is executed when `commitWork()` is called. + +**Signature** + +```apex +Commitable toMerge(SObject mergeToRecord, SObject duplicatedRecord); +Commitable toMerge(SObject mergeToRecord, List duplicateRecords); +Commitable toMerge(SObject mergeToRecord, Id duplicatedRecordId); +Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds); +``` + +### Single Duplicate Record + +**Signature** + +```apex +Commitable toMerge(SObject mergeToRecord, SObject duplicatedRecord); +``` + +**Standard DML** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Account duplicateAccount = [SELECT Id FROM Account WHERE ...]; + +Database.merge(masterAccount, duplicateAccount); +``` + +**DML Lib** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Account duplicateAccount = [SELECT Id FROM Account WHERE ...]; + +new DML() + .toMerge(masterAccount, duplicateAccount) + .commitWork(); +``` + +#### By Record ID + +Merge using a duplicate record ID directly. + +**Signature** + +```apex +Commitable toMerge(SObject mergeToRecord, Id duplicatedRecordId); +``` + +**Standard DML** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Id duplicateAccountId = '001xx000003DGbYAAW'; + +Database.merge(masterAccount, duplicateAccountId); +``` + +**DML Lib** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Id duplicateAccountId = '001xx000003DGbYAAW'; + +new DML() + .toMerge(masterAccount, duplicateAccountId) + .commitWork(); +``` + +### Multiple Duplicate Records + +**Signature** + +```apex +Commitable toMerge(SObject mergeToRecord, List duplicateRecords); +Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds); +``` + +**Standard DML** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +List duplicateAccounts = [SELECT Id FROM Account WHERE ...]; + +Database.merge(masterAccount, duplicateAccounts); +``` + +**DML Lib** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +List duplicateAccounts = [SELECT Id FROM Account WHERE ...]; + +new DML() + .toMerge(masterAccount, duplicateAccounts) + .commitWork(); +``` + +#### By Record IDs + +Merge using a collection of duplicate record IDs. + +**Signature** + +```apex +Commitable toMerge(SObject mergeToRecord, Iterable duplicatedRecordIds); +``` + +**Standard DML** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Set duplicateAccountIds = new Set{ duplicateId1, duplicateId2 }; + +Database.merge(masterAccount, new List(duplicateAccountIds)); +``` + +**DML Lib** + +```apex +Account masterAccount = [SELECT Id FROM Account WHERE ...]; +Set duplicateAccountIds = new Set{ duplicateId1, duplicateId2 }; + +new DML() + .toMerge(masterAccount, duplicateAccountIds) + .commitWork(); +``` diff --git a/website/docs/dml/upsert.md b/website/docs/dml/upsert.md index 3365d27..22e9a94 100644 --- a/website/docs/dml/upsert.md +++ b/website/docs/dml/upsert.md @@ -30,8 +30,10 @@ Register records for upsert. The actual DML is executed when `commitWork()` is c ```apex Commitable toUpsert(SObject record); +Commitable toUpsert(SObject record, SObjectField externalIdField); Commitable toUpsert(DML.Record record); -Commitable toUpsert(Iterable records); +Commitable toUpsert(List records); +Commitable toUpsert(List records, SObjectField externalIdField); Commitable toUpsert(DML.Records records); ``` @@ -124,6 +126,39 @@ new DML() .commitWork(); ``` +#### With External Id Field + +Upsert using a custom external ID field instead of the standard Id field. + +**Signature** + +```apex +Commitable toUpsert(SObject record, SObjectField externalIdField); +``` + +**Standard DML** + +```apex +Account account = new Account( + MyExternalId__c = 'EXT-001', + Name = 'Acme Corp' +); +Database.upsert(account, Account.MyExternalId__c); +``` + +**DML Lib** + +```apex +Account account = new Account( + MyExternalId__c = 'EXT-001', + Name = 'Acme Corp' +); + +new DML() + .toUpsert(account, Account.MyExternalId__c) + .commitWork(); +``` + ### Multiple Records **Signature** @@ -234,6 +269,39 @@ new DML() .commitWork(); ``` +#### With External Id Field + +Upsert multiple records using a custom external ID field instead of the standard Id field. + +**Signature** + +```apex +Commitable toUpsert(List records, SObjectField externalIdField); +``` + +**Standard DML** + +```apex +List accounts = new List{ + new Account(MyExternalId__c = 'EXT-001', Name = 'Acme Corp'), + new Account(MyExternalId__c = 'EXT-002', Name = 'Global Inc') +}; +Database.upsert(accounts, Account.MyExternalId__c); +``` + +**DML Lib** + +```apex +List accounts = new List{ + new Account(MyExternalId__c = 'EXT-001', Name = 'Acme Corp'), + new Account(MyExternalId__c = 'EXT-002', Name = 'Global Inc') +}; + +new DML() + .toUpsert(accounts, Account.MyExternalId__c) + .commitWork(); +``` + ## upsertImmediately Upsert records immediately and return the operation result without calling `commitWork()`. diff --git a/website/docs/mocking/merge.md b/website/docs/mocking/merge.md new file mode 100644 index 0000000..5bc661f --- /dev/null +++ b/website/docs/mocking/merge.md @@ -0,0 +1,227 @@ +--- +outline: deep +--- + +# Merge + +Mock merge operations in unit tests to avoid actual database merges. + +::: warning +The `DML.mock()` and `DML.retrieveResultFor()` methods are `@TestVisible` and should only be used in test classes. +::: + +::: tip +- **No database operations**: Mocked merges don't touch the database +- **Records must have IDs**: Both master and duplicate records must have IDs assigned before mocking +- **Results are captured**: All operation details are available via `DML.retrieveResultFor()` +- **Selective mocking**: Use `mergesFor()` to mock specific SObject types while allowing others to execute +::: + +**Example** + +```apex +public class AccountService { + public void mergeAccounts(Id masterId, Id duplicateId) { + Account master = [SELECT Id FROM Account WHERE Id = :masterId]; + Account duplicate = [SELECT Id FROM Account WHERE Id = :duplicateId]; + + new DML() + .toMerge(master, duplicate) + .identifier('AccountService.mergeAccounts') + .commitWork(); + } +} +``` + +```apex +@IsTest +static void shouldMergeAccounts() { + // Setup + Account master = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Master' + ); + Account duplicate = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Duplicate' + ); + + DML.mock('AccountService.mergeAccounts').allMerges(); + + // Test + Test.startTest(); + new AccountService().mergeAccounts(master.Id, duplicate.Id); + Test.stopTest(); + + // Verify + DML.Result result = DML.retrieveResultFor('AccountService.mergeAccounts'); + + DML.OperationResult mergeResult = result.mergesOf(Account.SObjectType); + Assert.areEqual(1, mergeResult.successes().size(), '1 merge should succeed'); +} +``` + +## allMerges + +Mock all merge operations regardless of SObject type. + +**Signature** + +```apex +DML.mock(String identifier).allMerges(); +``` + +**Class** + +```apex +public class MergeService { + public void mergeDuplicates(Account master, Account duplicate) { + new DML() + .toMerge(master, duplicate) + .identifier('MergeService.mergeDuplicates') + .commitWork(); + } +} +``` + +**Test** + +```apex +@IsTest +static void shouldMockMergeOperation() { + // Setup + Account master = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType) + Name = 'Master' + ); + Account duplicate = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType) + Name = 'Duplicate' + ); + + DML.mock('MergeService.mergeDuplicates').allMerges(); + + // Test + Test.startTest(); + new MergeService().mergeDuplicates(master, duplicate); + Test.stopTest(); + + // Verify + DML.Result result = DML.retrieveResultFor('MergeService.mergeDuplicates'); + + Assert.areEqual(1, result.merges().size(), '1 merge operation mocked'); + Assert.isTrue(result.mergesOf(Account.SObjectType).recordResults()[0].isSuccess(), 'Merge should succeed'); + Assert.isNotNull(result.mergesOf(Account.SObjectType).recordResults()[0].id(), 'Should have mocked record Id'); +} +``` + +## mergesFor + +Mock merge operations only for a specific SObject type. Other SObject types will be merged in the database. + +**Signature** + +```apex +DML.mock(String identifier).mergesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldMockOnlyLeadMerges() { + // Setup - Real accounts, mocked leads + Account masterAcc = new Account(Name = 'Master Account'); + Account dupAcc = new Account(Name = 'Duplicate Account'); + insert new List{ masterAcc, dupAcc }; + + Lead masterLead = new Lead( + Id = DML.randomIdGenerator.get(Lead.SObjectType), + LastName = 'Master', + Company = 'Test' + ); + Lead dupLead = new Lead( + Id = DML.randomIdGenerator.get(Lead.SObjectType), + LastName = 'Duplicate', + Company = 'Test' + ); + + DML.mock('MergeService.mergeRecords').mergesFor(Lead.SObjectType); + + // Test + Test.startTest(); + new DML() + .toMerge(masterAcc, dupAcc) + .toMerge(masterLead, dupLead) + .identifier('MergeService.mergeRecords') + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account merge executed - only master remains'); + Assert.areEqual(1, result.mergesOf(Lead.SObjectType).successes().size(), 'Lead merge mocked'); +} +``` + +## Retrieving Results + +Use `DML.retrieveResultFor()` to access the mocked operation results. + +**Signature** + +```apex +DML.Result result = DML.retrieveResultFor(String identifier); +``` + +**Class** + +```apex +public class MergeService { + public void mergeAccounts(Account master, Account duplicate) { + new DML() + .toMerge(master, duplicate) + .identifier('MergeService.mergeAccounts') + .commitWork(); + } +} +``` + +**Test** + +```apex +@IsTest +static void shouldAccessMergeResults() { + // Setup + Account master = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Master' + ); + Account duplicate = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Duplicate' + ); + + DML.mock('MergeService.mergeAccounts').allMerges(); + + // Test + Test.startTest(); + new MergeService().mergeAccounts(master, duplicate); + Test.stopTest(); + + // Verify + DML.Result result = DML.retrieveResultFor('MergeService.mergeAccounts'); + DML.OperationResult opResult = result.mergesOf(Account.SObjectType); + + // Check operation metadata + Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.MERGE_DML, opResult.operationType(), 'Should be MERGE operation'); + Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + + // Check record results + List recordResults = opResult.recordResults(); + Assert.areEqual(1, recordResults.size(), 'Should have 1 record result'); + Assert.isTrue(recordResults[0].isSuccess(), 'Record should be successful'); + Assert.isNotNull(recordResults[0].id(), 'Record should have mocked ID'); +} +``` + From 2f0e686cf05c575374081a4ec4e7e3069c49236b Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 19:24:01 +0100 Subject: [PATCH 17/22] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5fca3d..ec900b8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The DML Lib provides functional constructs for DML statements in Apex. -DML Lib is part of [Apex Fluently](https://apexfluently.beyondthecloud.dev/), a suite of production-ready Salesforce libraries by Beyond the Cloud. +DML Lib is part of [Apex Fluently](https://apexfluently.beyondthecloud.dev/), a suite of production-ready Salesforce libraries by [Beyond the Cloud](https://blog.beyondthecloud.dev/blog). For more details, please refer to the [documentation](https://dml.beyondthecloud.dev). From 08bb99fa907eb8c50d88487ac5541ee222d89a93 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 19:25:07 +0100 Subject: [PATCH 18/22] search --- website/docs/.vitepress/config.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/.vitepress/config.mts b/website/docs/.vitepress/config.mts index 4c9bd58..6d8587f 100644 --- a/website/docs/.vitepress/config.mts +++ b/website/docs/.vitepress/config.mts @@ -28,7 +28,9 @@ export default defineConfig({ { text: 'Home', link: '/' }, { text: 'Documentation', link: '/introduction' } ], - + search: { + provider: 'local' + }, sidebar: [ { text: 'Docs', From 1dfc0fbaa733f4ac8732ed5bcaddb78d54be1bfb Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 19:58:16 +0100 Subject: [PATCH 19/22] Refactoring --- force-app/main/default/classes/DML_Test.cls | 1404 +++++++++---------- 1 file changed, 700 insertions(+), 704 deletions(-) diff --git a/force-app/main/default/classes/DML_Test.cls b/force-app/main/default/classes/DML_Test.cls index 10224e2..83b30b9 100644 --- a/force-app/main/default/classes/DML_Test.cls +++ b/force-app/main/default/classes/DML_Test.cls @@ -15,21 +15,21 @@ @IsTest private class DML_Test { - // INSERT + // INSERT - @IsTest - static void toInsertSingleRecord() { - // Setup + @IsTest + static void toInsertSingleRecord() { + // Setup Account account1 = getAccount(1); - // Test - Test.startTest(); + // Test + Test.startTest(); DML.Result result = new DML() .toInsert(account1) - .commitWork(); - Test.stopTest(); + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Single record should be inserted.'); Assert.isNotNull(account1.Id, 'Account should be inserted and have an Id.'); @@ -48,7 +48,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Inserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Inserted operation result should contain insert type.'); Assert.isFalse(operationResult.hasFailures(), 'Inserted operation result should not have failures.'); - } + } @IsTest static void insertImmediatelySingleRecord() { @@ -68,41 +68,41 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Inserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.INSERT_DML, result.operationType(), 'Inserted operation result should contain insert type.'); Assert.isFalse(result.hasFailures(), 'Inserted operation result should not have failures.'); - } + } - @IsTest - static void toInsertWithRelationshipSingleRecord() { - // Setup + @IsTest + static void toInsertWithRelationshipSingleRecord() { + // Setup Account newAccount = getAccount(1); Contact newContact = getContact(1); - // Test - Test.startTest(); - new DML() - .toInsert(newAccount) - .toInsert(DML.Record(newContact).withRelationship(Contact.AccountId, newAccount)) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toInsert(newAccount) + .toInsert(DML.Record(newContact).withRelationship(Contact.AccountId, newAccount)) + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Contact should be inserted.'); - Assert.areEqual(newAccount.Id, newContact.AccountId, 'Contact should be related to Account.'); - } + Assert.areEqual(newAccount.Id, newContact.AccountId, 'Contact should be related to Account.'); + } - @IsTest + @IsTest static void toInsertWithInvalidRelationshipSingleRecord() { - // Setup + // Setup Account newAccount = getAccount(1); Contact newContact = getContact(1); - // Test - Test.startTest(); + // Test + Test.startTest(); Exception expectedException = null; try { - new DML() + new DML() .toInsert(newAccount) .toInsert(DML.Record(newContact).withRelationship(Contact.LastName, newAccount)) .commitWork(); @@ -175,16 +175,16 @@ private class DML_Test { // Test Test.startTest(); DML.Result result = new DML() - .toInsert(account1) - .toInsert(account2) - .commitWork(); - Test.stopTest(); + .toInsert(account1) + .toInsert(account2) + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be inserted.'); - - Assert.isNotNull(account1.Id, 'Account 1 should be inserted and have an Id.'); - Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); + + Assert.isNotNull(account1.Id, 'Account 1 should be inserted and have an Id.'); + Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 1 result, because 2 Account records are grouped.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -201,11 +201,11 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Inserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Inserted operation result should contain insert type.'); Assert.isFalse(operationResult.hasFailures(), 'Inserted operation result should not have failures.'); - } + } - @IsTest + @IsTest static void insertImmediatelyMultipleRecords() { - // Setup + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); @@ -227,55 +227,55 @@ private class DML_Test { Assert.isFalse(result.hasFailures(), 'Inserted operation result should not have failures.'); } - @IsTest - static void toInsertWithRelationshipMultipleRecords() { - // Setup + @IsTest + static void toInsertWithRelationshipMultipleRecords() { + // Setup Account newAccount = getAccount(1); Contact newContact1 = getContact(1); Contact newContact2 = getContact(2); List contacts = new List{ newContact1, newContact2 }; - // Test - Test.startTest(); - new DML() - .toInsert(newAccount) - .toInsert(DML.Records(contacts).withRelationship(Contact.AccountId, newAccount)) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toInsert(newAccount) + .toInsert(DML.Records(contacts).withRelationship(Contact.AccountId, newAccount)) + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); Assert.areEqual(2, [SELECT COUNT() FROM Contact], 'Contacts should be inserted.'); Assert.areEqual(newAccount.Id, newContact1.AccountId, 'Contact should be related to Account.'); - Assert.areEqual(newAccount.Id, newContact2.AccountId, 'Contact 2 should be related to Account.'); - } + Assert.areEqual(newAccount.Id, newContact2.AccountId, 'Contact 2 should be related to Account.'); + } - @IsTest + @IsTest static void toInsertWithInvalidTargetRelationshipFieldMultipleRecords() { - // Setup + // Setup Account newAccount = getAccount(1); Contact newContact1 = getContact(1); Contact newContact2 = getContact(2); List contacts = new List{ newContact1, newContact2 }; - // Test - Test.startTest(); + // Test + Test.startTest(); Exception expectedException = null; try { - new DML() + new DML() .toInsert(newAccount) .toInsert(DML.Records(contacts).withRelationship(Contact.LastName, newAccount)) - .commitWork(); + .commitWork(); } catch (Exception e) { expectedException = e; } - Test.stopTest(); + Test.stopTest(); - // Verify + // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); Assert.areEqual('Invalid argument: LastName. Field supplied is not a relationship field.', expectedException.getMessage(), 'Expected exception message should be thrown.'); } @@ -392,160 +392,160 @@ private class DML_Test { Assert.areEqual(Lead.SObjectType, leadOperationResult.objectType(), 'Inserted operation result should contain Lead object type.'); Assert.areEqual(DML.OperationType.INSERT_DML, leadOperationResult.operationType(), 'Inserted operation result should contain insert type.'); Assert.isFalse(leadOperationResult.hasFailures(), 'Inserted operation result should not have failures.'); - } + } - @IsTest - static void toInsertListOfRecords() { - // Setup - List accounts = new List{ + @IsTest + static void toInsertListOfRecords() { + // Setup + List accounts = new List{ getAccount(1), getAccount(2), getAccount(3) - }; - - // Test - Test.startTest(); - new DML() - .toInsert(accounts) - .commitWork(); - Test.stopTest(); - - // Verify + }; + + // Test + Test.startTest(); + new DML() + .toInsert(accounts) + .commitWork(); + Test.stopTest(); + + // Verify Assert.areEqual(3, [SELECT COUNT() FROM Account], 'Accounts should be inserted.'); - Assert.isNotNull(accounts[0].Id, 'Account 1 should be inserted and have an Id.'); - Assert.isNotNull(accounts[1].Id, 'Account 2 should be inserted and have an Id.'); - Assert.isNotNull(accounts[2].Id, 'Account 3 should be inserted and have an Id.'); - } + Assert.isNotNull(accounts[0].Id, 'Account 1 should be inserted and have an Id.'); + Assert.isNotNull(accounts[1].Id, 'Account 2 should be inserted and have an Id.'); + Assert.isNotNull(accounts[2].Id, 'Account 3 should be inserted and have an Id.'); + } - @IsTest - static void toInsertWithExistingIds() { - // Setup + @IsTest + static void toInsertWithExistingIds() { + // Setup Account account = getAccount(1); - insert account; - - DmlException expectedException = null; - - // Test - Test.startTest(); - try { - new DML() - .toInsert(account) - .commitWork(); - } catch (DmlException e) { - expectedException = e; - } - Test.stopTest(); - - // Verify - Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Only new records can be registered as new.', expectedException.getMessage(), 'Expected exception message should be thrown.'); - } - - @IsTest - static void toInsertWithPartialSuccess() { - // Setup - List accounts = new List{ + insert account; + + DmlException expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toInsert(account) + .commitWork(); + } catch (DmlException e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Only new records can be registered as new.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toInsertWithPartialSuccess() { + // Setup + List accounts = new List{ getAccount(1), - new Account() // Name is required - }; - - // Test - Test.startTest(); - new DML() - .toInsert(accounts) - .allowPartialSuccess() - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only one account should be inserted, because one has missing required field.'); - } - - @IsTest + new Account() // Name is required + }; + + // Test + Test.startTest(); + new DML() + .toInsert(accounts) + .allowPartialSuccess() + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Only one account should be inserted, because one has missing required field.'); + } + + @IsTest static void toInsertUserMode() { - // Setup + // Setup Case newCase = getCase(1); Exception expectedException = null; - // Test - Test.startTest(); + // Test + Test.startTest(); System.runAs(minimumAccessUser()) { try { - new DML() + new DML() .toInsert(newCase) .commitWork(); // user mode by default } catch (Exception e) { expectedException = e; } } - Test.stopTest(); + Test.stopTest(); - // Verify + // Verify Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); Assert.areEqual('Access to entity \'Case\' denied', expectedException.getMessage(), 'Expected exception message should be thrown.'); - } + } - @IsTest + @IsTest static void toInsertWithUserModeExplicitlySet() { - // Setup + // Setup Case newCase = getCase(1); Exception expectedException = null; - // Test - Test.startTest(); - System.runAs(minimumAccessUser()) { - try { - new DML() - .toInsert(newCase) + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + try { + new DML() + .toInsert(newCase) .userMode() .commitWork(); } catch (Exception e) { - expectedException = e; - } - } - Test.stopTest(); - - // Verify - Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Access to entity \'Case\' denied', expectedException.getMessage(), 'Expected exception message should be thrown.'); - } - - @IsTest - static void toInsertSystemMode() { - // Setup + expectedException = e; + } + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Access to entity \'Case\' denied', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toInsertSystemMode() { + // Setup Case newCase = getCase(1); - // Test - Test.startTest(); - System.runAs(minimumAccessUser()) { - new DML() - .toInsert(newCase) - .systemMode() - .commitWork(); - } - Test.stopTest(); - - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Case], 'Case should be inserted.'); - Assert.isNotNull(newCase.Id, 'Case should be inserted and have an Id.'); - } - - @IsTest + // Test + Test.startTest(); + System.runAs(minimumAccessUser()) { + new DML() + .toInsert(newCase) + .systemMode() + .commitWork(); + } + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Case], 'Case should be inserted.'); + Assert.isNotNull(newCase.Id, 'Case should be inserted and have an Id.'); + } + + @IsTest static void toInsertSingleRecordWithMocking() { - // Setup + // Setup Account account1 = getAccount(1); DML.mock('dmlMockId').allInserts(); - // Test - Test.startTest(); - new DML() + // Test + Test.startTest(); + new DML() .toInsert(account1) .identifier('dmlMockId') - .commitWork(); + .commitWork(); Test.stopTest(); DML.Result result = DML.retrieveResultFor('dmlMockId'); @@ -577,11 +577,11 @@ private class DML_Test { .toInsert(account2) .identifier('dmlMockId') .commitWork(); - Test.stopTest(); + Test.stopTest(); DML.Result result = DML.retrieveResultFor('dmlMockId'); - // Verify + // Verify Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be inserted to the database.'); Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 1 result.'); Assert.areEqual(Account.SObjectType, result.insertsOf(Account.SObjectType).objectType(), 'Inserted operation result should contain Account object type.'); @@ -611,11 +611,11 @@ private class DML_Test { .toInsert(contact1) .identifier('dmlMockId') .commitWork(); - Test.stopTest(); + Test.stopTest(); DML.Result result = DML.retrieveResultFor('dmlMockId'); - // Verify + // Verify Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should not be inserted, because it was mocked.'); Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Contact should be inserted, because only Account was mocked.'); Assert.areEqual(2, result.inserts().size(), 'Inserted operation result should contain 2 results.'); @@ -672,7 +672,7 @@ private class DML_Test { Assert.isNotNull(result.insertsOf(Contact.SObjectType).recordResults()[0].id(), 'Inserted operation result should contain a mocked record Id.'); Assert.isNotNull(account1.Id, 'Account should have mocked Id.'); Assert.isNotNull(contact1.Id, 'Contact should have mocked Id.'); - } + } @IsTest static void toInsertWithMockingException() { @@ -812,7 +812,7 @@ private class DML_Test { // Verify Assert.areEqual(0, [SELECT COUNT() FROM Account], 'No records should be inserted to the database.'); Assert.areEqual(0, dmlStatementsAfter - dmlStatementsBefore, 'No DML statements should be executed.'); - Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 0 results.'); + Assert.areEqual(1, result.inserts().size(), 'Inserted operation result should contain 1 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); Assert.areEqual(0, result.updates().size(), 'Updated operation result should contain 0 results.'); Assert.areEqual(0, result.deletes().size(), 'Deleted operation result should contain 0 results.'); @@ -869,25 +869,25 @@ private class DML_Test { Assert.isFalse(operationResult.hasFailures(), 'Inserted operation result should not have failures.'); } - // UPDATE + // UPDATE - @IsTest - static void toUpdateSingleRecord() { - // Setup + @IsTest + static void toUpdateSingleRecord() { + // Setup Account account1 = getAccount(1); insert account1; - // Test - Test.startTest(); + // Test + Test.startTest(); account1.Name = 'Updated Test Account'; DML.Result result = new DML() .toUpdate(account1) - .commitWork(); - Test.stopTest(); + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); Assert.areEqual('Updated Test Account', account1.Name, 'Account should be updated.'); @@ -906,7 +906,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Updated operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Updated operation result should contain update type.'); Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); - } + } @IsTest static void toUpdateSingleRecordTwice() { @@ -1009,9 +1009,9 @@ private class DML_Test { Assert.areEqual(0, result.events().size(), 'Published operation result should contain 0 results.'); } - @IsTest + @IsTest static void updateImmediatelySingleRecord() { - // Setup + // Setup Account account1 = getAccount(1); insert account1; account1.Name = 'Updated Test Account'; @@ -1031,54 +1031,54 @@ private class DML_Test { Assert.isFalse(result.hasFailures(), 'Updated operation result should not have failures.'); } - @IsTest - static void toUpdateWithRelationshipSingleRecord() { - // Setup + @IsTest + static void toUpdateWithRelationshipSingleRecord() { + // Setup Account newAccount = getAccount(1); Contact newContact = getContact(1); - insert newContact; + insert newContact; - // Test - Test.startTest(); - new DML() - .toInsert(newAccount) - .toUpdate(DML.Record(newContact).withRelationship(Contact.AccountId, newAccount)) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toInsert(newAccount) + .toUpdate(DML.Record(newContact).withRelationship(Contact.AccountId, newAccount)) + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); Assert.areEqual(1, [SELECT COUNT() FROM Contact], 'Contact should be updated.'); - List contacts = [SELECT Id, AccountId FROM Contact WHERE Id = :newContact.Id]; + List contacts = [SELECT Id, AccountId FROM Contact WHERE Id = :newContact.Id]; - Assert.areEqual(newAccount.Id, contacts[0].AccountId, 'Contact should be related to Account.'); - } + Assert.areEqual(newAccount.Id, contacts[0].AccountId, 'Contact should be related to Account.'); + } - @IsTest - static void toUpdateMultipleRecords() { - // Setup + @IsTest + static void toUpdateMultipleRecords() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - insert new List{ account1, account2 }; + insert new List{ account1, account2 }; // Test Test.startTest(); - account1.Name = 'Updated Test Account 1'; - account2.Name = 'Updated Test Account 2'; + account1.Name = 'Updated Test Account 1'; + account2.Name = 'Updated Test Account 2'; DML.Result result = new DML() - .toUpdate(account1) - .toUpdate(account2) - .commitWork(); - Test.stopTest(); + .toUpdate(account1) + .toUpdate(account2) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); + // Verify + Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); - Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); - Assert.areEqual('Updated Test Account 2', account2.Name, 'Account 2 should be updated.'); + Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); + Assert.areEqual('Updated Test Account 2', account2.Name, 'Account 2 should be updated.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -1095,11 +1095,11 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Updated operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Updated operation result should contain update type.'); Assert.isFalse(operationResult.hasFailures(), 'Updated operation result should not have failures.'); - } + } - @IsTest + @IsTest static void updateImmediatelyMultipleRecords() { - // Setup + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); insert new List{ account1, account2 }; @@ -1125,45 +1125,45 @@ private class DML_Test { Assert.isFalse(result.hasFailures(), 'Updated operation result should not have failures.'); } - @IsTest - static void toUpdateWithRelationshipMultipleRecords() { - // Setup + @IsTest + static void toUpdateWithRelationshipMultipleRecords() { + // Setup Account newAccount = getAccount(1); Contact newContact = getContact(1); Contact newContact2 = getContact(2); - List contactsToCreate = new List{ newContact, newContact2 }; - insert contactsToCreate; + List contactsToCreate = new List{ newContact, newContact2 }; + insert contactsToCreate; - // Test - Test.startTest(); - new DML() - .toInsert(newAccount) - .toUpdate(DML.Records(contactsToCreate).withRelationship(Contact.AccountId, newAccount)) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toInsert(newAccount) + .toUpdate(DML.Records(contactsToCreate).withRelationship(Contact.AccountId, newAccount)) + .commitWork(); + Test.stopTest(); - // Verify + // Verify Assert.areEqual(2, [SELECT COUNT() FROM Contact], 'Contacts should be updated.'); Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); - List contacts = [SELECT Id, AccountId FROM Contact WHERE Id IN :contactsToCreate]; + List contacts = [SELECT Id, AccountId FROM Contact WHERE Id IN :contactsToCreate]; - Assert.areEqual(newAccount.Id, contacts[0].AccountId, 'Contact should be related to Account.'); - Assert.areEqual(newAccount.Id, contacts[1].AccountId, 'Contact 2 should be related to Account.'); - } + Assert.areEqual(newAccount.Id, contacts[0].AccountId, 'Contact should be related to Account.'); + Assert.areEqual(newAccount.Id, contacts[1].AccountId, 'Contact 2 should be related to Account.'); + } - @IsTest - static void toUpdateMultipleRecordsTypes() { - // Setup + @IsTest + static void toUpdateMultipleRecordsTypes() { + // Setup Account account1 = getAccount(1); Opportunity opportunity1 = getOpportunity(1); Lead lead1 = getLead(1); insert new List{ account1, opportunity1, lead1 }; - // Test - Test.startTest(); + // Test + Test.startTest(); account1.Name = 'Updated Test Account'; opportunity1.Name = 'Updated Test Opportunity'; lead1.FirstName = 'Updated Test'; @@ -1172,13 +1172,13 @@ private class DML_Test { .toUpdate(account1) .toUpdate(opportunity1) .toUpdate(lead1) - .commitWork(); - Test.stopTest(); + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); - Assert.areEqual(1, [SELECT COUNT() FROM Opportunity], 'Opportunity should be updated.'); - Assert.areEqual(1, [SELECT COUNT() FROM Lead], 'Lead should be updated.'); + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); + Assert.areEqual(1, [SELECT COUNT() FROM Opportunity], 'Opportunity should be updated.'); + Assert.areEqual(1, [SELECT COUNT() FROM Lead], 'Lead should be updated.'); Assert.areEqual('Updated Test Account', account1.Name, 'Account should be updated.'); Assert.areEqual('Updated Test Opportunity', opportunity1.Name, 'Opportunity should be updated.'); @@ -1215,78 +1215,78 @@ private class DML_Test { Assert.areEqual(Lead.SObjectType, leadOperationResult.objectType(), 'Updated operation result should contain Lead object type.'); Assert.areEqual(DML.OperationType.UPDATE_DML, leadOperationResult.operationType(), 'Updated operation result should contain update type.'); Assert.isFalse(leadOperationResult.hasFailures(), 'Updated operation result should not have failures.'); - } + } - @IsTest - static void toUpdateListOfRecords() { - // Setup - List accounts = new List{ + @IsTest + static void toUpdateListOfRecords() { + // Setup + List accounts = new List{ getAccount(1), getAccount(2), getAccount(3) }; - insert accounts; + insert accounts; // Test Test.startTest(); - accounts[0].Name = 'Updated Test Account 1'; - accounts[1].Name = 'Updated Test Account 2'; - accounts[2].Name = 'Updated Test Account 3'; + accounts[0].Name = 'Updated Test Account 1'; + accounts[1].Name = 'Updated Test Account 2'; + accounts[2].Name = 'Updated Test Account 3'; - new DML() - .toUpdate(accounts) - .commitWork(); - Test.stopTest(); + new DML() + .toUpdate(accounts) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(3, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); + // Verify + Assert.areEqual(3, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); - Assert.areEqual('Updated Test Account 1', accounts[0].Name, 'Account 1 should be updated.'); - Assert.areEqual('Updated Test Account 2', accounts[1].Name, 'Account 2 should be updated.'); - Assert.areEqual('Updated Test Account 3', accounts[2].Name, 'Account 3 should be updated.'); - } + Assert.areEqual('Updated Test Account 1', accounts[0].Name, 'Account 1 should be updated.'); + Assert.areEqual('Updated Test Account 2', accounts[1].Name, 'Account 2 should be updated.'); + Assert.areEqual('Updated Test Account 3', accounts[2].Name, 'Account 3 should be updated.'); + } - @IsTest - static void toUpdateWithoutExistingIds() { - // Setup + @IsTest + static void toUpdateWithoutExistingIds() { + // Setup Account account = getAccount(1); - - DmlException expectedException = null; - - // Test - Test.startTest(); - try { - new DML() - .toUpdate(account) - .commitWork(); - } catch (DmlException e) { - expectedException = e; - } - Test.stopTest(); - - // Verify - Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Only existing records can be updated.', expectedException.getMessage(), 'Expected exception message should be thrown.'); - } - - @IsTest - static void toUpdateWithPartialSuccess() { - // Setup - List accounts = new List{ getAccount(1), getAccount(2) }; - insert accounts; - - // Test - Test.startTest(); - accounts[0].Name = null; - accounts[1].Name = 'Test Account 1 New Name'; - - new DML() - .toUpdate(accounts) - .allowPartialSuccess() - .commitWork(); - Test.stopTest(); - - // Verify + + DmlException expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toUpdate(account) + .commitWork(); + } catch (DmlException e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Only existing records can be updated.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toUpdateWithPartialSuccess() { + // Setup + List accounts = new List{ getAccount(1), getAccount(2) }; + insert accounts; + + // Test + Test.startTest(); + accounts[0].Name = null; + accounts[1].Name = 'Test Account 1 New Name'; + + new DML() + .toUpdate(accounts) + .allowPartialSuccess() + .commitWork(); + Test.stopTest(); + + // Verify Assert.areEqual(2, [SELECT COUNT() FROM Account WHERE Name IN ('Test Account 1', 'Test Account 1 New Name')], 'Accounts should be present with expected names after partial success update.'); } @@ -1317,20 +1317,20 @@ private class DML_Test { Assert.areEqual('Access to entity \'Case\' denied', expectedException.getMessage(), 'Expected exception message should be thrown.'); } - @IsTest + @IsTest static void toUpdateWithUserModeExplicitlySet() { - // Setup + // Setup Case newCase = getCase(1); insert newCase; Exception expectedException = null; - // Test - Test.startTest(); + // Test + Test.startTest(); System.runAs(minimumAccessUser()) { try { newCase.Subject = 'Updated Test Case'; - new DML() + new DML() .toUpdate(newCase) .userMode() .commitWork(); @@ -1777,11 +1777,11 @@ private class DML_Test { DML.Result result = new DML() .toUpsert(account1) - .commitWork(); - Test.stopTest(); + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be updated.'); Assert.areEqual('Updated Test Account', account1.Name, 'Account should be updated.'); @@ -1800,7 +1800,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Upserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Upserted operation result should contain upsert type.'); Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); - } + } @IsTest static void upsertImmediatelySingleRecord() { @@ -1825,20 +1825,20 @@ private class DML_Test { } - @IsTest - static void toUpsertSingleNewRecord() { - // Setup + @IsTest + static void toUpsertSingleNewRecord() { + // Setup Account account1 = getAccount(1); - // Test - Test.startTest(); + // Test + Test.startTest(); DML.Result result = new DML() .toUpsert(account1) - .commitWork(); - Test.stopTest(); + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be inserted.'); Assert.isNotNull(account1.Id, 'Account should be inserted and have an Id.'); @@ -1857,54 +1857,54 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Upserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Upserted operation result should contain upsert type.'); Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); - } + } - @IsTest - static void toUpsertMultipleExistingRecords() { - // Setup + @IsTest + static void toUpsertMultipleExistingRecords() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - insert new List{ account1, account2 }; + insert new List{ account1, account2 }; // Test Test.startTest(); - account1.Name = 'Updated Test Account 1'; - account2.Name = 'Updated Test Account 2'; + account1.Name = 'Updated Test Account 1'; + account2.Name = 'Updated Test Account 2'; - new DML() - .toUpsert(account1) - .toUpsert(account2) - .commitWork(); - Test.stopTest(); + new DML() + .toUpsert(account1) + .toUpsert(account2) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); + // Verify + Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be updated.'); - Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); - Assert.areEqual('Updated Test Account 2', account2.Name, 'Account 2 should be updated.'); - } + Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); + Assert.areEqual('Updated Test Account 2', account2.Name, 'Account 2 should be updated.'); + } - @IsTest - static void toUpsertMultipleNewRecords() { - // Setup + @IsTest + static void toUpsertMultipleNewRecords() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - // Test - Test.startTest(); - new DML() - .toUpsert(account1) - .toUpsert(account2) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toUpsert(account1) + .toUpsert(account2) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be inserted.'); + // Verify + Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Accounts should be inserted.'); - Assert.isNotNull(account1.Id, 'Account 1 should be inserted and have an Id.'); - Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); - } + Assert.isNotNull(account1.Id, 'Account 1 should be inserted and have an Id.'); + Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); + } @IsTest static void upsertImmediatelyMultipleRecords() { @@ -1932,96 +1932,96 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Upserted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UPSERT_DML, result.operationType(), 'Upserted operation result should contain upsert type.'); Assert.isFalse(result.hasFailures(), 'Upserted operation result should not have failures.'); - } + } - @IsTest - static void toUpsertExistingAndNewRecords() { - // Setup + @IsTest + static void toUpsertExistingAndNewRecords() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - insert account1; - - // Test - Test.startTest(); + insert account1; + + // Test + Test.startTest(); account1.Name = 'Updated Test Account 1'; - new DML() - .toUpsert(account1) - .toUpsert(account2) - .commitWork(); - Test.stopTest(); - - // Verify + new DML() + .toUpsert(account1) + .toUpsert(account2) + .commitWork(); + Test.stopTest(); + + // Verify Assert.areEqual(2, [SELECT COUNT() FROM Account], 'Both accounts should be upserted.'); - Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); - Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); - } + Assert.areEqual('Updated Test Account 1', account1.Name, 'Account 1 should be updated.'); + Assert.isNotNull(account2.Id, 'Account 2 should be inserted and have an Id.'); + } - @IsTest - static void toUpsertListOfRecords() { - // Setup - List existingAccounts = new List{ + @IsTest + static void toUpsertListOfRecords() { + // Setup + List existingAccounts = new List{ getAccount(1), getAccount(2), getAccount(3) }; - insert existingAccounts; + insert existingAccounts; // Test Test.startTest(); - existingAccounts[0].Name = 'Updated Test Account 1'; - existingAccounts[1].Name = 'Updated Test Account 2'; - existingAccounts[2].Name = 'Updated Test Account 3'; + existingAccounts[0].Name = 'Updated Test Account 1'; + existingAccounts[1].Name = 'Updated Test Account 2'; + existingAccounts[2].Name = 'Updated Test Account 3'; - List newAccounts = new List{ + List newAccounts = new List{ getAccount(1), getAccount(2), getAccount(3) }; - new DML() - .toUpsert(existingAccounts) - .toUpsert(newAccounts) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(6, [SELECT COUNT() FROM Account], 'Accounts should be updated and inserted.'); - - Assert.areEqual('Updated Test Account 1', existingAccounts[0].Name, 'Account 1 should be updated.'); - Assert.areEqual('Updated Test Account 2', existingAccounts[1].Name, 'Account 2 should be updated.'); - Assert.areEqual('Updated Test Account 3', existingAccounts[2].Name, 'Account 3 should be updated.'); - - Assert.isNotNull(newAccounts[0].Id, 'New Account 1 should be inserted and have an Id.'); - Assert.isNotNull(newAccounts[1].Id, 'New Account 2 should be inserted and have an Id.'); - Assert.isNotNull(newAccounts[2].Id, 'New Account 3 should be inserted and have an Id.'); - } - - @IsTest - static void toUpsertWithPartialSuccess() { - // Setup - List accounts = new List{ + new DML() + .toUpsert(existingAccounts) + .toUpsert(newAccounts) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(6, [SELECT COUNT() FROM Account], 'Accounts should be updated and inserted.'); + + Assert.areEqual('Updated Test Account 1', existingAccounts[0].Name, 'Account 1 should be updated.'); + Assert.areEqual('Updated Test Account 2', existingAccounts[1].Name, 'Account 2 should be updated.'); + Assert.areEqual('Updated Test Account 3', existingAccounts[2].Name, 'Account 3 should be updated.'); + + Assert.isNotNull(newAccounts[0].Id, 'New Account 1 should be inserted and have an Id.'); + Assert.isNotNull(newAccounts[1].Id, 'New Account 2 should be inserted and have an Id.'); + Assert.isNotNull(newAccounts[2].Id, 'New Account 3 should be inserted and have an Id.'); + } + + @IsTest + static void toUpsertWithPartialSuccess() { + // Setup + List accounts = new List{ getAccount(1), getAccount(2) - }; - insert accounts; + }; + insert accounts; // Test Test.startTest(); accounts[0].Name = null; // Name will not change, because it's set to null - accounts[1].Name = 'Test Account 1 New Name'; + accounts[1].Name = 'Test Account 1 New Name'; accounts.add(getAccount(3)); // New account - - new DML() - .toUpsert(accounts) - .allowPartialSuccess() - .commitWork(); - Test.stopTest(); - - // Verify + + new DML() + .toUpsert(accounts) + .allowPartialSuccess() + .commitWork(); + Test.stopTest(); + + // Verify Assert.areEqual(3, [SELECT COUNT() FROM Account WHERE Name IN ('Test Account 1', 'Test Account 1 New Name', 'Test Account 3')], 'Accounts should be upserted with expected names.'); } @@ -2292,7 +2292,7 @@ private class DML_Test { Assert.isNotNull(result.upsertsOf(Contact.SObjectType).recordResults()[0].id(), 'Upserted operation result should contain a mocked record Id.'); Assert.areEqual('Upserted Test Account 1', account1.Name, 'Account 1 should be updated.'); Assert.areEqual('Upserted Test Contact 1', contact1.FirstName, 'Contact 1 should be updated.'); - } + } @IsTest static void toUpsertMultipleRecordsWithMockingSpecificSObjectType() { @@ -2339,7 +2339,7 @@ private class DML_Test { Assert.isTrue(result.upsertsOf(Contact.SObjectType).recordResults()[0].isSuccess(), 'Upserted operation result should contain a successful record result.'); Assert.isNotNull(result.upsertsOf(Account.SObjectType).recordResults()[0].id(), 'Upserted operation result should contain a mocked record Id.'); Assert.isNotNull(result.upsertsOf(Contact.SObjectType).recordResults()[0].id(), 'Upserted operation result should contain a mocked record Id.'); - } + } @IsTest static void toUpsertWithMockingException() { @@ -2536,38 +2536,38 @@ private class DML_Test { Assert.isFalse(operationResult.hasFailures(), 'Upserted operation result should not have failures.'); } - // DELETE + // DELETE - @IsTest - static void toDeleteSingleRecordById() { - // Setup - Account account = insertAccount(); - - // Test - Test.startTest(); - new DML() - .toDelete(account.Id) - .commitWork(); - Test.stopTest(); + @IsTest + static void toDeleteSingleRecordById() { + // Setup + Account account = insertAccount(); + + // Test + Test.startTest(); + new DML() + .toDelete(account.Id) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); - } + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + } - @IsTest - static void toDeleteSingleRecord() { - // Setup + @IsTest + static void toDeleteSingleRecord() { + // Setup Account account1 = insertAccount(); - - // Test - Test.startTest(); + + // Test + Test.startTest(); DML.Result result = new DML() .toDelete(account1) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -2584,7 +2584,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); - } + } @IsTest static void deleteImmediatelySingleRecord() { @@ -2604,7 +2604,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, result.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(result.hasFailures(), 'Deleted operation result should not have failures.'); - } + } @IsTest static void deleteImmediatelySingleRecordById() { @@ -2624,26 +2624,26 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, result.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(result.hasFailures(), 'Deleted operation result should not have failures.'); - } + } - @IsTest - static void toDeleteMultipleRecords() { - // Setup + @IsTest + static void toDeleteMultipleRecords() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - insert new List{ account1, account2 }; - - // Test - Test.startTest(); + insert new List{ account1, account2 }; + + // Test + Test.startTest(); DML.Result result = new DML() - .toDelete(account1) - .toDelete(account2) - .commitWork(); - Test.stopTest(); + .toDelete(account1) + .toDelete(account2) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -2660,7 +2660,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); - } + } @IsTest static void deleteImmediatelyMultipleRecords() { @@ -2682,65 +2682,65 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, result.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(result.hasFailures(), 'Deleted operation result should not have failures.'); - } + } @IsTest - static void deleteImmediatelyMultipleRecordsByIds() { - // Setup + static void deleteImmediatelyMultipleRecordsByIds() { + // Setup Account account1 = getAccount(1); Account account2 = getAccount(2); - insert new List{ account1, account2 }; - - // Test - Test.startTest(); + insert new List{ account1, account2 }; + + // Test + Test.startTest(); DML.OperationResult result = new DML() - .deleteImmediately(new List{ account1.Id, account2.Id }); - Test.stopTest(); + .deleteImmediately(new List{ account1.Id, account2.Id }); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); Assert.areEqual(2, result.records().size(), 'Deleted operation result should contain the deleted records.'); Assert.areEqual(2, result.recordResults().size(), 'Deleted operation result should contain the deleted record results.'); Assert.areEqual(Account.SObjectType, result.objectType(), 'Deleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, result.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(result.hasFailures(), 'Deleted operation result should not have failures.'); - } + } - @IsTest - static void toDeleteListOfRecords() { - // Setup - List accounts = insertAccounts(); + @IsTest + static void toDeleteListOfRecords() { + // Setup + List accounts = insertAccounts(); - // Test - Test.startTest(); - new DML() - .toDelete(accounts) - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toDelete(accounts) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); - } + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + } - @IsTest - static void toDeleteMultipleRecordsById() { - // Setup - List accounts = insertAccounts(); + @IsTest + static void toDeleteMultipleRecordsById() { + // Setup + List accounts = insertAccounts(); - // Test - Test.startTest(); - Set accountIds = new Map(accounts).keySet(); + // Test + Test.startTest(); + Set accountIds = new Map(accounts).keySet(); - new DML() - .toDelete(accountIds) - .commitWork(); - Test.stopTest(); + new DML() + .toDelete(accountIds) + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); - } + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + } @IsTest static void toDeleteMultipleRecordsTypes() { @@ -2796,52 +2796,52 @@ private class DML_Test { Assert.areEqual(Lead.SObjectType, leadOperationResult.objectType(), 'Deleted operation result should contain Lead object type.'); Assert.areEqual(DML.OperationType.DELETE_DML, leadOperationResult.operationType(), 'Deleted operation result should contain delete type.'); Assert.isFalse(leadOperationResult.hasFailures(), 'Deleted operation result should not have failures.'); - } + } - @IsTest - static void toDeleteWithoutExistingIds() { - // Setup + @IsTest + static void toDeleteWithoutExistingIds() { + // Setup Account account = getAccount(1); - - DmlException expectedException = null; - - // Test - Test.startTest(); - try { - new DML() - .toDelete(account) - .commitWork(); - } catch (DmlException e) { - expectedException = e; - } - Test.stopTest(); - - // Verify - Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); - Assert.areEqual('Only existing records can be registered as deleted.', expectedException.getMessage(), 'Expected exception message should be thrown.'); - } - - @IsTest - static void toDeleteWithPartialSuccess() { - // Setup - List accounts = new List{ + + DmlException expectedException = null; + + // Test + Test.startTest(); + try { + new DML() + .toDelete(account) + .commitWork(); + } catch (DmlException e) { + expectedException = e; + } + Test.stopTest(); + + // Verify + Assert.isNotNull(expectedException, 'Expected exception to be thrown.'); + Assert.areEqual('Only existing records can be registered as deleted.', expectedException.getMessage(), 'Expected exception message should be thrown.'); + } + + @IsTest + static void toDeleteWithPartialSuccess() { + // Setup + List accounts = new List{ getAccount(1), getAccount(2) - }; + }; - insert accounts; + insert accounts; - // Test - Test.startTest(); - new DML() - .toDelete(accounts) - .allowPartialSuccess() - .commitWork(); - Test.stopTest(); + // Test + Test.startTest(); + new DML() + .toDelete(accounts) + .allowPartialSuccess() + .commitWork(); + Test.stopTest(); - // Verify - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); - } + // Verify + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + } @IsTest static void toDeleteWithUserMode() { @@ -3092,7 +3092,7 @@ private class DML_Test { Assert.isTrue(result.deletesOf(Contact.SObjectType).recordResults()[0].isSuccess(), 'Deleted operation result should contain a successful record result.'); Assert.isNotNull(result.deletesOf(Account.SObjectType).recordResults()[0].id(), 'Deleted operation result should contain a mocked record Id.'); Assert.isNotNull(result.deletesOf(Contact.SObjectType).recordResults()[0].id(), 'Deleted operation result should contain a mocked record Id.'); - } + } @IsTest static void toDeleteWithMockingException() { @@ -3565,44 +3565,44 @@ private class DML_Test { Assert.isFalse(operationResult.hasFailures(), 'Deleted operation result should not have failures.'); } - // UNDELETE + // UNDELETE - @IsTest - static void toUndeleteSingleRecordById() { - // Setup - Account account = insertAccount(); - delete account; + @IsTest + static void toUndeleteSingleRecordById() { + // Setup + Account account = insertAccount(); + delete account; - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); - // Test - Test.startTest(); - new DML() - .toUndelete(account.Id) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be undeleted.'); - } + // Test + Test.startTest(); + new DML() + .toUndelete(account.Id) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be undeleted.'); + } - @IsTest - static void toUndeleteSingleRecord() { - // Setup + @IsTest + static void toUndeleteSingleRecord() { + // Setup Account account1 = insertAccount(); delete account1; - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Account should be deleted.'); - // Test - Test.startTest(); + // Test + Test.startTest(); DML.Result result = new DML() .toUndelete(account1) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be undeleted.'); + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(1, [SELECT COUNT() FROM Account], 'Account should be undeleted.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -3619,7 +3619,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Undeleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Undeleted operation result should contain undelete type.'); Assert.isFalse(operationResult.hasFailures(), 'Undeleted operation result should not have failures.'); - } + } @IsTest static void undeleteImmediatelySingleRecord() { @@ -3642,7 +3642,7 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Undeleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UNDELETE_DML, result.operationType(), 'Undeleted operation result should contain undelete type.'); Assert.isFalse(result.hasFailures(), 'Undeleted operation result should not have failures.'); - } + } @IsTest static void undeleteImmediatelySingleRecordById() { @@ -3665,46 +3665,46 @@ private class DML_Test { Assert.areEqual(Account.SObjectType, result.objectType(), 'Undeleted operation result should contain Account object type.'); Assert.areEqual(DML.OperationType.UNDELETE_DML, result.operationType(), 'Undeleted operation result should contain undelete type.'); Assert.isFalse(result.hasFailures(), 'Undeleted operation result should not have failures.'); - } - - @IsTest - static void toUndeleteMultipleRecordsById() { - // Setup - List accounts = insertAccounts(); - delete accounts; - - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); - - // Test - Test.startTest(); - Set accountIds = new Map(accounts).keySet(); - - new DML() - .toUndelete(accountIds) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(accounts.size(), [SELECT COUNT() FROM Account], 'Accounts should be undeleted.'); - } - - @IsTest - static void toUndeleteMultipleRecords() { - // Setup - List accounts = insertAccounts(); - delete accounts; - - Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); - - // Test - Test.startTest(); + } + + @IsTest + static void toUndeleteMultipleRecordsById() { + // Setup + List accounts = insertAccounts(); + delete accounts; + + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + + // Test + Test.startTest(); + Set accountIds = new Map(accounts).keySet(); + + new DML() + .toUndelete(accountIds) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(accounts.size(), [SELECT COUNT() FROM Account], 'Accounts should be undeleted.'); + } + + @IsTest + static void toUndeleteMultipleRecords() { + // Setup + List accounts = insertAccounts(); + delete accounts; + + Assert.areEqual(0, [SELECT COUNT() FROM Account], 'Accounts should be deleted.'); + + // Test + Test.startTest(); DML.Result result = new DML() - .toUndelete(accounts) - .commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(accounts.size(), [SELECT COUNT() FROM Account], 'Accounts should be undeleted.'); + .toUndelete(accounts) + .commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(accounts.size(), [SELECT COUNT() FROM Account], 'Accounts should be undeleted.'); Assert.areEqual(0, result.inserts().size(), 'Inserted operation result should contain 0 results.'); Assert.areEqual(0, result.upserts().size(), 'Upserted operation result should contain 0 results.'); @@ -4075,7 +4075,7 @@ private class DML_Test { Assert.isTrue(result.undeletesOf(Contact.SObjectType).recordResults()[0].isSuccess(), 'Undeleted operation result should contain a successful record result.'); Assert.isNotNull(result.undeletesOf(Account.SObjectType).recordResults()[0].id(), 'Undeleted operation result should contain a mocked record Id.'); Assert.isNotNull(result.undeletesOf(Contact.SObjectType).recordResults()[0].id(), 'Undeleted operation result should contain a mocked record Id.'); - } + } @IsTest static void toUndeleteMultipleRecordsWithMockingSpecificSObjectType() { @@ -4122,7 +4122,7 @@ private class DML_Test { Assert.isTrue(result.undeletesOf(Contact.SObjectType).recordResults()[0].isSuccess(), 'Undeleted operation result should contain a successful record result.'); Assert.isNotNull(result.undeletesOf(Account.SObjectType).recordResults()[0].id(), 'Undeleted operation result should contain a mocked record Id.'); Assert.isNotNull(result.undeletesOf(Contact.SObjectType).recordResults()[0].id(), 'Undeleted operation result should contain a mocked record Id.'); - } + } @IsTest static void toUndeleteWithMockingException() { @@ -4908,7 +4908,7 @@ private class DML_Test { Assert.isTrue(expectedException.getMessage().contains('A single merge request must contain at least one record to merge, got none'), 'Expected exception message should be thrown.'); } - // PLATFORM EVENT + // PLATFORM EVENT @IsTest static void toPublishSingleRecord() { @@ -6097,56 +6097,56 @@ private class DML_Test { Assert.areEqual(1, dmlStatementsAfter - dmlStatementsBefore, 'DML statements should be 1, because second commitWork() should not do anything.'); } - // COMPLEX - - @IsTest - static void registerComplex() { - // Setup - DML uow = new DML(); - - // Test - Test.startTest(); - for(Integer i = 0 ; i < 10 ; i++) { - Opportunity newOpportunity = new Opportunity( - Name = 'UoW Test Name ' + i, - StageName = 'Open', - CloseDate = System.today() - ); - - uow.toInsert(newOpportunity); - - for(Integer j = 0 ; j < i + 1 ; j++) { - Product2 product = new Product2(Name = newOpportunity.Name + ' : Product : ' + i); - - uow.toInsert(product); - - PricebookEntry pbe = new PricebookEntry( - UnitPrice = 10, - IsActive = true, - UseStandardPrice = false, - Pricebook2Id = Test.getStandardPricebookId() - ); - - uow.toInsert( - DML.Record(pbe) - .withRelationship(PricebookEntry.Product2Id, product) - ); - - uow.toInsert( - DML.Record(new OpportunityLineItem(Quantity = 1, TotalPrice = 10)) - .withRelationship(OpportunityLineItem.PricebookEntryId, pbe) - .withRelationship(OpportunityLineItem.OpportunityId, newOpportunity) - ); - } - } - uow.commitWork(); - Test.stopTest(); - - // Verify - Assert.areEqual(10, [SELECT COUNT() FROM Opportunity], 'Opportunities should be inserted.'); - Assert.areEqual(55, [SELECT COUNT() FROM Product2], 'Products should be inserted.'); - Assert.areEqual(55, [SELECT COUNT() FROM PricebookEntry], 'Pricebook entries should be inserted.'); - } + // COMPLEX + + @IsTest + static void registerComplex() { + // Setup + DML uow = new DML(); + + // Test + Test.startTest(); + for(Integer i = 0 ; i < 10 ; i++) { + Opportunity newOpportunity = new Opportunity( + Name = 'UoW Test Name ' + i, + StageName = 'Open', + CloseDate = System.today() + ); + + uow.toInsert(newOpportunity); + + for(Integer j = 0 ; j < i + 1 ; j++) { + Product2 product = new Product2(Name = newOpportunity.Name + ' : Product : ' + i); + + uow.toInsert(product); + + PricebookEntry pbe = new PricebookEntry( + UnitPrice = 10, + IsActive = true, + UseStandardPrice = false, + Pricebook2Id = Test.getStandardPricebookId() + ); + + uow.toInsert( + DML.Record(pbe) + .withRelationship(PricebookEntry.Product2Id, product) + ); + + uow.toInsert( + DML.Record(new OpportunityLineItem(Quantity = 1, TotalPrice = 10)) + .withRelationship(OpportunityLineItem.PricebookEntryId, pbe) + .withRelationship(OpportunityLineItem.OpportunityId, newOpportunity) + ); + } + } + uow.commitWork(); + Test.stopTest(); + + // Verify + Assert.areEqual(10, [SELECT COUNT() FROM Opportunity], 'Opportunities should be inserted.'); + Assert.areEqual(55, [SELECT COUNT() FROM Product2], 'Products should be inserted.'); + Assert.areEqual(55, [SELECT COUNT() FROM PricebookEntry], 'Pricebook entries should be inserted.'); + } // HOOK @@ -6183,9 +6183,9 @@ private class DML_Test { Assert.isNotNull(result, 'Result should not be null.'); } - } + } - // HELPERS + // HELPERS static Account getAccount(Integer index) { return new Account(Name = 'Test Account ' + index); @@ -6204,47 +6204,43 @@ private class DML_Test { } static Case getCase(Integer index) { - return new Case(Status = 'New', Origin = 'Web'); + return new Case(Status = 'New', Origin = 'Web', Subject = 'Test ' + index); } - static Task getTask(Integer index) { - return new Task(Subject = 'Test ' + index, Type = 'Other'); - } - - static Account insertAccount() { - Account account = new Account(Name = 'Test Account'); - insert account; + static Account insertAccount() { + Account account = new Account(Name = 'Test Account'); + insert account; - return account; - } + return account; + } @SuppressWarnings('PMD.AvoidNonRestrictiveQueries') static List getAccounts() { return [SELECT Id, Name FROM Account]; - } - - static List insertAccounts() { - List accounts = new List{ - new Account(Name = 'Test Account 1'), - new Account(Name = 'Test Account 2'), - new Account(Name = 'Test Account 3') - }; - insert accounts; - - return accounts; - } - - static User minimumAccessUser() { - return new User( - Alias = 'newUser', - Email = 'newuser@testorg.com', - EmailEncodingKey = 'UTF-8', - LastName = 'Testing', - LanguageLocaleKey = 'en_US', - LocaleSidKey = 'en_US', - Profile = new Profile(Name = 'Minimum Access - Salesforce'), - TimeZoneSidKey = 'America/Los_Angeles', + } + + static List insertAccounts() { + List accounts = new List{ + new Account(Name = 'Test Account 1'), + new Account(Name = 'Test Account 2'), + new Account(Name = 'Test Account 3') + }; + insert accounts; + + return accounts; + } + + static User minimumAccessUser() { + return new User( + Alias = 'newUser', + Email = 'newuser@testorg.com', + EmailEncodingKey = 'UTF-8', + LastName = 'Testing', + LanguageLocaleKey = 'en_US', + LocaleSidKey = 'en_US', + Profile = new Profile(Name = 'Minimum Access - Salesforce'), + TimeZoneSidKey = 'America/Los_Angeles', UserName = 'btcdmllibuser@testorg.com' - ); - } + ); + } } \ No newline at end of file From 5763cbb0e612480212a9a4db6d3ccc30da1c46eb Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 20:49:53 +0100 Subject: [PATCH 20/22] Documentation update --- website/docs/mocking/delete.md | 113 +++++++++++++++++++++++++- website/docs/mocking/insert.md | 108 +++++++++++++++++++++++-- website/docs/mocking/merge.md | 135 +++++++++++++++++++++++++++++-- website/docs/mocking/publish.md | 104 +++++++++++++++++++++++- website/docs/mocking/undelete.md | 113 +++++++++++++++++++++++++- website/docs/mocking/update.md | 111 ++++++++++++++++++++++++- website/docs/mocking/upsert.md | 106 +++++++++++++++++++++++- 7 files changed, 761 insertions(+), 29 deletions(-) diff --git a/website/docs/mocking/delete.md b/website/docs/mocking/delete.md index ea8f11b..561942b 100644 --- a/website/docs/mocking/delete.md +++ b/website/docs/mocking/delete.md @@ -183,10 +183,115 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('AccountService.deleteAccount'); - DML.OperationResult opResult = result.deletesOf(Account.SObjectType); + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.DELETE_DML, opResult.operationType(), 'Should be DELETE operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.DELETE_DML, operationResult.operationType(), 'Should be DELETE operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); +} +``` + +## Exception + +Simulate DML exceptions for delete operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnDeletes + +Throw an exception for all delete operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnDeletes(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnDelete() { + // Setup + DML.mock('myDmlId').exceptionOnDeletes(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test & Verify + try { + new DML() + .toDelete(accountId) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Delete failed')); + } +} +``` + +### exceptionOnDeletesFor + +Throw an exception only for delete operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnDeletesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForAccountDeletes() { + // Setup - Exception only for Account deletes + DML.mock('myDmlId').exceptionOnDeletesFor(Account.SObjectType); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + Id contactId = DML.randomIdGenerator.get(Contact.SObjectType); + + // Test & Verify + try { + new DML() + .toDelete(accountId) + .toDelete(contactId) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Delete failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnDeletes(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test - no exception thrown + DML.Result result = new DML() + .toDelete(accountId) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.deletesOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); } ``` diff --git a/website/docs/mocking/insert.md b/website/docs/mocking/insert.md index 89f69ff..36f53f9 100644 --- a/website/docs/mocking/insert.md +++ b/website/docs/mocking/insert.md @@ -195,17 +195,115 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('AccountService.createAccount'); - DML.OperationResult opResult = result.insertsOf(Account.SObjectType); + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); // Check operation metadata - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.INSERT_DML, opResult.operationType(), 'Should be INSERT operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.INSERT_DML, operationResult.operationType(), 'Should be INSERT operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); // Check record results - List recordResults = opResult.recordResults(); + List recordResults = operationResult.recordResults(); Assert.areEqual(1, recordResults.size(), 'Should have 1 record result'); Assert.isTrue(recordResults[0].isSuccess(), 'Record should be successful'); Assert.isNotNull(recordResults[0].id(), 'Record should have mocked ID'); } ``` + +## Exception + +Simulate DML exceptions for insert operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnInserts + +Throw an exception for all insert operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnInserts(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnInsert() { + // Setup + DML.mock('myDmlId').exceptionOnInserts(); + + // Test & Verify + try { + new DML() + .toInsert(new Account(Name = 'Test Account')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Insert failed')); + } +} +``` + +### exceptionOnInsertsFor + +Throw an exception only for insert operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnInsertsFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForAccountInserts() { + // Setup - Exception only for Account inserts + DML.mock('myDmlId').exceptionOnInsertsFor(Account.SObjectType); + + // Test & Verify + try { + new DML() + .toInsert(new Account(Name = 'Test Account')) + .toInsert(new Contact(LastName = 'Doe')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Insert failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnInserts(); + + // Test - no exception thrown + DML.Result result = new DML() + .toInsert(new Account(Name = 'Test Account')) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.insertsOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); +} +``` diff --git a/website/docs/mocking/merge.md b/website/docs/mocking/merge.md index 5bc661f..aee2618 100644 --- a/website/docs/mocking/merge.md +++ b/website/docs/mocking/merge.md @@ -210,18 +210,143 @@ static void shouldAccessMergeResults() { // Verify DML.Result result = DML.retrieveResultFor('MergeService.mergeAccounts'); - DML.OperationResult opResult = result.mergesOf(Account.SObjectType); + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); // Check operation metadata - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.MERGE_DML, opResult.operationType(), 'Should be MERGE operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.MERGE_DML, operationResult.operationType(), 'Should be MERGE operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); // Check record results - List recordResults = opResult.recordResults(); + List recordResults = operationResult.recordResults(); Assert.areEqual(1, recordResults.size(), 'Should have 1 record result'); Assert.isTrue(recordResults[0].isSuccess(), 'Record should be successful'); Assert.isNotNull(recordResults[0].id(), 'Record should have mocked ID'); } ``` +## Exception + +Simulate DML exceptions for merge operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnMerges + +Throw an exception for all merge operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnMerges(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnMerge() { + // Setup + Account master = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Master' + ); + Account duplicate = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Duplicate' + ); + + DML.mock('myDmlId').exceptionOnMerges(); + + // Test & Verify + try { + new DML() + .toMerge(master, duplicate) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Merge failed')); + } +} +``` + +### exceptionOnMergesFor + +Throw an exception only for merge operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnMergesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForLeadMerges() { + // Setup - Exception only for Lead merges + Lead masterLead = new Lead( + Id = DML.randomIdGenerator.get(Lead.SObjectType), + LastName = 'Master', + Company = 'Test' + ); + Lead dupLead = new Lead( + Id = DML.randomIdGenerator.get(Lead.SObjectType), + LastName = 'Duplicate', + Company = 'Test' + ); + + DML.mock('myDmlId').exceptionOnMergesFor(Lead.SObjectType); + + // Test & Verify + try { + new DML() + .toMerge(masterLead, dupLead) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Merge failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + Account master = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Master' + ); + Account duplicate = new Account( + Id = DML.randomIdGenerator.get(Account.SObjectType), + Name = 'Duplicate' + ); + + DML.mock('myDmlId').exceptionOnMerges(); + + // Test - no exception thrown + DML.Result result = new DML() + .toMerge(master, duplicate) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.mergesOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); +} +``` diff --git a/website/docs/mocking/publish.md b/website/docs/mocking/publish.md index aee5e72..2bae124 100644 --- a/website/docs/mocking/publish.md +++ b/website/docs/mocking/publish.md @@ -186,9 +186,107 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('NotificationService.sendAlert'); - DML.OperationResult opResult = result.eventsOf(AlertEvent__e.SObjectType); + DML.OperationResult operationResult = result.eventsOf(AlertEvent__e.SObjectType); - Assert.areEqual(AlertEvent__e.SObjectType, opResult.objectType(), 'Should be AlertEvent__e type'); - Assert.areEqual(DML.OperationType.PUBLISH_DML, opResult.operationType(), 'Should be PUBLISH operation'); + Assert.areEqual(AlertEvent__e.SObjectType, operationResult.objectType(), 'Should be AlertEvent__e type'); + Assert.areEqual(DML.OperationType.PUBLISH_DML, operationResult.operationType(), 'Should be PUBLISH operation'); +} +``` + +## Exception + +Simulate exceptions for publish operations without actually publishing events. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnPublishes + +Throw an exception for all publish operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnPublishes(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnPublish() { + // Setup + DML.mock('myDmlId').exceptionOnPublishes(); + + // Test & Verify + try { + new DML() + .toPublish(new MyEvent__e(Message__c = 'Test')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Publish failed')); + } +} +``` + +### exceptionOnPublishesFor + +Throw an exception only for publish operations on a specific event type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnPublishesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForOrderEvents() { + // Setup - Exception only for OrderEvent__e publishes + DML.mock('myDmlId').exceptionOnPublishesFor(OrderEvent__e.SObjectType); + + // Test & Verify + try { + new DML() + .toPublish(new OrderEvent__e(OrderId__c = '12345')) + .toPublish(new ShipmentEvent__e(TrackingNumber__c = 'ABC')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Publish failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnPublishes(); + + // Test - no exception thrown + DML.Result result = new DML() + .toPublish(new MyEvent__e(Message__c = 'Test')) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.eventsOf(MyEvent__e.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); } ``` diff --git a/website/docs/mocking/undelete.md b/website/docs/mocking/undelete.md index aed5f09..a510b19 100644 --- a/website/docs/mocking/undelete.md +++ b/website/docs/mocking/undelete.md @@ -183,10 +183,115 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('AccountService.restoreAccount'); - DML.OperationResult opResult = result.undeletesOf(Account.SObjectType); + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.UNDELETE_DML, opResult.operationType(), 'Should be UNDELETE operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.UNDELETE_DML, operationResult.operationType(), 'Should be UNDELETE operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); +} +``` + +## Exception + +Simulate DML exceptions for undelete operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnUndeletes + +Throw an exception for all undelete operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUndeletes(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnUndelete() { + // Setup + DML.mock('myDmlId').exceptionOnUndeletes(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test & Verify + try { + new DML() + .toUndelete(accountId) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Undelete failed')); + } +} +``` + +### exceptionOnUndeletesFor + +Throw an exception only for undelete operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUndeletesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForAccountUndeletes() { + // Setup - Exception only for Account undeletes + DML.mock('myDmlId').exceptionOnUndeletesFor(Account.SObjectType); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + Id contactId = DML.randomIdGenerator.get(Contact.SObjectType); + + // Test & Verify + try { + new DML() + .toUndelete(accountId) + .toUndelete(contactId) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Undelete failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnUndeletes(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test - no exception thrown + DML.Result result = new DML() + .toUndelete(accountId) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.undeletesOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); } ``` diff --git a/website/docs/mocking/update.md b/website/docs/mocking/update.md index 2d78796..2828a84 100644 --- a/website/docs/mocking/update.md +++ b/website/docs/mocking/update.md @@ -187,10 +187,113 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('AccountService.archiveAccount'); - DML.OperationResult opResult = result.updatesOf(Account.SObjectType); + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.UPDATE_DML, opResult.operationType(), 'Should be UPDATE operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.UPDATE_DML, operationResult.operationType(), 'Should be UPDATE operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); +} +``` + +## Exception + +Simulate DML exceptions for update operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnUpdates + +Throw an exception for all update operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUpdates(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnUpdate() { + // Setup + DML.mock('myDmlId').exceptionOnUpdates(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test & Verify + try { + new DML() + .toUpdate(new Account(Id = accountId, Name = 'Updated Name')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Update failed')); + } +} +``` + +### exceptionOnUpdatesFor + +Throw an exception only for update operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUpdatesFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForAccountUpdates() { + // Setup - Exception only for Account updates + DML.mock('myDmlId').exceptionOnUpdatesFor(Account.SObjectType); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test & Verify + try { + new DML() + .toUpdate(new Account(Id = accountId, Name = 'Updated Name')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Update failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnUpdates(); + + Id accountId = DML.randomIdGenerator.get(Account.SObjectType); + + // Test - no exception thrown + DML.Result result = new DML() + .toUpdate(new Account(Id = accountId, Name = 'Updated Name')) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.updatesOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); } ``` diff --git a/website/docs/mocking/upsert.md b/website/docs/mocking/upsert.md index f51a1fc..39a6975 100644 --- a/website/docs/mocking/upsert.md +++ b/website/docs/mocking/upsert.md @@ -193,10 +193,108 @@ static void shouldAccessRecordResults() { // Verify DML.Result result = DML.retrieveResultFor('AccountService.upsertAccount'); - DML.OperationResult opResult = result.upsertsOf(Account.SObjectType); + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); - Assert.areEqual(Account.SObjectType, opResult.objectType(), 'Should be Account type'); - Assert.areEqual(DML.OperationType.UPSERT_DML, opResult.operationType(), 'Should be UPSERT operation'); - Assert.isFalse(opResult.hasFailures(), 'Should have no failures'); + Assert.areEqual(Account.SObjectType, operationResult.objectType(), 'Should be Account type'); + Assert.areEqual(DML.OperationType.UPSERT_DML, operationResult.operationType(), 'Should be UPSERT operation'); + Assert.isFalse(operationResult.hasFailures(), 'Should have no failures'); +} +``` + +## Exception + +Simulate DML exceptions for upsert operations without touching the database. + +::: tip allowPartialSuccess +When `allowPartialSuccess()` is used, exceptions are **not thrown**. Instead, failures are recorded in the `Result` object. Use `hasFailures()` and `recordResults()` to check for errors. +::: + +### exceptionOnUpserts + +Throw an exception for all upsert operations. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUpserts(); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnUpsert() { + // Setup + DML.mock('myDmlId').exceptionOnUpserts(); + + // Test & Verify + try { + new DML() + .toUpsert(new Account(Name = 'Test Account')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Upsert failed')); + } +} +``` + +### exceptionOnUpsertsFor + +Throw an exception only for upsert operations on a specific SObject type. + +**Signature** + +```apex +DML.mock(String identifier).exceptionOnUpsertsFor(SObjectType objectType); +``` + +**Test** + +```apex +@IsTest +static void shouldThrowExceptionOnlyForContactUpserts() { + // Setup - Exception only for Contact upserts + DML.mock('myDmlId').exceptionOnUpsertsFor(Contact.SObjectType); + + // Test & Verify + try { + new DML() + .toUpsert(new Account(Name = 'Test Account')) + .toUpsert(new Contact(LastName = 'Doe')) + .identifier('myDmlId') + .commitWork(); + Assert.fail('Expected exception'); + } catch (DmlException e) { + Assert.isTrue(e.getMessage().contains('Upsert failed')); + } +} +``` + +### allowPartialSuccess + +When using `allowPartialSuccess()`, failures are captured in the result instead of throwing an exception. + +**Test** + +```apex +@IsTest +static void shouldCaptureFailureInResult() { + // Setup + DML.mock('myDmlId').exceptionOnUpserts(); + + // Test - no exception thrown + DML.Result result = new DML() + .toUpsert(new Account(Name = 'Test Account')) + .allowPartialSuccess() + .identifier('myDmlId') + .commitWork(); + + // Verify + DML.OperationResult operationResult = result.upsertsOf(Account.SObjectType); + + Assert.isTrue(operationResult.hasFailures(), 'Should have failures'); + Assert.isFalse(operationResult.recordResults()[0].isSuccess(), 'Record should be marked as failed'); } ``` From c1ae9fd38a160bd456d48c45fde1ac223961e08f Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 20:55:35 +0100 Subject: [PATCH 21/22] mocking --- website/docs/.vitepress/config.mts | 1 + website/docs/mocking/mocking.md | 98 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 website/docs/mocking/mocking.md diff --git a/website/docs/.vitepress/config.mts b/website/docs/.vitepress/config.mts index 6d8587f..fd4fad3 100644 --- a/website/docs/.vitepress/config.mts +++ b/website/docs/.vitepress/config.mts @@ -57,6 +57,7 @@ export default defineConfig({ text: 'Mocking', collapsed: true, items: [ + { text: 'Introduction', link: '/mocking/mocking' }, { text: 'Insert', link: '/mocking/insert' }, { text: 'Update', link: '/mocking/update' }, { text: 'Upsert', link: '/mocking/upsert' }, diff --git a/website/docs/mocking/mocking.md b/website/docs/mocking/mocking.md new file mode 100644 index 0000000..f7a2a06 --- /dev/null +++ b/website/docs/mocking/mocking.md @@ -0,0 +1,98 @@ +--- +outline: deep +--- + +# Mocking + +Mock DML operations in unit tests to avoid actual database changes while still verifying your code's behavior. + +## How It Works + +1. **Add an identifier** to your DML operation in the production code +2. **Register a mock** for that identifier in your test +3. **Execute the code** - DML operations are intercepted, no database changes occur +4. **Verify results** using `DML.retrieveResultFor()` + +## Quick Example + +**Class** + +```apex +public class AccountService { + public void createAccount() { + new DML() + .toInsert(new Account(Name = 'Acme')) + .identifier('AccountService.createAccount') + .commitWork(); + } +} +``` + +**Test** + +```apex +@IsTest +static void shouldCreateAccount() { + // 1. Register mock + DML.mock('AccountService.createAccount').allInserts(); + + // 2. Execute + Test.startTest(); + new AccountService().createAccount(); + Test.stopTest(); + + // 3. Verify - no records in database, but results captured + DML.Result result = DML.retrieveResultFor('AccountService.createAccount'); + Assert.areEqual(1, result.insertsOf(Account.SObjectType).successes().size()); +} +``` + +## Key Benefits + +- **Fast tests** - No database operations means faster execution +- **IDs assigned** - Records receive valid mock IDs for relationship testing +- **Selective mocking** - Mock specific SObject types while allowing others to execute +- **Exception simulation** - Test error handling without real failures + +## Available Methods + +```apex +// Mock all operations +Mockable allDmls(); + +// Mock by operation type +Mockable allInserts(); +Mockable allUpdates(); +Mockable allUpserts(); +Mockable allDeletes(); +Mockable allUndeletes(); +Mockable allMerges(); +Mockable allPublishes(); + +// Mock by SObject type +Mockable insertsFor(SObjectType objectType); +Mockable updatesFor(SObjectType objectType); +Mockable upsertsFor(SObjectType objectType); +Mockable deletesFor(SObjectType objectType); +Mockable undeletesFor(SObjectType objectType); +Mockable mergesFor(SObjectType objectType); +Mockable publishesFor(SObjectType objectType); + +// Simulate exceptions +Mockable exceptionOnInserts(); +Mockable exceptionOnUpdates(); +Mockable exceptionOnUpserts(); +Mockable exceptionOnDeletes(); +Mockable exceptionOnUndeletes(); +Mockable exceptionOnMerges(); +Mockable exceptionOnPublishes(); + +// Simulate exceptions for specific SObject type +Mockable exceptionOnInsertsFor(SObjectType objectType); +Mockable exceptionOnUpdatesFor(SObjectType objectType); +Mockable exceptionOnUpsertsFor(SObjectType objectType); +Mockable exceptionOnDeletesFor(SObjectType objectType); +Mockable exceptionOnUndeletesFor(SObjectType objectType); +Mockable exceptionOnMergesFor(SObjectType objectType); +Mockable exceptionOnPublishesFor(SObjectType objectType); +``` From a0a9bd774747927013b0225e1e8b12632e45cb96 Mon Sep 17 00:00:00 2001 From: Piotr PG Gajek Date: Sun, 21 Dec 2025 21:10:07 +0100 Subject: [PATCH 22/22] New package version --- sfdx-project.json | 7 ++++--- website/docs/installation.md | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sfdx-project.json b/sfdx-project.json index c893893..e8b461d 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -1,8 +1,8 @@ { "packageDirectories": [ { - "versionName": "ver 2.0", - "versionNumber": "2.0.0.NEXT", + "versionName": "ver 2.1", + "versionNumber": "2.1.0.NEXT", "path": "force-app", "default": true, "package": "DML Lib", @@ -17,6 +17,7 @@ "DML Lib": "0HoP600000001IfKAI", "DML Lib@0.1.0-1": "04tP6000002A7BxIAK", "DML Lib@1.8.0-1": "04tP6000002A7OrIAK", - "DML Lib@1.9.0-1": "04tP6000002AvdNIAS" + "DML Lib@1.9.0-1": "04tP6000002AvdNIAS", + "DML Lib@2.0.0-1": "04tP6000002CeRxIAK" } } \ No newline at end of file diff --git a/website/docs/installation.md b/website/docs/installation.md index c4d9153..1115aa8 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -10,21 +10,21 @@ Install the SOQL Lib unlocked package with `btcdev` namespace to your Salesforce environment: -`/packaging/installPackage.apexp?p0=04tP6000002AvdNIAS` +`/packaging/installPackage.apexp?p0=04tP6000002CeRxIAK` -[Install on Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002AvdNIAS) +[Install on Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002CeRxIAK) -[Install on Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002AvdNIAS) +[Install on Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002CeRxIAK) ## Install via Unmanaged Package Install the DML Lib unmanaged package without namespace to your Salesforce environment: -`/packaging/installPackage.apexp?p0=04tP60000029Hmr` +`/packaging/installPackage.apexp?p0=04tP60000029HoT` -[Install on Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tP60000029Hmr) +[Install on Sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tP60000029HoT) -[Install on Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP60000029Hmr) +[Install on Production](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP60000029HoT) ## Deploy via Button