├── .forceignore ├── .npmrc ├── .prettierignore ├── .gitignore ├── force-app ├── repository │ ├── SelectFunction.cls │ ├── SearchGroup.cls │ ├── Query.cls-meta.xml │ ├── Cursor.cls-meta.xml │ ├── CursorTest.cls-meta.xml │ ├── QueryTest.cls-meta.xml │ ├── Repository.cls-meta.xml │ ├── Aggregation.cls-meta.xml │ ├── AggregationTest.cls-meta.xml │ ├── DateFunction.cls-meta.xml │ ├── IRepository.cls-meta.xml │ ├── QueryField.cls-meta.xml │ ├── RepositoryTest.cls-meta.xml │ ├── SearchGroup.cls-meta.xml │ ├── SelectFunction.cls-meta.xml │ ├── AdditionalSoslObject.cls-meta.xml │ ├── AggregateRecord.cls-meta.xml │ ├── AggregateRepository.cls-meta.xml │ ├── FieldLevelHistory.cls-meta.xml │ ├── IAggregateRepository.cls-meta.xml │ ├── IHistoryRepository.cls-meta.xml │ ├── QueryFieldTest.cls-meta.xml │ ├── RepositorySortOrder.cls-meta.xml │ ├── AggregateRepositoryTest.cls-meta.xml │ ├── FieldLevelHistoryRepo.cls-meta.xml │ ├── FieldLevelHistoryRepoTest.cls-meta.xml │ ├── SObjectRepository.cls-meta.xml │ ├── SObjectRepositoryTest.cls-meta.xml │ ├── SObjectRepository.cls │ ├── DateFunction.cls │ ├── IHistoryRepository.cls │ ├── SObjectRepositoryTest.cls │ ├── AdditionalSoslObject.cls │ ├── QueryFieldTest.cls │ ├── AggregationTest.cls │ ├── AggregateRecord.cls │ ├── QueryField.cls │ ├── IAggregateRepository.cls │ ├── FieldLevelHistory.cls │ ├── RepositorySortOrder.cls │ ├── FieldLevelHistoryRepo.cls │ ├── Aggregation.cls │ ├── IRepository.cls │ ├── FieldLevelHistoryRepoTest.cls │ ├── Cursor.cls │ ├── CursorTest.cls │ ├── AggregateRepositoryTest.cls │ ├── AggregateRepository.cls │ ├── QueryTest.cls │ ├── Query.cls │ ├── Repository.cls │ └── RepositoryTest.cls ├── dml │ ├── DML.cls-meta.xml │ ├── DMLMock.cls-meta.xml │ ├── DMLTest.cls-meta.xml │ ├── IDML.cls-meta.xml │ ├── IDML.cls │ ├── DML.cls │ ├── DMLTest.cls │ └── DMLMock.cls ├── factory │ ├── Factory.cls-meta.xml │ ├── RepoFactory.cls-meta.xml │ ├── LazyFactory.cls-meta.xml │ ├── RepoFactoryMock.cls-meta.xml │ ├── LazyFactoryTest.cls-meta.xml │ ├── RepoFactoryMockTest.cls-meta.xml │ ├── LazyFactory.cls │ ├── LazyFactoryTest.cls │ ├── Factory.cls │ ├── RepoFactoryMockTest.cls │ ├── RepoFactory.cls │ └── RepoFactoryMock.cls └── utils │ ├── TestingUtils.cls-meta.xml │ └── TestingUtils.cls ├── config └── project-scratch-def.json ├── example-app ├── ExampleFactory.cls-meta.xml ├── ExampleRepoFactory.cls-meta.xml ├── handlers │ ├── AccountHandler.cls-meta.xml │ ├── AccountHandlerTests.cls-meta.xml │ ├── ExampleSObjectTest.cls-meta.xml │ ├── AccountHandler.cls │ ├── AccountHandlerTests.cls │ └── ExampleSObjectTest.cls ├── triggers │ ├── TriggerHandler.cls-meta.xml │ ├── TriggerHandler_Tests.cls-meta.xml │ ├── TriggerHandler.cls │ └── TriggerHandler_Tests.cls ├── objects │ └── ExampleSObject__c │ │ ├── fields │ │ ├── Account__c.field-meta.xml │ │ └── SecondAccountLookup__c.field-meta.xml │ │ └── ExampleSObject__c.object-meta.xml ├── ExampleFactory.cls ├── testSuites │ └── ApexDmlMockingSuite.testSuite-meta.xml └── ExampleRepoFactory.cls ├── sfdx-project.json ├── .prettierrc ├── package.json ├── LICENSE ├── scripts ├── validate-history-query.apex └── test.ps1 ├── .github └── workflows │ └── deploy.yml ├── CODE_OF_CONDUCT.md └── Readme.md /.forceignore: -------------------------------------------------------------------------------- 1 | package.xml -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .sfdx 2 | sfdx-project.json 3 | package-lock.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sfdx/ 2 | .sf/ 3 | .vscode/ 4 | yarn.lock 5 | node_modules/ -------------------------------------------------------------------------------- /force-app/repository/SelectFunction.cls: -------------------------------------------------------------------------------- 1 | public enum SelectFunction { 2 | FORMAT, 3 | TOLABEL 4 | } 5 | -------------------------------------------------------------------------------- /force-app/repository/SearchGroup.cls: -------------------------------------------------------------------------------- 1 | public enum SearchGroup { 2 | ALL_FIELDS, 3 | EMAIL_FIELDS, 4 | NAME_FIELDS, 5 | PHONE_FIELDS, 6 | SIDEBAR_FIELDS 7 | } 8 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Apex DML Mocking", 3 | "edition": "Developer", 4 | "country": "US", 5 | "language": "en_US", 6 | "hasSampleData": true 7 | } 8 | -------------------------------------------------------------------------------- /force-app/dml/DML.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/dml/DMLMock.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/dml/DMLTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/dml/IDML.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/ExampleFactory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/factory/Factory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/Query.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/ExampleRepoFactory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/Cursor.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/CursorTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/QueryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/Repository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/utils/TestingUtils.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/handlers/AccountHandler.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/triggers/TriggerHandler.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/factory/LazyFactory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactoryMock.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/Aggregation.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/AggregationTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/DateFunction.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/IRepository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/QueryField.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/repository/RepositoryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/SearchGroup.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/SelectFunction.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/handlers/AccountHandlerTests.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/handlers/ExampleSObjectTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /example-app/triggers/TriggerHandler_Tests.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/factory/LazyFactoryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactoryMockTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/AdditionalSoslObject.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRecord.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRepository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistory.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/IAggregateRepository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/IHistoryRepository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/QueryFieldTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/repository/RepositorySortOrder.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRepositoryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistoryRepo.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistoryRepoTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | -------------------------------------------------------------------------------- /force-app/repository/SObjectRepository.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/repository/SObjectRepositoryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | }, 7 | { 8 | "path": "example-app" 9 | } 10 | ], 11 | "namespace": "", 12 | "sfdcLoginUrl": "https://login.salesforce.com", 13 | "sourceApiVersion": "64.0" 14 | } -------------------------------------------------------------------------------- /force-app/repository/SObjectRepository.cls: -------------------------------------------------------------------------------- 1 | public without sharing class SObjectRepository extends FieldLevelHistoryRepo { 2 | public SObjectRepository( 3 | Schema.SObjectType repoType, 4 | List queryFields, 5 | RepoFactory repoFactory 6 | ) { 7 | super(repoType, queryFields, repoFactory); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /force-app/repository/DateFunction.cls: -------------------------------------------------------------------------------- 1 | // https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_date_functions.htm 2 | public enum DateFunction { 3 | CALENDAR_MONTH, 4 | CALENDAR_QUARTER, 5 | CALENDAR_YEAR, 6 | DAY_IN_MONTH, 7 | DAY_IN_WEEK, 8 | DAY_IN_YEAR, 9 | DAY_ONLY, 10 | FISCAL_MONTH, 11 | FISCAL_QUARTER, 12 | FISCAL_YEAR, 13 | HOUR_IN_DAY, 14 | WEEK_IN_MONTH, 15 | WEEK_IN_YEAR 16 | } 17 | -------------------------------------------------------------------------------- /force-app/repository/IHistoryRepository.cls: -------------------------------------------------------------------------------- 1 | public interface IHistoryRepository extends IAggregateRepository { 2 | // essentially duplicating the method signatures from IRepository 3 | // but with FieldLevelHistory return types 4 | List getHistory(Query query); 5 | List getHistory(List queries); 6 | List getAllHistory(); 7 | 8 | // only needs to be called for standard objects since all 9 | // custom objects use "ParentId" 10 | IHistoryRepository setParentField(Schema.SObjectField parentField); 11 | } 12 | -------------------------------------------------------------------------------- /force-app/repository/SObjectRepositoryTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class SObjectRepositoryTest { 3 | @IsTest 4 | static void queriesWithGenericRepo() { 5 | Account acc = new Account(Name = SObjectRepositoryTest.class.getName() + System.now().getTime()); 6 | insert acc; 7 | 8 | List results = new SObjectRepository( 9 | Schema.Account.SObjectType, 10 | new List(), 11 | new RepoFactory() 12 | ) 13 | .getAll(); 14 | 15 | Assert.areEqual(1, results.size()); 16 | Assert.areEqual(acc.Id, results[0].Id); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example-app/objects/ExampleSObject__c/fields/Account__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Account__c 4 | SetNull 5 | false 6 | 7 | Account 8 | Example SObjects 9 | Example_SObjects 10 | false 11 | false 12 | Lookup 13 | 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 120, 6 | "arrowParens": "avoid", 7 | "overrides": [ 8 | { 9 | "files": "**/lwc/**/*.html", 10 | "options": { 11 | "parser": "lwc" 12 | } 13 | }, 14 | { 15 | "files": "*.{cmp,page,component}", 16 | "options": { 17 | "parser": "html" 18 | } 19 | }, 20 | { 21 | "files": "*.{cls, apex}", 22 | "options": { 23 | "parser": "apex" 24 | } 25 | } 26 | ], 27 | "plugins": [ 28 | "prettier-plugin-apex" 29 | ], 30 | "$schema": "https://json.schemastore.org/prettierrc" 31 | } -------------------------------------------------------------------------------- /example-app/objects/ExampleSObject__c/fields/SecondAccountLookup__c.field-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | SecondAccountLookup__c 4 | SetNull 5 | false 6 | 7 | Account 8 | Example SObjects (Second Account Lookup) 9 | Example_SObjects1 10 | false 11 | false 12 | Lookup 13 | 14 | -------------------------------------------------------------------------------- /force-app/repository/AdditionalSoslObject.cls: -------------------------------------------------------------------------------- 1 | public class AdditionalSoslObject { 2 | public final Schema.SObjectType objectType; 3 | public final Integer queryLimit; 4 | public final List queryFilters; 5 | public final List selectFields; 6 | 7 | @SuppressWarnings('PMD.ExcessiveParameterList') 8 | public AdditionalSoslObject( 9 | Schema.SObjectType objectType, 10 | List selectFields, 11 | List queryFilters, 12 | Integer queryLimit 13 | ) { 14 | this.objectType = objectType; 15 | this.queryFilters = queryFilters; 16 | this.queryLimit = queryLimit; 17 | this.selectFields = selectFields; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /force-app/repository/QueryFieldTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class QueryFieldTest { 3 | @IsTest 4 | static void parentFieldChainsConcatenatedProperly() { 5 | QueryField queryField = new QueryField( 6 | new List{ Contact.AccountId, Account.OwnerId }, 7 | new List{ User.Email } 8 | ); 9 | 10 | Assert.areEqual('Account.Owner.Email', queryField.toString()); 11 | } 12 | 13 | @IsTest 14 | static void concatenatesFieldsProperly() { 15 | QueryField queryField = new QueryField(new List{ Contact.AccountId, Contact.LastName }); 16 | 17 | Assert.areEqual('AccountId,LastName', queryField.toString()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /force-app/repository/AggregationTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class AggregationTest { 3 | @IsTest 4 | static void shouldCorrectlyFormatSum() { 5 | String expectedAlias = 'myAlias'; 6 | Aggregation agg = Aggregation.sum(Opportunity.Amount, expectedAlias); 7 | Assert.areEqual('SUM(Amount) myAlias', agg.toString()); 8 | Assert.areEqual(expectedAlias, agg.getAlias()); 9 | } 10 | 11 | @IsTest 12 | static void shouldCorrectlyFormatCountDistinct() { 13 | String expectedAlias = 'myAlias'; 14 | Aggregation agg = Aggregation.countDistinct(Opportunity.StageName, expectedAlias); 15 | Assert.areEqual('COUNT_DISTINCT(StageName) myAlias', agg.toString()); 16 | Assert.areEqual(expectedAlias, agg.getAlias()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example-app/ExampleFactory.cls: -------------------------------------------------------------------------------- 1 | public virtual class ExampleFactory extends Factory { 2 | public final ExampleRepoFactory repoFactory = new ExampleRepoFactory(); 3 | 4 | @TestVisible 5 | private static ExampleFactory factory; 6 | 7 | protected ExampleFactory() { 8 | // enforce getFactory() as the sole way to interact with this class 9 | } 10 | 11 | public static ExampleFactory getFactory() { 12 | if (factory == null) { 13 | factory = new ExampleFactory(); 14 | } 15 | return factory; 16 | } 17 | 18 | public virtual TriggerHandler getAccountHandler() { 19 | return new AccountHandler(this); 20 | } 21 | 22 | @TestVisible 23 | private ExampleFactory withMocks { 24 | get { 25 | this.repoFactory.setFacade(new RepoFactoryMock.FacadeMock()); 26 | return this; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example-app/testSuites/ApexDmlMockingSuite.testSuite-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AccountHandlerTests 4 | AggregateRepositoryTest 5 | AggregationTest 6 | CursorTest 7 | DMLTest 8 | ExampleSObjectTest 9 | FieldLevelHistoryRepoTest 10 | QueryTest 11 | QueryFieldTest 12 | RepositoryTest 13 | RepoFactoryMockTest 14 | TriggerHandler_Tests 15 | 16 | -------------------------------------------------------------------------------- /example-app/ExampleRepoFactory.cls: -------------------------------------------------------------------------------- 1 | public without sharing class ExampleRepoFactory extends RepoFactory { 2 | // your app can expand on the repositories provided by the base instance here, like so 3 | 4 | public IAggregateRepository getOppRepo() { 5 | List queryFields = new List{ 6 | Opportunity.IsWon, 7 | Opportunity.StageName 8 | // etc ... 9 | }; 10 | IAggregateRepository oppRepo = this.facade.getRepo(Opportunity.SObjectType, queryFields, this); 11 | oppRepo.addParentFields( 12 | new List{ Opportunity.AccountId }, 13 | new List{ Account.Id } 14 | ); 15 | return oppRepo; 16 | } 17 | 18 | public IHistoryRepository getOppFieldHistoryRepo() { 19 | return this.facade.getRepo(OpportunityFieldHistory.SObjectType, new List(), this) 20 | .setParentField(OpportunityFieldHistory.OpportunityId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /force-app/factory/LazyFactory.cls: -------------------------------------------------------------------------------- 1 | public without sharing virtual class LazyFactory { 2 | private static final Map CACHED_INSTANCES = new Map(); 3 | 4 | public interface Instance { 5 | } 6 | 7 | public Instance load(String typeName) { 8 | return this.load(this.getTypeFromName(typeName, '')); 9 | } 10 | 11 | public Instance load(String typeName, String namespace) { 12 | return this.load(this.getTypeFromName(typeName, namespace)); 13 | } 14 | 15 | public Instance load(Type type) { 16 | Instance possibleInstance = CACHED_INSTANCES.get(type.getName()); 17 | if (possibleInstance == null) { 18 | possibleInstance = (Instance) type.newInstance(); 19 | CACHED_INSTANCES.put(type.getName(), possibleInstance); 20 | } 21 | return possibleInstance; 22 | } 23 | 24 | private Type getTypeFromName(String typeName, String possibleNamespace) { 25 | return Type.forName(possibleNamespace, typeName); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /force-app/factory/LazyFactoryTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class LazyFactoryTest { 3 | private static Integer loadCounter = 0; 4 | 5 | @IsTest 6 | static void properlyCachesLazyLoadedInstances() { 7 | ExtendedLazyFactory extendedExampleFactory = new ExtendedLazyFactory(); 8 | 9 | Example firstExample = (Example) extendedExampleFactory.example; 10 | Example secondExample = (Example) extendedExampleFactory.example; 11 | 12 | Assert.areEqual(1, loadCounter); 13 | // prove that even without overriding "equals" and a setter for the 14 | // ExtendedLazyFactory that the instances returned are the same 15 | Assert.areEqual(firstExample, secondExample); 16 | } 17 | 18 | private class ExtendedLazyFactory extends LazyFactory { 19 | public Instance example { 20 | get { 21 | return this.load(Example.class); 22 | } 23 | } 24 | } 25 | 26 | private class Example implements LazyFactory.Instance { 27 | public Example() { 28 | loadCounter++; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example-app/handlers/AccountHandler.cls: -------------------------------------------------------------------------------- 1 | public class AccountHandler extends TriggerHandler { 2 | private final IRepository oppRepo; 3 | 4 | public AccountHandler(ExampleFactory factory) { 5 | this.oppRepo = factory.repoFactory.getOppRepo(); 6 | } 7 | 8 | public override void afterInsert(List insertedRecords, Map unused) { 9 | List insertedAccounts = (List) insertedRecords; 10 | this.createOppAutomatically(insertedAccounts); 11 | } 12 | 13 | private void createOppAutomatically(List insertedAccounts) { 14 | List oppsToInsert = new List(); 15 | for (Account insertedAccount : insertedAccounts) { 16 | oppsToInsert.add( 17 | new Opportunity( 18 | Name = 'Prospecting Opp for: ' + insertedAccount.Name, 19 | AccountId = insertedAccount.Id, 20 | StageName = 'Open', 21 | CloseDate = Date.today() 22 | ) 23 | ); 24 | } 25 | this.oppRepo.doInsert(oppsToInsert); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apex-dml-mocking", 3 | "version": "1.0.2", 4 | "description": "Easy DML / SOQL mocking through the Factory pattern in Apex", 5 | "dependencies": {}, 6 | "devDependencies": { 7 | "@salesforce/cli": "2.105.6", 8 | "prettier-plugin-apex": "2.2.6", 9 | "prettier": "3.6.2" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/jamessimone/apex-dml-mocking.git" 14 | }, 15 | "scripts": { 16 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,yaml,yml}\"", 17 | "scan": "sf code-analyzer run -r pmd:1 -r pmd:2 -r pmd:3 --workspace force-app" 18 | }, 19 | "keywords": [ 20 | "apex", 21 | "salesforce", 22 | "factory", 23 | "dml", 24 | "mocks", 25 | "unit", 26 | "test", 27 | "soql" 28 | ], 29 | "author": "James Simone", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/jamessimone/apex-dml-mocking/issues" 33 | }, 34 | "homepage": "https://github.com/jamessimone/apex-dml-mocking#readme" 35 | } 36 | -------------------------------------------------------------------------------- /force-app/factory/Factory.cls: -------------------------------------------------------------------------------- 1 | public virtual class Factory { 2 | public final RepoFactory repoFactory = new RepoFactory(); 3 | 4 | @TestVisible 5 | private static Factory factory; 6 | 7 | @SuppressWarnings('PMD.EmptyStatementBlock') 8 | protected Factory() { 9 | // enforce getFactory() as the sole entry point for usage 10 | } 11 | 12 | public static Factory getFactory() { 13 | // production code can only initialize the factory through this method 14 | // but tests can provide an alternative factory implementation 15 | if (factory == null) { 16 | factory = new Factory(); 17 | } 18 | 19 | return factory; 20 | } 21 | 22 | // create methods to initialize your objects here 23 | // (an example is included in the example-app folder) 24 | 25 | @TestVisible 26 | private Factory withMocks { 27 | // you can call "withMocks" after "getFactory" in tests to swap out 28 | // how repositories are created and DML is performed 29 | get { 30 | this.repoFactory.setFacade(new RepoFactoryMock.FacadeMock()); 31 | return this; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Simone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /force-app/dml/IDML.cls: -------------------------------------------------------------------------------- 1 | public interface IDML { 2 | Database.SaveResult doInsert(SObject record); 3 | List doInsert(List recordList); 4 | Database.SaveResult doUpdate(SObject record); 5 | List doUpdate(List recordList); 6 | Database.UpsertResult doUpsert(SObject record); 7 | List doUpsert(List recordList); 8 | List doUpsert(List recordList, Schema.SObjectField externalIDField); 9 | Database.UndeleteResult doUndelete(SObject record); 10 | List doUndelete(List recordList); 11 | 12 | Database.DeleteResult doDelete(SObject record); 13 | List doDelete(List recordList); 14 | Database.DeleteResult doHardDelete(SObject record); 15 | List doHardDelete(List recordList); 16 | 17 | Database.SaveResult publish(SObject platformEvent); 18 | List publish(List platformEvent); 19 | 20 | IDML setOptions(Database.DMLOptions options); 21 | IDML setOptions(Database.DMLOptions options, System.AccessLevel accessLevel); 22 | } 23 | -------------------------------------------------------------------------------- /example-app/handlers/AccountHandlerTests.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class AccountHandlerTests { 3 | @IsTest 4 | static void itShouldInsertNewOppsWhenAccountsAreInserted() { 5 | Account acc = new Account(Name = 'Test Corp.', Id = TestingUtils.generateId(Account.SObjectType)); 6 | 7 | // this is a unit test; an integration test would have a trigger on Account, 8 | // and you would insert/update the Account in order to test end-to-end that your trigger 9 | // correctly called your handler, which correctly did the things you cared about. 10 | // a unit test for a handler does not rely on DML; it does not actually update entities in the database; 11 | // it purely tests inputs and outputs for the expected results 12 | ExampleFactory.getFactory() 13 | .withMocks.getAccountHandler() 14 | .afterInsert(new List{ acc }, new Map(new List{ acc })); 15 | 16 | Opportunity insertedOpp = (Opportunity) DMLMock.Inserted.Opportunities.singleOrDefault; 17 | Assert.areNotEqual(null, insertedOpp, 'Opp should have been inserted!'); 18 | Assert.areEqual('Prospecting Opp for: Test Corp.', insertedOpp.Name); 19 | Assert.areEqual(acc.Id, insertedOpp.AccountId); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /scripts/validate-history-query.apex: -------------------------------------------------------------------------------- 1 | if ([SELECT COUNT() FROM OpportunityFieldHistory] == 0) { 2 | List opps = [SELECT Id, Name FROM Opportunity LIMIT 1]; 3 | if (opps.isEmpty()) { 4 | Opportunity newOpp = new Opportunity(StageName = 'History', Name = 'History Field Tracking Integration Test', CloseDate = System.today(), Amount = 5); 5 | insert newOpp; 6 | opps.add(newOpp); 7 | } 8 | 9 | Opportunity opp = opps[0]; 10 | opp.Name = 'Something New'; 11 | update opp; 12 | System.debug('Updated opportunity to create history record'); 13 | } else { 14 | System.debug('Histories already exist, continuing ...'); 15 | } 16 | 17 | Exception ex; 18 | List histories; 19 | try { 20 | histories = new FieldLevelHistoryRepo( 21 | OpportunityFieldHistory.SObjectType, 22 | new List(), 23 | new RepoFactory() 24 | ).setParentField(OpportunityFieldHistory.OpportunityId).getAllHistory(); 25 | System.debug(histories); 26 | } catch (Exception e) { 27 | ex = e; 28 | } 29 | 30 | if (histories.isEmpty()) { 31 | ex = new IllegalArgumentException('History record(s) were not retrieved correctly!'); 32 | } 33 | if (ex == null) { 34 | System.debug('Finished successfully!'); 35 | } else { 36 | throw ex; 37 | } 38 | 39 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRecord.cls: -------------------------------------------------------------------------------- 1 | public class AggregateRecord { 2 | private final Map keyToAggregateResult = new Map(); 3 | private static final String COUNT_KEY = 'countKey'; 4 | 5 | public AggregateRecord putAll(Map values) { 6 | this.keyToAggregateResult.putAll(values); 7 | return this; 8 | } 9 | 10 | public Object get(String key) { 11 | return this.keyToAggregateResult.get(key); 12 | } 13 | 14 | public Integer getCount() { 15 | return (Integer) this.keyToAggregateResult.get(COUNT_KEY); 16 | } 17 | 18 | public AggregateRecord setCount(Integer countAmount) { 19 | this.keyToAggregateResult.put(COUNT_KEY, countAmount); 20 | return this; 21 | } 22 | 23 | public Boolean equals(Object that) { 24 | if (that instanceof AggregateResult) { 25 | Map thatKeyToAggregateResult = ((AggregateResult) that).getPopulatedFieldsAsMap(); 26 | return this.keyToAggregateResult.equals(thatKeyToAggregateResult); 27 | } else if (that instanceof AggregateRecord) { 28 | return this.keyToAggregateResult.equals(((AggregateRecord) that).keyToAggregateResult); 29 | } 30 | return false; 31 | } 32 | 33 | public Integer hashCode() { 34 | return this.keyToAggregateResult.hashCode(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /force-app/repository/QueryField.cls: -------------------------------------------------------------------------------- 1 | public without sharing virtual class QueryField { 2 | private final String fieldValue; 3 | 4 | public QueryField(List fieldTokens) { 5 | this(getFieldNames(fieldTokens)); 6 | } 7 | 8 | public QueryField(List fieldNames) { 9 | this.fieldValue = String.join(fieldNames, ','); 10 | } 11 | 12 | public QueryField(Schema.SObjectField token) { 13 | this.fieldValue = token.toString(); 14 | } 15 | 16 | public QueryField(List parentFieldChain, List parentFields) { 17 | String base = ''; 18 | while (parentFieldChain.isEmpty() == false) { 19 | base += parentFieldChain.remove(0).getDescribe().getRelationshipName() + '.'; 20 | } 21 | List fields = new List(); 22 | for (Schema.SObjectField field : parentFields) { 23 | fields.add(base + field.toString()); 24 | } 25 | this.fieldValue = String.join(fields, ','); 26 | } 27 | 28 | public override String toString() { 29 | return this.fieldValue; 30 | } 31 | 32 | private static List getFieldNames(List fieldTokens) { 33 | List fieldNames = new List(); 34 | for (Schema.SObjectField token : fieldTokens) { 35 | fieldNames.add(token.toString()); 36 | } 37 | return fieldNames; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /force-app/repository/IAggregateRepository.cls: -------------------------------------------------------------------------------- 1 | public interface IAggregateRepository extends IRepository { 2 | IAggregateRepository groupBy(Schema.SObjectField fieldToken); 3 | IAggregateRepository groupBy(DateFunction dateFunction, Schema.SObjectField fieldToken, String alias); 4 | IAggregateRepository groupBy(List parentFieldChain); 5 | 6 | IAggregateRepository addHaving(Aggregation aggregation, Query.Operator operator, Object value); 7 | 8 | IAggregateRepository addSortOrder(Aggregation aggregate, RepositorySortOrder sortOrder); 9 | IAggregateRepository addSortOrder( 10 | DateFunction dateFunction, 11 | Schema.SObjectField fieldToken, 12 | RepositorySortOrder sortOrder 13 | ); 14 | 15 | // always a fun one 16 | Integer count(); 17 | Integer count(Query query); 18 | Integer count(List queries); 19 | // with support for sum, count, count distinct, average, max, min 20 | List aggregate(Aggregation aggregation); 21 | List aggregate(Aggregation aggregation, Query query); 22 | List aggregate(Aggregation aggregation, List queries); 23 | List aggregate(List aggregations); 24 | List aggregate(List aggregations, Query query); 25 | List aggregate(List aggregations, List queries); 26 | } 27 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistory.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.PropertyNamingConventions') 2 | public class FieldLevelHistory { 3 | private String parentLookupName = 'ParentId'; 4 | 5 | public Datetime CreatedDate { get; set; } 6 | public Id CreatedById { get; set; } 7 | public Object NewValue { get; set; } 8 | public Object OldValue { get; set; } 9 | public String Field { get; set; } 10 | public Id ParentId { get; private set; } 11 | 12 | public override String toString() { 13 | return JSON.serialize(this); 14 | } 15 | 16 | public FieldLevelHistory setValues(Map values) { 17 | this.CreatedById = (Id) values.get('CreatedById'); 18 | Object possibleCreatedDate = values.get('CreatedDate'); 19 | this.CreatedDate = Datetime.valueOfGmt(String.valueOf(possibleCreatedDate).replace('T', ' ').remove('"')); 20 | this.Field = (String) values.get('Field'); 21 | this.NewValue = values.get('NewValue'); 22 | this.OldValue = values.get('OldValue'); 23 | this.setParentId(values); 24 | return this; 25 | } 26 | 27 | public FieldLevelHistory setParentLookup(Schema.SObjectField fieldToken) { 28 | if (fieldToken != null) { 29 | this.parentLookupName = fieldToken.getDescribe().getName(); 30 | } 31 | return this; 32 | } 33 | 34 | private void setParentId(Map values) { 35 | this.ParentId = (Id) values.get(this.parentLookupName); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /force-app/utils/TestingUtils.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public class TestingUtils { 3 | private static Integer startingNumber = 1; 4 | 5 | @SuppressWarnings('PMD.EagerlyLoadedDescribeSObjectResult') 6 | public static String generateId(Schema.SObjectType sObjectType) { 7 | String result = String.valueOf(startingNumber++); 8 | Schema.DescribeSObjectResult describe = sObjectType.getDescribe(Schema.SObjectDescribeOptions.DEFERRED); 9 | String keyPrefix = describe.getKeyPrefix(); 10 | if (keyPrefix == null) { 11 | switch on describe.getAssociateEntityType() { 12 | when 'History' { 13 | keyPrefix = '017'; 14 | } 15 | when 'Share' { 16 | keyPrefix = '02c'; 17 | } 18 | } 19 | } 20 | return keyPrefix + '0'.repeat(12 - result.length()) + result; 21 | } 22 | 23 | public static SObject generateId(SObject objectInstance) { 24 | objectInstance.Id = objectInstance.Id ?? generateId(objectInstance.getSObjectType()); 25 | return objectInstance; 26 | } 27 | 28 | public static void generateIds(List records) { 29 | for (SObject record : records) { 30 | generateId(record); 31 | } 32 | } 33 | 34 | @IsTest 35 | static void generatesFakeHistoryIds() { 36 | Assert.isNotNull(generateId(LeadHistory.SObjectType)); 37 | } 38 | 39 | @IsTest 40 | static void generatesFakeShareRecords() { 41 | Assert.isNotNull(generateId(LeadShare.SObjectType)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactoryMockTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class RepoFactoryMockTest { 3 | @IsTest 4 | static void addsChildrenInMemory() { 5 | Account acc = new Account(); 6 | String expectedOppName = 'Hello'; 7 | List opps = new List{ new Opportunity(Name = expectedOppName) }; 8 | // can't simply do acc.Opportunities = opps - this leads to "Field is not writeable: Account.Opportunities" on deploy 9 | Assert.areEqual(0, acc.Opportunities.size(), 'Test has started under the wrong conditions'); 10 | 11 | acc = (Account) RepoFactoryMock.addChildrenToRecord(acc, Opportunity.AccountId, opps); 12 | 13 | Assert.areEqual(1, acc.Opportunities.size()); 14 | Assert.areEqual(expectedOppName, acc.Opportunities.get(0).Name); 15 | } 16 | 17 | @IsTest 18 | static void addsMockCursor() { 19 | Account acc = new Account(Id = TestingUtils.generateId(Schema.Account.SObjectType)); 20 | Account secondAccount = new Account(Id = TestingUtils.generateId(Schema.Account.SObjectType)); 21 | RepoFactoryMock.CursorResults.put( 22 | Account.SObjectType, 23 | new List{ new RepoFactoryMock.CursorMock(new List{ acc, secondAccount }) } 24 | ); 25 | 26 | RepoFactoryMock.FacadeMock facade = new RepoFactoryMock.FacadeMock(); 27 | IHistoryRepository repo = facade.getRepo( 28 | Account.SObjectType, 29 | new List(), 30 | new RepoFactoryMock() 31 | ); 32 | Cursor cursor = repo.getCursor(new List()); 33 | 34 | Assert.areEqual(2, cursor.getNumRecords()); 35 | Assert.areEqual(acc.Id, cursor.fetch(0, 1)[0].Id); 36 | // verify we don't overindex past the actual cursor end 37 | Assert.areEqual(secondAccount.Id, cursor.fetch(1, 3)[0].Id); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /force-app/repository/RepositorySortOrder.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.PropertyNamingConventions') 2 | public class RepositorySortOrder { 3 | private final SortOrder sortOrder; 4 | private NullSortOrder nullSortOrder; 5 | 6 | public enum SortOrder { 7 | ASCENDING, 8 | DESCENDING 9 | } 10 | 11 | public enum NullSortOrder { 12 | FIRST, 13 | LAST 14 | } 15 | 16 | public static final RepositorySortOrder ASCENDING { 17 | get { 18 | if (ASCENDING == null) { 19 | ASCENDING = new RepositorySortOrder(SortOrder.ASCENDING); 20 | } 21 | return ASCENDING; 22 | } 23 | set; 24 | } 25 | 26 | public static final RepositorySortOrder DESCENDING { 27 | get { 28 | if (DESCENDING == null) { 29 | DESCENDING = new RepositorySortOrder(SortOrder.DESCENDING); 30 | } 31 | return DESCENDING; 32 | } 33 | set; 34 | } 35 | 36 | public RepositorySortOrder(SortOrder sortOrder) { 37 | this(sortOrder, null); 38 | } 39 | 40 | public RepositorySortOrder(SortOrder sortOrder, NullSortOrder nullSortOrder) { 41 | this.sortOrder = sortOrder; 42 | this.nullSortOrder = nullSortOrder; 43 | } 44 | 45 | public override String toString() { 46 | String base = this.sortOrder == RepositorySortOrder.SortOrder.ASCENDING ? 'ASC' : 'DESC'; 47 | if (this.nullSortOrder != null) { 48 | base += ' ' + 'NULLS ' + this.nullSortOrder.name(); 49 | } 50 | return base; 51 | } 52 | 53 | public Boolean equals(Object thatObj) { 54 | if (thatObj instanceof RepositorySortOrder) { 55 | RepositorySortOrder that = (RepositorySortOrder) thatObj; 56 | return this.nullSortOrder == that.nullSortOrder && this.sortOrder == that.sortOrder; 57 | } 58 | return false; 59 | } 60 | 61 | public Integer hashCode() { 62 | return this.toString().hashCode(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Unique name for this workflow 2 | name: Apex DML Mocking Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - 'sfdx-project.json' 10 | - '**/README.md' 11 | - 'package.json' 12 | - 'LICENSE' 13 | - '.gitignore' 14 | - '.prettierignore' 15 | - '.prettierrc' 16 | pull_request: 17 | types: [opened, synchronize] 18 | paths-ignore: 19 | - 'sfdx-project.json' 20 | - '**/README.md' 21 | - 'package.json' 22 | - '.gitignore' 23 | - '.prettierignore' 24 | - '.prettierrc' 25 | 26 | jobs: 27 | scratch-org-test: 28 | runs-on: ubuntu-latest 29 | environment: Test 30 | steps: 31 | # Checkout the code 32 | - name: 'Checkout source code' 33 | uses: actions/checkout@v4 34 | with: 35 | ref: ${{ github.head_ref }} 36 | 37 | - name: 'Setup node' 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: '22' 41 | cache: 'npm' 42 | 43 | - name: 'Install NPM' 44 | run: npm ci 45 | 46 | - name: 'Static Code Analysis' 47 | run: npm run scan 48 | 49 | # Authenticate using JWT flow 50 | - name: 'Auth to dev hub' 51 | shell: bash 52 | run: | 53 | echo "${{ env.DEVHUB_SERVER_KEY }}" > ./jwt-server.key 54 | npx sf org login jwt --client-id ${{ env.DEVHUB_CONSUMER_KEY }} --username ${{ env.DEVHUB_USERNAME }} --jwt-key-file ./jwt-server.key --set-default-dev-hub 55 | npx sf config set target-org ${{ env.DEVHUB_USERNAME }} 56 | env: 57 | DEVHUB_USERNAME: ${{ secrets.DEVHUB_USERNAME }} 58 | DEVHUB_CONSUMER_KEY: ${{ secrets.DEVHUB_CONSUMER_KEY }} 59 | DEVHUB_SERVER_KEY: ${{ secrets.DEVHUB_SERVER_KEY }} 60 | 61 | - name: 'Deploy & Test' 62 | shell: pwsh 63 | run: '. ./scripts/test.ps1' 64 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistoryRepo.cls: -------------------------------------------------------------------------------- 1 | public virtual without sharing class FieldLevelHistoryRepo extends AggregateRepository implements IHistoryRepository { 2 | private Schema.SObjectField parentFieldToken; 3 | private final Set fullHistoryFields; 4 | 5 | private Boolean isHistoryQuery = false; 6 | 7 | public FieldLevelHistoryRepo( 8 | Schema.SObjectType repoType, 9 | List queryFields, 10 | RepoFactory repoFactory 11 | ) { 12 | super(repoType, queryFields, repoFactory); 13 | this.fullHistoryFields = this.repoType.getDescribe(SObjectDescribeOptions.DEFERRED).fields.getMap().keySet(); 14 | } 15 | 16 | public virtual List getAllHistory() { 17 | return this.getHistory(new List()); 18 | } 19 | 20 | public List getHistory(Query query) { 21 | return this.getHistory(new List{ query }); 22 | } 23 | 24 | public virtual List getHistory(List queries) { 25 | this.isHistoryQuery = true; 26 | List unwrappedHistoryRecords = this.get(queries); 27 | this.isHistoryQuery = false; 28 | if (unwrappedHistoryRecords instanceof List) { 29 | return (List) unwrappedHistoryRecords; 30 | } 31 | 32 | List historyRecords = new List(); 33 | for (Object obj : unwrappedHistoryRecords) { 34 | FieldLevelHistory historyRecord = new FieldLevelHistory() 35 | .setParentLookup(this.parentFieldToken) 36 | .setValues((Map) JSON.deserializeUntyped(JSON.serialize(obj))); 37 | historyRecords.add(historyRecord); 38 | } 39 | return historyRecords; 40 | } 41 | 42 | public FieldLevelHistoryRepo setParentField(Schema.SObjectField parentField) { 43 | this.parentFieldToken = parentField; 44 | return this; 45 | } 46 | 47 | protected virtual override Set addSelectFields() { 48 | return this.isHistoryQuery ? this.fullHistoryFields : super.addSelectFields(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /force-app/repository/Aggregation.cls: -------------------------------------------------------------------------------- 1 | public class Aggregation { 2 | private enum Operation { 3 | COUNT, 4 | COUNT_DISTINCT, 5 | SUM, 6 | AVERAGE, 7 | MAX, 8 | MIN 9 | } 10 | 11 | private final Operation op; 12 | private final String fieldName; 13 | private final String alias; 14 | 15 | private Aggregation(Operation op, Schema.SObjectField fieldToken, String alias) { 16 | this.op = op; 17 | this.fieldName = fieldToken.getDescribe().getName(); 18 | this.alias = alias; 19 | } 20 | 21 | public static Aggregation sum(Schema.SObjectField fieldToken, String alias) { 22 | return new Aggregation(Operation.SUM, fieldToken, alias); 23 | } 24 | 25 | public static Aggregation count(Schema.SObjectField fieldToken, String alias) { 26 | return new Aggregation(Operation.COUNT, fieldToken, alias); 27 | } 28 | 29 | public static Aggregation countDistinct(Schema.SObjectfield fieldToken, String alias) { 30 | return new Aggregation(Operation.COUNT_DISTINCT, fieldToken, alias); 31 | } 32 | 33 | public static Aggregation average(Schema.SObjectfield fieldToken, String alias) { 34 | return new Aggregation(Operation.AVERAGE, fieldToken, alias); 35 | } 36 | 37 | public static Aggregation max(Schema.SObjectfield fieldToken, String alias) { 38 | return new Aggregation(Operation.MAX, fieldToken, alias); 39 | } 40 | 41 | public static Aggregation min(Schema.SObjectfield fieldToken, String alias) { 42 | return new Aggregation(Operation.MIN, fieldToken, alias); 43 | } 44 | 45 | public String getAlias() { 46 | return this.alias; 47 | } 48 | 49 | public String getFieldName() { 50 | return this.fieldName; 51 | } 52 | 53 | public String getBaseAggregation() { 54 | return this.op.name() + '(' + fieldName + ')'; 55 | } 56 | 57 | public override String toString() { 58 | return this.getBaseAggregation() + ' ' + this.alias; 59 | } 60 | 61 | public Boolean equals(Object that) { 62 | return this.toString() == that.toString(); 63 | } 64 | 65 | public Integer hashCode() { 66 | return this.toString().hashCode(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactory.cls: -------------------------------------------------------------------------------- 1 | public virtual class RepoFactory { 2 | private static final Map CACHED_REPOS = new Map(); 3 | private Boolean shouldPrintBindVars = false; 4 | 5 | public Facade facade { 6 | get { 7 | if (this.facade == null) { 8 | this.facade = new Facade(); 9 | } 10 | return this.facade; 11 | } 12 | protected set; 13 | } 14 | 15 | @SuppressWarnings('PMD.AvoidBooleanMethodParameters') 16 | public RepoFactory setShouldPrintBindVars(Boolean shouldPrintBindVars) { 17 | this.shouldPrintBindVars = shouldPrintBindVars; 18 | return this; 19 | } 20 | 21 | public IHistoryRepository getProfileRepo() { 22 | return this.facade.getRepo(Profile.SObjectType, new List{ Profile.Name }, this); 23 | } 24 | 25 | public IDML getDML() { 26 | return this.facade.getDML(); 27 | } 28 | 29 | public RepoFactory setFacade(Facade mockFacade) { 30 | if (Test.isRunningTest() == false) { 31 | throw new IllegalArgumentException('Should not call this outside of tests'); 32 | } 33 | this.facade = mockFacade; 34 | return this; 35 | } 36 | 37 | public virtual class Facade { 38 | public virtual IDML getDML() { 39 | return new DML(); 40 | } 41 | 42 | public virtual IHistoryRepository getRepo( 43 | Schema.SObjectType repoType, 44 | List queryFields, 45 | RepoFactory repoFactory 46 | ) { 47 | IHistoryRepository potentiallyCachedInstance = CACHED_REPOS.get(repoType); 48 | if (potentiallyCachedInstance == null) { 49 | potentiallyCachedInstance = new FieldLevelHistoryRepo(repoType, queryFields, repoFactory); 50 | CACHED_REPOS.put(repoType, potentiallyCachedInstance); 51 | System.debug(System.LoggingLevel.FINER, 'Instantiating new repository of type: ' + repoType); 52 | } else { 53 | System.debug(System.LoggingLevel.FINER, 'Using cached repository of type: ' + repoType); 54 | } 55 | potentiallyCachedInstance.setShouldPrintBindVars(repoFactory.shouldPrintBindVars); 56 | return potentiallyCachedInstance; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example-app/handlers/ExampleSObjectTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class ExampleSObjectTest { 3 | @TestSetup 4 | static void setup() { 5 | Account acc = new Account(Name = ExampleSObjectTest.class.getName()); 6 | insert acc; 7 | insert new ExampleSObject__c(Account__c = acc.Id, SecondAccountLookup__c = acc.Id, Name = 'Child'); 8 | } 9 | 10 | @IsTest 11 | static void correctlyReturnsCustomObjectParents() { 12 | Repository repo = new Repository( 13 | ExampleSObject__c.SObjectType, 14 | new List{ ExampleSObject__c.Id, ExampleSObject__c.Name }, 15 | new RepoFactory() 16 | ); 17 | repo.addParentFields( 18 | new List{ ExampleSObject__c.Account__c }, 19 | new List{ Account.Name } 20 | ); 21 | repo.addParentFields( 22 | new List{ ExampleSObject__c.SecondAccountLookup__c }, 23 | new List{ Account.Name } 24 | ); 25 | 26 | List children = repo.getAll(); 27 | 28 | ExampleSObject__c expected = [SELECT Id, Account__r.Name, SecondAccountLookup__r.Name FROM ExampleSObject__c]; 29 | Assert.areEqual(1, children.size()); 30 | Assert.areEqual(expected.Account__r.Name, children.get(0).Account__r.Name); 31 | Assert.areEqual(expected.SecondAccountLookup__r.Name, children.get(0).SecondAccountLookup__r.Name); 32 | } 33 | 34 | @IsTest 35 | static void correctlyReturnsCustomObjectChildren() { 36 | Repository repo = new Repository( 37 | Account.SObjectType, 38 | new List{ Account.Name }, 39 | new RepoFactory() 40 | ); 41 | repo.addChildFields(ExampleSObject__c.Account__c, new List{ ExampleSObject__c.Name }); 42 | repo.addChildFields( 43 | ExampleSObject__c.SecondAccountLookup__c, 44 | new List{ ExampleSObject__c.Name } 45 | ); 46 | 47 | Account expected = [ 48 | SELECT (SELECT Name FROM Example_SObjects__r), (SELECT Name FROM Example_SObjects1__r) 49 | FROM Account 50 | ]; 51 | Account actual = (Account) repo.getAll().get(0); 52 | Assert.areEqual(1, actual.Example_SObjects__r.size()); 53 | Assert.areEqual(expected.Example_SObjects__r, actual.Example_SObjects__r); 54 | Assert.areEqual(1, actual.Example_SObjects1__r.size()); 55 | Assert.areEqual(expected.Example_SObjects1__r, actual.Example_SObjects1__r); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/test.ps1: -------------------------------------------------------------------------------- 1 | $DebugPreference = 'Continue' 2 | $ErrorActionPreference = 'Stop' 3 | # This is also the same script that runs on Github via the Github Action configured in .github/workflows - there, the 4 | # DEVHUB_SFDX_URL.txt file is populated in a build step 5 | $testInvocation = 'npx sf apex run test -s ApexDmlMockingSuite -r human -w 20 -d ./tests/apex --concise' 6 | $userAlias = 'apex-dml-mocking-scratch' 7 | 8 | function Remove-Scratch-Org() { 9 | try { 10 | Write-Debug "Deleting scratch org ..." 11 | npx sf org delete scratch --no-prompt -o $userAlias 12 | } catch { 13 | Write-Debug "Scratch org deletion failed, continuing ..." 14 | } 15 | } 16 | 17 | function Start-Deploy() { 18 | Write-Debug "Deploying source ..." 19 | npx sf project deploy start --source-dir force-app 20 | npx sf project deploy start --source-dir example-app 21 | } 22 | 23 | function Start-Tests() { 24 | Write-Debug "Starting test run ..." 25 | Invoke-Expression $testInvocation 26 | $testRunId = Get-Content tests/apex/test-run-id.txt 27 | $specificTestRunJson = Get-Content "tests/apex/test-result-$testRunId.json" | ConvertFrom-Json 28 | $testFailure = $false 29 | if ($specificTestRunJson.summary.outcome -eq "Failed") { 30 | $testFailure = $true 31 | } 32 | 33 | if ($true -eq $testFailure) { 34 | Remove-Scratch-Org 35 | throw 'Test run failure!' 36 | } 37 | 38 | npx sf apex run -f scripts/validate-history-query.apex -o $userAlias 39 | Remove-Scratch-Org 40 | } 41 | 42 | Write-Debug "Starting build script" 43 | 44 | # For local dev, store currently auth'd org to return to 45 | # Also store test command shared between script branches, below 46 | $scratchOrgAllotment = ((npx sf org list limits --json | ConvertFrom-Json).result | Where-Object -Property name -eq "DailyScratchOrgs").remaining 47 | 48 | Write-Debug "Total remaining scratch orgs for the day: $scratchOrgAllotment" 49 | Write-Debug "Test command to use: $testInvocation" 50 | 51 | if($scratchOrgAllotment -gt 0) { 52 | try { 53 | Write-Debug "Beginning scratch org creation" 54 | # Create Scratch Org 55 | npx sf org create scratch --definition-file config/project-scratch-def.json --alias $userAlias --set-default --duration-days 1 56 | npx sf config set target-org $userAlias 57 | } catch { 58 | # Do nothing, we'll just try to deploy to the Dev Hub instead 59 | } 60 | } 61 | 62 | Start-Deploy 63 | Start-Tests 64 | 65 | Write-Debug "Build + testing finished successfully" 66 | 67 | -------------------------------------------------------------------------------- /force-app/repository/IRepository.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.AvoidBooleanMethodParameters,PMD.ExcessiveParameterList') 2 | public interface IRepository extends IDML { 3 | Cursor getCursor(List queries); 4 | 5 | Database.QueryLocator getQueryLocator(List queries); 6 | Database.QueryLocator getQueryLocator(List queries, Boolean shouldAddChildFields); 7 | 8 | List get(Query query); 9 | List get(List queries); 10 | List getAll(); 11 | 12 | List> getSosl(String searchTerm, Query query); 13 | List> getSosl(String searchTerm, List queries); 14 | List> getSosl(String searchTerm, List queries, List additionalSoslObjects); 15 | IRepository setSearchGroup(SearchGroup searchGroup); 16 | 17 | IRepository setShouldPrintBindVars(Boolean shouldPrintBindVars); 18 | IRepository clearBindVars(); 19 | IRepository setAccessLevel(System.AccessLevel accessLevel); 20 | IRepository setLimit(Integer limitAmount); 21 | 22 | IRepository addSortOrder(Schema.SObjectField fieldToken, RepositorySortOrder sortOrder); 23 | IRepository addSortOrder(List fieldChain, RepositorySortOrder sortOrder); 24 | 25 | IRepository addBaseFields(List fields); 26 | 27 | IRepository addFunctionBaseField(SelectFunction selectFunction, Schema.SObjectField field); 28 | IRepository addFunctionBaseFields(SelectFunction selectFunction, List fields); 29 | IRepository addFunctionBaseFields(SelectFunction selectFunction, Map fieldsToAliases); 30 | 31 | IRepository addParentFields(Schema.SObjectField relationshipField, List parentFields); 32 | IRepository addParentFields(List relationshipFields, List parentFields); 33 | 34 | IRepository addChildFields(Schema.SObjectField childFieldToken, List childFields); 35 | IRepository addChildFields(Schema.SObjectField childFieldToken, IRepository childRepo); 36 | IRepository addChildFields( 37 | Schema.SObjectField childFieldToken, 38 | List childFields, 39 | List optionalWhereFilters, 40 | Map fieldToSortOrder, 41 | Integer limitBy 42 | ); 43 | IRepository addChildFields( 44 | Schema.SObjectField childFieldToken, 45 | IRepository childRepo, 46 | List optionalWhereFilters, 47 | Map fieldToSortOrder, 48 | Integer limitBy 49 | ); 50 | IRepository addChildFields( 51 | Schema.SObjectField childFieldToken, 52 | List childFields, 53 | List optionalWhereFilters, 54 | Map fieldToSortOrder, 55 | Integer limitBy 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /force-app/repository/FieldLevelHistoryRepoTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class FieldLevelHistoryRepoTest { 3 | @IsTest 4 | static void itShouldContinueToActAsBaseRepo() { 5 | IHistoryRepository historyRepo = new FieldLevelHistoryRepoTest.ExampleRepoFactory().getOppFieldHistoryRepo(); 6 | 7 | List histories = historyRepo.get( 8 | Query.equals(OpportunityFieldHistory.OpportunityId, null) 9 | ); 10 | 11 | Assert.areNotEqual(null, histories); 12 | } 13 | 14 | @IsTest 15 | static void itShouldContinueToActAsAggregateRepo() { 16 | Aggregation count = Aggregation.count(OpportunityFieldHistory.Id, 'countId'); 17 | IAggregateRepository repo = new FieldLevelHistoryRepoTest.ExampleRepoFactory().getOppFieldHistoryRepo(); 18 | repo.groupBy(OpportunityFieldHistory.OpportunityId); 19 | 20 | List records = repo.aggregate(count); 21 | 22 | // It's not much of an assert, but at least we know the query went through successfully 23 | Assert.areEqual(true, records.isEmpty()); 24 | } 25 | 26 | @IsTest 27 | static void itShouldQueryHistoryRecords() { 28 | IHistoryRepository historyRepo = new FieldLevelHistoryRepoTest.ExampleRepoFactory().getOppFieldHistoryRepo(); 29 | 30 | List histories = historyRepo.getAllHistory(); 31 | // History records can't be created during Apex unit testing, but we can at least validate the query 32 | // and prove that the FieldLevelHistory decorator list is returned properly 33 | Assert.areEqual(true, histories.isEmpty()); 34 | } 35 | 36 | @IsTest 37 | static void itShouldAllowMockingOfHistoryRecords() { 38 | FieldLevelHistory mockRecord = new FieldLevelHistory(); 39 | mockRecord.setValues( 40 | new Map{ 41 | 'CreatedDate' => System.now(), 42 | 'Id' => TestingUtils.generateId(OpportunityFieldHistory.SObjectType), 43 | 'Field' => 'Amount', 44 | 'OldValue' => 0, 45 | 'NewValue' => 1, 46 | 'OpportunityId' => TestingUtils.generateId(Opportunity.SObjectType) 47 | } 48 | ); 49 | RepoFactoryMock.HistoryResults.put(OpportunityFieldHistory.SObjectType, new List{ mockRecord }); 50 | 51 | IHistoryRepository historyRepo = new FieldLevelHistoryRepoTest.ExampleRepoFactory().getOppFieldHistoryRepo(); 52 | 53 | List histories = historyRepo.getAllHistory(); 54 | Assert.areNotEqual(true, histories.isEmpty()); 55 | Assert.areEqual(mockRecord, histories[0]); 56 | } 57 | 58 | private without sharing class ExampleRepoFactory extends RepoFactory { 59 | private ExampleRepoFactory() { 60 | this.facade = new RepoFactoryMock.FacadeMock(); 61 | } 62 | 63 | public IAggregateRepository getOppRepo() { 64 | List queryFields = new List{ 65 | Opportunity.IsWon, 66 | Opportunity.StageName 67 | // etc ... 68 | }; 69 | IAggregateRepository oppRepo = this.facade.getRepo(Opportunity.SObjectType, queryFields, this); 70 | oppRepo.addParentFields( 71 | new List{ Opportunity.AccountId }, 72 | new List{ Account.Id } 73 | ); 74 | return oppRepo; 75 | } 76 | 77 | public IHistoryRepository getOppFieldHistoryRepo() { 78 | return this.facade.getRepo(OpportunityFieldHistory.SObjectType, new List(), this) 79 | .setParentField(OpportunityFieldHistory.OpportunityId); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /example-app/triggers/TriggerHandler.cls: -------------------------------------------------------------------------------- 1 | public virtual class TriggerHandler { 2 | protected TriggerHandler() { 3 | if (!Trigger.isExecuting && !Test.isRunningTest()) { 4 | throw new TriggerHandlerException('TriggerHandler used outside of triggers / testing'); 5 | } 6 | } 7 | 8 | public void execute() { 9 | switch on Trigger.operationType { 10 | when BEFORE_INSERT { 11 | this.beforeInsert(Trigger.new); 12 | } 13 | when BEFORE_UPDATE { 14 | this.beforeUpdate(Trigger.new, Trigger.newMap, Trigger.old, Trigger.oldMap); 15 | } 16 | when BEFORE_DELETE { 17 | this.beforeDelete(Trigger.old, Trigger.oldMap); 18 | } 19 | when AFTER_INSERT { 20 | this.afterInsert(Trigger.new, Trigger.newMap); 21 | } 22 | when AFTER_UPDATE { 23 | this.afterUpdate(Trigger.new, Trigger.newMap, Trigger.old, Trigger.oldMap); 24 | } 25 | when AFTER_DELETE { 26 | this.afterDelete(Trigger.old, Trigger.oldMap); 27 | } 28 | when AFTER_UNDELETE { 29 | this.afterUndelete(Trigger.new, Trigger.newMap); 30 | } 31 | } 32 | } 33 | 34 | protected List getUpdatedRecordsWithChangedField(SObjectField field) { 35 | return this.getUpdatedRecordsWithChangedFields(new List{ field }); 36 | } 37 | 38 | protected List getUpdatedRecordsWithChangedFields(List fields) { 39 | List updatedRecords = new List(); 40 | 41 | for (SObject record : Trigger.new) { 42 | SObject oldRecord = Trigger.oldMap.get(record.Id); 43 | for (SObjectField field : fields) { 44 | if (record.get(field) != oldRecord.get(field)) { 45 | updatedRecords.add(record); 46 | break; 47 | } 48 | } 49 | } 50 | return updatedRecords; 51 | } 52 | 53 | // you can choose whether you would want these methods to be test visible or not 54 | // in practice, tests that involve handlers are typically your integration tests, and 55 | // mocking should not occur for those, or should be used sparingly. For this example, 56 | // in order to keep things VERY light, I am raising the visibility of this method to show off 57 | // simple mocking in a handler. In reality, your handlers themselves would probably be invoking objects 58 | // doing something CONSIDERABLY more complicated than inserting an opportunity for new accounts, and your mocking 59 | // would instead be part of the unit tests for those objects 60 | protected virtual void beforeInsert(List newRecords) { 61 | } 62 | protected virtual void beforeUpdate( 63 | List updatedRecords, 64 | Map updatedRecordsMap, 65 | List oldRecords, 66 | Map oldRecordsMap 67 | ) { 68 | } 69 | protected virtual void beforeDelete(List deletedRecords, Map deletedRecordsMap) { 70 | } 71 | @TestVisible 72 | protected virtual void afterInsert(List newRecords, Map newRecordsMap) { 73 | } 74 | protected virtual void afterUpdate( 75 | List updatedRecords, 76 | Map updatedRecordsMap, 77 | List oldRecords, 78 | Map oldRecordsMap 79 | ) { 80 | } 81 | protected virtual void afterDelete(List deletedRecords, Map deletedRecordsMap) { 82 | } 83 | protected virtual void afterUndelete(List undeletedRecords, Map undeletedRecordsMap) { 84 | } 85 | 86 | private class TriggerHandlerException extends Exception { 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /example-app/triggers/TriggerHandler_Tests.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class TriggerHandler_Tests { 3 | @IsTest 4 | static void itShouldPerformBeforeInsert() { 5 | TestTriggerHandler testHandler = new TestTriggerHandler(); 6 | 7 | testHandler.beforeInsert(null); 8 | 9 | Assert.areEqual(TriggerOperation.BEFORE_INSERT, testHandler.Method); 10 | } 11 | 12 | @IsTest 13 | static void itShouldPerformBeforeUpdate() { 14 | TestTriggerHandler testHandler = new TestTriggerHandler(); 15 | 16 | testHandler.beforeUpdate(null, null, null, null); 17 | 18 | Assert.areEqual(TriggerOperation.BEFORE_UPDATE, testHandler.Method); 19 | } 20 | 21 | @IsTest 22 | static void itShouldPerformBeforeDelete() { 23 | TestTriggerHandler testHandler = new TestTriggerHandler(); 24 | 25 | testHandler.beforeDelete(null, null); 26 | 27 | Assert.areEqual(TriggerOperation.BEFORE_DELETE, testHandler.Method); 28 | } 29 | 30 | @IsTest 31 | static void itShouldPerformAfterInsert() { 32 | TestTriggerHandler testHandler = new TestTriggerHandler(); 33 | 34 | testHandler.afterInsert(null, null); 35 | 36 | Assert.areEqual(TriggerOperation.AFTER_INSERT, testHandler.Method); 37 | } 38 | 39 | @IsTest 40 | static void itShouldPerformAfterUpdate() { 41 | TestTriggerHandler testHandler = new TestTriggerHandler(); 42 | 43 | testHandler.afterUpdate(null, null, null, null); 44 | 45 | Assert.areEqual(TriggerOperation.AFTER_UPDATE, testHandler.Method); 46 | } 47 | 48 | @IsTest 49 | static void itShouldPerformAfterDelete() { 50 | TestTriggerHandler testHandler = new TestTriggerHandler(); 51 | 52 | testHandler.afterDelete(null, null); 53 | 54 | Assert.areEqual(TriggerOperation.AFTER_DELETE, testHandler.Method); 55 | } 56 | 57 | @IsTest 58 | static void itShouldPerformAfterUndelete() { 59 | TestTriggerHandler testHandler = new TestTriggerHandler(); 60 | 61 | testHandler.afterUndelete(null, null); 62 | 63 | Assert.areEqual(TriggerOperation.AFTER_UNDELETE, testHandler.Method); 64 | } 65 | 66 | private class TestTriggerHandler extends TriggerHandler { 67 | public TriggerOperation Method { get; private set; } 68 | 69 | @TestVisible 70 | protected override void beforeInsert(List newRecords) { 71 | this.Method = TriggerOperation.BEFORE_INSERT; 72 | } 73 | @TestVisible 74 | protected override void beforeUpdate( 75 | List updatedRecords, 76 | Map updatedRecordsMap, 77 | List oldRecords, 78 | Map oldRecordsMap 79 | ) { 80 | this.Method = TriggerOperation.BEFORE_UPDATE; 81 | } 82 | @TestVisible 83 | protected override void beforeDelete(List deletedRecords, Map deletedRecordsMap) { 84 | this.Method = TriggerOperation.BEFORE_DELETE; 85 | } 86 | @TestVisible 87 | protected override void afterInsert(List newRecords, Map newRecordsMap) { 88 | this.Method = TriggerOperation.AFTER_INSERT; 89 | } 90 | @TestVisible 91 | protected override void afterUpdate( 92 | List updatedRecords, 93 | Map updatedRecordsMap, 94 | List oldRecords, 95 | Map oldRecordsMap 96 | ) { 97 | this.Method = TriggerOperation.AFTER_UPDATE; 98 | } 99 | @TestVisible 100 | protected override void afterDelete(List deletedRecords, Map deletedRecordsMap) { 101 | this.Method = TriggerOperation.AFTER_DELETE; 102 | } 103 | @TestVisible 104 | protected override void afterUndelete(List undeletedRecords, Map undeletedRecordsMap) { 105 | this.Method = TriggerOperation.AFTER_UNDELETE; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /force-app/repository/Cursor.cls: -------------------------------------------------------------------------------- 1 | public virtual without sharing class Cursor { 2 | public static final Integer MAX_FETCH_SIZE { 3 | get { 4 | MAX_FETCH_SIZE = MAX_FETCH_SIZE ?? maxRecordsPerFetchCall; 5 | return MAX_FETCH_SIZE; 6 | } 7 | private set; 8 | } 9 | 10 | private static final Integer MAX_FETCHES_PER_TRANSACTION = Limits.getLimitFetchCallsOnApexCursor(); 11 | 12 | @TestVisible 13 | private static Integer maxRecordsPerFetchCall = 2000; 14 | 15 | @TestVisible 16 | private static Integer localFetchesMade; 17 | 18 | private Integer cursorNumRecords; 19 | private Integer fetchesPerTransaction = MAX_FETCHES_PER_TRANSACTION; 20 | private final Database.Cursor cursor; 21 | 22 | public Cursor(String finalQuery, Map bindVars, System.AccessLevel accessLevel) { 23 | try { 24 | this.cursor = Database.getCursorWithBinds(finalQuery, bindVars, accessLevel); 25 | } catch (FatalCursorException e) { 26 | System.debug( 27 | System.LoggingLevel.WARN, 28 | 'Error creating cursor. This can happen if there are no records returned by the query: ' + e.getMessage() 29 | ); 30 | } 31 | } 32 | 33 | public Cursor setFetchesPerTransaction(Integer possibleFetchesPerTransaction) { 34 | // Handle accidental round downs from Integer division 35 | if (possibleFetchesPerTransaction == 0) { 36 | return this; 37 | } 38 | if (possibleFetchesPerTransaction > MAX_FETCHES_PER_TRANSACTION) { 39 | System.debug( 40 | System.LoggingLevel.DEBUG, 41 | 'Fetches per transaction: ' + 42 | possibleFetchesPerTransaction + 43 | ' exceeded platform max fetches per transaction: ' + 44 | MAX_FETCHES_PER_TRANSACTION + 45 | ', defaulting to platform max' 46 | ); 47 | possibleFetchesPerTransaction = MAX_FETCHES_PER_TRANSACTION; 48 | } 49 | this.fetchesPerTransaction = possibleFetchesPerTransaction; 50 | return this; 51 | } 52 | 53 | @SuppressWarnings('PMD.EmptyStatementBlock') 54 | protected Cursor() { 55 | } 56 | 57 | public virtual List fetch(Integer start, Integer advanceBy) { 58 | if (this.getNumRecords() == 0) { 59 | System.debug(System.LoggingLevel.DEBUG, 'Bypassing fetch call, no records to fetch'); 60 | return new List(); 61 | } 62 | localFetchesMade = localFetchesMade ?? 0; 63 | Integer localStart = start; 64 | List results = new List(); 65 | while ( 66 | localFetchesMade < this.fetchesPerTransaction && 67 | results.size() < this.getNumRecords() && 68 | localStart < start + advanceBy 69 | ) { 70 | Integer actualAdvanceBy = this.getAdvanceBy(localStart, advanceBy); 71 | results.addAll(this.cursor?.fetch(localStart, actualAdvanceBy) ?? new List()); 72 | localStart += actualAdvanceBy; 73 | localFetchesMade++; 74 | } 75 | return results; 76 | } 77 | 78 | public virtual Integer getNumRecords() { 79 | this.cursorNumRecords = this.cursorNumRecords ?? this.cursor?.getNumRecords() ?? 0; 80 | return this.cursorNumRecords; 81 | } 82 | 83 | protected Integer getAdvanceBy(Integer start, Integer advanceBy) { 84 | Integer possibleFetchSize = Math.min(advanceBy, this.getNumRecords() - start); 85 | if (possibleFetchSize > maxRecordsPerFetchCall) { 86 | System.debug( 87 | System.LoggingLevel.DEBUG, 88 | 'Fetch size: ' + 89 | possibleFetchSize + 90 | ' exceeded platform max fetch size of ' + 91 | maxRecordsPerFetchCall + 92 | ', defaulting to max fetch size' 93 | ); 94 | possibleFetchSize = maxRecordsPerFetchCall; 95 | } else if (possibleFetchSize < 0) { 96 | possibleFetchSize = 0; 97 | } 98 | return possibleFetchSize; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /force-app/repository/CursorTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class CursorTest { 3 | @IsTest 4 | static void itCapsAdvanceByArgument() { 5 | String accountName = 'helloWorld!'; 6 | insert new Account(Name = accountName); 7 | String query = 'SELECT Name FROM Account WHERE Name = :bindVar0'; 8 | Map bindVars = new Map{ 'bindVar0' => accountName }; 9 | 10 | Cursor instance = new Cursor(query, bindVars, System.AccessLevel.SYSTEM_MODE); 11 | 12 | Assert.areEqual(1, instance.getNumRecords()); 13 | Assert.areEqual(accountName, instance.fetch(0, 1000).get(0).get('Name')); 14 | Assert.areEqual(1, System.Limits.getApexCursorRows()); 15 | } 16 | 17 | @IsTest 18 | static void itCapsmaxRecordsPerFetchCall() { 19 | Cursor.maxRecordsPerFetchCall = 20; 20 | Integer oneMoreThanMaxFetch = Cursor.maxRecordsPerFetchCall + 1; 21 | 22 | List accounts = new List(); 23 | for (Integer i = 0; i < oneMoreThanMaxFetch; i++) { 24 | accounts.add(new Account(Name = 'Fetch ' + i)); 25 | } 26 | insert accounts; 27 | 28 | Exception ex; 29 | List results; 30 | Cursor instance = new Cursor('SELECT Id FROM Account', new Map(), System.AccessLevel.SYSTEM_MODE); 31 | try { 32 | results = instance.fetch(0, oneMoreThanMaxFetch); 33 | } catch (System.InvalidParameterValueException e) { 34 | ex = e; 35 | } 36 | 37 | Assert.areEqual(null, ex?.getMessage()); 38 | Assert.areEqual(2, Cursor.localFetchesMade); 39 | Assert.areEqual(oneMoreThanMaxFetch, results.size()); 40 | } 41 | 42 | @IsTest 43 | static void itFetchesMultipleTimesPerTransactionWhenMoreThanMaxFetch() { 44 | Cursor.maxRecordsPerFetchCall = 20; 45 | List accounts = new List(); 46 | Set expectedFetchNames = new Set(); 47 | for (Integer i = 0; i < Cursor.maxRecordsPerFetchCall + 1; i++) { 48 | String accountName = 'Fetch' + i; 49 | expectedFetchNames.add(accountName); 50 | accounts.add(new Account(Name = accountName)); 51 | } 52 | insert accounts; 53 | 54 | Integer oneMoreThanMaxFetch = Cursor.maxRecordsPerFetchCall + 1; 55 | Cursor instance = new Cursor('SELECT Name FROM Account', new Map(), System.AccessLevel.SYSTEM_MODE); 56 | List results = instance.setFetchesPerTransaction(2).fetch(0, oneMoreThanMaxFetch); 57 | 58 | Assert.areEqual(Cursor.maxRecordsPerFetchCall + 1, results.size()); 59 | Assert.areEqual(2, Cursor.localFetchesMade); 60 | Set actuallyFetchedNames = new Set(); 61 | for (Account account : (List) results) { 62 | actuallyFetchedNames.add(account.Name); 63 | } 64 | Assert.areEqual(expectedFetchNames, actuallyFetchedNames); 65 | } 66 | 67 | @IsTest 68 | static void itFetchesMultipleTimesPerTransaction() { 69 | Cursor.maxRecordsPerFetchCall = 1; 70 | insert new List{ new Account(Name = 'One'), new Account(Name = 'Two') }; 71 | 72 | Cursor instance = new Cursor('SELECT Id FROM Account', new Map(), System.AccessLevel.SYSTEM_MODE) 73 | .setFetchesPerTransaction(2); 74 | List results = instance.fetch(0, 2); 75 | 76 | Assert.areEqual(2, instance.getNumRecords()); 77 | Assert.areEqual(2, results.size()); 78 | results = instance.fetch(2, 1); 79 | Assert.areEqual(0, results.size()); 80 | } 81 | 82 | @IsTest 83 | static void fetchesCorrectAmountOfRecords() { 84 | List accounts = new List(); 85 | for (Integer i = 0; i < 10; i++) { 86 | accounts.add(new Account(Name = 'Fetch ' + i)); 87 | } 88 | insert accounts; 89 | 90 | Cursor instance = new Cursor('SELECT Id FROM Account', new Map(), System.AccessLevel.SYSTEM_MODE) 91 | .setFetchesPerTransaction(10); 92 | List results = instance.fetch(0, 2); 93 | 94 | Assert.areEqual(2, results.size(), '' + results); 95 | Assert.areEqual(1, Cursor.localFetchesMade); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /force-app/dml/DML.cls: -------------------------------------------------------------------------------- 1 | public virtual without sharing class DML implements IDML { 2 | /** 3 | * To use this class properly, see the Factory class: 4 | * DML should be injected as a dependency by the factory 5 | * then in your tests, the Factory.withMocks method 6 | * becomes your one-stop-shop signal for switching to the DMLMock in tests 7 | */ 8 | @TestVisible 9 | private static final Integer MAX_DML_CHUNKING = 10; 10 | 11 | private System.AccessLevel accessLevel = System.AccessLevel.SYSTEM_MODE; 12 | 13 | private Database.DMLOptions options { 14 | get { 15 | if (this.options == null) { 16 | this.options = new Database.DMLOptions(); 17 | } 18 | this.options.OptAllOrNone = this.options.OptAllOrNone ?? true; 19 | return this.options; 20 | } 21 | set; 22 | } 23 | 24 | private enum OperationLoggingValue { 25 | INSERTING, 26 | UPDATING, 27 | UPSERTING, 28 | DELETING, 29 | UNDELETING, 30 | PUBLISHING 31 | } 32 | 33 | public virtual Database.SaveResult doInsert(SObject record) { 34 | return this.doInsert(new List{ record })[0]; 35 | } 36 | public virtual List doInsert(List records) { 37 | this.sortToPreventChunkingErrors(records); 38 | log(OperationLoggingValue.INSERTING, records); 39 | return Database.insert(records, this.options, this.accessLevel); 40 | } 41 | 42 | public virtual Database.SaveResult doUpdate(SObject record) { 43 | return this.doUpdate(new List{ record })[0]; 44 | } 45 | public virtual List doUpdate(List records) { 46 | this.sortToPreventChunkingErrors(records); 47 | log(OperationLoggingValue.UPDATING, records); 48 | return Database.update(records, this.options, this.accessLevel); 49 | } 50 | 51 | public virtual Database.UpsertResult doUpsert(SObject record) { 52 | return this.doUpsert(new List{ record })[0]; 53 | } 54 | 55 | public virtual List doUpsert(List records) { 56 | this.sortToPreventChunkingErrors(records); 57 | log(OperationLoggingValue.UPSERTING, records); 58 | return Database.upsert(records, this.options.OptAllOrNone, this.accessLevel); 59 | } 60 | 61 | public virtual List doUpsert(List records, Schema.SObjectField externalIdField) { 62 | this.sortToPreventChunkingErrors(records); 63 | log(OperationLoggingValue.UPSERTING, records); 64 | return Database.upsert(records, externalIdField, this.options.OptAllOrNone, this.accessLevel); 65 | } 66 | 67 | public virtual Database.UndeleteResult doUndelete(SObject record) { 68 | return this.doUndelete(new List{ record })[0]; 69 | } 70 | public virtual List doUndelete(List records) { 71 | log(OperationLoggingValue.UNDELETING, records); 72 | return Database.undelete(records, this.options.OptAllOrNone, this.accessLevel); 73 | } 74 | 75 | public virtual Database.DeleteResult doDelete(SObject record) { 76 | return this.doDelete(new List{ record })[0]; 77 | } 78 | public virtual List doDelete(List records) { 79 | log(OperationLoggingValue.DELETING, records); 80 | return Database.delete(records, this.options.OptAllOrNone, this.accessLevel); 81 | } 82 | 83 | public virtual Database.DeleteResult doHardDelete(SObject record) { 84 | return this.doHardDelete(new List{ record })[0]; 85 | } 86 | public virtual List doHardDelete(List records) { 87 | List results = this.doDelete(records); 88 | System.debug(System.LoggingLevel.FINE, 'emptying recycling bin...'); 89 | Database.emptyRecycleBin(records); 90 | return results; 91 | } 92 | 93 | public virtual Database.SaveResult publish(SObject event) { 94 | log(OperationLoggingValue.PUBLISHING, event); 95 | return EventBus.publish(event); 96 | } 97 | public virtual List publish(List events) { 98 | log(OperationLoggingValue.PUBLISHING, events); 99 | return EventBus.publish(events); 100 | } 101 | 102 | public DML setOptions(Database.DMLOptions options) { 103 | return this.setOptions(options, this.accessLevel); 104 | } 105 | 106 | public DML setOptions(Database.DMLOptions options, System.AccessLevel accessLevel) { 107 | this.options = options ?? this.options; 108 | this.accessLevel = accessLevel; 109 | return this; 110 | } 111 | 112 | private void sortToPreventChunkingErrors(List records) { 113 | // prevents a chunking error that can occur if SObject types are in the list out of order. 114 | // no need to sort if the list size is below the limit 115 | if (records.size() >= MAX_DML_CHUNKING) { 116 | records.sort(); 117 | } 118 | } 119 | 120 | private static void log(OperationLoggingValue loggingValue, Object recordOrRecords) { 121 | Integer size = (recordOrRecords instanceof List) ? ((List) recordOrRecords).size() : 1; 122 | System.debug( 123 | System.LoggingLevel.FINE, 124 | loggingValue.name().toLowerCase() + ' ' + size + ' record' + (size > 1 ? 's' : '') + '...' 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /example-app/objects/ExampleSObject__c/ExampleSObject__c.object-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accept 5 | Default 6 | 7 | 8 | Accept 9 | Large 10 | Default 11 | 12 | 13 | Accept 14 | Small 15 | Default 16 | 17 | 18 | CancelEdit 19 | Default 20 | 21 | 22 | CancelEdit 23 | Large 24 | Default 25 | 26 | 27 | CancelEdit 28 | Small 29 | Default 30 | 31 | 32 | Clone 33 | Default 34 | 35 | 36 | Clone 37 | Large 38 | Default 39 | 40 | 41 | Clone 42 | Small 43 | Default 44 | 45 | 46 | Delete 47 | Default 48 | 49 | 50 | Delete 51 | Large 52 | Default 53 | 54 | 55 | Delete 56 | Small 57 | Default 58 | 59 | 60 | Edit 61 | Default 62 | 63 | 64 | Edit 65 | Large 66 | Default 67 | 68 | 69 | Edit 70 | Small 71 | Default 72 | 73 | 74 | List 75 | Default 76 | 77 | 78 | List 79 | Large 80 | Default 81 | 82 | 83 | List 84 | Small 85 | Default 86 | 87 | 88 | New 89 | Default 90 | 91 | 92 | New 93 | Large 94 | Default 95 | 96 | 97 | New 98 | Small 99 | Default 100 | 101 | 102 | SaveEdit 103 | Default 104 | 105 | 106 | SaveEdit 107 | Large 108 | Default 109 | 110 | 111 | SaveEdit 112 | Small 113 | Default 114 | 115 | 116 | Tab 117 | Default 118 | 119 | 120 | Tab 121 | Large 122 | Default 123 | 124 | 125 | Tab 126 | Small 127 | Default 128 | 129 | 130 | View 131 | Default 132 | 133 | 134 | View 135 | Large 136 | Default 137 | 138 | 139 | View 140 | Small 141 | Default 142 | 143 | false 144 | SYSTEM 145 | Deployed 146 | false 147 | true 148 | false 149 | false 150 | false 151 | false 152 | false 153 | true 154 | true 155 | 156 | 157 | 158 | Text 159 | 160 | Example SObjects 161 | 162 | ReadWrite 163 | Public 164 | 165 | -------------------------------------------------------------------------------- /force-app/dml/DMLTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class DMLTest { 3 | @TestSetup 4 | static void setup() { 5 | Contact con = new Contact(LastName = 'Test1'); 6 | con.Email = 'something@something.com'; 7 | insert new List{ new Account(Name = 'TestAccount'), con }; 8 | } 9 | 10 | @IsTest 11 | static void shouldPerformInsert() { 12 | Contact contact = new Contact(LastName = 'Test2'); 13 | new DML().doInsert(contact); 14 | 15 | Assert.isNotNull(contact.Id); 16 | } 17 | 18 | @IsTest 19 | static void shouldPerformUpdate() { 20 | Contact contact = [SELECT Id FROM Contact]; 21 | 22 | contact.FirstName = 'Harry'; 23 | new DML().doUpdate(contact); 24 | 25 | Assert.isTrue(contact.FirstName == 'Harry'); 26 | } 27 | 28 | @IsTest 29 | static void shouldNotFailWhileUpdatingEmptyList() { 30 | List contacts = new List(); 31 | new DML().doUpdate(contacts); 32 | 33 | Assert.isTrue(true, 'Should make it here'); 34 | } 35 | 36 | @IsTest 37 | static void shouldPerformUpsert() { 38 | Contact contact = [SELECT Id FROM Contact]; 39 | contact.FirstName = 'Harry'; 40 | new DML().doUpsert(contact); 41 | 42 | contact = [SELECT Id, FirstName FROM Contact WHERE Id = :contact.Id]; 43 | 44 | Assert.isTrue(contact.FirstName == 'Harry'); 45 | } 46 | 47 | @IsTest 48 | static void shouldPerformDelete() { 49 | Contact contact = [SELECT Id FROM Contact]; 50 | 51 | new DML().doDelete(contact); 52 | List deletedContacts = [SELECT Id, IsDeleted FROM Contact ALL ROWS]; 53 | 54 | Assert.isTrue(deletedContacts[0].IsDeleted); 55 | } 56 | 57 | @IsTest 58 | static void shouldPerformHardDelete() { 59 | Contact contact = [SELECT Id FROM Contact]; 60 | 61 | new DML().doHardDelete(contact); 62 | List deletedContacts = [SELECT Id, IsDeleted FROM Contact ALL ROWS]; 63 | 64 | Assert.isTrue(deletedContacts[0].IsDeleted); 65 | } 66 | 67 | @IsTest 68 | static void shouldPerformUndelete() { 69 | Contact contact = [SELECT Id FROM Contact]; 70 | 71 | IDML dml = new DML(); 72 | dml.doDelete(contact); 73 | dml.doUndelete(contact); 74 | 75 | List notDeletedContacts = [SELECT Id FROM Contact]; 76 | Assert.isTrue(!notDeletedContacts.isEmpty()); 77 | } 78 | 79 | @IsTest 80 | static void shouldRollbackUpsertOnError() { 81 | Account one = new Account(Name = 'Test1'); 82 | Account two = new Account(); 83 | try { 84 | // this should fail because name is a required field on Account 85 | new DML().doUpsert(new List{ one, two }); 86 | Assert.fail('Should throw in above unit'); 87 | } catch (Exception e) { 88 | Assert.isInstanceOfType(e, System.DmlException.class); 89 | // do nothing, in this case 90 | } 91 | 92 | Assert.areEqual(null, one.Id); 93 | Assert.areEqual(null, two.Id); 94 | } 95 | 96 | @IsTest 97 | static void shouldNotFailDueToChunkingError() { 98 | List records = new List(); 99 | List accounts = new List(); 100 | List contacts = new List(); 101 | 102 | for (Integer i = 0; i < DML.MAX_DML_CHUNKING; i++) { 103 | Account a = new Account(Name = '' + i); 104 | accounts.add(a); 105 | records.add(a); 106 | 107 | Contact c = new Contact(LastName = '' + i); 108 | contacts.add(c); 109 | records.add(c); 110 | } 111 | 112 | insert accounts; 113 | insert contacts; 114 | 115 | try { 116 | new DML().doUpdate(records); 117 | } catch (Exception ex) { 118 | Assert.isNull(ex, ex.getMessage()); 119 | } 120 | } 121 | 122 | @IsTest 123 | static void shouldFakeDMLOperations() { 124 | // some of these operations aren't awesome 125 | // but this ensures adequate code coverage prior to 126 | // being used elsewhere in the codebase for mocking 127 | IDML mock = new DMLMock(); 128 | 129 | List accs = new List{ new Account() }; 130 | 131 | mock.doInsert(accs); 132 | 133 | mock.doUpdate(accs); 134 | 135 | mock.doUpsert(accs); 136 | mock.doUpsert(accs, Account.Name); 137 | 138 | mock.doDelete(accs); 139 | 140 | mock.doUndelete(accs); 141 | 142 | DMLMock.RecordsWrapper wrapper = DMLMock.Inserted.ofType(Account.SObjectType); 143 | Assert.areEqual(accs, wrapper.Records); 144 | Assert.areEqual(1, wrapper.size()); 145 | Assert.areEqual(accs[0], wrapper.singleOrDefault); 146 | Assert.areEqual(accs[0], wrapper.firstOrDefault); 147 | 148 | Assert.areEqual(true, wrapper.hasId(accs[0].Id)); 149 | Assert.areEqual(true, wrapper.hasId(accs[0].Id, Account.Id)); 150 | } 151 | 152 | @IsTest 153 | static void publishesEvents() { 154 | BatchApexErrorEvent first = new BatchApexErrorEvent(); 155 | BatchApexErrorEvent second = new BatchApexErrorEvent(); 156 | 157 | IDML dml = new DML(); 158 | Database.SaveResult firstResult = dml.publish(first); 159 | Database.SaveResult secondResult = dml.publish(new List{ second }).get(0); 160 | 161 | Assert.areEqual(true, firstResult.isSuccess()); 162 | Assert.areEqual(true, secondResult.isSuccess()); 163 | } 164 | 165 | @IsTest 166 | static void shouldPassNonNullValueOnOperatonThatDoesNotUseDmlOptions() { 167 | Database.DMLOptions dmlOptions = new Database.DMLOptions(); 168 | dmlOptions.AllowFieldTruncation = true; 169 | Contact contact = new Contact(LastName = 'Test2'); 170 | 171 | new DML().setOptions(dmlOptions).doUpsert(contact); 172 | 173 | Assert.isNotNull(contact.Id); 174 | } 175 | 176 | @IsTest 177 | static void upsertWithDefaultSetOptionsWorks() { 178 | // on some code paths, like insert, OptAllOrNone not being initialized on Database.DMLOptions is fine 179 | // but on upsert (and possibly other operations), that specific value is always checked 180 | Contact con = new Contact(LastName = 'Upsert'); 181 | 182 | new DML().setOptions(new Database.DMLOptions(), System.AccessLevel.SYSTEM_MODE).doUpsert(con); 183 | 184 | Assert.isNotNull(con.Id); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Apex DML Mocking 2 | 3 | Welcome to the SFDX project home for blazing fast Apex unit tests! For your consideration, this is an _example_ of how to implement the full CRUD (Create Read Update Delete) mocking implementation within your own Salesforce orgs. You can find out more information by perusing: 4 | 5 | - [force-app](/force-app) for implementation details 6 | - [example-app](/example-app) for an example Account Handler with mocking set up 7 | 8 | Writing tests that scale as the size of your organization grows is an increasingly challenging problem in the Salesforce world. It's not uncommon in large companies for deploys to last several hours; the vast majority of that time is spent running tests to verify that your code coverage is good enough for the deploy to succeed. Tests don't _need_ to take that long. 9 | 10 | This repo shows you how you can mock your SOQL queries and DML statements in Apex by using lightweight wrappers that are dependency injected into your business logic objects. This allows you to replace expensive test setup and test teardown with a fake database. I've used this method to cut testing time down by 90%+ -- in a small org, with only a few hundred tests, running tests and deploying can be done in under five minutes (easily). In large orgs, with many hundreds or thousands of tests, overall testing time tends to scale more linearly with organizational complexity; there are additional optimizations that can be done in these orgs to keep deploys in the 10 minute(s) range. 11 | 12 | ## Access Level & DML Option Setting 13 | 14 | Both `IDML` and `IRepository` instances returned by the framework support the method `IDML setOptions(Database.DMLOptions options, System.AccessLevel accessLevel);`. Note that if DML options are not set by default, this framework uses true for the `allOrNone` value when performing DML, as that is consistent with the standard for calling DML operations without specifying that property. Please also note that DML options are not "expired" after having been set -- if you are using an instance of `IRepository` or `IDML` and are performing multiple DML operations using that same instance, the DML options that have been set will continue to apply to subsequent operations until `setOptions` is re-called, or a new instance is initialized. DML options are _not_ shared between instances; they are not statically set. Passing `null` for the DML options value will only update the access level. 15 | 16 | By default, all operations are run using `System.AccessMode.SYSTEM_MODE`. You can either override this (for `IDML` and `IRepository` instances) by calling `setOptions`, as shown above, or by calling `IRepository setAccessLevel(System.AccessLevel accessLevel);` on `IRepository` instances. Like DML options, the access level that is set for an instance is then the one used for subsequent operations involving that repository instance. 17 | 18 | ## DML Mocking Basics 19 | 20 | Try checking out the source code for the DML wrapping classes: 21 | 22 | - [DML](force-app/dml/DML.cls) 23 | - [DMLMock](force-app/dml/DMLMock.cls) 24 | 25 | ## SOQL Mocking Basics 26 | 27 | Take a look at the following classes to understand how you can replace raw SOQL in your code with testable (and extendable) strongly typed queries: 28 | 29 | - [Repository](force-app/repository/Repository.cls) 30 | - [Query](force-app/repository/Query.cls) 31 | 32 | Then, move on to the more complicated examples: 33 | 34 | - [AggregateRepository](force-app/repository/AggregateRepository.cls) 35 | - [Aggregation](force-app/repository/Aggregation.cls) 36 | - [AggregateRepositoryTests](force-app/repository/AggregateRepositoryTests.cls) - a good example of how to use the above two classes 37 | - [FieldLevelHistoryRepo](force-app/repository/FieldLevelHistoryRepo.cls) 38 | - [FieldLevelHistory](force-app/repository/FieldLevelHistory.cls) 39 | 40 | While opinionated in implementation, these classes are also just scratching the surface of what's possible when taking advantage of the Factory pattern in combination with the Repository pattern, including full support for: 41 | 42 | - strongly typed subqueries (queries returning children records) 43 | - strongly typed parent-level fields 44 | - the ability to easily extend classes like `Repository` to include things like limits, order bys, etc ... 45 | 46 | ## Dependency Injection Basics 47 | 48 | The "Factory" pattern is of particular importance for DML mocking, because it allows you to have only _one_ stub in your code for deciding whether or not to use mocks when running tests; crucially, the stub is only available when tests are being run: you cannot mock things in production-grade code. 49 | 50 | You can have as many Factories as you'd like. I like to break my Factories out by responsibility: 51 | 52 | - A factory for Trigger handlers 53 | - A [factory](force-app/factory/Factory.cls) for basic classes 54 | - The [RepoFactory](force-app/factory/RepoFactory.cls) for CRUD related objects 55 | 56 | It's a pretty standard approach. You might choose to break things down by (business) domain. There's no right way. 57 | 58 | ## Lazy-Loading Dependencies 59 | 60 | The one (possible) downside to using a big Factory class comes with respect to runtime performance when referencing the factory. This is because the Apex compiler "spiders" out, loading type signatures referenced by public methods. While this performance hit is typically negligible, there are some niche use-cases where loading _every_ class reference you need means a performance hit that you can't afford to take. For these cases, consider using the [LazyFactory](force-app/factory/LazyFactory.cls) approach - initializing it directly within your dependencies, and using getters within the `LazyFactory` to reference the dependencies you need. By using getters (see the example in [LazyFactoryTest](force-app/factory/LazyFactoryTest.cls)), you can delay the type-loading and spidering until a dependency is actually used. 61 | 62 | ## Package-Based Development 63 | 64 | These repository (as of 18 May 2023) has been slightly reworked to provide better support for package-based development. The updates are primarily to show how the `example-app` folder can be in a completely separate package while still allowing for strongly-typed references (and package-specific factories and repo factories) to be referenced properly. For a concrete example, check out: 65 | 66 | - [The unit tests in HistoryRepoTests](example-app/history/HistoryRepoTests.cls) 67 | - [The extended factory](example-app/ExampleFactory.cls) 68 | - [The extended repo factory](example-app/ExampleRepoFactory.cls) 69 | 70 | --- 71 | 72 | ## More Information 73 | 74 | For more information on these patterns and how to use them, consider the free resources I've published under [The Joys Of Apex](https://www.jamessimone.net/blog/joys-of-apex/). Thanks for your time! 75 | -------------------------------------------------------------------------------- /force-app/dml/DMLMock.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.FieldNamingConventions,PMD.PropertyNamingConventions') 2 | @IsTest 3 | public class DMLMock extends DML { 4 | public static List InsertedRecords = new List(); 5 | public static List UpsertedRecords = new List(); 6 | public static List UpdatedRecords = new List(); 7 | public static List DeletedRecords = new List(); 8 | public static List UndeletedRecords = new List(); 9 | public static List PublishedRecords = new List(); 10 | 11 | public override List doInsert(List records) { 12 | TestingUtils.generateIds(records); 13 | InsertedRecords.addAll(records); 14 | return (List) createDatabaseResults(Database.SaveResult.class, records); 15 | } 16 | 17 | public override List doUpdate(List records) { 18 | UpdatedRecords.addAll(records); 19 | return (List) createDatabaseResults(Database.SaveResult.class, records); 20 | } 21 | 22 | public override List doUpsert(List records) { 23 | TestingUtils.generateIds(records); 24 | UpsertedRecords.addAll(records); 25 | return (List) createDatabaseResults(Database.UpsertResult.class, records); 26 | } 27 | public override List doUpsert(List records, Schema.SObjectField field) { 28 | return this.doUpsert(records); 29 | } 30 | 31 | public override List doUndelete(List records) { 32 | UndeletedRecords.addAll(records); 33 | return (List) createDatabaseResults(Database.UndeleteResult.class, records); 34 | } 35 | 36 | public override List doDelete(List records) { 37 | if (records?.isEmpty() == false) { 38 | DeletedRecords.addAll(records); 39 | } 40 | return (List) createDatabaseResults(Database.DeleteResult.class, records); 41 | } 42 | 43 | public override List doHardDelete(List records) { 44 | return this.doDelete(records); 45 | } 46 | 47 | public override Database.SaveResult publish(SObject event) { 48 | PublishedRecords.add(event); 49 | return (Database.SaveResult) createDatabaseResult(Database.SaveResult.class, event); 50 | } 51 | public override List publish(List events) { 52 | PublishedRecords.addAll(events); 53 | return (List) createDatabaseResults(Database.SaveResult.class, events); 54 | } 55 | 56 | public static RecordsWrapper Inserted { 57 | get { 58 | return new RecordsWrapper(InsertedRecords); 59 | } 60 | } 61 | 62 | public static RecordsWrapper Upserted { 63 | get { 64 | return new RecordsWrapper(UpsertedRecords); 65 | } 66 | } 67 | 68 | public static RecordsWrapper Updated { 69 | get { 70 | return new RecordsWrapper(UpdatedRecords); 71 | } 72 | } 73 | 74 | public static RecordsWrapper Deleted { 75 | get { 76 | return new RecordsWrapper(DeletedRecords); 77 | } 78 | } 79 | 80 | public static RecordsWrapper Undeleted { 81 | get { 82 | return new RecordsWrapper(UndeletedRecords); 83 | } 84 | } 85 | 86 | public static RecordsWrapper Published { 87 | get { 88 | return new RecordsWrapper(PublishedRecords); 89 | } 90 | } 91 | 92 | public class RecordsWrapper { 93 | List recordList; 94 | private RecordsWrapper(List recordList) { 95 | this.recordList = recordList; 96 | } 97 | 98 | public RecordsWrapper ofType(Schema.SObjectType sObjectType) { 99 | return new RecordsWrapper(this.getRecordsMatchingType(recordList, sObjectType)); 100 | } 101 | 102 | public RecordsWrapper Accounts { 103 | get { 104 | return this.ofType(Schema.Account.SObjectType); 105 | } 106 | } 107 | 108 | public RecordsWrapper Leads { 109 | get { 110 | return this.ofType(Schema.Lead.SObjectType); 111 | } 112 | } 113 | 114 | public RecordsWrapper Contacts { 115 | get { 116 | return this.ofType(Schema.Contact.SObjectType); 117 | } 118 | } 119 | 120 | public RecordsWrapper Opportunities { 121 | get { 122 | return this.ofType(Schema.Opportunity.SObjectType); 123 | } 124 | } 125 | 126 | public RecordsWrapper Tasks { 127 | get { 128 | return this.ofType(Schema.Task.SObjectType); 129 | } 130 | } 131 | 132 | public List Records { 133 | get { 134 | return recordList; 135 | } 136 | } 137 | 138 | public Boolean hasId(Id recordId) { 139 | return this.hasId(recordId, 'Id'); 140 | } 141 | 142 | public Boolean hasId(Id relatedId, Schema.SObjectField idField) { 143 | return this.hasId(relatedId, idField.toString()); 144 | } 145 | 146 | public Boolean hasId(Id relatedId, String idFieldName) { 147 | for (SObject record : this.recordList) { 148 | if (record.get(idFieldName) == relatedId) { 149 | return true; 150 | } 151 | } 152 | return false; 153 | } 154 | 155 | public Integer size() { 156 | return this.recordList.size(); 157 | } 158 | 159 | public SObject singleOrDefault { 160 | get { 161 | if (recordList.size() > 1) { 162 | throw new IllegalArgumentException('More than one value in records list'); 163 | } 164 | return recordList.size() == 0 ? null : recordList[0]; 165 | } 166 | } 167 | 168 | public SObject firstOrDefault { 169 | get { 170 | if (recordList.size() > 0) { 171 | return recordList[0]; 172 | } 173 | return null; 174 | } 175 | } 176 | 177 | private List getRecordsMatchingType(List records, Schema.SObjectType sObjectType) { 178 | List matchingRecords = new List(); 179 | for (SObject record : records) { 180 | if (record.getSObjectType() == sObjectType) { 181 | matchingRecords.add(record); 182 | } 183 | } 184 | return matchingRecords; 185 | } 186 | } 187 | 188 | private static List createDatabaseResults(Type clazz, List records) { 189 | List results = (List) Type.forName('List<' + clazz.getName() + '>').newInstance(); 190 | for (SObject record : records) { 191 | results.add(createDatabaseResult(clazz, record)); 192 | } 193 | return results; 194 | } 195 | 196 | private static Object createDatabaseResult(Type clazz, SObject record) { 197 | return JSON.deserialize('{"success": true, "id": "' + record.Id + '"}', clazz); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRepositoryTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class AggregateRepositoryTest { 3 | @IsTest 4 | static void shouldAggregateSum() { 5 | Account parent = new Account(Name = AggregateRepositoryTest.class.getName(), NumberOfEmployees = 1); 6 | Account secondParent = new Account(Name = 'Second parent', NumberOfEmployees = 1); 7 | insert new List{ parent, secondParent }; 8 | 9 | Opportunity opp = new Opportunity( 10 | Name = 'opp', 11 | Amount = 1, 12 | AccountId = parent.Id, 13 | StageName = 'sum', 14 | CloseDate = System.today() 15 | ); 16 | Opportunity secondOpp = new Opportunity( 17 | Name = 'opp2', 18 | Amount = 1, 19 | AccountId = secondParent.Id, 20 | StageName = 'sum', 21 | CloseDate = System.today() 22 | ); 23 | Opportunity anotherSecondParentMatch = new Opportunity( 24 | Name = 'opp3', 25 | Amount = 1, 26 | AccountId = secondParent.Id, 27 | StageName = 'sum', 28 | CloseDate = System.today() 29 | ); 30 | insert new List{ opp, secondOpp, anotherSecondParentMatch }; 31 | 32 | Aggregation sum = Aggregation.sum(Opportunity.Amount, 'oppSum'); 33 | IAggregateRepository repo = new AggregateRepository( 34 | Opportunity.SObjectType, 35 | new List{ Opportunity.AccountId, Opportunity.Id, Opportunity.Amount }, 36 | new RepoFactory() 37 | ); 38 | repo.groupBy(Opportunity.AccountId); 39 | List results = repo.aggregate(sum); 40 | 41 | Assert.areEqual(2, results?.size()); 42 | for (AggregateRecord res : results) { 43 | if (res.get('AccountId') == secondParent.Id) { 44 | Assert.areEqual(2, res.get(sum.getAlias())); 45 | } else { 46 | Assert.areEqual(1, res.get(sum.getAlias())); 47 | } 48 | } 49 | Assert.areEqual( 50 | 1, 51 | repo.groupBy(new List{ Opportunity.AccountId, Account.NumberOfEmployees }) 52 | .aggregate(sum) 53 | .size() 54 | ); 55 | } 56 | 57 | @IsTest 58 | static void shouldReturnCountOnFieldNameCorrectly() { 59 | insert new List{ 60 | new Opportunity(Name = 'opp', StageName = 'sum', CloseDate = System.today()), 61 | new Opportunity(Name = 'opp2', Amount = 1, StageName = 'sum', CloseDate = System.today()) 62 | }; 63 | 64 | IAggregateRepository repo = new AggregateRepository( 65 | Opportunity.SObjectType, 66 | new List{ Opportunity.AccountId, Opportunity.Id, Opportunity.Amount }, 67 | new RepoFactory() 68 | ); 69 | Aggregation countOfAmount = Aggregation.count(Opportunity.Amount, 'wowza'); 70 | List results = repo.aggregate(countOfAmount); 71 | 72 | Assert.areEqual(1, results.size()); 73 | Assert.areEqual(1, results[0].get(countOfAmount.getAlias())); 74 | Assert.areEqual( 75 | 1, 76 | repo.addHaving(countOfAmount, Query.Operator.GREATER_THAN, 0) 77 | .groupBy(Opportunity.CloseDate) 78 | .aggregate(countOfAmount)[0] 79 | .get(countOfAmount.getAlias()) 80 | ); 81 | // prove equality works 82 | Assert.isTrue(results.get(0).equals([SELECT COUNT(Amount) wowza FROM Opportunity].get(0))); 83 | Assert.areEqual(results.get(0), results.get(0)); 84 | } 85 | 86 | @IsTest 87 | static void shouldReturnCountAsInteger() { 88 | insert new List{ 89 | new Opportunity(Name = 'opp', StageName = 'sum', CloseDate = System.today()), 90 | new Opportunity(Name = 'opp2', Amount = 1, StageName = 'sum', CloseDate = System.today()) 91 | }; 92 | IAggregateRepository repo = new AggregateRepository( 93 | Opportunity.SObjectType, 94 | new List{ Opportunity.AccountId, Opportunity.Id, Opportunity.Amount }, 95 | new RepoFactory() 96 | ); 97 | 98 | Assert.areEqual(2, repo.count()); 99 | } 100 | 101 | @IsTest 102 | static void mocksAggregateResultsSuccessfully() { 103 | Aggregation countOfAmount = Aggregation.count(Opportunity.Amount, 'wowza'); 104 | Aggregation sum = Aggregation.sum(Opportunity.Amount, 'oppSum'); 105 | String accountKey = 'AccountId'; 106 | Map mockAggregateResult = new Map{ 107 | countOfAmount.getAlias() => 5, 108 | sum.getAlias() => 10, 109 | accountKey => TestingUtils.generateId(Account.SObjectType) 110 | }; 111 | AggregateRecord res = new AggregateRecord().putAll(mockAggregateResult); 112 | RepoFactoryMock.AggregateResults.put(Opportunity.SObjectType, new List{ res }); 113 | 114 | OppRepoFactory repoFactory = new OppRepoFactory(); 115 | repoFactory.setFacade(new RepoFactoryMock.FacadeMock()); 116 | 117 | List results = repoFactory 118 | .getOppRepo() 119 | .groupBy(Opportunity.AccountId) 120 | .aggregate(new List{ countOfAmount, sum }); 121 | 122 | Assert.areEqual(1, results.size()); 123 | AggregateRecord returnedResult = results.get(0); 124 | Assert.areEqual(mockAggregateResult.get(accountKey), returnedResult.get(accountKey)); 125 | Assert.areEqual(mockAggregateResult.get(countOfAmount.getAlias()), returnedResult.get(countOfAmount.getAlias())); 126 | Assert.areEqual(mockAggregateResult.get(sum.getAlias()), returnedResult.get(sum.getAlias())); 127 | } 128 | 129 | @IsTest 130 | static void shouldOrderByDateFunction() { 131 | IAggregateRepository cpaRepo = new AggregateRepository( 132 | ContactPointAddress.SObjectType, 133 | new List{ ContactPointAddress.Name, ContactPointAddress.ActiveFromDate }, 134 | new RepoFactory() 135 | ); 136 | 137 | insert new List{ 138 | new ContactPointAddress(Name = 'A', ActiveFromDate = System.today()), 139 | new ContactPointAddress(Name = 'B', ActiveFromDate = System.today().addYears(-2)), 140 | new ContactPointAddress(Name = 'C', ActiveFromDate = System.today().addYears(2)), 141 | new ContactPointAddress(Name = 'D', ActiveFromDate = System.today().addYears(5)) // should be excluded through limit 142 | }; 143 | 144 | String nameAlias = 'name'; 145 | cpaRepo.setLimit(3); 146 | List results = cpaRepo.groupBy( 147 | DateFunction.CALENDAR_YEAR, 148 | ContactPointAddress.ActiveFromDate, 149 | 'activeDate' 150 | ) 151 | .addSortOrder(DateFunction.CALENDAR_YEAR, ContactPointAddress.ActiveFromDate, RepositorySortOrder.ASCENDING) 152 | .aggregate(Aggregation.max(ContactPointAddress.Name, nameAlias)); 153 | 154 | Assert.areEqual(3, results.size()); 155 | Assert.isNotNull(results.get(0).get('activeDate')); 156 | Assert.areEqual('B', results.get(0).get(nameAlias)); 157 | Assert.areEqual('A', results.get(1).get(nameAlias)); 158 | Assert.areEqual('C', results.get(2).get(nameAlias)); 159 | 160 | Aggregation maxName = Aggregation.max(ContactPointAddress.Name, 'maxName'); 161 | AggregateRecord result = cpaRepo.addSortOrder(maxName, RepositorySortOrder.DESCENDING).aggregate(maxName).get(0); 162 | Assert.areEqual('D', result.get(maxName.getAlias())); 163 | } 164 | 165 | private class OppRepoFactory extends RepoFactory { 166 | public IAggregateRepository getOppRepo() { 167 | List queryFields = new List{ 168 | Opportunity.IsWon, 169 | Opportunity.StageName 170 | // etc ... 171 | }; 172 | IAggregateRepository oppRepo = this.facade.getRepo(Opportunity.SObjectType, queryFields, this); 173 | oppRepo.addParentFields( 174 | new List{ Opportunity.AccountId }, 175 | new List{ Account.Id } 176 | ); 177 | return oppRepo; 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /force-app/repository/AggregateRepository.cls: -------------------------------------------------------------------------------- 1 | public without sharing virtual class AggregateRepository extends Repository implements IAggregateRepository { 2 | protected final Set groupedByFieldNames = new Set(); 3 | private final Set havingFields = new Set(); 4 | private List aggregations; 5 | private Boolean isNumberCountQuery = false; 6 | 7 | @TestVisible 8 | private class GroupBy { 9 | private final String selectName; 10 | private final String groupByName; 11 | 12 | public GroupBy(String groupByName, String alias) { 13 | this.selectName = groupByName + ' ' + alias; 14 | this.groupByName = groupByName; 15 | } 16 | 17 | public GroupBy(String fieldName) { 18 | this.selectName = fieldName; 19 | this.groupByName = fieldName; 20 | } 21 | 22 | public String getSelectName() { 23 | return this.selectName; 24 | } 25 | 26 | public String getGroupByName() { 27 | return this.groupByName; 28 | } 29 | 30 | public Boolean equals(Object other) { 31 | if (other instanceof GroupBy) { 32 | GroupBy that = (GroupBy) other; 33 | return this.groupByName == that.groupByName && this.selectName == that.selectName; 34 | } 35 | return false; 36 | } 37 | 38 | public Integer hashCode() { 39 | return this.selectName.hashCode() + this.groupByName.hashCode(); 40 | } 41 | } 42 | 43 | public AggregateRepository( 44 | Schema.SObjectType repoType, 45 | List queryFields, 46 | RepoFactory repoFactory 47 | ) { 48 | super(repoType, queryFields, repoFactory); 49 | } 50 | 51 | public IAggregateRepository groupBy(DateFunction dateFunction, Schema.SObjectField fieldToken, String alias) { 52 | String groupByName = this.getFormattedDateFunction(dateFunction, fieldToken); 53 | this.groupedByFieldNames.add(new GroupBy(groupByName, alias)); 54 | return this; 55 | } 56 | 57 | public IAggregateRepository groupBy(Schema.SObjectField fieldToken) { 58 | this.groupedByFieldNames.add(new GroupBy(fieldToken.getDescribe().getName())); 59 | return this; 60 | } 61 | 62 | public IAggregateRepository groupBy(List parentFieldChain) { 63 | String parentFieldGroupBy = ''; 64 | while (parentFieldChain.size() > 1) { 65 | parentFieldGroupBy += parentFieldChain.remove(0).getDescribe().getRelationshipName() + '.'; 66 | } 67 | this.groupedByFieldNames.add(new GroupBy(parentFieldGroupBy + parentFieldChain.remove(0).getDescribe().getName())); 68 | return this; 69 | } 70 | 71 | public IAggregateRepository addSortOrder(Aggregation aggregate, RepositorySortOrder sortOrder) { 72 | this.fieldToSortOrder.put(aggregate.getBaseAggregation(), sortOrder); 73 | return this; 74 | } 75 | 76 | public IAggregateRepository addSortOrder( 77 | DateFunction dateFunction, 78 | Schema.SObjectField fieldToken, 79 | RepositorySortOrder sortOrder 80 | ) { 81 | this.fieldToSortOrder.put(this.getFormattedDateFunction(dateFunction, fieldToken), sortOrder); 82 | return this; 83 | } 84 | 85 | public IAggregateRepository addHaving(Aggregation aggregation, Query.Operator operator, Object value) { 86 | Query aggQuery = new AggregateQuery(operator, value); 87 | this.havingFields.add(aggregation.getBaseAggregation() + ' ' + aggQuery); 88 | this.bindVars.putAll(aggQuery.getBindVars()); 89 | return this; 90 | } 91 | 92 | public Integer count() { 93 | return this.count(new List()); 94 | } 95 | public Integer count(Query query) { 96 | return this.count(new List{ query }); 97 | } 98 | public virtual Integer count(List queries) { 99 | this.isNumberCountQuery = true; 100 | String finalQuery = this.getFinalQuery(queries); 101 | this.logQuery('count query:\n' + finalQuery); 102 | Integer recordCount = Database.countQueryWithBinds(finalQuery, this.bindVars, this.accessLevel); 103 | System.debug(System.LoggingLevel.FINER, 'number of results: ' + recordCount); 104 | this.clearState(); 105 | this.isNumberCountQuery = false; 106 | return recordCount; 107 | } 108 | 109 | public List aggregate(Aggregation aggregation) { 110 | return this.aggregate(new List{ aggregation }, new List()); 111 | } 112 | public List aggregate(Aggregation aggregation, Query query) { 113 | return this.aggregate(new List{ aggregation }, new List{ query }); 114 | } 115 | public List aggregate(Aggregation aggregation, List queries) { 116 | return this.aggregate(new List{ aggregation }, queries); 117 | } 118 | public List aggregate(List aggregations) { 119 | return this.aggregate(aggregations, new List()); 120 | } 121 | public List aggregate(List aggregations, Query query) { 122 | return this.aggregate(aggregations, new List{ query }); 123 | } 124 | public virtual List aggregate(List aggregations, List queries) { 125 | this.aggregations = aggregations; 126 | 127 | List results = (List) this.get(queries); 128 | List aggregateRecords = new List(); 129 | for (AggregateResult result : results) { 130 | AggregateRecord aggRecord = new AggregateRecord(); 131 | aggRecord.putAll(result.getPopulatedFieldsAsMap()); 132 | aggregateRecords.add(aggRecord); 133 | } 134 | 135 | this.clearState(); 136 | return aggregateRecords; 137 | } 138 | 139 | protected virtual override Set addSelectFields() { 140 | Set baseFields = new Set(); 141 | if (this.isNumberCountQuery) { 142 | baseFields.add('COUNT()'); 143 | return baseFields; 144 | } 145 | 146 | if (this.aggregations != null) { 147 | for (Aggregation agg : aggregations) { 148 | baseFields.add(agg.toString()); 149 | } 150 | } 151 | 152 | for (GroupBy groupBy : this.groupedByFieldNames) { 153 | baseFields.add(groupBy.getSelectName()); 154 | } 155 | return baseFields.isEmpty() ? super.addSelectFields() : baseFields; 156 | } 157 | 158 | protected override String getFinalQuery(List queries) { 159 | String baseString = super.getFinalQuery(queries); 160 | if (this.groupedByFieldNames.isEmpty() == false) { 161 | String potentialOrderBy = null; 162 | String orderByKey = '\nORDER BY'; 163 | if (baseString.contains(orderByKey)) { 164 | potentialOrderBy = baseString.substringAfter(orderByKey); 165 | baseString = baseString.replace(orderByKey + potentialOrderBy, ''); 166 | } 167 | baseString += '\nGROUP BY '; 168 | for (GroupBy groupBy : this.groupedByFieldNames) { 169 | baseString += groupBy.getGroupByName() + ','; 170 | } 171 | baseString = baseString.removeEnd(','); 172 | // having is only valid with a grouping 173 | if (this.havingFields.isEmpty() == false) { 174 | baseString += '\nHAVING ' + String.join(this.havingFields, ','); 175 | } 176 | if (potentialOrderBy != null) { 177 | baseString += orderByKey + potentialOrderBy; 178 | } 179 | } 180 | return baseString; 181 | } 182 | 183 | protected override void clearState() { 184 | super.clearState(); 185 | this.havingFields.clear(); 186 | this.groupedByFieldNames.clear(); 187 | this.aggregations = null; 188 | } 189 | 190 | private String getFormattedDateFunction(DateFunction dateFunction, Schema.SObjectField fieldToken) { 191 | return dateFunction.name() + '(' + fieldToken + ')'; 192 | } 193 | 194 | private class AggregateQuery extends Query { 195 | public AggregateQuery(Query.Operator op, Object value) { 196 | super('', op, value); 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /force-app/factory/RepoFactoryMock.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.FieldNamingConventions') 2 | @IsTest 3 | public class RepoFactoryMock extends RepoFactory { 4 | @TestVisible 5 | private static final Map> AggregateResults = new Map>(); 6 | @TestVisible 7 | private static final List AggregatesMade = new List(); 8 | @TestVisible 9 | private static final List QueryResults = new List(); 10 | @TestVisible 11 | private static final List QueriesMade = new List(); 12 | @TestVisible 13 | private static final Map> CursorResults = new Map>(); 14 | @TestVisible 15 | private static final Map> HistoryResults = new Map>(); 16 | @TestVisible 17 | private static final Map> GroupByFields = new Map>(); 18 | @TestVisible 19 | private static final Map> FieldToSortOrders = new Map>(); 20 | 21 | private static final Map> CACHED_CHILD_RELATIONSHIPS = new Map>(); 22 | 23 | @TestVisible 24 | private static Boolean alwaysUseMock = false; 25 | 26 | private class ChildrenSObjects { 27 | public final List records; 28 | public final Boolean done = true; 29 | public final Integer totalSize; 30 | 31 | public ChildrenSObjects(List records) { 32 | this.records = records; 33 | this.totalSize = records.size(); 34 | } 35 | } 36 | 37 | public static SObject addChildrenToRecord(SObject record, Schema.SObjectField childField, List children) { 38 | List childRelationships = CACHED_CHILD_RELATIONSHIPS.get(record.getSObjectType()); 39 | if (childRelationships == null) { 40 | childRelationships = record.getSObjectType() 41 | .getDescribe(Schema.SObjectDescribeOptions.FULL) 42 | .getChildRelationships(); 43 | CACHED_CHILD_RELATIONSHIPS.put(record.getSObjectType(), childRelationships); 44 | } 45 | String relationshipName; 46 | for (Schema.ChildRelationship childRelationship : childRelationships) { 47 | if (childRelationship.getField() == childField) { 48 | relationshipName = childRelationship.getRelationshipName(); 49 | break; 50 | } 51 | } 52 | if (relationshipName != null) { 53 | String serializedMeta = JSON.serialize(record).removeEnd('}'); 54 | String childrenJson = '"' + relationshipName + '" : ' + JSON.serialize(new ChildrenSObjects(children)); 55 | serializedMeta += ',' + childrenJson + '}'; 56 | return (SObject) JSON.deserialize(serializedMeta, SObject.class); 57 | } 58 | return record; 59 | } 60 | 61 | public class FacadeMock extends RepoFactory.Facade { 62 | public override IDML getDML() { 63 | return new DMLMock(); 64 | } 65 | 66 | public override IHistoryRepository getRepo( 67 | Schema.SObjectType repoType, 68 | List queryFields, 69 | RepoFactory repoFactory 70 | ) { 71 | return getRepoFromSObjectType(repoType, super.getRepo(repoType, queryFields, repoFactory), repoFactory); 72 | } 73 | } 74 | 75 | private static IHistoryRepository getRepoFromSObjectType( 76 | SObjectType sObjectType, 77 | IAggregateRepository fallback, 78 | RepoFactory repoFactory 79 | ) { 80 | IHistoryRepository repo; 81 | List queriedResults = getResults(sObjectType); 82 | List aggRecords = AggregateResults.get(sObjectType); 83 | List historyRecords = HistoryResults.get(SObjectType); 84 | List cursorResults = CursorResults.get(sObjectType); 85 | 86 | if ( 87 | queriedResults.size() > 0 || 88 | aggRecords?.size() > 0 || 89 | historyRecords?.size() > 0 || 90 | cursorResults?.size() > 0 || 91 | alwaysUseMock == true 92 | ) { 93 | RepoMock mock = new RepoMock(sObjectType, repoFactory); 94 | mock.results.addAll(queriedResults); 95 | if (aggRecords != null) { 96 | mock.aggRecords.addAll(aggRecords); 97 | } 98 | if (historyRecords != null) { 99 | mock.historyRecords.addAll(historyRecords); 100 | } 101 | repo = mock; 102 | } else { 103 | repo = (IHistoryRepository) fallback; 104 | } 105 | return repo; 106 | } 107 | 108 | private static List getResults(Schema.SObjectType sobjType) { 109 | List resultList = new List(); 110 | for (SObject potentialResult : QueryResults) { 111 | if (potentialResult.getSObjectType() == sobjType) { 112 | resultList.add(potentialResult); 113 | } 114 | } 115 | return resultList; 116 | } 117 | 118 | private class RepoMock extends FieldLevelHistoryRepo { 119 | private final List results = new List(); 120 | private final List aggRecords = new List(); 121 | private final List historyRecords = new List(); 122 | 123 | private RepoMock(Schema.SObjectType sObjectType, RepoFactory repoFactory) { 124 | super(sObjectType, new List(), repoFactory); 125 | } 126 | 127 | public override Cursor getCursor(List queries) { 128 | QueriesMade.addAll(queries); 129 | List cursorResults = CursorResults.get(this.repoType); 130 | return cursorResults.remove(0); 131 | } 132 | 133 | public override List getHistory(List queries) { 134 | QueriesMade.addAll(queries); 135 | this.trackFieldToSortOrder(); 136 | return this.historyRecords; 137 | } 138 | 139 | public override List getAll() { 140 | return this.get(new List()); 141 | } 142 | 143 | public override List get(Query query) { 144 | return this.get(new List{ query }); 145 | } 146 | 147 | public override List get(List queries) { 148 | QueriesMade.addAll(queries); 149 | this.trackFieldToSortOrder(); 150 | this.clearState(); 151 | return this.results; 152 | } 153 | 154 | public override List> getSosl( 155 | String searchTerm, 156 | List queries, 157 | List additionalSoslObjects 158 | ) { 159 | QueriesMade.addAll(queries); 160 | List> results = new List>{ this.results }; 161 | for (AdditionalSoslObject additionalSoslObject : additionalSoslObjects) { 162 | if (additionalSoslObject.objectType != this.repoType) { 163 | results.add(getResults(additionalSoslObject.objectType)); 164 | QueriesMade.addAll(additionalSoslObject.queryFilters); 165 | } 166 | } 167 | return results; 168 | } 169 | 170 | public override Integer count(List queries) { 171 | QueriesMade.addAll(queries); 172 | List results = AggregateResults.get(this.repoType); 173 | this.clearState(); 174 | if (results == null) { 175 | return super.count(queries); 176 | } else if (results.isEmpty() == false) { 177 | return results.remove(0).getCount(); 178 | } 179 | return null; 180 | } 181 | 182 | public override List aggregate(List aggregations, List queries) { 183 | AggregatesMade.addAll(aggregations); 184 | QueriesMade.addAll(queries); 185 | 186 | List fields = GroupByFields.get(this.repoType); 187 | if (fields == null) { 188 | fields = new List(); 189 | GroupByFields.put(this.repoType, fields); 190 | } 191 | fields.addAll(this.groupedByFieldNames); 192 | 193 | this.trackFieldToSortOrder(); 194 | 195 | return this.aggRecords; 196 | } 197 | 198 | private void trackFieldToSortOrder() { 199 | Map localFieldToSortOrders = FieldToSortOrders.get(this.repoType); 200 | if (localFieldToSortOrders == null) { 201 | localFieldToSortOrders = new Map(); 202 | FieldToSortOrders.put(this.repoType, localFieldToSortOrders); 203 | } 204 | localFieldToSortOrders.putAll(this.fieldToSortOrder); 205 | } 206 | } 207 | 208 | public class CursorMock extends Cursor { 209 | private final List records; 210 | 211 | public CursorMock(List records) { 212 | this.records = records; 213 | } 214 | 215 | public override List fetch(Integer start, Integer advanceBy) { 216 | List clonedRecords = records.deepClone(); 217 | clonedRecords.clear(); 218 | for (Integer index = start; index < this.getAdvanceBy(start, advanceBy) + start; index++) { 219 | clonedRecords.add(this.records[index]); 220 | } 221 | return clonedRecords; 222 | } 223 | 224 | public override Integer getNumRecords() { 225 | return this.records.size(); 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /force-app/repository/QueryTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class QueryTest { 3 | @IsTest 4 | static void itShouldEncapsulateSobjectFieldsAndValues() { 5 | Query basicQuery = Query.equals(Opportunity.IsWon, true); 6 | 7 | Assert.areEqual('IsWon = true', basicQuery.toString()); 8 | } 9 | 10 | @IsTest 11 | static void itShouldEqualAnotherQueryWithTheSameValues() { 12 | Query basicQuery = Query.equals(Opportunity.IsWon, true); 13 | Query sameQuery = Query.equals(Opportunity.IsWon, true); 14 | Assert.areEqual(basicQuery, sameQuery); 15 | } 16 | 17 | @IsTest 18 | static void itShouldEqualAnotherQueryWithTheSameStringValues() { 19 | Query basicQuery = Query.equals(Opportunity.Name, 'some string value'); 20 | Query sameQuery = Query.equals(Opportunity.Name, 'some string value'); 21 | 22 | Assert.areEqual(basicQuery, sameQuery); 23 | } 24 | 25 | @IsTest 26 | static void itShouldNotEqualAnotherQueryWithDifferentStringValues() { 27 | Query basicQuery = Query.equals(Opportunity.Name, 'some string value'); 28 | Query differentQuery = Query.equals(Opportunity.Name, 'another string value'); 29 | 30 | System.assertNotEquals(basicQuery, differentQuery); 31 | } 32 | 33 | @IsTest 34 | static void itWorksWithDatetimes() { 35 | Datetime sevenDaysAgo = System.now().addDays(-7); 36 | Query greaterThan = Query.greaterThan(Opportunity.CreatedDate, sevenDaysAgo); 37 | Query basicQuery = Query.greaterThanOrEqual(Opportunity.CreatedDate, sevenDaysAgo); 38 | Query.shouldPerformStrictEquals = true; 39 | 40 | Assert.areEqual('CreatedDate > ' + sevenDaysAgo, greaterThan.toString()); 41 | Assert.areEqual('CreatedDate >= ' + sevenDaysAgo, basicQuery.toString()); 42 | Assert.areEqual(sevenDaysAgo, greaterThan.getBindVars().get('bindVar0')); 43 | Assert.areEqual(sevenDaysAgo, basicQuery.getBindVars().get('bindVar1')); 44 | } 45 | 46 | @IsTest 47 | static void itShouldProperlyFormatComparisonQueries() { 48 | Query lessThan = Query.lessThan(Opportunity.Amount, 0); 49 | Query lessThanOrEqual = Query.lessThanOrEqual(Opportunity.Amount, 0); 50 | 51 | Assert.areEqual('Amount < :bindVar0', lessThan.toString()); 52 | Assert.areEqual(0, lessThan.getBindVars().get('bindVar0')); 53 | Assert.areEqual('Amount <= :bindVar1', lessThanOrEqual.toString()); 54 | Assert.areEqual(0, lessThanOrEqual.getBindVars().get('bindVar1')); 55 | 56 | Query notEquals = Query.notEquals(Opportunity.Amount, 0); 57 | Assert.areEqual('Amount != :bindVar2', notEquals.toString()); 58 | Assert.areEqual(0, notEquals.getBindVars().get('bindVar2')); 59 | Query notEqualsIterable = Query.notEquals(Opportunity.Amount, new List{ 0, 1, 2 }); 60 | Assert.areEqual('Amount != :bindVar3', notEqualsIterable.toString()); 61 | Assert.areEqual(new List{ 0, 1, 2 }, notEqualsIterable.getBindVars().get('bindVar3')); 62 | } 63 | 64 | @IsTest 65 | static void itShouldProperlyHandleNumbers() { 66 | Double number1 = 1261992; 67 | Integer number2 = 1; 68 | Decimal number3 = 1.00; 69 | Long number4 = 1234567890; 70 | 71 | Query doubleQuery = Query.equals(Opportunity.Amount, number1); 72 | Query intQuery = Query.equals(Opportunity.Amount, number2); 73 | Query decimalQuery = Query.equals(Opportunity.Amount, number3); 74 | Query longQuery = Query.equals(Opportunity.Amount, number4); 75 | Query.shouldPerformStrictEquals = true; 76 | 77 | Assert.areEqual('Amount = ' + number1, doubleQuery.toString(), 'double'); 78 | Assert.areEqual(number1, doubleQuery.getBindVars().get('bindVar0')); 79 | Assert.areEqual('Amount = ' + number2, intQuery.toString(), 'int'); 80 | Assert.areEqual(number2, intQuery.getBindVars().get('bindVar1')); 81 | Assert.areEqual('Amount = ' + number3, decimalQuery.toString(), 'decimal'); 82 | Assert.areEqual(number3, decimalQuery.getBindVars().get('bindVar2')); 83 | Assert.areEqual('Amount = ' + number4, longQuery.toString(), 'long'); 84 | Assert.areEqual(number4, longQuery.getBindVars().get('bindVar3')); 85 | } 86 | 87 | @IsTest 88 | static void itShouldProperlyHandleNulls() { 89 | Id nullId = null; 90 | 91 | Query idQuery = Query.equals(Opportunity.Id, nullId); 92 | 93 | Assert.areEqual('Id = null', idQuery.toString()); 94 | } 95 | 96 | @IsTest 97 | static void itShouldAllowOrStatements() { 98 | Id nullId = null; 99 | String expectedQuery = '(Id = null OR Id != null)'; 100 | 101 | Query orQuery = Query.orQuery(Query.equals(Account.Id, nullId), Query.notEquals(Account.Id, nullId)); 102 | 103 | Assert.areEqual(expectedQuery, orQuery.toString()); 104 | } 105 | 106 | @IsTest 107 | static void itShouldAllowNestedAndStatements() { 108 | String regexExpected = '\\(LastName = :bindVar\\d OR LastName = :bindVar\\d OR \\(FirstName = :bindVar\\d AND \\(LastName != :bindVar\\d OR LastName != :bindVar\\d\\)\\)\\)'; 109 | // String expected = '(LastName = :bindVar0 OR LastName = :bindVar1 OR (FirstName = :bindVar2 AND (LastName != :bindVar3 OR LastName != :bindVar4)))'; 110 | 111 | Query output = Query.orQuery( 112 | new List{ 113 | Query.equals(Contact.LastName, 'asd'), 114 | Query.equals(Contact.LastName, 'asb'), 115 | Query.andQuery( 116 | Query.equals(Contact.FirstName, 'John'), 117 | Query.orQuery(Query.notEquals(Contact.LastName, 'a'), Query.notEquals(Contact.LastName, 'b')) 118 | ) 119 | } 120 | ); 121 | 122 | Assert.areEqual(true, Pattern.compile(regexExpected).matcher(output.toString()).matches()); 123 | // Assert.areEqual(expected, output.toString()); 124 | } 125 | 126 | @IsTest 127 | static void itShouldAllowLikeStatements() { 128 | String expectedName = '%someName%'; 129 | 130 | Query likeQuery = Query.likeQuery(Account.Name, expectedName); 131 | 132 | Assert.areEqual('Name LIKE :bindVar0', likeQuery.toString()); 133 | Assert.areEqual(expectedName, likeQuery.getBindVars().get('bindVar0')); 134 | } 135 | 136 | @IsTest 137 | static void itShouldAllowNotLikeStatements() { 138 | String expectedName = '%someName%'; 139 | 140 | Query notLike = Query.notLike(Account.Name, expectedName); 141 | Query.shouldPerformStrictEquals = true; 142 | 143 | Assert.areEqual('NOT Name LIKE ' + expectedName, notLike.toString()); 144 | Assert.areEqual(expectedName, notLike.getBindVars().get('bindVar0')); 145 | } 146 | 147 | @IsTest 148 | static void itShouldAllowNotLikeWithLists() { 149 | String firstVal = '%one'; 150 | String secondVal = 'two%'; 151 | List values = new List{ firstVal, secondVal }; 152 | 153 | Query notLike = Query.notLike(Account.Name, values); 154 | Query.shouldPerformStrictEquals = true; 155 | 156 | Assert.areEqual('NOT Name LIKE ' + values, notLike.toString()); 157 | Assert.areEqual(values, notLike.getBindVars().get('bindVar0')); 158 | } 159 | 160 | @IsTest 161 | static void itShouldAllowParentFieldsForFiltering() { 162 | Query parentQuery = Query.equals(Group.DeveloperName, 'SOME_CONSTANT.DeveloperName') 163 | .usingParent(GroupMember.GroupId); 164 | Assert.areEqual('Group.DeveloperName = :bindVar0', parentQuery.toString()); 165 | Assert.areEqual('SOME_CONSTANT.DeveloperName', parentQuery.getBindVars().get('bindVar0')); 166 | 167 | Query oliParentQuery = Query.equals(Profile.Name, 'System Administrator') 168 | .usingParent( 169 | new List{ 170 | OpportunityLineItem.OpportunityId, 171 | Opportunity.AccountId, 172 | Account.OwnerId, 173 | User.ProfileId 174 | } 175 | ); 176 | 177 | Assert.areEqual('SOME_CONSTANT.DeveloperName', parentQuery.getBindVars().get('bindVar0')); 178 | Assert.areEqual('Opportunity.Account.Owner.Profile.Name = :bindVar1', oliParentQuery.toString()); 179 | Assert.areEqual('System Administrator', oliParentQuery.getBindVars().get('bindVar1')); 180 | } 181 | 182 | @IsTest 183 | static void itAllowsEmptyCollectionsForNotEquals() { 184 | Query notEquals = Query.notEquals(Opportunity.AccountId, new Set()); 185 | 186 | Assert.areEqual('AccountId != :bindVar0', notEquals.toString()); 187 | Assert.areEqual(new Set(), notEquals.getBindVars().get('bindVar0')); 188 | } 189 | 190 | @IsTest 191 | static void itAllowsSubqueries() { 192 | Query subquery = Query.subquery( 193 | Contact.AccountId, 194 | Account.Id, 195 | Query.andQuery( 196 | new List{ 197 | Query.equals(Account.AnnualRevenue, 50), 198 | Query.equals(Account.Industry, 'Tech'), 199 | Query.orQuery( 200 | new List{ Query.equals(Account.NumberOfEmployees, 1), Query.equals(Account.Site, 'web3') } 201 | ) 202 | } 203 | ) 204 | ); 205 | 206 | String regex = 'AccountId IN \\(SELECT Id FROM Account WHERE \\(AnnualRevenue = :bindVar\\d AND Industry = :bindVar\\d AND \\(NumberOfEmployees = :bindVar\\d OR Site = :bindVar\\d\\)\\)\\)'; 207 | Assert.areEqual(true, Pattern.compile(regex).matcher(subquery.toString()).matches()); 208 | // Assert.areEqual( 209 | // 'AccountId IN (SELECT Id FROM Account WHERE (AnnualRevenue = :bindVar0 AND Industry = :bindVar1 AND (NumberOfEmployees = :bindVar2 OR Site = :bindVar3)))', 210 | // subquery.toString() 211 | // ); 212 | } 213 | 214 | @IsTest 215 | static void itWorksWithCollectionsForSoslQueriesNotIn() { 216 | List fakeAccountIds = new List{ 217 | TestingUtils.generateId(Account.SObjectType), 218 | TestingUtils.generateId(Account.SObjectType) 219 | }; 220 | 221 | Query query = Query.notEquals(Account.Id, fakeAccountIds); 222 | 223 | Assert.areEqual('Id NOT IN (\'' + String.join(fakeAccountIds, '\',\'') + '\')', query.toSoslString()); 224 | } 225 | 226 | @IsTest 227 | static void itWorksWithCollectionsForSoslQueriesIn() { 228 | List fakeAccountIds = new List{ 229 | TestingUtils.generateId(Account.SObjectType), 230 | TestingUtils.generateId(Account.SObjectType) 231 | }; 232 | 233 | Query query = Query.equals(Account.Id, fakeAccountIds); 234 | 235 | Assert.areEqual('Id IN (\'' + String.join(fakeAccountIds, '\',\'') + '\')', query.toSoslString()); 236 | } 237 | 238 | @IsTest 239 | static void itWorksForSingularValueInCollections() { 240 | List fakeAccountIds = new List{ TestingUtils.generateId(Account.SObjectType) }; 241 | 242 | Query query = Query.equals(Account.Id, fakeAccountIds); 243 | 244 | Assert.areEqual('Id IN (\'' + String.join(fakeAccountIds, '\',\'') + '\')', query.toSoslString()); 245 | } 246 | 247 | @IsTest 248 | static void itDoesNotAddEmptyParenthesisToTheEnd() { 249 | Query query = Query.andQuery( 250 | new List{ Query.equals(Account.Id, new List()), Query.orQuery(new List()) } 251 | ); 252 | 253 | Assert.areEqual('(Id = :bindVar0)', query.toString()); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /force-app/repository/Query.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings( 2 | 'PMD.EmptyStatementBlock,PMD.ExcessivePublicCount,PMD.ExcessiveParameterList,PMD.FieldNamingConventions' 3 | ) 4 | public virtual class Query { 5 | private Boolean hasBeenCompared = false; 6 | private Boolean isSoslEmpty = false; 7 | private String predicateKey; 8 | 9 | public enum Operator { 10 | EQUALS, 11 | NOT_EQUALS, 12 | LESS_THAN, 13 | LESS_THAN_OR_EQUAL, 14 | GREATER_THAN, 15 | GREATER_THAN_OR_EQUAL, 16 | ALIKE, // like is a reserved word 17 | NOT_LIKE 18 | } 19 | 20 | public final Operator operator; 21 | private final String field; 22 | private final Schema.SObjectField fieldToken; 23 | private final Object predicate; 24 | private final Map bindVars = new Map(); 25 | 26 | private static final String BIND_VAR_MERGE = 'bindVar{0}'; 27 | private static final String EMPTYISH_STRING = '()'; 28 | 29 | private static Integer BIND_VAR_NUMBER = 0; 30 | @TestVisible 31 | private static Boolean shouldPerformStrictEquals = false; 32 | 33 | public Boolean isSoslEmpty() { 34 | return this.isSoslEmpty; 35 | } 36 | 37 | public Query usingParent(Schema.SObjectField parentField) { 38 | return this.usingParent(new List{ parentField }); 39 | } 40 | 41 | public Query usingParent(List parentFields) { 42 | parentFields.add(this.fieldToken); 43 | return new ParentQuery(parentFields, this.operator, this.predicate); 44 | } 45 | 46 | public static Query subquery(Schema.SObjectField field, Schema.SObjectField innerMatchingField, Query subcondition) { 47 | return subquery(field, innerMatchingField.getDescribe().getSObjectType(), innerMatchingField, subcondition); 48 | } 49 | 50 | public static Query subquery( 51 | Schema.SObjectField field, 52 | Schema.SObjectType objectType, 53 | Schema.SObjectField innerMatchingField, 54 | Query subcondition 55 | ) { 56 | return new SubQuery(field, objectType, innerMatchingField, subcondition); 57 | } 58 | 59 | public static Query equals(SObjectField field, Object predicate) { 60 | return new Query(field, Operator.EQUALS, predicate); 61 | } 62 | 63 | public static Query notEquals(SObjectField field, Object predicate) { 64 | return new Query(field, Operator.NOT_EQUALS, predicate); 65 | } 66 | 67 | public static Query lessThan(SObjectField field, Object predicate) { 68 | return new Query(field, Operator.LESS_THAN, predicate); 69 | } 70 | 71 | public static Query lessThanOrEqual(SObjectField field, Object predicate) { 72 | return new Query(field, Operator.LESS_THAN_OR_EQUAL, predicate); 73 | } 74 | 75 | public static Query greaterThan(SObjectField field, Object predicate) { 76 | return new Query(field, Operator.GREATER_THAN, predicate); 77 | } 78 | 79 | public static Query greaterThanOrEqual(SObjectField field, Object predicate) { 80 | return new Query(field, Operator.GREATER_THAN_OR_EQUAL, predicate); 81 | } 82 | 83 | // like is a reserved keyword 84 | public static Query likeQuery(SObjectField field, Object predicate) { 85 | return new Query(field, Operator.ALIKE, predicate); 86 | } 87 | 88 | public static Query notLike(SObjectField field, Object predicate) { 89 | return new Query(field, Operator.NOT_LIKE, predicate); 90 | } 91 | 92 | // or is a reserved keyword 93 | public static Query orQuery(Query innerQuery, Query secondInnerQuery) { 94 | return orQuery(new List{ innerQuery, secondInnerQuery }); 95 | } 96 | 97 | public static Query orQuery(List innerQueries) { 98 | return new OrQuery(innerQueries); 99 | } 100 | 101 | // and is a reserved keyword 102 | public static Query andQuery(Query innerQuery, Query secondInnerQuery) { 103 | return andQuery(new List{ innerQuery, secondInnerQuery }); 104 | } 105 | 106 | public static Query andQuery(List innerQueries) { 107 | return new AndQuery(innerQueries); 108 | } 109 | 110 | public static String getBuiltUpParentFieldName(List parentFields) { 111 | String builtUpFieldName = ''; 112 | for (Integer index = 0; index < parentFields.size(); index++) { 113 | Schema.DescribeFieldResult parentFieldDescribe = parentFields[index].getDescribe(); 114 | builtUpFieldName += index == parentFields.size() - 1 115 | ? parentFieldDescribe.getName() 116 | : (parentFieldDescribe.getRelationshipName() ?? parentFieldDescribe.getName().replace('__c', '__r')) + '.'; 117 | } 118 | return builtUpFieldName; 119 | } 120 | 121 | private class SubQuery extends Query { 122 | private final Schema.SObjectField field; 123 | private final Schema.SObjectType objectType; 124 | private final Schema.SObjectField innerMatchingField; 125 | private final Query subcondition; 126 | 127 | public SubQuery( 128 | Schema.SObjectField field, 129 | Schema.SObjectType objectType, 130 | Schema.SObjectField innerMatchingField, 131 | Query subcondition 132 | ) { 133 | this.field = field; 134 | this.objectType = objectType; 135 | this.innerMatchingField = innerMatchingField; 136 | this.subcondition = subcondition; 137 | } 138 | 139 | public override String toString() { 140 | String whereClause = ' WHERE ' + this.subcondition.toString(); 141 | this.bindVars.putAll(this.subcondition.getBindVars()); 142 | return this.field.getDescribe().getName() + 143 | ' IN (SELECT ' + 144 | this.innerMatchingField + 145 | ' FROM ' + 146 | this.objectType + 147 | whereClause + 148 | ')'; 149 | } 150 | } 151 | 152 | private abstract class DelimitedQuery extends Query { 153 | private final List queries; 154 | 155 | public DelimitedQuery(List queries) { 156 | super(); 157 | this.queries = queries; 158 | } 159 | 160 | public abstract String getDelimiter(); 161 | 162 | public override String toString() { 163 | String baseString = '('; 164 | for (Query innerQuery : this.queries) { 165 | String potentialString = innerQuery.toString(); 166 | if (String.isNotBlank(potentialString) && potentialString != EMPTYISH_STRING) { 167 | baseString += potentialString + this.getDelimiter(); 168 | this.bindVars.putAll(innerQuery.getBindVars()); 169 | } 170 | } 171 | String potentialFinalString = baseString.removeEnd(this.getDelimiter()) + ')'; 172 | return potentialFinalString == EMPTYISH_STRING ? '' : potentialFinalString; 173 | } 174 | } 175 | 176 | private class AndQuery extends DelimitedQuery { 177 | private final String delimiter = ' AND '; 178 | 179 | public AndQuery(List queries) { 180 | super(queries); 181 | } 182 | 183 | public override String getDelimiter() { 184 | return this.delimiter; 185 | } 186 | } 187 | 188 | private class OrQuery extends DelimitedQuery { 189 | private final String delimiter = ' OR '; 190 | 191 | public OrQuery(List queries) { 192 | super(queries); 193 | } 194 | 195 | public override String getDelimiter() { 196 | return this.delimiter; 197 | } 198 | } 199 | 200 | private class ParentQuery extends Query { 201 | private ParentQuery(List parentFields, Operator operator, Object predicate) { 202 | super(getBuiltUpParentFieldName(parentFields), operator, predicate); 203 | } 204 | } 205 | 206 | protected Query() { 207 | } 208 | 209 | protected Query(String fieldName, Operator operator, Object predicate) { 210 | this.field = fieldName; 211 | this.operator = operator; 212 | this.predicate = predicate; 213 | } 214 | 215 | private Query(SObjectField fieldToken, Operator operator, Object predicate) { 216 | this(fieldToken.getDescribe().getName(), operator, predicate); 217 | this.fieldToken = fieldToken; 218 | } 219 | 220 | public Map getBindVars() { 221 | return this.bindVars; 222 | } 223 | 224 | public virtual override String toString() { 225 | String predicateValue = this.getPredicate(this.predicate); 226 | String printedValue = ' ' + (shouldPerformStrictEquals ? this.predicate : predicateValue); 227 | if (this.operator == Query.Operator.NOT_LIKE) { 228 | // who knows why this is the format they wanted 229 | return String.format(this.getOperator(), new List{ this.field }) + printedValue; 230 | } 231 | return this.field + ' ' + this.getOperator() + printedValue; 232 | } 233 | 234 | public String toSoslString() { 235 | String startingString = this.toString(); 236 | for (String key : this.bindVars.keySet()) { 237 | startingString = startingString.replace(':' + key, this.getSoslPredicate(this.bindVars.get(key))); 238 | } 239 | if (this.predicate instanceof Iterable) { 240 | Iterable localPredicate = (Iterable) this.predicate; 241 | if (localPredicate.iterator().hasNext() == false) { 242 | return ''; 243 | } 244 | String operatorToReplace; 245 | String newOperator; 246 | switch on this.operator { 247 | when EQUALS { 248 | operatorToReplace = '='; 249 | newOperator = 'IN'; 250 | } 251 | when NOT_EQUALS { 252 | operatorToReplace = '!='; 253 | newOperator = 'NOT IN'; 254 | } 255 | } 256 | if (operatorToReplace != null) { 257 | startingString = startingString.replace(operatorToReplace, newOperator); 258 | } 259 | } 260 | if (startingString.endsWith(EMPTYISH_STRING)) { 261 | this.isSoslEmpty = true; 262 | } 263 | return startingString; 264 | } 265 | 266 | public Boolean equals(Object thatObject) { 267 | if ((thatObject instanceof Query) == false) { 268 | return false; 269 | } 270 | Query that = (Query) thatObject; 271 | if (this.hasBeenCompared == false && that.hasBeenCompared == false) { 272 | that.toString(); 273 | this.toString(); 274 | } 275 | this.hasBeenCompared = true; 276 | that.hasBeenCompared = true; 277 | 278 | Boolean areEqual = 279 | this.field == that.field && 280 | this.operator == that.operator && 281 | this.bindVars.values() == that.bindVars.values(); 282 | if (areEqual == false) { 283 | shouldPerformStrictEquals = true; 284 | } 285 | 286 | return areEqual; 287 | } 288 | 289 | public Integer hashCode() { 290 | return this.toString().hashCode(); 291 | } 292 | 293 | private String getOperator() { 294 | String returnVal = ''; 295 | switch on this.operator { 296 | when EQUALS { 297 | returnVal = '='; 298 | } 299 | when NOT_EQUALS { 300 | returnVal = '!='; 301 | } 302 | when LESS_THAN { 303 | returnVal = '<'; 304 | } 305 | when LESS_THAN_OR_EQUAL { 306 | returnVal = '<='; 307 | } 308 | when GREATER_THAN { 309 | returnVal = '>'; 310 | } 311 | when GREATER_THAN_OR_EQUAL { 312 | returnVal = '>='; 313 | } 314 | when ALIKE { 315 | returnVal = 'LIKE'; 316 | } 317 | when NOT_LIKE { 318 | returnVal = 'NOT {0} LIKE'; 319 | } 320 | } 321 | return returnVal; 322 | } 323 | 324 | private String getPredicate(Object predicate) { 325 | if (predicate == null || predicate instanceof Boolean) { 326 | return '' + predicate; 327 | } 328 | if (this.predicateKey == null) { 329 | this.predicateKey = String.format(BIND_VAR_MERGE, new List{ BIND_VAR_NUMBER.format() }); 330 | BIND_VAR_NUMBER++; 331 | this.bindVars.put(this.predicateKey, predicate); 332 | } 333 | return ':' + this.predicateKey; 334 | } 335 | 336 | private String getSoslPredicate(Object predicate) { 337 | if (predicate == null) { 338 | return 'null'; 339 | } else if (predicate instanceof Datetime) { 340 | // the most annoying one 341 | Datetime dt = (Datetime) predicate; 342 | return dt.format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'', 'Greenwich Mean Time'); 343 | } else if (predicate instanceof Iterable) { 344 | Iterable localPredicates = (Iterable) predicate; 345 | if (localPredicates.iterator().hasNext() == false) { 346 | return EMPTYISH_STRING; 347 | } 348 | List innerStrings = new List(); 349 | for (Object innerPred : localPredicates) { 350 | // recurse for string value 351 | String innerString = this.getSoslPredicate(innerPred); 352 | innerStrings.add(innerString); 353 | } 354 | String start = '('; 355 | String ending = ')'; 356 | return start + String.join(innerStrings, ',') + ending; 357 | } else if (predicate instanceof String) { 358 | String input = (String) predicate; 359 | return '\'' + String.escapeSingleQuotes(input) + '\''; 360 | } 361 | 362 | return String.valueOf(predicate); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /force-app/repository/Repository.cls: -------------------------------------------------------------------------------- 1 | @SuppressWarnings('PMD.AvoidBooleanMethodParameters,PMD.ExcessiveParameterList,PMD.ExcessivePublicCount,PMD.TooManyFields') 2 | public virtual without sharing class Repository implements IRepository { 3 | private final Map childToRelationshipNames; 4 | private final Map relationshipNameToChildQuery = new Map(); 5 | private final IDML dml; 6 | @TestVisible 7 | private final List queryFields; 8 | private final Set selectFields = new Set(); 9 | private final Set childBindVarKeys = new Set(); 10 | 11 | protected final Schema.SObjectType repoType; 12 | protected final Map bindVars = new Map(); 13 | 14 | protected System.AccessLevel accessLevel = System.AccessLevel.SYSTEM_MODE; 15 | protected final Map fieldToSortOrder = new Map(); 16 | 17 | private Boolean shouldPrintBindVars = false; 18 | private Boolean baseSelectUsed = false; 19 | private Boolean isSosl = false; 20 | private Boolean shouldAddChildFields = true; 21 | private Integer limitAmount; 22 | private SearchGroup soslSearchGroup = SearchGroup.ALL_FIELDS; 23 | 24 | public Repository(Schema.SObjectType repoType, List queryFields, RepoFactory repoFactory) { 25 | this.dml = repoFactory.getDml(); 26 | this.queryFields = queryFields; 27 | this.repoType = repoType; 28 | this.childToRelationshipNames = this.getChildRelationshipNames(repoType); 29 | } 30 | 31 | // SOQL 32 | public virtual Cursor getCursor(List queries) { 33 | String finalQuery = this.getFinalQuery(queries); 34 | this.logQuery('cursor query:\n' + finalQuery); 35 | Cursor cursor = new Cursor(finalQuery, this.bindVars, this.accessLevel); 36 | this.clearState(); 37 | System.debug(System.LoggingLevel.FINER, 'number of results: ' + cursor.getNumRecords()); 38 | return cursor; 39 | } 40 | 41 | public Database.QueryLocator getQueryLocator(List queries) { 42 | return this.getQueryLocator(queries, this.shouldAddChildFields); 43 | } 44 | 45 | public Database.QueryLocator getQueryLocator(List queries, Boolean shouldAddChildFields) { 46 | Boolean originalValue = this.shouldAddChildFields; 47 | this.shouldAddChildFields = shouldAddChildFields; 48 | String finalQuery = this.getFinalQuery(queries); 49 | this.logQuery('query locator query:\n' + finalQuery); 50 | Database.QueryLocator locator = Database.getQueryLocatorWithBinds( 51 | this.getFinalQuery(queries), 52 | this.bindVars, 53 | this.accessLevel 54 | ); 55 | this.clearState(); 56 | this.shouldAddChildFields = originalValue; 57 | 58 | return locator; 59 | } 60 | 61 | public virtual List get(Query query) { 62 | return this.get(new List{ query }); 63 | } 64 | 65 | public virtual List get(List queries) { 66 | String finalQuery = this.getFinalQuery(queries); 67 | return this.performQuery(finalQuery); 68 | } 69 | 70 | public virtual List getAll() { 71 | return this.get(new List()); 72 | } 73 | 74 | public Repository setLimit(Integer limitAmount) { 75 | this.limitAmount = limitAmount; 76 | return this; 77 | } 78 | 79 | public Repository addSortOrder(Schema.SObjectField fieldToken, RepositorySortOrder sortOrder) { 80 | this.fieldToSortOrder.put(fieldToken.getDescribe().getName(), sortOrder); 81 | return this; 82 | } 83 | 84 | public Repository addSortOrder(List parentFieldChain, RepositorySortOrder sortOrder) { 85 | this.fieldToSortOrder.put(Query.getBuiltUpParentFieldName(parentFieldChain), sortOrder); 86 | return this; 87 | } 88 | 89 | public Repository addBaseFields(List fields) { 90 | Set uniqueFields = new Set(this.queryFields); 91 | uniqueFields.addAll(fields); 92 | this.queryFields.clear(); 93 | this.queryFields.addAll(uniqueFields); 94 | return this; 95 | } 96 | 97 | public Repository addFunctionBaseField(SelectFunction selectFunction, Schema.SObjectField field) { 98 | return this.addFunctionBaseFields(selectFunction, new List{ field }); 99 | } 100 | 101 | public Repository addFunctionBaseFields(SelectFunction selectFunction, List fields) { 102 | Map fieldsToAliases = new Map(); 103 | for (Schema.SObjectField field : fields) { 104 | fieldsToAliases.put( 105 | field, 106 | this.queryFields.contains(field) ? field.getDescribe().getName() + '_' + selectFunction.name() : null 107 | ); 108 | } 109 | return this.addFunctionBaseFields(selectFunction, fieldsToAliases); 110 | } 111 | 112 | public Repository addFunctionBaseFields( 113 | SelectFunction selectFunction, 114 | Map fieldsToAliases 115 | ) { 116 | Map fieldNamesToAliases = new Map(); 117 | for (Schema.SObjectField field : fieldsToAliases.keySet()) { 118 | fieldNamesToAliases.put(field.getDescribe().getName(), fieldsToAliases.get(field)); 119 | } 120 | this.selectFields.addAll(this.getFunctionBaseFields(selectFunction, fieldNamesToAliases)); 121 | return this; 122 | } 123 | 124 | public Repository addParentFields(Schema.SObjectField parentType, List parentFields) { 125 | return this.addParentFields(new List{ parentType }, parentFields); 126 | } 127 | 128 | public Repository addParentFields(List parentTypes, List parentFields) { 129 | this.selectFields.addAll(this.getParentFields(parentTypes, parentFields)); 130 | return this; 131 | } 132 | 133 | public Repository addChildFields(Schema.SObjectField childFieldToken, List childFields) { 134 | return this.addChildFields( 135 | childFieldToken, 136 | childFields, 137 | new List(), 138 | new Map(), 139 | null 140 | ); 141 | } 142 | 143 | public Repository addChildFields(Schema.SObjectField childFieldToken, IRepository childRepo) { 144 | return this.addChildFields( 145 | childFieldToken, 146 | childRepo, 147 | new List(), 148 | new Map(), 149 | null 150 | ); 151 | } 152 | 153 | public Repository addChildFields( 154 | Schema.SObjectField childFieldToken, 155 | List childFields, 156 | List optionalWhereFilters, 157 | Map fieldToSortOrder, 158 | Integer limitBy 159 | ) { 160 | return this.addChildFields( 161 | childFieldToken, 162 | new List{ new QueryField(childFields) }, 163 | optionalWhereFilters, 164 | fieldToSortOrder, 165 | limitBy 166 | ); 167 | } 168 | 169 | public Repository addChildFields( 170 | Schema.SObjectField childFieldToken, 171 | IRepository childRepo, 172 | List optionalWhereFilters, 173 | Map fieldToSortOrder, 174 | Integer limitBy 175 | ) { 176 | Repository cr = (Repository) childRepo; 177 | cr.selectFields.addAll(cr.addSelectFields()); 178 | cr.selectFields.addAll(cr.relationshipNameToChildQuery.values()); 179 | Set localSelectFields = cr.selectFields; 180 | localSelectFields.remove('Id'); 181 | 182 | return this.addChildFields( 183 | childFieldToken, 184 | new List{ new QueryField(new List(localSelectFields)) }, 185 | optionalWhereFilters, 186 | fieldToSortOrder, 187 | limitBy 188 | ); 189 | } 190 | 191 | public Repository addChildFields( 192 | Schema.SObjectField childFieldToken, 193 | List childFields, 194 | List optionalWhereFilters, 195 | Map fieldToSortOrder, 196 | Integer limitBy 197 | ) { 198 | if (this.childToRelationshipNames.containsKey(childFieldToken) == false || this.shouldAddChildFields == false) { 199 | return this; 200 | } 201 | 202 | String baseSubselect = 203 | '(SELECT {0} FROM {1}' + 204 | this.addWheres(optionalWhereFilters) + 205 | this.getOrderBys(fieldToSortOrder) + 206 | this.getLimitAmount(limitBy) + 207 | ')'; 208 | 209 | Set childFieldNames = new Set{ 'Id' }; 210 | for (QueryField childField : childFields) { 211 | childFieldNames.add(childField.toString()); 212 | } 213 | 214 | for (Query query : optionalWhereFilters) { 215 | this.childBindVarKeys.addAll(query.getBindVars().keySet()); 216 | } 217 | 218 | String relationshipName = this.childToRelationshipNames.get(childFieldToken); 219 | String childFieldsToAdd = String.format( 220 | baseSubselect, 221 | new List{ String.join(childFieldNames, ','), this.childToRelationshipNames.get(childFieldToken) } 222 | ); 223 | this.relationshipNameToChildQuery.put(relationshipName, childFieldsToAdd); 224 | return this; 225 | } 226 | 227 | public Repository setAccessLevel(System.AccessLevel accessLevel) { 228 | this.setOptions(null, accessLevel); 229 | return this; 230 | } 231 | 232 | public Repository clearBindVars() { 233 | for (String key : this.bindVars.keySet()) { 234 | if (this.childBindVarKeys.contains(key) == false) { 235 | this.bindVars.remove(key); 236 | } 237 | } 238 | return this; 239 | } 240 | 241 | public Repository setShouldPrintBindVars(Boolean shouldPrintBindVars) { 242 | this.shouldPrintBindVars = shouldPrintBindVars; 243 | return this; 244 | } 245 | 246 | protected virtual Set addSelectFields() { 247 | this.baseSelectUsed = true; 248 | return this.addSelectFields(this.queryFields); 249 | } 250 | 251 | protected virtual String getFinalQuery(List queries) { 252 | return this.getSelectAndFrom() + 253 | this.addWheres(queries) + 254 | this.getOrderBys(this.fieldToSortOrder) + 255 | this.getLimitAmount(this.limitAmount); 256 | } 257 | 258 | protected virtual void clearState() { 259 | this.clearBindVars(); 260 | this.fieldToSortOrder.clear(); 261 | this.limitAmount = null; 262 | } 263 | 264 | private List getFunctionBaseFields(SelectFunction selectFunction, Map fieldsToAliases) { 265 | List functionBaseFields = new List(); 266 | for (String field : fieldsToAliases.keySet()) { 267 | Object alias = fieldsToAliases.get(field); 268 | functionBaseFields.add( 269 | String.format(selectFunction.name() + '({0}){1}', new List{ field, alias == null ? '' : ' ' + alias }) 270 | ); 271 | } 272 | return functionBaseFields; 273 | } 274 | 275 | private List getParentFields( 276 | List parentTypes, 277 | List parentFieldTokens 278 | ) { 279 | List parentFields = new List(); 280 | String parentBase = ''; 281 | for (Schema.SObjectField parentToken : parentTypes) { 282 | String parentName = parentToken.getDescribe().getRelationshipName() ?? 283 | parentToken.toString().replace('__c', '__r'); 284 | parentBase += parentName + '.'; 285 | } 286 | for (Schema.SObjectField parentField : parentFieldTokens) { 287 | parentFields.add(parentBase + parentField.getDescribe().getName()); 288 | } 289 | return parentFields; 290 | } 291 | 292 | private Map getChildRelationshipNames(Schema.SObjectType repoType) { 293 | Map localChildToRelationshipNames = new Map(); 294 | for (Schema.ChildRelationship childRelationship : repoType.getDescribe().getChildRelationships()) { 295 | localChildToRelationshipNames.put(childRelationship.getField(), childRelationship.getRelationshipName()); 296 | } 297 | return localChildToRelationshipNames; 298 | } 299 | 300 | private String getSelectAndFrom() { 301 | Set localSelectFields = this.addSelectFields(); 302 | if (this.baseSelectUsed) { 303 | localSelectFields.addAll(this.selectFields); 304 | this.baseSelectUsed = false; 305 | } 306 | localSelectFields.addAll(this.relationshipNameToChildQuery.values()); 307 | return 'SELECT ' + String.join(localSelectFields, ', ') + '\nFROM ' + this.repoType; 308 | } 309 | 310 | private Set addSelectFields(List fields) { 311 | Set fieldStrings = new Set{ 'Id' }; 312 | for (SObjectField field : fields) { 313 | fieldStrings.add(field.getDescribe().getName()); 314 | } 315 | return fieldStrings; 316 | } 317 | 318 | private String addWheres(List queries) { 319 | List wheres = new List(); 320 | for (Query qry : queries) { 321 | String possibleWhere = this.isSosl ? qry.toSoslString() : qry.toString(); 322 | if (qry.isSoslEmpty() == false && String.isNotBlank(possibleWhere)) { 323 | wheres.add(possibleWhere); 324 | this.bindVars.putAll(qry.getBindVars()); 325 | } 326 | } 327 | 328 | String whereClause = String.join(wheres, '\nAND '); 329 | return wheres.isEmpty() || String.isBlank(whereClause) ? '' : '\nWHERE ' + whereClause; 330 | } 331 | 332 | private List performQuery(String finalQuery) { 333 | this.logQuery('performQuery query:\n' + finalQuery); 334 | List results = Database.queryWithBinds(finalQuery, this.bindVars, this.accessLevel); 335 | this.clearState(); 336 | System.debug(System.LoggingLevel.FINER, 'number of results: ' + results.size() + '\nresults: \n' + results); 337 | return results; 338 | } 339 | 340 | private String getOrderBys(Map sortOrders) { 341 | String orderByString = ''; 342 | if (sortOrders.isEmpty() == false) { 343 | orderByString += ' \nORDER BY '; 344 | String separator = ', '; 345 | for (String field : sortOrders.keySet()) { 346 | orderByString += field + ' ' + sortOrders.get(field).toString() + separator; 347 | } 348 | orderByString = orderByString.removeEnd(separator); 349 | } 350 | return orderByString; 351 | } 352 | 353 | private String getLimitAmount(Integer limitAmount) { 354 | return (limitAmount != null ? '\nLIMIT ' + limitAmount : ''); 355 | } 356 | 357 | // SOSL 358 | 359 | public List> getSosl(String searchTerm, Query queryFilter) { 360 | return this.getSosl(searchTerm, new List{ queryFilter }); 361 | } 362 | 363 | public virtual List> getSosl(String searchTerm, List queryFilters) { 364 | return this.getSosl(searchTerm, queryFilters, new List()); 365 | } 366 | 367 | public virtual List> getSosl( 368 | String searchTerm, 369 | List queryFilters, 370 | List additionalSoslObjects 371 | ) { 372 | this.isSosl = true; 373 | List orderedSearchObjects = new List{ 374 | new AdditionalSoslObject(this.repoType, this.queryFields, queryFilters, this.limitAmount) 375 | }; 376 | orderedSearchObjects.addAll(additionalSoslObjects); 377 | String searchQuery = 378 | 'FIND \'' + 379 | String.escapeSingleQuotes(searchTerm) + 380 | '\' IN ' + 381 | this.soslSearchGroup.name().replace('_', ' ') + 382 | ' RETURNING ' + 383 | this.formatAdditionalSoslObjects(orderedSearchObjects); 384 | 385 | this.logQuery('search query:\n' + searchQuery); 386 | List> results = Search.query(searchQuery, this.accessLevel); 387 | System.debug(System.LoggingLevel.FINER, 'number of results: ' + results.size() + '\nresults: \n' + results); 388 | this.clearState(); 389 | this.isSosl = false; 390 | return results; 391 | } 392 | 393 | public Repository setSearchGroup(SearchGroup searchGroup) { 394 | this.soslSearchGroup = searchGroup; 395 | return this; 396 | } 397 | 398 | private String formatAdditionalSoslObjects(List soslObjects) { 399 | List objectsPreJoin = new List(); 400 | for (AdditionalSoslObject soslObject : soslObjects) { 401 | objectsPreJoin.add( 402 | soslObject.objectType + 403 | '(' + 404 | String.join(this.addSelectFields(soslObject.selectFields), ',') + 405 | this.addWheres(soslObject.queryFilters) + 406 | this.getLimitAmount(soslObject.queryLimit) + 407 | ')' 408 | ); 409 | } 410 | return String.join(objectsPreJoin, ','); 411 | } 412 | 413 | protected void logQuery(String logString) { 414 | if (this.shouldPrintBindVars) { 415 | logString += '\n\nBind vars: ' + JSON.serializePretty(this.bindVars); 416 | } 417 | System.debug(System.LoggingLevel.DEBUG, logString); 418 | } 419 | 420 | // DML 421 | public Database.SaveResult doInsert(SObject record) { 422 | return this.dml.doInsert(record); 423 | } 424 | public List doInsert(List records) { 425 | return this.dml.doInsert(records); 426 | } 427 | 428 | public Database.SaveResult doUpdate(SObject record) { 429 | return this.dml.doUpdate(record); 430 | } 431 | public List doUpdate(List records) { 432 | return this.dml.doUpdate(records); 433 | } 434 | 435 | public Database.UpsertResult doUpsert(SObject record) { 436 | return this.dml.doUpsert(record); 437 | } 438 | public List doUpsert(List records) { 439 | return this.dml.doUpsert(records); 440 | } 441 | public List doUpsert(List records, Schema.SObjectField field) { 442 | return this.dml.doUpsert(records, field); 443 | } 444 | 445 | public Database.UndeleteResult doUndelete(SObject record) { 446 | return this.dml.doUnDelete(record); 447 | } 448 | public List doUndelete(List records) { 449 | return this.dml.doUndelete(records); 450 | } 451 | 452 | public Database.DeleteResult doDelete(SObject record) { 453 | return this.dml.doDelete(record); 454 | } 455 | public List doDelete(List records) { 456 | return this.dml.doDelete(records); 457 | } 458 | 459 | public Database.DeleteResult doHardDelete(SObject record) { 460 | return this.dml.doHardDelete(record); 461 | } 462 | public List doHardDelete(List records) { 463 | return this.dml.doHardDelete(records); 464 | } 465 | 466 | public Database.SaveResult publish(SObject event) { 467 | return this.dml.publish(event); 468 | } 469 | public List publish(List events) { 470 | return this.dml.publish(events); 471 | } 472 | 473 | public IDML setOptions(Database.DMLOptions options) { 474 | return this.setOptions(options, this.accessLevel); 475 | } 476 | 477 | public IDML setOptions(Database.DMLOptions options, System.AccessLevel accessLevel) { 478 | this.accessLevel = accessLevel; 479 | return this.dml.setOptions(options, accessLevel); 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /force-app/repository/RepositoryTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class RepositoryTest { 3 | @IsTest 4 | static void itShouldTakeInAQuery() { 5 | Query basicQuery = Query.equals(ContactPointAddress.PreferenceRank, 1); 6 | IRepository repo = new ContactPointAddressRepo(); 7 | 8 | repo.get(basicQuery); 9 | Assert.areEqual(1, Limits.getQueries()); 10 | } 11 | 12 | @IsTest 13 | static void itShouldHandleListsAndSetsOfIdsOrStrings() { 14 | Id accountId = TestingUtils.generateId(Account.SObjectType); 15 | Id secondAccountId = TestingUtils.generateId(Account.SObjectType); 16 | List ids = new List{ accountId, secondAccountId }; 17 | Set setIds = new Set(ids); 18 | Set cpaNames = new Set{ 'Open', 'Closed' }; 19 | 20 | Query listQuery = Query.equals(ContactPointAddress.ParentId, ids); 21 | Query setQuery = Query.equals(ContactPointAddress.Id, setIds); 22 | Query setStringQuery = Query.equals(ContactPointAddress.Name, cpaNames); 23 | 24 | IRepository repo = new ContactPointAddressRepo(); 25 | 26 | repo.get(listQuery); 27 | repo.get(setQuery); 28 | repo.get(setStringQuery); 29 | Assert.areEqual(3, Limits.getQueries()); 30 | Assert.areEqual('ParentId = :bindVar0', listQuery.toString()); 31 | Assert.areEqual(ids, listQuery.getBindVars().get('bindVar0')); 32 | Assert.areEqual('Id = :bindVar1', setQuery.toString()); 33 | Assert.areEqual(setIds, setQuery.getBindVars().get('bindVar1')); 34 | Assert.areEqual('Name = :bindVar2', setStringQuery.toString()); 35 | Assert.areEqual(cpaNames, setStringQuery.getBindVars().get('bindVar2')); 36 | } 37 | 38 | @IsTest 39 | static void itShouldQueryWithEmptyCollections() { 40 | Query listQuery = Query.equals(ContactPointAddress.Id, new List()); 41 | 42 | IRepository repo = new ContactPointAddressRepo(); 43 | 44 | List results = repo.get(listQuery); 45 | Assert.areEqual(0, results.size()); 46 | Assert.areEqual(1, Limits.getQueries()); 47 | } 48 | 49 | @IsTest 50 | static void itShouldSearchWithEmptyCollections() { 51 | Query listQuery = Query.equals(ContactPointAddress.Id, new List()); 52 | 53 | IRepository repo = new ContactPointAddressRepo(); 54 | 55 | List> results = repo.getSosl('aaa', listQuery); 56 | Assert.areEqual(1, results.size()); 57 | Assert.areEqual(0, results.get(0).size()); 58 | Assert.areEqual(1, Limits.getSoslQueries()); 59 | } 60 | 61 | @IsTest 62 | static void itShouldRespectOrStatementsInQueries() { 63 | ContactPointAddress cpa = new ContactPointAddress(Name = 'Test Or', PreferenceRank = 1); 64 | ContactPointAddress secondCpa = new ContactPointAddress(Name = 'Test Or Two', PreferenceRank = 2); 65 | insert new List{ cpa, secondCpa }; 66 | 67 | IRepository repo = new ContactPointAddressRepo(); 68 | 69 | Id nullId = null; 70 | Query andQuery = Query.equals(ContactPointAddress.ParentId, nullId); 71 | Query secondAnd = Query.notEquals(ContactPointAddress.Id, nullId); 72 | Query orQuery = Query.orQuery( 73 | Query.equals(ContactPointAddress.PreferenceRank, cpa.PreferenceRank), 74 | Query.equals(ContactPointAddress.PreferenceRank, secondCpa.PreferenceRank) 75 | ); 76 | 77 | List cpas = repo.get(new List{ andQuery, secondAnd, orQuery }); 78 | Assert.areEqual(2, cpas.size()); 79 | } 80 | 81 | @IsTest 82 | static void itShouldRespectNotLikeSyntaxForMultipleValues() { 83 | ContactPointAddress cpa = new ContactPointAddress(Name = 'Test Or', PreferenceRank = 1); 84 | ContactPointAddress secondCpa = new ContactPointAddress(Name = 'Something different', PreferenceRank = 2); 85 | insert new List{ cpa, secondCpa }; 86 | 87 | IRepository repo = new ContactPointAddressRepo(); 88 | 89 | Query notLike = Query.notLike(ContactPointAddress.Name, new List{ cpa.Name, 'someOtherString' }); 90 | 91 | List cpas = repo.get(notLike); 92 | 93 | Assert.areEqual(1, cpas.size()); 94 | Assert.areEqual(secondCpa.Id, cpas[0].Id); 95 | } 96 | 97 | @IsTest 98 | static void itShouldAddParentFields() { 99 | GroupMemberRepo repo = new GroupMemberRepo(); 100 | repo.addParentFields( 101 | new List{ GroupMember.GroupId }, 102 | new List{ Group.Id } 103 | ); 104 | 105 | Group grp = new Group(Name = RepositoryTest.class.getName()); 106 | insert grp; 107 | GroupMember member = new GroupMember(GroupId = grp.Id, UserOrGroupId = UserInfo.getUserId()); 108 | insert member; 109 | List members = repo.get(Query.equals(GroupMember.Id, member.Id)); 110 | 111 | Assert.areEqual(1, members.size()); 112 | Assert.areEqual(grp.Id, members[0].Group.Id); 113 | } 114 | 115 | @IsTest 116 | static void itShouldAddChildFields() { 117 | IRepository repo = new AccountRepo() 118 | .addChildFields( 119 | Contact.AccountId, 120 | new List{ 121 | new QueryField(Contact.AccountId), 122 | new QueryField(Contact.LastName), 123 | new QueryField( 124 | new List{ Contact.AccountId }, 125 | new List{ Account.Name } 126 | ) 127 | }, 128 | new List(), 129 | new Map(), 130 | 1 131 | ); 132 | 133 | Account acc = new Account(Name = 'Parent'); 134 | insert acc; 135 | Contact con = new Contact(AccountId = acc.Id, LastName = 'Child'); 136 | insert con; 137 | 138 | List accounts = repo.getAll(); 139 | 140 | Assert.areEqual(1, accounts.size()); 141 | Assert.areEqual(1, accounts.get(0).Contacts.size()); 142 | Contact returnedCon = accounts.get(0).Contacts.get(0); 143 | Assert.areEqual(con.LastName, returnedCon.LastName); 144 | Assert.areEqual(acc.Name, returnedCon.Account.Name); 145 | } 146 | 147 | @IsTest 148 | static void itSupportsCallingAddChildFieldsWithSameArgumentsTwice() { 149 | IRepository repo = new AccountRepo() 150 | .addChildFields( 151 | Contact.AccountId, 152 | new List{ 153 | new QueryField(Contact.AccountId), 154 | new QueryField(Contact.LastName), 155 | new QueryField( 156 | new List{ Contact.AccountId }, 157 | new List{ Account.Name } 158 | ) 159 | }, 160 | new List{ Query.notEquals(Account.Name, 'somepredicate') }, 161 | new Map(), 162 | 1 163 | ); 164 | repo.addChildFields( 165 | Contact.AccountId, 166 | new List{ 167 | new QueryField(Contact.AccountId), 168 | new QueryField(Contact.LastName), 169 | new QueryField( 170 | new List{ Contact.AccountId }, 171 | new List{ Account.Name } 172 | ) 173 | }, 174 | new List{ Query.notEquals(Account.Name, 'somepredicate') }, 175 | new Map(), 176 | 1 177 | ); 178 | 179 | Account acc = new Account(Name = 'Parent'); 180 | insert acc; 181 | Contact con = new Contact(AccountId = acc.Id, LastName = 'Child'); 182 | insert con; 183 | 184 | List accounts = repo.getAll(); 185 | 186 | Assert.areEqual(1, accounts.size()); 187 | Assert.areEqual(1, accounts.get(0).Contacts.size()); 188 | Contact returnedCon = accounts.get(0).Contacts.get(0); 189 | Assert.areEqual(con.LastName, returnedCon.LastName); 190 | Assert.areEqual(acc.Name, returnedCon.Account.Name); 191 | } 192 | 193 | @IsTest 194 | static void itShouldAddChildFieldsFromRepo() { 195 | IRepository repo = new AccountRepo() 196 | .addChildFields( 197 | Opportunity.AccountId, 198 | new OpportunityRepo().addChildFields(OpportunityContactRole.OpportunityId, new OpportunityContactRoleRepo()) 199 | ); 200 | 201 | Account acc = new Account(Name = 'Parent'); 202 | insert acc; 203 | Contact con = new Contact(AccountId = acc.Id, LastName = 'Child Contact'); 204 | insert con; 205 | Opportunity opp = new Opportunity( 206 | AccountId = acc.Id, 207 | Name = 'Chlid', 208 | StageName = Opportunity.StageName.getDescribe().getPicklistValues().get(0).getValue(), 209 | CloseDate = Date.today() 210 | ); 211 | insert opp; 212 | OpportunityContactRole ocr = new OpportunityContactRole( 213 | OpportunityId = opp.Id, 214 | ContactId = con.Id, 215 | Role = OpportunityContactRole.Role.getDescribe().getPicklistValues().get(0).getValue() 216 | ); 217 | insert ocr; 218 | 219 | List accounts = repo.getAll(); 220 | Assert.areEqual(1, accounts.size()); 221 | Assert.areEqual(1, accounts.get(0).Opportunities.size()); 222 | Opportunity returnedOpp = accounts.get(0).Opportunities.get(0); 223 | Assert.areEqual(opp.Name, returnedOpp.Name); 224 | Assert.areEqual(1, returnedOpp.OpportunityContactRoles.size()); 225 | OpportunityContactRole returnedOcr = returnedOpp.OpportunityContactRoles.get(0); 226 | Assert.areEqual(ocr.Role, returnedOcr.Role); 227 | } 228 | 229 | @IsTest 230 | static void itAddsChildFieldsWithFilters() { 231 | String nameFilter = 'Child'; 232 | IRepository repo = new AccountRepo() 233 | .addChildFields( 234 | Contact.AccountId, 235 | new List{ Contact.AccountId, Contact.LastName }, 236 | new List{ Query.equals(Contact.LastName, nameFilter) }, 237 | new Map(), 238 | 1 239 | ); 240 | 241 | Account acc = new Account(Name = 'Parent'); 242 | insert acc; 243 | Contact con = new Contact(AccountId = acc.Id, LastName = nameFilter); 244 | Contact secondRecord = new Contact(AccountId = acc.Id, LastName = nameFilter); 245 | Contact excluded = new Contact(AccountId = acc.Id, LastName = 'Excluded'); 246 | insert new List{ con, secondRecord, excluded }; 247 | 248 | List accounts = repo.getAll(); 249 | Assert.areEqual(1, accounts.size()); 250 | Assert.areEqual(1, accounts.get(0).Contacts.size()); 251 | } 252 | 253 | @IsTest 254 | static void itAddsChildFieldsWithFiltersFromRepo() { 255 | String nameFilter = 'Child'; 256 | IRepository repo = new AccountRepo() 257 | .addChildFields( 258 | Contact.AccountId, 259 | new ContactRepo(), 260 | new List{ Query.equals(Contact.LastName, nameFilter) }, 261 | new Map(), 262 | 1 263 | ); 264 | 265 | Account acc = new Account(Name = 'Parent'); 266 | insert acc; 267 | Contact con = new Contact(AccountId = acc.Id, LastName = nameFilter); 268 | Contact secondRecord = new Contact(AccountId = acc.Id, LastName = nameFilter); 269 | Contact excluded = new Contact(AccountId = acc.Id, LastName = 'Excluded'); 270 | insert new List{ con, secondRecord, excluded }; 271 | 272 | List accounts = repo.getAll(); 273 | Assert.areEqual(1, accounts.size()); 274 | Assert.areEqual(1, accounts.get(0).Contacts.size()); 275 | // verify that filters can be used multiple times 276 | accounts = repo.getAll(); 277 | Assert.areEqual(1, accounts.size()); 278 | Assert.areEqual(1, accounts.get(0).Contacts.size()); 279 | } 280 | 281 | @IsTest 282 | static void itShouldSortAndLimitCorrectly() { 283 | insert new List{ 284 | new Account(Name = 'Two', AnnualRevenue = 1), 285 | new Account(Name = 'One'), 286 | new Account(Name = 'Three') 287 | }; 288 | 289 | List accounts = new AccountRepo() 290 | .addSortOrder(Account.Name, RepositorySortOrder.ASCENDING) 291 | .addSortOrder( 292 | Account.AnnualRevenue, 293 | new RepositorySortOrder(RepositorySortOrder.SortOrder.DESCENDING, RepositorySortOrder.NullSortOrder.LAST) 294 | ) 295 | .setLimit(1) 296 | .getAll(); 297 | 298 | Assert.areEqual(1, accounts.size()); 299 | Assert.areEqual('One', accounts.get(0).get(Account.Name)); 300 | } 301 | 302 | @IsTest 303 | static void itSortsByParentFields() { 304 | List accounts = new List{ 305 | new Account(Name = 'Should Not Be Returned'), 306 | new Account(Name = 'Parent B'), 307 | new Account(Name = 'Should Be Returned'), 308 | new Account(Name = 'Parent A') 309 | }; 310 | insert accounts; 311 | 312 | accounts[0].ParentId = accounts[1].Id; 313 | accounts[2].ParentId = accounts[3].Id; 314 | update accounts; 315 | 316 | List returnedAccounts = new AccountRepo() 317 | .addParentFields( 318 | new List{ Account.ParentId }, 319 | new List{ Account.Id, Account.Name } 320 | ) 321 | .addSortOrder( 322 | new List{ Account.ParentId, Account.Name }, 323 | new RepositorySortOrder(RepositorySortOrder.SortOrder.ASCENDING, RepositorySortOrder.NullSortOrder.LAST) 324 | ) 325 | .setLimit(1) 326 | .getAll(); 327 | 328 | Assert.areEqual(accounts[2].Name, returnedAccounts[0].Name); 329 | Assert.areEqual(accounts[2].Id, returnedAccounts[0].Id); 330 | Assert.areEqual(accounts[3].Id, returnedAccounts[0].Parent.Id); 331 | Assert.areEqual(accounts[3].Name, returnedAccounts[0].Parent.Name); 332 | } 333 | 334 | @IsTest 335 | static void itShouldDecorateDmlMethods() { 336 | IRepository repo = new RepoFactory().setFacade(new RepoFactoryMock.FacadeMock()).getProfileRepo(); 337 | Account acc = new Account(); 338 | List accs = new List{ acc }; 339 | 340 | repo.setAccessLevel(System.AccessLevel.USER_MODE) 341 | .setOptions(new Database.DMLOptions(), System.AccessLevel.USER_MODE); 342 | 343 | repo.doInsert(acc); 344 | repo.doInsert(accs); 345 | Assert.areEqual(acc, DMLMock.Inserted.Accounts.firstOrDefault); 346 | 347 | repo.doUpdate(acc); 348 | repo.doUpdate(accs); 349 | Assert.areEqual(acc, DMLMock.Updated.Accounts.firstOrDefault); 350 | 351 | repo.doUpsert(acc); 352 | repo.doUpsert(accs); 353 | Assert.areEqual(acc, DMLMock.Upserted.Accounts.firstOrDefault); 354 | 355 | repo.doDelete(acc); 356 | repo.doDelete(accs); 357 | Assert.areEqual(acc, DMLMock.Deleted.Accounts.firstOrDefault); 358 | 359 | repo.doUndelete(acc); 360 | repo.doUndelete(accs); 361 | Assert.areEqual(acc, DMLMock.Undeleted.Accounts.firstOrDefault); 362 | 363 | repo.doHardDelete(acc); 364 | repo.doHardDelete(accs); 365 | Assert.areEqual(acc, DMLMock.Deleted.Accounts.firstOrDefault); 366 | 367 | BatchApexErrorEvent event = new BatchApexErrorEvent(); 368 | repo.publish(event); 369 | repo.publish(new List{ event }); 370 | Assert.areEqual(event, DMLMock.Published.firstOrDefault); 371 | } 372 | 373 | @IsTest 374 | static void itPerformsSoslQueries() { 375 | ContactPointAddress cpa = new ContactPointAddress(Name = 'hello world', PreferenceRank = 1); 376 | insert cpa; 377 | Test.setFixedSearchResults(new List{ cpa.Id }); 378 | 379 | List> results = new ContactPointAddressRepo() 380 | .setSearchGroup(SearchGroup.NAME_FIELDS) 381 | .getSosl('hel', Query.equals(ContactPointAddress.PreferenceRank, 1)); 382 | 383 | Assert.areEqual(cpa.Id, results.get(0).get(0).Id); 384 | } 385 | 386 | @IsTest 387 | static void itSearchesForAdditionalObjects() { 388 | ContactPointPhone record = new ContactPointPhone(TelephoneNumber = 'hello universe'); 389 | insert record; 390 | 391 | Test.setFixedSearchResults(new List{ record.Id }); 392 | 393 | List> results = new ContactPointAddressRepo() 394 | .setSearchGroup(SearchGroup.NAME_FIELDS) 395 | .getSosl( 396 | 'hel', 397 | new List{ Query.equals(ContactPointAddress.PreferenceRank, 1) }, 398 | new List{ 399 | new AdditionalSoslObject( 400 | ContactPointPhone.SObjectType, 401 | new List(), 402 | new List{ Query.equals(ContactPointPhone.TelephoneNumber, 'hello universe') }, 403 | 1 404 | ) 405 | } 406 | ); 407 | 408 | Assert.areEqual(record.Id, results.get(1).get(0).Id); 409 | } 410 | 411 | @IsTest 412 | static void itHandlesEmptySoslSearches() { 413 | ContactPointPhone record = new ContactPointPhone(TelephoneNumber = 'hello universe'); 414 | insert record; 415 | 416 | Test.setFixedSearchResults(new List{ record.Id }); 417 | 418 | List> results = new ContactPointAddressRepo() 419 | .setSearchGroup(SearchGroup.NAME_FIELDS) 420 | .getSosl( 421 | 'hel', 422 | new List(), 423 | new List{ 424 | new AdditionalSoslObject( 425 | ContactPointPhone.SObjectType, 426 | new List(), 427 | new List{ 428 | Query.notEquals(ContactPointPhone.Id, new List()), 429 | Query.equals(ContactPointPhone.TelephoneNumber, 'hello universe') 430 | }, 431 | 1 432 | ) 433 | } 434 | ); 435 | 436 | Assert.areEqual(record.Id, results.get(1).get(0).Id); 437 | } 438 | 439 | @IsTest 440 | static void itAddsFunctionFieldWithoutAlias() { 441 | Account acc = new Account( 442 | Name = 'hello world', 443 | Industry = Account.Industry.getDescribe().getPicklistValues().get(0).getValue() 444 | ); 445 | insert acc; 446 | 447 | IRepository repo = new AccountRepo(); 448 | repo.addFunctionBaseField(SelectFunction.TOLABEL, Account.Industry); 449 | 450 | Test.startTest(); 451 | List results = repo.setLimit(1).getAll(); 452 | Test.stopTest(); 453 | 454 | Assert.areEqual( 455 | Account.Industry.getDescribe().getPicklistValues().get(0).getLabel(), 456 | results.get(0).Industry, 457 | 'Account.Industry should equal label' 458 | ); 459 | } 460 | 461 | @IsTest 462 | static void itAddsBaseFieldAndFunctionFieldWithAutomaticAlias() { 463 | Date activeFromDate = Date.newInstance(2001, 1, 1); 464 | ContactPointAddress cpa = new ContactPointAddress( 465 | Name = 'hello world', 466 | PreferenceRank = 1, 467 | ActiveFromDate = activeFromDate 468 | ); 469 | insert cpa; 470 | 471 | IRepository repo = new ContactPointAddressRepo(); 472 | repo.addBaseFields(new List{ ContactPointAddress.ActiveFromDate }); 473 | repo.addFunctionBaseField(SelectFunction.FORMAT, ContactPointAddress.ActiveFromDate); 474 | 475 | String activeFromDateFormatted; 476 | List results = new List(); 477 | 478 | Test.startTest(); 479 | results = repo.setLimit(1).getAll(); 480 | activeFromDateFormatted = activeFromDate.format(); 481 | Test.stopTest(); 482 | 483 | Assert.isNotNull(results.get(0).ActiveFromDate, 'ActiveFromDate should not be null'); 484 | Assert.areEqual( 485 | activeFromDateFormatted, 486 | results.get(0).get('ActiveFromDate_FORMAT'), 487 | 'ActiveFromDate_FORMAT should equal ' + activeFromDateFormatted 488 | ); 489 | } 490 | 491 | @IsTest 492 | static void itAddsBaseFieldAndFunctionFieldWithManualAlias() { 493 | Date activeFromDate = Date.newInstance(2001, 1, 1); 494 | ContactPointAddress cpa = new ContactPointAddress( 495 | Name = 'hello world', 496 | PreferenceRank = 1, 497 | ActiveFromDate = activeFromDate 498 | ); 499 | insert cpa; 500 | 501 | IRepository repo = new ContactPointAddressRepo(); 502 | repo.addBaseFields(new List{ ContactPointAddress.ActiveFromDate }); 503 | repo.addFunctionBaseFields( 504 | SelectFunction.FORMAT, 505 | new Map{ ContactPointAddress.ActiveFromDate => 'AktivSeit' } 506 | ); 507 | 508 | String activeFromDateFormatted; 509 | List results = new List(); 510 | 511 | Test.startTest(); 512 | results = repo.setLimit(1).getAll(); 513 | activeFromDateFormatted = activeFromDate.format(); 514 | Test.stopTest(); 515 | 516 | Assert.isNotNull(results.get(0).ActiveFromDate, 'ActiveFromDate should not be null'); 517 | Assert.areEqual( 518 | activeFromDateFormatted, 519 | results.get(0).get('AktivSeit'), 520 | 'AktivSeit should equal ' + activeFromDateFormatted 521 | ); 522 | } 523 | 524 | @IsTest 525 | static void itShouldReturnACursor() { 526 | List accounts = new List{ 527 | new Account(Name = 'One'), 528 | new Account(Name = 'Two'), 529 | new Account(Name = 'Three') 530 | }; 531 | insert accounts; 532 | 533 | IRepository repo = new AccountRepo(); 534 | Cursor cursor = repo.getCursor( 535 | new List{ Query.orQuery(Query.equals(Account.Name, 'One'), Query.equals(Account.Name, 'Two')) } 536 | ); 537 | Assert.areEqual(2, cursor.getNumRecords()); 538 | Assert.areEqual(accounts[0].Id, cursor.fetch(0, 1).get(0).Id); 539 | Assert.areEqual(accounts[1].Id, cursor.fetch(1, 1).get(0).Id); 540 | } 541 | 542 | @IsTest 543 | static void itShouldReturnAnEmptyCursor() { 544 | List accounts = new List{ new Account(Name = 'One'), new Account(Name = 'Two') }; 545 | insert accounts; 546 | 547 | IRepository repo = new AccountRepo(); 548 | Cursor cursor = repo.getCursor(new List{ Query.equals(Account.Name, 'Three') }); 549 | Assert.areEqual(0, cursor.getNumRecords()); 550 | Assert.areEqual(new List(), cursor.fetch(-111, -11111)); 551 | } 552 | 553 | private class GroupMemberRepo extends Repository { 554 | public GroupMemberRepo() { 555 | super(GroupMember.SObjectType, new List{ GroupMember.GroupId }, new RepoFactory()); 556 | } 557 | } 558 | 559 | private class ContactPointAddressRepo extends Repository { 560 | public ContactPointAddressRepo() { 561 | super( 562 | ContactPointAddress.SObjectType, 563 | new List{ 564 | ContactPointAddress.Id, 565 | ContactPointAddress.PreferenceRank, 566 | ContactPointAddress.ParentId, 567 | ContactPointAddress.Name 568 | }, 569 | new RepoFactory() 570 | ); 571 | } 572 | } 573 | 574 | private class AccountRepo extends Repository { 575 | public AccountRepo() { 576 | super(Account.SObjectType, new List{ Account.Name }, new RepoFactory()); 577 | } 578 | } 579 | 580 | private class ContactRepo extends Repository { 581 | public ContactRepo() { 582 | super(Contact.SObjectType, new List{ Contact.LastName }, new RepoFactory()); 583 | } 584 | } 585 | 586 | private class OpportunityRepo extends Repository { 587 | public OpportunityRepo() { 588 | super(Opportunity.SObjectType, new List{ Opportunity.Name }, new RepoFactory()); 589 | } 590 | } 591 | 592 | private class OpportunityContactRoleRepo extends Repository { 593 | public OpportunityContactRoleRepo() { 594 | super( 595 | OpportunityContactRole.SObjectType, 596 | new List{ OpportunityContactRole.Role }, 597 | new RepoFactory() 598 | ); 599 | } 600 | } 601 | } 602 | --------------------------------------------------------------------------------