diff --git a/subfolders/fflib/.github/ISSUE_TEMPLATE/bug_report.md b/subfolders/fflib/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..92c0956aeba --- /dev/null +++ b/subfolders/fflib/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +(A clear and concise description of what the bug is.) + +**To Reproduce** + +(Please provide a public github repo with a full SFDX project that demonstrates the problem. If the repro case can be followed with a single example Apex class against a scratch org with just the fflib-apex-common and fflib-apex-mocks project deployed into it, you don't need to provide a github repo) + +Steps to reproduce the behavior: +1. Create a scratch org as follows.... +2. Run the following Anonymous Apex.... +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots and text of error observed** +If applicable, add screenshots to help explain your problem. Also, paste the text of the raw error into the issue as well so that it can be found by others via search. + +**Version** +Did you try to reproduce the problem against the latest fflib-apex-common code? diff --git a/subfolders/fflib/.github/ISSUE_TEMPLATE/feature_request.md b/subfolders/fflib/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..bbcbbe7d615 --- /dev/null +++ b/subfolders/fflib/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/subfolders/fflib/.github/workflows/deploy.and.test.yml b/subfolders/fflib/.github/workflows/deploy.and.test.yml new file mode 100644 index 00000000000..394bf826646 --- /dev/null +++ b/subfolders/fflib/.github/workflows/deploy.and.test.yml @@ -0,0 +1,31 @@ +name: Create a Scratch Org, Push Source and Run Apex Tests + +on: + push: + pull_request_target: + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + - name: Install SFDX CLI and authorize DevHub + uses: apex-enterprise-patterns/setup-sfdx@v1 #We're using a fork of https://github.com/sfdx-actions/setup-sfdx for safety + with: + sfdx-auth-url: ${{ secrets.DEVHUB_SFDXURL }} + - run: sfdx force:config:set defaultdevhubusername=SFDX-ENV -g #Even though the setup-sfdx action uses --setdefaultdevhubusername, it doesn't seem to stick since it uses --setdefaultusername so we brute force it here + - run: echo y | sfdx plugins:install shane-sfdx-plugins + - run: sfdx force:org:create -f config/project-scratch-def.json --setdefaultusername -d 1 + - run: sfdx shane:github:src:install -c -g apex-enterprise-patterns -r fflib-apex-mocks -p sfdx-source/apex-mocks + - run: sfdx force:source:push + - run: sfdx force:apex:test:run -w 5 + - name: Destroy scratch org + run: sfdx force:org:delete -p + if: always() + diff --git a/subfolders/fflib/.gitignore b/subfolders/fflib/.gitignore new file mode 100644 index 00000000000..e403467fae7 --- /dev/null +++ b/subfolders/fflib/.gitignore @@ -0,0 +1,52 @@ +# General Project related +.DS_Store +target/ +temp/ +/deploy/* +/debug/ +**/dep-dir.txt +*.prefs +build.properties +/sfdx-source/core/main/default + +# MavensMate IDE related +*mm.log +*.sublime-build +*.sublime-project +*.sublime-settings +*.sublime-workspace +.sublime-project +.tm_properties + +# Eclipse IDE Related +.project +.settings/ +salesforce.schema +Referenced Packages/ + +# VS Code IDE Related +.vscode/ +.history/ + +# IntelliJ IDE Related +.idea/ +*.iml +*.iws +IlluminatedCloud/ +.project.license + +# SFDX Related +.sfdx/ +sfdx-source/common-base/main/default/ +sfdx-source/untracked/ +.execanon + +# NPM Related +package.json +/node_modules +package-lock.json + + +sfdx-source/group* +research/ +.pmdCache diff --git a/subfolders/fflib/LICENSE b/subfolders/fflib/LICENSE new file mode 100644 index 00000000000..03f67b613b2 --- /dev/null +++ b/subfolders/fflib/LICENSE @@ -0,0 +1,23 @@ +Copyright (c), FinancialForce.com, inc +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the FinancialForce.com, inc nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/subfolders/fflib/README.md b/subfolders/fflib/README.md new file mode 100644 index 00000000000..2c06119f3f1 --- /dev/null +++ b/subfolders/fflib/README.md @@ -0,0 +1,62 @@ +FFLib Apex Common +================= + +![Push Source and Run Apex Tests](https://github.com/apex-enterprise-patterns/fflib-apex-common/workflows/Create%20a%20Scratch%20Org,%20Push%20Source%20and%20Run%20Apex%20Tests/badge.svg) + + +**Dependencies:** Must deploy [ApexMocks](https://github.com/apex-enterprise-patterns/fflib-apex-mocks) before deploying this library + + + Deploy to Salesforce + + +Updates +======= + +- **April 2020**, **IMPORTANT CHANGE**, the directory format of this project repo was converted to [Salesforce DX Source Format](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_source_file_format.htm). While the GIT commit history was maintained, it is not visible on GitHub. If you need to see the history, either clone the repo and execute `git log --follow` from the command line or refer to this [tag](https://github.com/apex-enterprise-patterns/fflib-apex-common/tree/metadata-format-prior-to-dx-source-format-conversion) of the codebase prior to conversion. +- **September 2014**, **IMPORTANT CHANGE**, changes applied to support Dreamforce 2014 advanced presentation, library now provides Application factories for major layers and support for ApexMocks. More details to follow! As a result [ApexMocks](https://github.com/apex-enterprise-patterns/fflib-apex-mocks) must be deployed to the org before deploying this library. The sample application [here](https://github.com/apex-enterprise-patterns/fflib-apex-common-samplecode) has also been updated to demonstrate the new features! +- **July 2014**, **IMPORTANT CHANGE**, prior **23rd July 2014**, both the ``fflib_SObjectDomain.onValidate()`` and ``fflib_SObjectDomain.onValidate(Map existingRecords)`` methods where called during an on **after update** trigger event. From this point on the ``onValidate()`` method will only be called during on **after insert**. If you still require the orignal behaviour add the line ``Configuration.enableOldOnUpdateValidateBehaviour();`` into your constructor. +- **June 2014**, New classes providing utilities to support security and dynamic queries, in addition to improvements to existing Apex Enterprise Pattern base classes. Read more [here](http://andyinthecloud.com/2014/06/28/financialforce-apex-common-updates/). +- **June 2014**, Experimental [branch](https://github.com/apex-enterprise-patterns/fflib-apex-common/tree/fls-support-experiment) supporting automated FLS checking, see [README](https://github.com/apex-enterprise-patterns/fflib-apex-common/tree/fls-support-experiment#expirimental-crud-and-fls-support) for more details. + +This Library +============ + +Is derived from the **Dreamforce 2012** presentation on [Apex Enterprise Patterns](https://github.com/financialforcedev/df12-apex-enterprise-patterns) and progresses the patterns further with a more general ongoing home for them. It also adds some form of namespace qualification from the previous version. So that classes are grouped together more easily in the IDE's and packages. Below you can find comprehensive articles and videos on the use of these patterns. There is also a **working sample application** illustrating the patterns [here](https://github.com/apex-enterprise-patterns/fflib-apex-common-samplecode). + +![Alt text](/images/patternsturning.png "Optional title") + +Application Enterprise Patterns on Force.com +============================================ + +Design patterns are an invaluable tool for developers and architects looking to build enterprise solutions. Here are presented some tried and tested enterprise application engineering patterns that have been used in other platforms and languages. We will discuss and illustrate how patterns such as Data Mapper, Service Layer, Unit of Work and of course Model View Controller can be applied to Force.com. Applying these patterns can help manage governed resources (such as DML) better, encourage better separation-of-concerns in your logic and enforce Force.com coding best practices. + +Dreamforce Session and Slides +----------------------------- + +- View slides for the **Dreamforce 2013** session [here](https://docs.google.com/file/d/0B6brfGow3cD8RVVYc1dCX2s0S1E/edit) +- Video recording of the **Dreamforce 2013** session [here](http://www.youtube.com/watch?v=qlq46AEAlLI). +- Video recording of the **Advanced Apex Enterprise Dreamforce 2014** session [here](http://dreamforce.vidyard.com/watch/7QtP2628KmtXfmiwI-7B1w%20). +- View slides for the **Dreamforce 2015** session [here](http://www.slideshare.net/andyinthecloud/building-strong-foundations-apex-enterprise-patterns) + +Documentation +------------- + +- [Apex Sharing and applying to Apex Enterprise Patterns](http://andyinthecloud.com/2016/01/10/apex-sharing-and-applying-to-apex-enterprise-patterns/) +- [Tips for Migrating to Apex Enterprise Patterns](http://andyinthecloud.com/2015/09/30/tips-for-migrating-to-apex-enterprise-patterns/) +- [Great Contributions to Apex Enterprise Patterns](http://andyinthecloud.com/2015/07/25/great-contributions-to-apex-enterprise-patterns/) +- [Unit Testing, Apex Enterprise Patterns and ApexMocks – Part 1](http://andyinthecloud.com/2015/03/22/unit-testing-with-apex-enterprise-patterns-and-apexmocks-part-1/) +- [Unit Testing, Apex Enterprise Patterns and ApexMocks – Part 2](http://andyinthecloud.com/2015/03/29/unit-testing-apex-enterprise-patterns-and-apexmocks-part-2/) +- [Apex Enterprise Patterns - Separation of Concerns](http://wiki.developerforce.com/page/Apex_Enterprise_Patterns_-_Separation_of_Concerns) +- [Apex Enterprise Patterns - Service Layer](http://wiki.developerforce.com/page/Apex_Enterprise_Patterns_-_Service_Layer) +- [Apex Enterprise Patterns - Domain Layer](http://wiki.developerforce.com/page/Apex_Enterprise_Patterns_-_Domain_Layer) +- [Apex Enterprise Patterns - Selector Layer](https://github.com/financialforcedev/df12-apex-enterprise-patterns#data-mapper-selector) + +**Other Related Blogs** + +- [Preview of Advanced Apex Patterns Session (Application Factory and ApexMocks Features)](http://andyinthecloud.com/2014/08/26/preview-of-advanced-apex-enterprise-patterns-session/) +- [Unit Testing with the Domain Layer](http://andyinthecloud.com/2014/03/23/unit-testing-with-the-domain-layer/) +- [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensmate-templates-and-apex-enterprise-patterns/) +- [FinancialForce Apex Common Updates](http://andyinthecloud.com/2014/06/28/financialforce-apex-common-updates/) + diff --git a/subfolders/fflib/config/project-scratch-def.json b/subfolders/fflib/config/project-scratch-def.json new file mode 100644 index 00000000000..020b6b8e0a4 --- /dev/null +++ b/subfolders/fflib/config/project-scratch-def.json @@ -0,0 +1,9 @@ +{ + "orgName": "apex-common", + "edition": "Developer", + "settings": { + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true + } + } +} diff --git a/subfolders/fflib/images/patternsturning.png b/subfolders/fflib/images/patternsturning.png new file mode 100644 index 00000000000..4981c0572f1 Binary files /dev/null and b/subfolders/fflib/images/patternsturning.png differ diff --git a/subfolders/fflib/sfdx-project.json b/subfolders/fflib/sfdx-project.json new file mode 100644 index 00000000000..6cfb92e19b7 --- /dev/null +++ b/subfolders/fflib/sfdx-project.json @@ -0,0 +1,11 @@ +{ + "packageDirectories": [ + { + "path": "sfdx-source/apex-common", + "default": true + } + ], + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "53.0" +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls new file mode 100644 index 00000000000..427de5514e7 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls @@ -0,0 +1,500 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Class provides inner classes implementing factories for the main components + * of the Apex Enterprise Patterns, Service, Unit Of Work, Selector and Domain. + * See the sample applications Application.cls file for an example + **/ +public virtual class fflib_Application +{ + /** + * Class implements a Unit of Work factory + **/ + public virtual class UnitOfWorkFactory implements fflib_IUnitOfWorkFactory + { + protected List m_objectTypes; + protected fflib_ISObjectUnitOfWork m_mockUow; + + /** + * Constructs a Unit Of Work factory + **/ + public UnitOfWorkFactory() { } + + /** + * Constructs a Unit Of Work factory + * + * @param objectTypes List of SObjectTypes in dependency order + **/ + public UnitOfWorkFactory(List objectTypes) + { + m_objectTypes = objectTypes.clone(); + } + + /** + * Returns a new fflib_SObjectUnitOfWork configured with the + * SObjectType list provided in the constructor, returns a Mock implementation + * if set via the setMock method + **/ + public virtual fflib_ISObjectUnitOfWork newInstance() + { + // Mock? + if(m_mockUow!=null) + return m_mockUow; + return new fflib_SObjectUnitOfWork(m_objectTypes); + } + + /** + * Returns a new fflib_SObjectUnitOfWork configured with the + * SObjectType list provided in the constructor, returns a Mock implementation + * if set via the setMock method + **/ + public virtual fflib_ISObjectUnitOfWork newInstance(fflib_SObjectUnitOfWork.IDML dml) + { + // Mock? + if(m_mockUow!=null) + return m_mockUow; + return new fflib_SObjectUnitOfWork(m_objectTypes, dml); + } + + /** + * Returns a new fflib_SObjectUnitOfWork configured with the + * SObjectType list specified, returns a Mock implementation + * if set via the setMock method + * + * @remark If mock is set, the list of SObjectType in the mock could be different + * then the list of SObjectType specified in this method call + **/ + public virtual fflib_ISObjectUnitOfWork newInstance(List objectTypes) + { + // Mock? + if(m_mockUow!=null) + return m_mockUow; + return new fflib_SObjectUnitOfWork(objectTypes); + } + + /** + * Returns a new fflib_SObjectUnitOfWork configured with the + * SObjectType list specified, returns a Mock implementation + * if set via the setMock method + * + * @remark If mock is set, the list of SObjectType in the mock could be different + * then the list of SObjectType specified in this method call + **/ + public virtual fflib_ISObjectUnitOfWork newInstance(List objectTypes, fflib_SObjectUnitOfWork.IDML dml) + { + // Mock? + if(m_mockUow!=null) + return m_mockUow; + return new fflib_SObjectUnitOfWork(objectTypes, dml); + } + + @TestVisible + protected virtual void setMock(fflib_ISObjectUnitOfWork mockUow) + { + m_mockUow = mockUow; + } + } + + /** + * Simple Service Factory implementation + **/ + public virtual class ServiceFactory implements fflib_IServiceFactory + { + protected Map m_serviceInterfaceTypeByServiceImplType; + + protected Map m_serviceInterfaceTypeByMockService; + + /** + * Constructs a simple Service Factory + **/ + public ServiceFactory() { } + + /** + * Constructs a simple Service Factory, + * using a Map of Apex Interfaces to Apex Classes implementing the interface + * Note that this will not check the Apex Classes given actually implement the interfaces + * as this information is not presently available via the Apex runtime + * + * @param serviceInterfaceTypeByServiceImplType Map ofi interfaces to classes + **/ + public ServiceFactory(Map serviceInterfaceTypeByServiceImplType) + { + m_serviceInterfaceTypeByServiceImplType = serviceInterfaceTypeByServiceImplType; + m_serviceInterfaceTypeByMockService = new Map(); + } + + /** + * Returns a new instance of the Apex class associated with the given Apex interface + * Will return any mock implementation of the interface provided via setMock + * Note that this method will not check the configured Apex class actually implements the interface + * + * @param serviceInterfaceType Apex interface type + * @exception Is thrown if there is no registered Apex class for the interface type + **/ + public virtual Object newInstance(Type serviceInterfaceType) + { + // Mock implementation? + if( m_serviceInterfaceTypeByMockService.containsKey( serviceInterfaceType ) ) + { + return m_serviceInterfaceTypeByMockService.get( serviceInterfaceType ); + } + + String serviceInterfaceName = serviceInterfaceType.getName(); + + // Check if a configured implementation exists in the Custom Metadata + Type serviceImpl = m_serviceInterfaceTypeByServiceImplType.get(serviceInterfaceType); + if ( serviceImpl != null ) + { + try { + return serviceImpl.newInstance(); + } + catch ( Exception e ) { + throw new DeveloperException( 'Implementation for service interface ' + serviceInterfaceName + ' (' + serviceImpl.getName() + ') could not be constructed', e ); + } + } + + // Check if we can use a default service instead, based on the name IServiceInterface => ServiceInterfaceImpl + if ( ! isAServiceInterfaceName( serviceInterfaceName ) ) + { + throw new DeveloperException( 'No implementation registered for service interface ' + serviceInterfaceName + ' and default implementation cannot be determined from the name (not an interface in the standard naming convention (Ixxxx)' ); + } + + String defaultServiceName = buildDefaultServiceName( serviceInterfaceName ); + + Type defaultServiceType = Type.forName( defaultServiceName ); + + if ( defaultServiceType == null ) { + throw new DeveloperException( 'No implementation registered for service interface ' + serviceInterfaceName + ' and no default implementation found with the name ' + defaultServiceName ); + } + + try { + if ( !serviceInterfaceName.endsWith( 'ILoggerService' ) ) + { + LoggerService.log( LoggerService.LEVEL.INFO, 'Using default implementation ' + defaultServiceName + ' for ' + serviceInterfaceName ); + } + + return defaultServiceType.newInstance(); + } + catch ( Exception e ) { + throw new DeveloperException( 'Default implementation for service interface ' + serviceInterfaceName + ' (' + defaultServiceName + ') could not be constructed', e ); + } + } + + @testVisible + private Boolean isAServiceInterfaceName( String serviceName ) + { + return ( ( ! serviceName.contains( '.' ) && serviceName.startsWith( 'I' ) ) || serviceName.substringAfterLast( '.' ).startsWith( 'I' ) ); + } + + @testVisible + private String buildDefaultServiceName( String serviceInterfaceName ) + { + String[] serviceInterfaceNameParts = serviceInterfaceName.split( '\\.' ); + String serviceInterfaceNameLastPart = serviceInterfaceNameParts[ serviceInterfaceNameParts.size() - 1 ]; + String defaultServiceImpl = serviceInterfaceNameLastPart.substringAfter( 'I' ) + 'Impl'; + serviceInterfaceNameParts[ serviceInterfaceNameParts.size() - 1 ] = defaultServiceImpl; + + return String.join( serviceInterfaceNameParts, '.' ); + } + + @TestVisible + protected virtual void setMock(Type serviceInterfaceType, Object serviceImpl) + { + m_serviceInterfaceTypeByMockService.put(serviceInterfaceType, serviceImpl); + } + } + + /** + * Class implements a Selector class factory + **/ + public virtual class SelectorFactory implements fflib_ISelectorFactory + { + protected Map m_sObjectBySelectorType; + protected Map m_sObjectByMockSelector; + + /** + * Constructs a simple Selector Factory + **/ + public SelectorFactory() { } + + /** + * Consturcts a Selector Factory linking SObjectType's with Apex Classes implement the fflib_ISObjectSelector interface + * Note that the factory does not check the given Apex Classes implement the interface + * currently this is not possible in Apex. + * + * @Param sObjectBySelectorType Map of SObjectType's to Selector Apex Classes + **/ + public SelectorFactory(Map sObjectBySelectorType) + { + m_sObjectBySelectorType = sObjectBySelectorType; + m_sObjectByMockSelector = new Map(); + } + + /** + * Creates a new instance of the associated Apex Class implementing fflib_ISObjectSelector + * for the given SObjectType, or if provided via setMock returns the Mock implementation + * + * @param sObjectType An SObjectType token, e.g. Account.SObjectType + **/ + public virtual fflib_ISObjectSelector newInstance(SObjectType sObjectType) + { + // Mock implementation? + if(m_sObjectByMockSelector.containsKey(sObjectType)) + return m_sObjectByMockSelector.get(sObjectType); + + // Determine Apex class for Selector class + Type selectorClass = m_sObjectBySelectorType.get(sObjectType); + if(selectorClass==null) + throw new DeveloperException('Selector class not found for SObjectType ' + sObjectType); + + // Construct Selector class and query by Id for the records + return (fflib_ISObjectSelector) selectorClass.newInstance(); + } + + /** + * Helper method to query the given SObject records + * Internally creates an instance of the registered Selector and calls its + * selectSObjectById method + * + * @param recordIds The SObject record Ids, must be all the same SObjectType + * @exception Is thrown if the record Ids are not all the same or the SObjectType is not registered + **/ + public virtual List selectById(Set recordIds) + { + // No point creating an empty Domain class, nor can we determine the SObjectType anyway + if(recordIds==null || recordIds.size()==0) + throw new DeveloperException('Invalid record Id\'s set'); + + // Determine SObjectType + SObjectType domainSObjectType = new List(recordIds)[0].getSObjectType(); + for(Id recordId : recordIds) + if(recordId.getSobjectType()!=domainSObjectType) + throw new DeveloperException('Unable to determine SObjectType, Set contains Id\'s from different SObject types'); + + // Construct Selector class and query by Id for the records + return newInstance(domainSObjectType).selectSObjectsById(recordIds); + } + + /** + * Helper method to query related records to those provided, for example + * if passed a list of Opportunity records and the Account Id field will + * construct internally a list of Account Ids and call the registered + * Account selector to query the related Account records, e.g. + * + * List accounts = + * (List) Application.Selector.selectByRelationship(myOpps, Opportunity.AccountId); + * + * @param relatedRecords used to extract the related record Ids, e.g. Opportunity records + * @param relationshipField field in the passed records that contains the relationship records to query, e.g. Opportunity.AccountId + **/ + public virtual List selectByRelationship(List relatedRecords, SObjectField relationshipField) + { + Set relatedIds = new Set(); + for(SObject relatedRecord : relatedRecords) + { + Id relatedId = (Id) relatedRecord.get(relationshipField); + if(relatedId!=null) + relatedIds.add(relatedId); + } + return selectById(relatedIds); + } + + @TestVisible + protected virtual void setMock(fflib_ISObjectSelector selectorInstance) + { + m_sObjectByMockSelector.put(selectorInstance.sObjectType(), selectorInstance); + } + } + + /** + * Class implements a Domain class factory + **/ + public virtual class DomainFactory implements fflib_IDomainFactory + { + protected fflib_Application.SelectorFactory m_selectorFactory; + + protected Map constructorTypeByObject; + + protected Map mockDomainByObject; + + /** + * Constructs a Domain factory + **/ + public DomainFactory() { } + + /** + * Constructs a Domain factory, using an instance of the Selector Factory + * and a map of Apex classes implementing fflib_ISObjectDomain by SObjectType + * Note this will not check the Apex classes provided actually implement the interfaces + * since this is not possible in the Apex runtime at present + * + * @param selectorFactory , e.g. Application.Selector + * @param constructorTypeByObject Map of Domain classes by ObjectType + **/ + public DomainFactory(fflib_Application.SelectorFactory selectorFactory, + Map constructorTypeByObject) + { + m_selectorFactory = selectorFactory; + this.constructorTypeByObject = constructorTypeByObject; + this.mockDomainByObject = new Map(); + } + + /** + * Constructs a Domain factory, using an instance of the Selector Factory + * and a map of Apex classes implementing fflib_ISObjectDomain by SObjectType + * Note this will not check the Apex classes provided actually implement the interfaces + * since this is not possible in the Apex runtime at present + * + * @param selectorFactory, e.g. Application.Selector + * @param sObjectByDomainConstructorType Map of Apex classes by SObjectType + **/ + public DomainFactory(fflib_Application.SelectorFactory selectorFactory, + Map sObjectByDomainConstructorType) + { + m_selectorFactory = selectorFactory; + this.constructorTypeByObject = getConstructorTypeByObject(sObjectByDomainConstructorType); + this.mockDomainByObject = new Map(); + } + + /** + * Dynamically constructs an instance of a Domain class for the given record Ids + * Internally uses the Selector Factory to query the records before passing to a + * dynamically constructed instance of the application Apex Domain class + * + * @param recordIds A list of Id's of the same type + * @exception Throws an exception via the Selector Factory if the Ids are not all of the same SObjectType + **/ + public virtual fflib_IDomain newInstance(Set recordIds) + { + return newInstance(m_selectorFactory.selectById(recordIds)); + + } + + /** + * Dynamically constructs an instance of the Domain class for the given records + * Will return a Mock implementation if one has been provided via setMock + * + * @param records A concrete list (e.g. List vs List) of records + * @exception Throws an exception if the SObjectType cannot be determined from the list + * or the constructor for Domain class was not registered for the SObjectType + **/ + public virtual fflib_IDomain newInstance(List records) + { + SObjectType domainSObjectType = records.getSObjectType(); + if(domainSObjectType==null) + throw new DeveloperException('Unable to determine SObjectType'); + + return newInstance((List) records, (Object) domainSObjectType); + } + + public virtual fflib_IDomain newInstance(List objects, Object objectType) + { + // Mock implementation? + if (mockDomainByObject.containsKey(objectType)) + return mockDomainByObject.get(objectType); + + // Determine SObjectType and Apex classes for Domain class + Type domainConstructorClass = constructorTypeByObject.get(objectType); + if(domainConstructorClass==null) + throw new DeveloperException('Domain constructor class not found for SObjectType ' + objectType); + + // Construct Domain class passing in the queried records + Object domainConstructor = domainConstructorClass.newInstance(); + + // for backwards compatibility + if (domainConstructor instanceof fflib_SObjectDomain.IConstructable2) + { + return (fflib_IDomain) + ((fflib_SObjectDomain.IConstructable2) domainConstructor) + .construct((List) objects, (SObjectType) objectType); + } + else if (domainConstructor instanceof fflib_SObjectDomain.IConstructable) + { + return (fflib_IDomain) + ((fflib_SObjectDomain.IConstructable) domainConstructor) + .construct((List) objects); + } + + return ((fflib_IDomainConstructor) domainConstructor) + .construct(objects); + } + + /** + * Dynamically constructs an instance of the Domain class for the given records and SObjectType + * Will return a Mock implementation if one has been provided via setMock + * + * @param records A list records + * @param domainSObjectType SObjectType for list of records + * @exception Throws an exception if the SObjectType is not specified or if constructor for Domain class was not registered for the SObjectType + * + * @remark Will support List but all records in the list will be assumed to be of + * the type specified in sObjectType + **/ + public virtual fflib_IDomain newInstance(List records, SObjectType domainSObjectType) + { + if(domainSObjectType==null) + throw new DeveloperException('Must specify sObjectType'); + + return newInstance( + (List) records, + (Object) domainSObjectType + ); + } + + @TestVisible + protected virtual void setMock(fflib_ISObjectDomain mockDomain) + { + mockDomainByObject.put((Object) mockDomain.sObjectType(), (fflib_IDomain) mockDomain); + } + + @TestVisible + protected virtual void setMock(fflib_IDomain mockDomain) + { + mockDomainByObject.put(mockDomain.getType(), mockDomain); + } + + protected virtual Map getConstructorTypeByObject(Map constructorTypeBySObjectType) + { + Map result = new Map(); + for (SObjectType sObjectType : constructorTypeBySObjectType.keySet()) + { + result.put( + (Object) sObjectType, + constructorTypeBySObjectType.get(sObjectType) + ); + } + return result; + } + } + + public class ApplicationException extends Exception { } + + /** + * Exception representing a developer coding error, not intended for end user eyes + **/ + public class DeveloperException extends Exception { } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Application.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls new file mode 100644 index 00000000000..298f5b8ed42 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls @@ -0,0 +1,30 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IDomain +{ + Object getType(); + List getObjects(); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomain.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls new file mode 100644 index 00000000000..c4096d1d00a --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls @@ -0,0 +1,29 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IDomainConstructor +{ + fflib_IDomain construct(List objects); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainConstructor.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls new file mode 100644 index 00000000000..1e0f982d5ae --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls @@ -0,0 +1,32 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IDomainFactory +{ + fflib_IDomain newInstance(Set recordIds); + fflib_IDomain newInstance(List records); + fflib_IDomain newInstance(List objects, Object objectType); + fflib_IDomain newInstance(List records, SObjectType domainSObjectType); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls-meta.xml new file mode 100644 index 00000000000..d75b0582fba --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IDomainFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls new file mode 100644 index 00000000000..80358b22dd7 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls @@ -0,0 +1,84 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IObjects extends fflib_IDomain +{ + /** + * @param value Values to check if they are part of the domain + * + * @return True if the provided value is part of the domain + */ + Boolean contains(Object value); + + /** + * @param values Values to check if they are part of the domain + * + * @return True if all the provided values are part of the domain + */ + Boolean containsAll(List values); + + /** + * @param values Values to check if they are part of the domain + * + * @return True if all the provided values are part of the domain + */ + Boolean containsAll(Set values); + + /** + * @param value Value to check if it is part of the domain + * + * @return True if the provided value is not part of the domain + */ + Boolean containsNot(Object value); + + /** + * @param values Values to check if they are part of the domain + * + * @return True if all the provided values are not part of the domain + */ + Boolean containsNot(List values); + + /** + * @param values Values to check if they are part of the domain + * + * @return True if all the provided values are not part of the domain + */ + Boolean containsNot(Set values); + + /** + * @return Returns True is the domain is empty + */ + Boolean isEmpty(); + + /** + * @return Returns True is the domain has objects + */ + Boolean isNotEmpty(); + + /** + * @return Returns the amount of records contained in the domain + */ + Integer size(); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IObjects.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls new file mode 100644 index 00000000000..3dfadce34d4 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls @@ -0,0 +1,38 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +public interface fflib_ISObjectDomain extends fflib_IDomain +{ + /** + * Returns the SObjectType this Domain class represents + **/ + Schema.SObjectType sObjectType(); + + /** + * Alternative to the Records property, provided to support mocking of Domain classes + **/ + List getRecords(); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectDomain.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls new file mode 100644 index 00000000000..6cc8a3baddc --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls @@ -0,0 +1,38 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +public interface fflib_ISObjectSelector +{ + /** + * Provides the SObjectType for the object the given Selector is providing query logic for + **/ + Schema.SObjectType sObjectType(); + + /** + * Selects by Id records using the fields defined by the Selector configuration + **/ + List selectSObjectsById(Set idSet); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectSelector.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls new file mode 100644 index 00000000000..a18e1256f40 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls @@ -0,0 +1,229 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * @see fflib_SObjectUnitOfWork + **/ +public interface fflib_ISObjectUnitOfWork +{ + /** + * Register a newly created SObject instance to be inserted when commitWork is called + * + * @param record A newly created SObject instance to be inserted during commitWork + **/ + void registerNew(SObject record); + /** + * Register a list of newly created SObject instances to be inserted when commitWork is called + * + * @param records A list of newly created SObject instances to be inserted during commitWork + **/ + void registerNew(List records); + /** + * Register a newly created SObject instance to be inserted when commitWork is called, + * you may also provide a reference to the parent record instance (should also be registered as new separately) + * + * @param record A newly created SObject instance to be inserted during commitWork + * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent + * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) + **/ + void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord); + /** + * Register a relationship between two records that have yet to be inserted to the database. This information will be + * used during the commitWork phase to make the references only when related records have been inserted to the database. + * + * @param record An existing or newly created record + * @param relatedToField A SObjectField reference to the lookup field that relates the two records together + * @param relatedTo A SObject instance (yet to be committed to the database) + */ + void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo); + /** + * Registers a relationship between a record and a Messaging.Email where the record has yet to be inserted + * to the database. This information will be + * used during the commitWork phase to make the references only when related records have been inserted to the database. + * + * @param email a single email message instance + * @param relatedTo A SObject instance (yet to be committed to the database) + */ + void registerRelationship(Messaging.SingleEmailMessage email, SObject relatedTo); + /** + * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This + * information will be used during the commitWork phase to make the lookup reference requested when inserted to the database. + * + * @param record An existing or newly created record + * @param relatedToField A SObjectField reference to the lookup field that relates the two records together + * @param externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId + * @param externalId A Object representing the targeted value of the externalIdField in said lookup + * + * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); + * + * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. + */ + void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId); + /** + * Register an existing record to be updated during the commitWork method + * + * @param record An existing record + **/ + void registerDirty(SObject record); + /** + * Register specific fields on records to be updated when work is committed + * + * If the records are previously registered as dirty, the dirty fields on the records in this call will overwrite + * the values of the previously registered dirty records + * + * @param records A list of existing records + * @param dirtyFields The fields to update if record is already registered + **/ + void registerDirty(List records, List dirtyFields); + /** + * Register specific fields on record to be updated when work is committed + * + * If the record has previously been registered as dirty, the dirty fields on the record in this call will overwrite + * the values of the previously registered dirty record + * + * @param record An existing record + * @param dirtyFields The fields to update if record is already registered + **/ + void registerDirty(SObject record, List dirtyFields); + /** + * Register an existing record to be updated when commitWork is called, + * you may also provide a reference to the parent record instance (should also be registered as new separately) + * + * @param record A newly created SObject instance to be inserted during commitWork + * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent + * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) + **/ + void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord); + /** + * Register a list of existing records to be updated during the commitWork method + * + * @param records A list of existing records + **/ + void registerDirty(List records); + /** + * Register an deleted record to be removed from the recycle bin during the commitWork method + * + * @param record An deleted record + **/ + void registerEmptyRecycleBin(SObject record); + /** + * Register deleted records to be removed from the recycle bin during the commitWork method + * + * @param records Deleted records + **/ + void registerEmptyRecycleBin(List records); + /** + * Register a new or existing record to be inserted or updated during the commitWork method + * + * @param record An new or existing record + **/ + void registerUpsert(SObject record); + /** + * Register a list of mix of new and existing records to be upserted during the commitWork method + * + * @param records A list of mix of existing and new records + **/ + void registerUpsert(List records); + /** + * Register an existing record to be deleted during the commitWork method + * + * @param record An existing record + **/ + void registerDeleted(SObject record); + /** + * Register a list of existing records to be deleted during the commitWork method + * + * @param records A list of existing records + **/ + void registerDeleted(List records); + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param records A list of existing records + **/ + void registerPermanentlyDeleted(List records); + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param record A list of existing records + **/ + void registerPermanentlyDeleted(SObject record); + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + void registerPublishBeforeTransaction(SObject record); + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param records A list of existing records + **/ + void registerPublishBeforeTransaction(List records); + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork has successfully + * completed + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + void registerPublishAfterSuccessTransaction(SObject record); + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork has successfully + * completed + * + * @param records A list of existing records + **/ + void registerPublishAfterSuccessTransaction(List records); + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork has caused an error + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + void registerPublishAfterFailureTransaction(SObject record); + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork has caused an + * error + * + * @param records A list of existing records + **/ + void registerPublishAfterFailureTransaction(List records); + /** + * Takes all the work that has been registered with the UnitOfWork and commits it to the database + **/ + void commitWork(); + /** + * Register a generic peace of work to be invoked during the commitWork phase + * + * @param work Work to be registered + **/ + void registerWork(fflib_SObjectUnitOfWork.IDoWork work); + /** + * Registers the given email to be sent during the commitWork + * + * @param email Email to be sent + **/ + void registerEmail(Messaging.Email email); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjectUnitOfWork.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls new file mode 100644 index 00000000000..bf30d9a276e --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls @@ -0,0 +1,42 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_ISObjects extends fflib_IObjects +{ + /** + * @return Return the SObject records contained in the domain + */ + List getRecords(); + + /** + * @return Return the SObject records ids contained in the domain + */ + Set getRecordIds(); + + /** + * @return Return the SObjectType of the SObjects contained in the domain + */ + SObjectType getSObjectType(); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISObjects.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls new file mode 100644 index 00000000000..a39095b289b --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls @@ -0,0 +1,31 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_ISelectorFactory +{ + fflib_ISObjectSelector newInstance(SObjectType sObjectType); + List selectById(Set recordIds); + List selectByRelationship(List relatedRecords, SObjectField relationshipField); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls-meta.xml new file mode 100644 index 00000000000..d75b0582fba --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_ISelectorFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls new file mode 100644 index 00000000000..93fa4124cc7 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls @@ -0,0 +1,29 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IServiceFactory +{ + Object newInstance(Type serviceInterfaceType); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls-meta.xml new file mode 100644 index 00000000000..d75b0582fba --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IServiceFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls new file mode 100644 index 00000000000..371ab1ccde9 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls @@ -0,0 +1,32 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public interface fflib_IUnitOfWorkFactory +{ + fflib_ISObjectUnitOfWork newInstance(); + fflib_ISObjectUnitOfWork newInstance(fflib_SObjectUnitOfWork.IDML dml); + fflib_ISObjectUnitOfWork newInstance(List objectTypes); + fflib_ISObjectUnitOfWork newInstance(List objectTypes, fflib_SObjectUnitOfWork.IDML dml); +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls-meta.xml new file mode 100644 index 00000000000..d75b0582fba --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_IUnitOfWorkFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 51.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls new file mode 100644 index 00000000000..44c4f069eaf --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls @@ -0,0 +1,113 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public virtual class fflib_Objects implements fflib_IObjects +{ + protected List objects { get; private set;} { objects = new List(); } + + /** + * Class constructor + */ + public fflib_Objects(List objects) + { + this.objects = objects.clone(); + } + + public virtual Object getType() + { + return Object.class; + } + + public List getObjects() + { + return this.objects; + } + + public Boolean contains(Object value) + { + return getObjects()?.contains(value); + } + + public Boolean containsAll(List values) + { + if (values == null) return false; + + return containsAll(new Set(values)); + } + + public Boolean containsAll(Set values) + { + if (values == null) return false; + + for (Object value : values) + { + if (!getObjects()?.contains(value)) return false; + } + return true; + } + + public Boolean containsNot(Object value) + { + return !contains(value); + } + + public Boolean containsNot(List values) + { + if (values == null) return true; + + return containsNot(new Set(values)); + } + + public Boolean containsNot(Set values) + { + if (values == null) return true; + + for (Object value : values) + { + if (getObjects()?.contains(value)) return false; + } + return true; + } + + public Boolean isEmpty() + { + return (getObjects() == null || getObjects().isEmpty()); + } + + public Boolean isNotEmpty() + { + return !isEmpty(); + } + + public Integer size() + { + return getObjects().size(); + } + + protected void setObjects(List objects) + { + this.objects = objects; + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_Objects.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls new file mode 100644 index 00000000000..9a5958e10a3 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls @@ -0,0 +1,821 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * QueryFactory provides an object-oriented way of building SOQL queries without resorting to string manipulation. + * This class is not meant to be used as a replacement for all SOQL queries, and due to the relatively high overhead in both CPU and describe calls + * should be used in places where highly dynamic queries, such as those that include field sets or are mutated heavily + * in multiple locations are a good fit for use with fflib_QueryFactory. + * + * To use call construct a new instance for each query you intend to make. + * To add additional fields to the query make use of the selectField(s) methods. + * + * Currently the WHERE clause of the query is manipulated as a single string, and is decidedly less OO-styled than other methods. + * This is expected to be expanded upon in the future. + * + * To include one or more ORDER BY clause(s), use one of the addOrdering methods. If not specified, the "NULLS FIRST" keywords + * will be included by default. Constructing Ordering instances manually is discouraged. + * + * Subselect Queries are supported with the subselectQuery methods. + * More than one sub-query can be added to a single query, but sub-queries can only be 1 level deep. + * An exception will thrown from the subselectQuery method when there is an attempt to add a subquery to a sub-query + * or to add a subquery to a query with an invalid relationship. + * + * Current limitations: + * - Aggregate functions are not currently supported. + * - Cross-object references currently require using String argument(s) to selectField(s). + * - The behavior of serializing and deserializing an fflib_QueryFactory instance is currently untested and undefined. + * + * There is a google doc providing additional guidance on the use of this class with field sets at + * https://docs.google.com/a/financialforce.com/document/d/1I4cxN4xHT4UJj_3Oi0YBL_MJ5chm-KG8kMN1D1un8-g/edit?usp=sharing +**/ +public class fflib_QueryFactory { //No explicit sharing declaration - inherit from caller + public enum SortOrder {ASCENDING, DESCENDING} + + /** + * This property is read-only and may not be set after instantiation. + * The {@link Schema.SObjectType} token of the SObject that will be used in the FROM clause of the resultant query. + **/ + public Schema.SObjectType table {get; private set;} + @TestVisible + private Set fields; + private String conditionExpression { + get { + if ( conditionCriteria != null && String.isNotBlank( conditionExpression ) ) { + // currently an impossible scenario, assuming that setCondition is always used to set the conditions up. + throw new fflib_Application.DeveloperException( 'Both criteria and expression were set on the query factory : ' + conditionExpression + ' + ' + conditionCriteria ); + } + else { + if ( conditionCriteria != null ) { + return conditionCriteria.toSOQL(); + } + } + return conditionExpression; + } + set; + } + private ISearchCriteria conditionCriteria; + private Integer limitCount; + private Integer offsetCount; + private List order; + /** + * Integrate checking for READ Field Level Security within the selectField(s) methods + * This can optionally be enforced (or not) by calling the setEnforceFLS method prior to calling + * one of the selectField or selectFieldset methods. + **/ + private Boolean enforceFLS; + + private Boolean sortSelectFields = true; + + /** + * The relationship and subselectQueryMap variables are used to support subselect queries. Subselects can be added to + * a query, as long as it isn't a subselect query itself. You may have many subselects inside + * a query, but they may only be 1 level deep (no subselect inside a subselect) + * to add a subselect, call the subselectQuery method, passing in the ChildRelationship. + **/ + private Schema.ChildRelationship relationship; + private Map subselectQueryMap; + + private String getFieldPath(String fieldName, Schema.sObjectType relatedSObjectType){ + if(!fieldName.contains('.')){ //single field + Schema.SObjectField token = fflib_SObjectDescribe.getDescribe(table).getField(fieldName.toLowerCase()); + if(token == null) + throw new InvalidFieldException(fieldName,this.table); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(this.table, token); + return token.getDescribe().getName(); + } + + //traversing FK relationship(s) + List fieldPath = new List(); + Schema.SObjectType lastSObjectType = table; + Iterator i = fieldName.split('\\.').iterator(); + while(i.hasNext()){ + String field = i.next(); + Schema.SObjectField token = fflib_SObjectDescribe.getDescribe(lastSObjectType).getField(field.toLowerCase()); + DescribeFieldResult tokenDescribe = token != null ? token.getDescribe() : null; + + if (token != null && enforceFLS) { + fflib_SecurityUtils.checkFieldIsReadable(lastSObjectType, token); + } + + if (token != null && i.hasNext() && tokenDescribe.getSoapType() == Schema.SoapType.ID) { + List relatedObjs = tokenDescribe.getReferenceTo(); //if it's polymorphic, it matters which one we use - i.e. Lead.Owner is GROUP|USER and each has different fields. + + if (relatedObjs.size() == 1 || relatedSObjectType == null) { + lastSObjectType = relatedObjs[0]; //caller did not specify the one to use or there's only one so use the first one + } + else{ + for (Schema.sObjectType sot : relatedObjs) { + if (fflib_SObjectDescribe.getDescribe(sot).getDescribe().getSObjectType() == relatedSObjectType) { + lastSObjectType = sot; + break; + } + } + } + + fieldPath.add(tokenDescribe.getRelationshipName()); + }else if(token != null && !i.hasNext()){ + fieldPath.add(tokenDescribe.getName()); + }else{ + if(token == null) + throw new InvalidFieldException(field,lastSObjectType); + else + throw new NonReferenceFieldException(lastSObjectType+'.'+field+' is not a lookup or master-detail field but is used in a cross-object query field.'); + } + } + + return String.join(fieldPath,'.'); + } + + private String getFieldPath(String fieldName) { + return this.getFieldPath(fieldName, null); + } + + @TestVisible + private static String getFieldTokenPath(Schema.SObjectField field){ + if(field == null){ + throw new InvalidFieldException('Invalid field: null'); + } + return field.getDescribe().getName(); + } + + /** + * fflib_QueryFactory instances will be considered equal if they produce the same SOQL query. + * A faster comparison will first be attempted to check if they apply to the same table, and contain the same number of fields selected. + * This method will never return true if the provided object is not an instance of fflib_QueryFactory. + * @param obj the object to check equality of. + **/ + public Boolean equals(Object obj){ + if( !(obj instanceof fflib_QueryFactory) || ((fflib_QueryFactory)obj).table != this.table || ((fflib_QueryFactory)obj).fields.size() != this.fields.size() ) + return false; + return ((fflib_QueryFactory)obj).toSOQL() == this.toSOQL(); + } + + /** + * Construct a new fflib_QueryFactory instance with no options other than the FROM clause. + * You *must* call selectField(s) before {@link #toSOQL} will return a valid, runnable query. + * @param table the SObject to be used in the FROM clause of the resultant query. This sets the value of {@link #table}. + **/ + public fflib_QueryFactory(Schema.SObjectType table){ + this.table = table; + fields = new Set(); + order = new List(); + enforceFLS = false; + } + + /** + * Construct a new fflib_QueryFactory instance with no options other than the FROM clause and the relationship. + * This should be used when constructing a subquery query for addition to a parent query. + * Objects created with this constructor cannot be added to another object using the subselectQuery method. + * You *must* call selectField(s) before {@link #toSOQL} will return a valid, runnable query. + * @param relationship the ChildRelationship to be used in the FROM Clause of the resultant Query (when set overrides value of table). This sets the value of {@link #relationship} and {@link #table}. + **/ + private fflib_QueryFactory(Schema.ChildRelationship relationship){ + this(relationship.getChildSObject()); + this.relationship = relationship; + } + + /** + * This method checks to see if the User has Read Access on {@link #table}. + * Asserts true if User has access. + **/ + public fflib_QueryFactory assertIsAccessible(){ + fflib_SecurityUtils.checkObjectIsReadable(table); + return this; + } + + /** + * This method sets a flag to indicate that this query should have FLS Read + * permission enforced. If this method is not called, the default behavior + * is that FLS read permission will not be checked. + * @param enforce whether to enforce field level security (read) + **/ + public fflib_QueryFactory setEnforceFLS(Boolean enforce){ + this.enforceFLS = enforce; + return this; + } + + /** + * Sets a flag to indicate that this query should have ordered + * query fields in the select statement (this at a small cost to performance). + * If you are processing large query sets, you should switch this off. + * @param doSort whether or not select fields should be sorted in the soql statement. + **/ + public fflib_QueryFactory setSortSelectFields(Boolean doSort){ + this.sortSelectFields = doSort; + return this; + } + /** + * Selects a single field from the SObject specified in {@link #table}. + * Selecting fields is idempotent, if this field is already selected calling this method will have no additional impact. + * @param fieldName the API name of the field to add to the query's SELECT clause. + **/ + public fflib_QueryFactory selectField(String fieldName){ + fields.add( getFieldPath(fieldName, null) ); + return this; + } + + /** + * Selects a single field from the SObject specified in {@link #table}. + * Selecting fields is idempotent, if this field is already selected calling this method will have no additional impact. + * @param fieldName the API name of the field to add to the query's SELECT clause. + * @param relatedSObjectType the related sObjectType to resolve polymorphic object fields. + **/ + public fflib_QueryFactory selectField(String fieldName, Schema.sOBjectType relatedObjectType) { + fields.add(getFieldPath(fieldName, relatedObjectType)); + return this; + } + + /** + * Selects a field, avoiding the possible ambiguity of String API names. + * @see #selectField(String) + * @param field the {@link Schema.SObjectField} to select with this query. + * @exception InvalidFieldException If the field is null {@code field}. + **/ + public fflib_QueryFactory selectField(Schema.SObjectField field){ + if(field == null) + throw new InvalidFieldException(null,this.table); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(table, field); + fields.add( getFieldTokenPath(field) ); + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(String)} multiple times. + * @param fieldNames the Set of field API names to select. + **/ + public fflib_QueryFactory selectFields(Set fieldNames){ + for(String fieldName:fieldNames){ + fields.add( getFieldPath(fieldName) ); + } + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(String)} multiple times. + * @param fieldNames the List of field API names to select. + **/ + public fflib_QueryFactory selectFields(List fieldNames){ + for(String fieldName:fieldNames) + fields.add( getFieldPath(fieldName) ); + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(Schema.SObjectField)} multiple times. + * @param fields the set of {@link Schema.SObjectField}s to select. + * @exception InvalidFieldException if the fields are null {@code fields}. + **/ + public fflib_QueryFactory selectFields(Set fields){ + for(Schema.SObjectField token:fields){ + if(token == null) + throw new InvalidFieldException(); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(table, token); + this.fields.add( getFieldTokenPath(token) ); + } + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(Schema.SObjectField)} multiple times. + * @param fields the set of {@link Schema.SObjectField}s to select. + * @exception InvalidFieldException if the fields are null {@code fields}. + **/ + public fflib_QueryFactory selectFields(List fields){ + for(Schema.SObjectField token:fields){ + if(token == null) + throw new InvalidFieldException(); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(table, token); + this.fields.add( getFieldTokenPath(token) ); + } + return this; + } + /** + * @see #selectFieldSet(Schema.FieldSet,Boolean) + **/ + public fflib_QueryFactory selectFieldSet(Schema.FieldSet fieldSet){ + return selectFieldSet(fieldSet,true); + } + /** + * This is equivalent to iterating the fields in the field set and calling {@link #selectField(String)} on each. + * @param fieldSet Select all fields included in the field set. + * @param allowCrossObject if false this method will throw an exception if any fields in the field set reference fields on a related record. + * @exception InvalidFieldSetException if the fieldset is invalid for table {@code fields}. + **/ + public fflib_QueryFactory selectFieldSet(Schema.FieldSet fieldSet, Boolean allowCrossObject){ + if(fieldSet.getSObjectType() != table) + throw new InvalidFieldSetException('Field set "'+fieldSet.getName()+'" is not for SObject type "'+table+'"'); + for(Schema.FieldSetMember field: fieldSet.getFields()){ + if(!allowCrossObject && field.getFieldPath().contains('.')) + throw new InvalidFieldSetException('Cross-object fields not allowed and field "'+field.getFieldPath()+'"" is a cross-object field.'); + fields.add( getFieldPath(field.getFieldPath()) ); + } + return this; + } + /** + * @param conditionExpression Sets the WHERE clause to the string provided. Do not include the "WHERE". + **/ + public fflib_QueryFactory setCondition(String conditionExpression){ + this.conditionExpression = conditionExpression; + this.conditionCriteria = null; + return this; + } + + /** + * @param conditionExpression Sets the WHERE clause to the string provided by the given ISearchCriteria + **/ + public fflib_QueryFactory setCondition(ISearchCriteria criteria){ + this.conditionCriteria = criteria; + this.conditionExpression = null; + return this; + } + + /** + * @returns the current value of the WHERE clause, if any, as set by {@link #setCondition} + **/ + public String getCondition(){ + return this.conditionExpression; + } + /** + * @param limitCount if not null causes a LIMIT clause to be added to the resulting query. + **/ + public fflib_QueryFactory setLimit(Integer limitCount){ + this.limitCount = limitCount; + return this; + } + /** + * @returns the current value of the LIMIT clause, if any. + **/ + public Integer getLimit(){ + return this.limitCount; + } + /** + * @param offsetCount if not null causes a OFFSET clause to be added to the resulting query. + **/ + public fflib_QueryFactory setOffset(Integer offsetCount){ + this.offsetCount = offsetCount; + return this; + } + /** + * @returns the current value of the OFFSET clause, if any. + **/ + public Integer getOffset(){ + return this.offsetCount; + } + /** + * @param o an instance of {@link fflib_QueryFactory.Ordering} to be added to the query's ORDER BY clause. + **/ + public fflib_QueryFactory addOrdering(Ordering o){ + this.order.add(o); + return this; + } + + /** + * @param o an instance of {@link fflib_QueryFactory.Ordering} to remove all existing (for instance defaults) and be added to the query's ORDER BY clause. + **/ + public fflib_QueryFactory setOrdering(Ordering o){ + this.order = new List{ o }; + return this; + } + /** + * @returns the list of orderings that will be used as the query's ORDER BY clause. You may remove elements from the returned list, or otherwise mutate it, to remove previously added orderings. + **/ + public List getOrderings(){ + return this.order; + } + + /** + * @returns the selected fields + **/ + public Set getSelectedFields() { + return this.fields; + } + + /** + * Add a subquery query to this query. If a subquery for this relationship already exists, it will be returned. + * If not, a new one will be created and returned. + * @deprecated Replaced by {@link #subselectQuery(String relationshipName)} and {@link #subselectQuery(ChildRelationship relationship)} + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param related The related object type + **/ + public fflib_QueryFactory subselectQuery(SObjectType related){ + System.debug(LoggingLevel.WARN, 'fflib_QueryFactory.subselectQuery(Schema.SObjectType) is deprecated and will be removed in a future release. Use fflib_QueryFactory.subselectQuery(String) or fflib_QueryFactory.subselectQuery(ChildRelationship) instead.'); + return setSubselectQuery(getChildRelationship(related), false); + } + + /** + * Add a subquery query to this query. If a subquery for this relationship already exists, it will be returned. + * If not, a new one will be created and returned. + * @deprecated Replaced by {@link #subselectQuery(String relationshipName, Boolean assertIsAccessible)} and {@link #subselectQuery(ChildRelationship relationship, Boolean assertIsAccessible)} + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param related The related object type + * @param assertIsAccessible indicates whether to check if the user has access to the subquery object + **/ + public fflib_QueryFactory subselectQuery(SObjectType related, Boolean assertIsAccessible){ + System.debug(LoggingLevel.WARN, 'fflib_QueryFactory.subselectQuery(Schema.SObjectType, Boolean) is deprecated and will be removed in a future release. Use fflib_QueryFactory.subselectQuery(String, Boolean) or fflib_QueryFactory.subselectQuery(ChildRelationship, Boolean) instead.'); + return setSubselectQuery(getChildRelationship(related), assertIsAccessible); + } + + /** + * Add a subquery query to this query. If a subquery for this relationshipName already exists, it will be returned. + * If not, a new one will be created and returned. + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param relationshipName The relationshipName to be added as a subquery + **/ + public fflib_QueryFactory subselectQuery(String relationshipName){ + return subselectQuery(relationshipName, false); + } + + /** + * Add a subquery query to this query. If a subquery for this relationship already exists, it will be returned. + * If not, a new one will be created and returned. + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param relationshipName The relationshipName to be added as a subquery + * @param assertIsAccessible indicates whether to check if the user has access to the subquery object + **/ + public fflib_QueryFactory subselectQuery(String relationshipName, Boolean assertIsAccessible){ + Schema.ChildRelationship relationship = getChildRelationship(relationshipName); + if (relationship != null) { + return setSubselectQuery(relationship, assertIsAccessible); + } + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery with relationshipName = '+relationshipName +'. Relationship does not exist for ' + table.getDescribe().getName()); + } + + /** + * Add a subquery query to this query. If a subquery for this relationshipName already exists, it will be returned. + * If not, a new one will be created and returned. + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param relationship The ChildRelationship to be added as a subquery + **/ + public fflib_QueryFactory subselectQuery(Schema.ChildRelationship relationship){ + return subselectQuery(relationship, false); + } + + /** + * Add a subquery query to this query. If a subquery for this relationship already exists, it will be returned. + * If not, a new one will be created and returned. + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param relationship The ChildRelationship to be added as a subquery + * @param assertIsAccessible indicates whether to check if the user has access to the subquery object + **/ + public fflib_QueryFactory subselectQuery(Schema.ChildRelationship relationship, Boolean assertIsAccessible){ + return setSubselectQuery(relationship, assertIsAccessible); + } + + /** + * Add a subquery query to this query. If a subquery for this relationship already exists, it will be returned. + * If not, a new one will be created and returned. + * @exception InvalidSubqueryRelationshipException If this method is called on a subselectQuery or with an invalid relationship + * @param relationship The ChildRelationship to be added as a subquery + **/ + private fflib_QueryFactory setSubselectQuery(Schema.ChildRelationship relationship, Boolean assertIsAccessible){ + if (this.relationship != null){ + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. You may not add a subselect query to a subselect query.'); + } + if (this.subselectQueryMap == null){ + this.subselectQueryMap = new Map(); + } + if (this.subselectQueryMap.containsKey(relationship)){ + return subselectQueryMap.get(relationship); + } + + fflib_QueryFactory subSelectQuery = new fflib_QueryFactory(relationship); + + //The child queryFactory should be configured in the same way as the parent by default - can override after if required + subSelectQuery.setSortSelectFields(sortSelectFields); + + if(assertIsAccessible){ + subSelectQuery.assertIsAccessible(); + } + subselectQueryMap.put(relationship, subSelectQuery); + return subSelectQuery; + } + + /** + * @returns the list of subquery instances of fflib_QueryFactory which will be added to the SOQL as relationship/child/sub-queries. + **/ + public List getSubselectQueries(){ + if (subselectQueryMap != null) { + return subselectQueryMap.values(); + } + return null; + } + + /** + * Get the ChildRelationship from the Table for the object type passed in. + * @param objType The object type of the child relationship to get + **/ + private Schema.ChildRelationship getChildRelationship(SObjectType objType){ + for (Schema.ChildRelationship childRow : table.getDescribe().getChildRelationships()){ + //occasionally on some standard objects (Like Contact child of Contact) do not have a relationship name. + //if there is no relationship name, we cannot query on it, so throw an exception. + if (childRow.getChildSObject() == objType && childRow.getRelationshipName() != null){ + return childRow; + } + } + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. Invalid relationship for table '+table + ' and objtype='+objType); + } + + /** + * Get the ChildRelationship from the Table for the relationship name passed in. + * @param relationshipName The name of the object's ChildRelationship on get + **/ + private Schema.ChildRelationship getChildRelationship(String relationshipName){ + for (Schema.ChildRelationship childRow : table.getDescribe().getChildRelationships()){ + if (childRow.getRelationshipName() == relationshipName){ + return childRow; + } + } + return null; + } + + /** + * Add a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param fieldName The string value of the field to be sorted on + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + * @param nullsLast whether to sort null values last (NULLS LAST keyword included). + **/ + public fflib_QueryFactory addOrdering(String fieldName, SortOrder direction, Boolean nullsLast){ + order.add( + new Ordering(getFieldPath(fieldName), direction, nullsLast) + ); + return this; + } + + /** + * Add a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param field The SObjectField to sort. This can only be a direct reference. + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + * @param nullsLast whether to sort null values last (NULLS LAST keyword included). + **/ + public fflib_QueryFactory addOrdering(SObjectField field, SortOrder direction, Boolean nullsLast){ + order.add( + new Ordering(getFieldTokenPath(field), direction, nullsLast) + ); + return this; + } + + /** + * Add a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * The "NULLS FIRST" keywords will be included by default. If "NULLS LAST" + * is required, use one of the overloaded addOrdering methods which include this parameter. + * @param fieldName The string value of the field to be sorted on + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory addOrdering(String fieldName, SortOrder direction){ + order.add( + new Ordering(getFieldPath(fieldName), direction) + ); + return this; + } + + /** + * Add a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * The "NULLS FIRST" keywords will be included by default. If "NULLS LAST" + * is required, use one of the overloaded addOrdering methods which include this parameter. + * @param field The SObjectField to sort. This can only be a direct reference. + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory addOrdering(SObjectField field, SortOrder direction){ + order.add( + new Ordering(getFieldTokenPath(field), direction) + ); + return this; + } + + /** + * Remove existing ordering and set a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param fieldName The string value of the field to be sorted on + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + * @param nullsLast whether to sort null values last (NULLS LAST keyword included). + **/ + public fflib_QueryFactory setOrdering(String fieldName, SortOrder direction, Boolean nullsLast){ + Ordering ordr = new Ordering(getFieldPath(fieldName), direction, nullsLast); + return setOrdering(ordr); + } + + /** + * Remove existing ordering and set a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param field The SObjectField to sort. This can only be a direct reference. + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + * @param nullsLast whether to sort null values last (NULLS LAST keyword included). + **/ + public fflib_QueryFactory setOrdering(SObjectField field, SortOrder direction, Boolean nullsLast){ + Ordering ordr = new Ordering(getFieldTokenPath(field), direction, nullsLast); + return setOrdering(ordr); + } + + /** + * Remove existing ordering and set a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param fieldName The string value of the field to be sorted on + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory setOrdering(String fieldName, SortOrder direction){ + Ordering ordr = new Ordering(getFieldPath(fieldName), direction); + return setOrdering(ordr); + } + + /** + * Remove existing ordering and set a field to be sorted on. This may be a direct field or a field + * related through an object lookup or master-detail relationship. + * Use the set to store unique field names, since we only want to sort + * by the same field one time. The sort expressions are stored in a list + * so that they are applied to the SOQL in the same order that they + * were added in. + * @param field The SObjectField to sort. This can only be a direct reference. + * @param direction the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory setOrdering(SObjectField field, SortOrder direction){ + Ordering ordr = new Ordering(getFieldTokenPath(field), direction); + return setOrdering(ordr); + } + + /** + * Convert the values provided to this instance into a full SOQL string for use with Database.query + * Check to see if subqueries queries need to be added after the field list. + **/ + public String toSOQL(){ + String result = 'SELECT '; + //if no fields have been added, just add the Id field so that the query or subquery will not just fail + if (fields.size() == 0){ + if (enforceFLS) fflib_SecurityUtils.checkFieldIsReadable(table, 'Id'); + result += 'Id'; + }else { + List fieldsToQuery = new List(fields); + + if(sortSelectFields){ + fieldsToQuery.sort(); + } + + result += String.join(fieldsToQuery,', '); + } + + if(subselectQueryMap != null && !subselectQueryMap.isEmpty()){ + for (fflib_QueryFactory childRow : subselectQueryMap.values()){ + result += ', (' + childRow.toSOQL() + ') '; + } + } + result += ' FROM ' + (relationship != null ? relationship.getRelationshipName() : table.getDescribe().getName()); + if( String.isNotBlank( conditionExpression ) ) + result += ' WHERE '+conditionExpression; + + if(order.size() > 0){ + result += ' ORDER BY '; + for(Ordering o:order) + result += o.toSOQL() +', '; + result = result.substring(0,result.length()-2); + } + + if(limitCount != null) + result += ' LIMIT '+limitCount; + + if(offsetCount != null && offsetCount > 0) + result += ' OFFSET '+offsetCount; + + return result; + } + + /** + * Create a "deep" clone of this object that can be safely mutated without affecting the cloned instance + * @return a deep clone of this fflib_QueryFactory + **/ + public fflib_QueryFactory deepClone(){ + + fflib_QueryFactory clone = new fflib_QueryFactory(this.table) + .setLimit(this.limitCount) + .setOffset(this.offsetCount) + .setCondition(this.conditionExpression) + .setEnforceFLS(this.enforceFLS); + + Map subqueries = this.subselectQueryMap; + if(subqueries != null) { + Map clonedSubqueries = new Map(); + for(Schema.ChildRelationship key : subqueries.keySet()) { + clonedSubqueries.put(key, subqueries.get(key).deepClone()); + } + clone.subselectQueryMap = clonedSubqueries; + } + + clone.relationship = this.relationship; + clone.order = this.order.clone(); + clone.fields = this.fields.clone(); + + return clone; + } + + public class Ordering{ + private SortOrder direction; + private boolean nullsLast; + private String field; + + public Ordering(String sobjType, String fieldName, SortOrder direction){ + this( + fflib_SObjectDescribe.getDescribe(sobjType).getField(fieldName), + direction + ); + } + /** + * Construct a new ordering instance for use with {@link fflib_QueryFactory#addOrdering} + * Once constructed it's properties may not be modified. + **/ + public Ordering(Schema.SObjectField field, SortOrder direction){ + this(fflib_QueryFactory.getFieldTokenPath(field), direction, false); //SOQL docs state NULLS FIRST is default behavior + } + public Ordering(Schema.SObjectField field, SortOrder direction, Boolean nullsLast){ + this(fflib_QueryFactory.getFieldTokenPath(field), direction, nullsLast); + } + @TestVisible + private Ordering(String field, SortOrder direction){ + this(field, direction, false); + } + @TestVisible + private Ordering(String field, SortOrder direction, Boolean nullsLast){ + this.direction = direction; + this.field = field; + this.nullsLast = nullsLast; + } + public String getField(){ + return this.field; + } + public SortOrder getDirection(){ + return direction; + } + public String toSOQL(){ + return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC') + (nullsLast ? ' NULLS LAST ' : ' NULLS FIRST '); + } + } + + + + public class InvalidFieldException extends Exception{ + private String fieldName; + private Schema.SObjectType objectType; + public InvalidFieldException(String fieldName, Schema.SObjectType objectType){ + this.objectType = objectType; + this.fieldName = fieldName; + this.setMessage( 'Invalid field \''+fieldName+'\' for object \''+objectType+'\'' ); + } + } + + public class InvalidFieldSetException extends Exception{} + public class NonReferenceFieldException extends Exception{} + public class InvalidSubqueryRelationshipException extends Exception{} +} diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_QueryFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls new file mode 100644 index 00000000000..ac1575a5e57 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls @@ -0,0 +1,357 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * fflib_SObjectDescribe is a semi-intelligent wrapper for standard apex Schema methods. + * It provides an internal caching layer, to avoid hitting describe limits from repeated use, + * as well as wrapper classes and methods to make common tasks like working with relationship field name oddities + * as well namespace handling. + * + * Of particular note for use in contexts that may be released as managed packages are the #getFields and get #getGlobalDescribe methods + * These return special immutable wrapper objects that automatically imply the current namespace (detected as the one this class is contained in) + * and allow an older API style of omitting the namespace when working with fields or global describe maps. + * This allows both upgrading old code to APIv29 by making use of these as a nearly drop in replacement, as well as keeping + * namespace detection logic encapsulated. +**/ +public class fflib_SObjectDescribe { + //internal implementation details + private Schema.SObjectType token; + private Schema.SObjectField nameField; + private Schema.DescribeSObjectResult describe { //lazy load - keep this lightweight until we need more data + get{ + if(describe == null) + describe = token.getDescribe(); + return describe; + } + set; + } + private Map fields { + get{ + if(fields == null) + fields = describe.fields.getMap(); + return fields; + } + set; + } + private Map fieldSets { + get{ + if(fieldSets == null) + fieldSets = describe.fieldSets.getMap(); + return fieldSets; + } + set; + } + private FieldsMap wrappedFields { + get{ + if(wrappedFields == null){ + wrappedFields = new FieldsMap(this.fields); + } + return wrappedFields; + } + set; + } + + private fflib_SObjectDescribe(Schema.SObjectType token){ + if(token == null) + throw new InvalidDescribeException('Invalid SObject type: null'); + this.token = token; + instanceCache.put( String.valueOf(token).toLowerCase() , this); + } + + //public instace methods + /** + * Returns the Schema.SObjectType this fflib_SObjectDescribe instance is based on. + **/ + public Schema.SObjectType getSObjectType(){ + return token; + } + /** + * This method is a convenient shorthand for calling getField(name, true) + **/ + public Schema.SObjectField getField(String name){ + return this.getField(name, true); + } + /** + * This method provides a simplified shorthand for calling #getFields and getting the provided field. + * Additionally it handles finding the correct SObjectField for relationship notation, + * e.g. getting the Account field on Contact would fail without being referenced as AccountId - both work here. + **/ + public Schema.SObjectField getField(String fieldName, Boolean implyNamespace){ + Schema.SObjectField result = wrappedFields.get( + (fieldName.endsWithIgnoreCase('__r') ? //resolve custom field cross-object (__r) syntax + (fieldName.removeEndIgnoreCase('__r')+'__c') : + fieldName), + implyNamespace + ); + if(result == null){ + result = wrappedFields.get(fieldName+'Id', implyNamespace); //in case it's a standard lookup in cross-object format + } + return result; + } + + /** + * Returns the field where isNameField() is true (if any); otherwise returns null + **/ + public Schema.SObjectField getNameField() + { + if(nameField == null) { + for(Schema.SObjectField field : wrappedFields.values()) { + if(field.getDescribe().isNameField()) { + nameField = field; + break; + } + } + } + return nameField; + } + + /** + * Returns the raw Schema.DescribeSObjectResult an fflib_SObjectDescribe instance wraps. + **/ + public Schema.DescribeSObjectResult getDescribe(){ + return describe; + } + /** + * This method returns the raw data and provides no namespace handling. + * Due to this, __use of this method is discouraged__ in favor of getFields(). + **/ + public Map getFieldsMap(){ + return fields; + } + public FieldsMap getFields(){ + return wrappedFields; + } + public Map getFieldSetsMap(){ + return fieldSets; + } + + + + private static Map rawGlobalDescribe { + get{ + if(rawGlobalDescribe == null) + rawGlobalDescribe = Schema.getGlobalDescribe(); + return rawGlobalDescribe; + } + set; + } + private static GlobalDescribeMap wrappedGlobalDescribe{ + get{ + if(wrappedGlobalDescribe == null){ + wrappedGlobalDescribe = new GlobalDescribeMap(rawGlobalDescribe); + } + return wrappedGlobalDescribe; + } + set; + } + /** + * This is used to cache fflib_SObjectDescribe instances as they're constructed + * to prevent repeatedly re-constructing the same type. + * These instances are not guaranteed to be, but typically will be, unique per sObject type due to the presence of flushCache. + **/ + private static Map instanceCache {get{ + if(instanceCache == null) + instanceCache = new Map(); + return instanceCache; + } + set; + } + public static fflib_SObjectDescribe getDescribe(String sObjectName){ + if(String.isBlank(sObjectName)) + return null; + fflib_SObjectDescribe result = instanceCache.get(sObjectName.toLowerCase()); + if(result == null){ + Schema.SObjectType token = wrappedGlobalDescribe.get(sObjectName.toLowerCase()); + if(token == null) + result = null; + else + result = new fflib_SObjectDescribe(token); + } + return result; + } + public static fflib_SObjectDescribe getDescribe(Schema.SObjectType token){ + if(token == null) + return null; + fflib_SObjectDescribe result = instanceCache.get(String.valueOf(token).toLowerCase()); + if(result == null) + result = new fflib_SObjectDescribe(token); + return result; + } + public static fflib_SObjectDescribe getDescribe(Schema.DescribeSObjectResult nativeDescribe){ + if(nativeDescribe == null) + return null; + fflib_SObjectDescribe result = instanceCache.get(nativeDescribe.getName().toLowerCase()); + if(result == null) + result = new fflib_SObjectDescribe(nativeDescribe.getSObjectType()); + return result; + } + public static fflib_SObjectDescribe getDescribe(SObject instance){ + if(instance == null) + return null; + return getDescribe(instance.getSObjectType()); + } + + //returns the same results as the native method, just with caching built in to avoid limits + public static Map getRawGlobalDescribe(){ + return rawGlobalDescribe; + } + public static GlobalDescribeMap getGlobalDescribe(){ + return wrappedGlobalDescribe; + } + //Useful when working in heap space constrained environments. + //Existing references to SObjectDescribe instances will continue to work. + public static void flushCache(){ + rawGlobalDescribe = null; + instanceCache = null; + } + + + /** + * This class handles emulating a Map's non-mutating instance methods and helps navigate the complex topic of + * handling implicit namespace behavior like pre-APIv29 did, while also allowing fully qualified references. + * Note that this requires the API version of fflib_SObjectDescribe to be 29 or higher to function properly. + * + * Due to the lack of language support for covariant return types subclasses are responsible for implementing the get methods. + * A minimal implementation of these would be a cast and returning getObject's result. + **/ + private abstract class NamespacedAttributeMap{ + @TestVisible + protected String currentNamespace; + protected Map values; + + protected NamespacedAttributeMap(Map values){ + //namespace detection courtesy http://salesforce.stackexchange.com/a/28977/60 + currentNamespace = fflib_SObjectDescribe.class.getName().substringBefore('fflib_SObjectDescribe').removeEnd('.').toLowerCase(); + this.values = values; + } + //A no-args constructor to allow subclasses with different constructor signatures + protected NamespacedAttributeMap(){ + this(new Map()); + } + /** + * A convenient shortcut for invoking #getObject(name, true) + **/ + protected virtual Object getObject(String name){ + return this.getObject(name, true); + } + /** + * + **/ + protected virtual Object getObject(String name, Boolean implyNamespace){ + if(name == null) //short-circuit lookup logic since null can't possibly be a valid field name, and it saves us null checking + return null; + String preferredValue = ((implyNamespace ? currentNamespace+'__' : '') + name).toLowerCase(); + if(values.containsKey(preferredValue)){ + return values.get(preferredValue); + }else if(implyNamespace){ + return values.get(name.toLowerCase()); + }else{ + return null; + } + } + public virtual Boolean containsKey(String name){ + return this.containsKey(name, true); + } + public virtual Boolean containsKey(String name, Boolean implyNamespace){ + if(name == null) //short-circuit lookup logic since null can't possibly be a valid field name, and it saves us null checking + return null; + String preferredValue = ((implyNamespace ? currentNamespace+'__' : '') + name).toLowerCase(); + return ( + values.containsKey(preferredValue) || + implyNamespace && values.containsKey(name.toLowerCase()) + ); + } + public virtual Integer size(){ + return values.size(); + } + /** + * Returns the key set of the map. + * Note: unlike other NamespacedAttributeMap methods keySet defaults implyNamespace to false if not specified. + **/ + public virtual Set keySet(){ + return this.keySet(false); + } + public virtual Set keySet(Boolean implyNamespace){ + if(implyNamespace){ + Set result = new Set(); + for(String key:values.keySet()){ + result.add( + key.removeStartIgnoreCase(currentNamespace+'__') + ); + } + return result; + }else{ + return values.keySet(); + } + } + } + + /** + * A subclass of NamespacedAttributeMap for handling the data returned by #Schema.DescribeSObjectResult.fields.getMap + **/ + public class FieldsMap extends NamespacedAttributeMap{ + + @TestVisible + private FieldsMap(Map values){ + super(values); + } + + public Schema.SObjectField get(String name){ + return this.get(name, true); + } + public Schema.SObjectField get(String name, Boolean implyNamespace){ + return (Schema.SObjectField) this.getObject(name, implyNamespace); + } + public List values(){ + return (List) values.values(); + } + + } + /** + * A subclass of NamespacedAttributeMap for handling the data returned by #Schema.getGlobalDescribe + **/ + public class GlobalDescribeMap extends NamespacedAttributeMap{ + @TestVisible + private GlobalDescribeMap(Map values){ + super(values); + } + + public Schema.SObjectType get(String name){ + return this.get(name, true); + } + public Schema.SObjectType get(String name, Boolean implyNamespace){ + return (Schema.SObjectType) this.getObject(name, implyNamespace); + } + public List values(){ + return (List) values.values(); + } + } + + + public abstract class DescribeException extends Exception{} + public class DuplicateDescribeException extends DescribeException{} //Test coverage for this requires APIv28's @testVisible annotation to force exception cases. + public class InvalidDescribeException extends DescribeException{} +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDescribe.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls new file mode 100644 index 00000000000..80a1a72f504 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls @@ -0,0 +1,1125 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Base class aiding in the implementation of a Domain Model around SObject collections + * + * Domain (software engineering). “a set of common requirements, terminology, and functionality + * for any software program constructed to solve a problem in that field”, + * http://en.wikipedia.org/wiki/Domain_(software_engineering) + * + * Domain Model, “An object model of the domain that incorporates both behavior and data.”, + * “At its worst business logic can be very complex. Rules and logic describe many different " + * "cases and slants of behavior, and it's this complexity that objects were designed to work with...” + * Martin Fowler, EAA Patterns + * http://martinfowler.com/eaaCatalog/domainModel.html + * + **/ +public virtual with sharing class fflib_SObjectDomain + extends fflib_SObjects2 + implements fflib_ISObjectDomain +{ + /** + * Provides access to the data represented by this domain class + **/ + public List Records { + get + { + return getRecords(); + } + } + + + /** + * Provides access to Trigger.oldMap and allowing it to be mocked in unit-tests + **/ + @TestVisible + protected Map ExistingRecords + { + get + { + if (ExistingRecords == null) + { + if (System.Test.isRunningTest() & Test.Database.hasRecords()) + { + // If in test context and records are in the mock database use those instead of Trigger.oldMap + ExistingRecords = Test.Database.oldRecords; + } + else + { + ExistingRecords = Trigger.oldMap; + } + } + return ExistingRecords; + + } + private set; + } + + /** + * Exposes the configuration for this domain class instance + **/ + public Configuration Configuration {get; private set;} + + /** + * DEPRECATED, This property has been moved to fflib_SObjects + **/ + public static fflib_SObjectDomain.ErrorFactory Errors {get; private set;} + + /** + * Useful during unit testing to access mock support for database inserts and updates (testing without DML) + **/ + public static TestFactory Test {get; private set;} + + /** + * Retains instances of domain classes implementing trigger stateful + **/ + private static Map> TriggerStateByClass; + + /** + * Retains the trigger tracking configuration used for each domain + **/ + private static Map TriggerEventByClass; + + static + { + Errors = new fflib_SObjectDomain.ErrorFactory(); + + Test = new TestFactory(); + + TriggerStateByClass = new Map>(); + + TriggerEventByClass = new Map(); + } + + /** + * Constructs the domain class with the data on which to apply the behaviour implemented within + * + * @param sObjectList A concrete list (e.g. List vs List) of records + + **/ + public fflib_SObjectDomain(List sObjectList) + { + this(sObjectList, sObjectList.getSObjectType()); + } + + /** + * Constructs the domain class with the data and type on which to apply the behaviour implemented within + * + * @param sObjectList A list (e.g. List, List, etc.) of records + * @param sObjectType The Schema.SObjectType of the records contained in the list + * + * @remark Will support List but all records in the list will be assumed to be of + * the type specified in sObjectType + **/ + public fflib_SObjectDomain(List sObjectList, SObjectType sObjectType) + { + // Ensure the domain class has its own copy of the data + super(sObjectList, sObjectType); + + // Configure the Domain object instance + Configuration = new Configuration(); + } + + /** + * Override this to apply defaults to the records, this is called by the handleBeforeInsert method + **/ + public virtual void onApplyDefaults() { } + + /** + * Override this to apply general validation to be performed during insert or update, called by the handleAfterInsert and handleAfterUpdate methods + **/ + public virtual void onValidate() { } + + /** + * Override this to apply validation to be performed during insert, called by the handleAfterUpdate method + **/ + public virtual void onValidate(Map existingRecords) { } + + /** + * Override this to perform processing during the before insert phase, this is called by the handleBeforeInsert method + **/ + public virtual void onBeforeInsert() { } + + /** + * Override this to perform processing during the before update phase, this is called by the handleBeforeUpdate method + **/ + public virtual void onBeforeUpdate(Map existingRecords) { } + + /** + * Override this to perform processing during the before delete phase, this is called by the handleBeforeDelete method + **/ + public virtual void onBeforeDelete() { } + + /** + * Override this to perform processing during the after insert phase, this is called by the handleAfterInsert method + **/ + public virtual void onAfterInsert() { } + + /** + * Override this to perform processing during the after update phase, this is called by the handleAfterUpdate method + **/ + public virtual void onAfterUpdate(Map existingRecords) { } + + /** + * Override this to perform processing during the after delete phase, this is called by the handleAfterDelete method + **/ + public virtual void onAfterDelete() { } + + /** + * Override this to perform processing during the after undelete phase, this is called by the handleAfterDelete method + **/ + public virtual void onAfterUndelete() { } + + /** + * Base handler for the Apex Trigger event Before Insert, calls the onApplyDefaults method, followed by onBeforeInsert + **/ + public virtual void handleBeforeInsert() + { + onApplyDefaults(); + onBeforeInsert(); + } + + /** + * Base handler for the Apex Trigger event Before Update, calls the onBeforeUpdate method + **/ + public virtual void handleBeforeUpdate(Map existingRecords) + { + onBeforeUpdate(existingRecords); + } + + /** + * Base handler for the Apex Trigger event Before Delete, calls the onBeforeDelete method + **/ + public virtual void handleBeforeDelete() + { + onBeforeDelete(); + } + + /** + * Base handler for the Apex Trigger event After Insert, checks object security and calls the onValidate and onAfterInsert methods + * + * @throws DomainException if the current user context is not able to create records + **/ + public virtual void handleAfterInsert() + { + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isCreateable()) + throw new DomainException('Permission to create an ' + SObjectDescribe.getName() + ' denied.'); + + onValidate(); + onAfterInsert(); + } + + /** + * Base handler for the Apex Trigger event After Update, checks object security and calls the onValidate, onValidate(Map) and onAfterUpdate methods + * + * @throws DomainException if the current user context is not able to update records + **/ + public virtual void handleAfterUpdate(Map existingRecords) + { + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isUpdateable()) + throw new DomainException('Permission to update an ' + SObjectDescribe.getName() + ' denied.'); + + if(Configuration.OldOnUpdateValidateBehaviour) + onValidate(); + onValidate(existingRecords); + onAfterUpdate(existingRecords); + } + + /** + * Base handler for the Apex Trigger event After Delete, checks object security and calls the onAfterDelete method + * + * @throws DomainException if the current user context is not able to delete records + **/ + public virtual void handleAfterDelete() + { + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isDeletable()) + throw new DomainException('Permission to delete an ' + SObjectDescribe.getName() + ' denied.'); + + onAfterDelete(); + } + + /** + * Base handler for the Apex Trigger event After Undelete, checks object security and calls the onAfterUndelete method + * + * @throws DomainException if the current user context is not able to delete records + **/ + public virtual void handleAfterUndelete() + { + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isUndeletable()) + throw new DomainException('Permission to undelete an ' + SObjectDescribe.getName() + ' denied.'); + + onAfterUndelete(); + } + + /** + * Returns the SObjectType this Domain class represents + **/ + public SObjectType sObjectType() + { + return getSObjectType(); + } + + /** + * Detects whether any values in context records have changed for given fields as strings + * Returns list of SObject records that have changes in the specified fields + **/ + public List getChangedRecords(Set fieldNames) + { + List changedRecords = new List(); + for (SObject newRecord : Records) + { + Id recordId = (Id) newRecord.get('Id'); + if (this.ExistingRecords == null || !this.ExistingRecords.containsKey(recordId)) + { + continue; + } + + SObject oldRecord = this.ExistingRecords.get(recordId); + for (String fieldName : fieldNames) + { + if (oldRecord.get(fieldName) != newRecord.get(fieldName)) + { + changedRecords.add(newRecord); + break; // prevents the records from being added multiple times + } + } + } + return changedRecords; + } + + /** + * Detects whether any values in context records have changed for given fields as tokens + * Returns list of SObject records that have changes in the specified fields + **/ + public List getChangedRecords(Set fieldTokens) + { + List changedRecords = new List(); + for (SObject newRecord : Records) + { + Id recordId = (Id) newRecord.get('Id'); + if (this.ExistingRecords == null || !this.ExistingRecords.containsKey(recordId)) + { + continue; + } + SObject oldRecord = this.ExistingRecords.get(recordId); + for (Schema.SObjectField fieldToken : fieldTokens) + { + if (oldRecord.get(fieldToken) != newRecord.get(fieldToken)) + { + changedRecords.add(newRecord); + break; // prevents the records from being added multiple times + } + } + } + return changedRecords; + } + + /** + * Interface used to aid the triggerHandler in constructing instances of Domain classes + **/ + public interface IConstructable + { + fflib_SObjectDomain construct(List sObjectList); + } + + /** + * Interface used to aid the triggerHandler in constructing instances of Domain classes + **/ + public interface IConstructable2 extends IConstructable + { + fflib_SObjectDomain construct(List sObjectList, SObjectType sObjectType); + } + + /** + * For Domain classes implementing the ITriggerStateful interface returns the instance + * of the domain class being shared between trigger invocations, returns null if + * the Domain class trigger has not yet fired or the given domain class does not implement + * the ITriggerStateful interface. Note this method is sensitive to recursion, meaning + * it will return the applicable domain instance for the level of recursion + **/ + public static fflib_SObjectDomain getTriggerInstance(Type domainClass) + { + List domains = TriggerStateByClass.get(domainClass); + if(domains==null || domains.size()==0) + return null; + return domains[domains.size()-1]; + } + + /** + * Method constructs the given Domain class with the current Trigger context + * before calling the applicable override methods such as beforeInsert, beforeUpdate etc. + **/ + public static void triggerHandler(Type domainClass) + { + // Process the trigger context + if(System.Test.isRunningTest() & Test.Database.hasRecords()) + { + // If in test context and records in the mock database delegate initially to the mock database trigger handler + Test.Database.testTriggerHandler(domainClass); + } + else + { + // Process the runtime Apex Trigger context + triggerHandler(domainClass, + Trigger.isBefore, + Trigger.isAfter, + Trigger.isInsert, + Trigger.isUpdate, + Trigger.isDelete, + Trigger.isUnDelete, + Trigger.new, + Trigger.oldMap); + } + } + + /** + * Calls the applicable override methods such as beforeInsert, beforeUpdate etc. based on a Trigger context + **/ + private static void triggerHandler(Type domainClass, Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, Boolean isUndelete, List newRecords, Map oldRecordsMap) + { + // After phase of trigger will reuse prior instance of domain class if ITriggerStateful implemented + fflib_SObjectDomain domainObject = isBefore ? null : popTriggerInstance(domainClass, isDelete ? oldRecordsMap.values() : newRecords); + if(domainObject==null) + { + // Construct the domain class constructor class + String domainClassName = domainClass.getName(); + Type constructableClass = domainClassName.endsWith('Constructor') ? Type.forName(domainClassName) : Type.forName(domainClassName+'.Constructor'); + IConstructable domainConstructor = (IConstructable) constructableClass.newInstance(); + + // Construct the domain class with the approprite record set + if(isInsert) domainObject = domainConstructor.construct(newRecords); + else if(isUpdate) domainObject = domainConstructor.construct(newRecords); + else if(isDelete) domainObject = domainConstructor.construct(oldRecordsMap.values()); + else if(isUndelete) domainObject = domainConstructor.construct(newRecords); + + // Should this instance be reused on the next trigger invocation? + if(domainObject.Configuration.TriggerStateEnabled) + // Push this instance onto the stack to be popped during the after phase + pushTriggerInstance(domainClass, domainObject); + } + + // has this event been disabled? + if(!getTriggerEvent(domainClass).isEnabled(isBefore, isAfter, isInsert, isUpdate, isDelete, isUndelete)) + { + return; + } + + // Invoke the applicable handler + if(isBefore) + { + if(isInsert) domainObject.handleBeforeInsert(); + else if(isUpdate) domainObject.handleBeforeUpdate(oldRecordsMap); + else if(isDelete) domainObject.handleBeforeDelete(); + } + else + { + if(isInsert) domainObject.handleAfterInsert(); + else if(isUpdate) domainObject.handleAfterUpdate(oldRecordsMap); + else if(isDelete) domainObject.handleAfterDelete(); + else if(isUndelete) domainObject.handleAfterUndelete(); + } + } + + /** + * Pushes to the stack of domain classes per type a domain object instance + **/ + private static void pushTriggerInstance(Type domainClass, fflib_SObjectDomain domain) + { + List domains = TriggerStateByClass.get(domainClass); + if(domains==null) + TriggerStateByClass.put(domainClass, domains = new List()); + domains.add(domain); + } + + /** + * Pops from the stack of domain classes per type a domain object instance and updates the record set + **/ + private static fflib_SObjectDomain popTriggerInstance(Type domainClass, List records) + { + List domains = TriggerStateByClass.get(domainClass); + if(domains==null || domains.size()==0) + return null; + fflib_SObjectDomain domain = domains.remove(domains.size()-1); + + domain.setObjects(records); + return domain; + } + + public static TriggerEvent getTriggerEvent(Type domainClass) + { + if(!TriggerEventByClass.containsKey(domainClass)) + { + TriggerEventByClass.put(domainClass, new TriggerEvent()); + } + + return TriggerEventByClass.get(domainClass); + } + + public class TriggerEvent + { + public boolean BeforeInsertEnabled {get; private set;} + public boolean BeforeUpdateEnabled {get; private set;} + public boolean BeforeDeleteEnabled {get; private set;} + + public boolean AfterInsertEnabled {get; private set;} + public boolean AfterUpdateEnabled {get; private set;} + public boolean AfterDeleteEnabled {get; private set;} + public boolean AfterUndeleteEnabled {get; private set;} + + public TriggerEvent() + { + this.enableAll(); + } + + // befores + public TriggerEvent enableBeforeInsert() {BeforeInsertEnabled = true; return this;} + public TriggerEvent enableBeforeUpdate() {BeforeUpdateEnabled = true; return this;} + public TriggerEvent enableBeforeDelete() {BeforeDeleteEnabled = true; return this;} + + public TriggerEvent disableBeforeInsert() {BeforeInsertEnabled = false; return this;} + public TriggerEvent disableBeforeUpdate() {BeforeUpdateEnabled = false; return this;} + public TriggerEvent disableBeforeDelete() {BeforeDeleteEnabled = false; return this;} + + // afters + public TriggerEvent enableAfterInsert() {AfterInsertEnabled = true; return this;} + public TriggerEvent enableAfterUpdate() {AfterUpdateEnabled = true; return this;} + public TriggerEvent enableAfterDelete() {AfterDeleteEnabled = true; return this;} + public TriggerEvent enableAfterUndelete() {AfterUndeleteEnabled = true; return this;} + + + public TriggerEvent disableAfterInsert() {AfterInsertEnabled = false; return this;} + public TriggerEvent disableAfterUpdate() {AfterUpdateEnabled = false; return this;} + public TriggerEvent disableAfterDelete() {AfterDeleteEnabled = false; return this;} + public TriggerEvent disableAfterUndelete(){AfterUndeleteEnabled = false; return this;} + + public TriggerEvent enableAll() + { + return this.enableAllBefore().enableAllAfter(); + } + + public TriggerEvent disableAll() + { + return this.disableAllBefore().disableAllAfter(); + } + + public TriggerEvent enableAllBefore() + { + return this.enableBeforeInsert().enableBeforeUpdate().enableBeforeDelete(); + } + + public TriggerEvent disableAllBefore() + { + return this.disableBeforeInsert().disableBeforeUpdate().disableBeforeDelete(); + } + + public TriggerEvent enableAllAfter() + { + return this.enableAfterInsert().enableAfterUpdate().enableAfterDelete().enableAfterUndelete(); + } + + public TriggerEvent disableAllAfter() + { + return this.disableAfterInsert().disableAfterUpdate().disableAfterDelete().disableAfterUndelete(); + } + + public boolean isEnabled(Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, Boolean isUndelete) + { + if(isBefore) + { + if(isInsert) return BeforeInsertEnabled; + else if(isUpdate) return BeforeUpdateEnabled; + else if(isDelete) return BeforeDeleteEnabled; + } + else if(isAfter) + { + if(isInsert) return AfterInsertEnabled; + else if(isUpdate) return AfterUpdateEnabled; + else if(isDelete) return AfterDeleteEnabled; + else if(isUndelete) return AfterUndeleteEnabled; + } + return true; // shouldnt ever get here! + } + } + + /** + * Fluent style Configuration system for Domain class creation + **/ + public class Configuration + { + /** + * Backwards compatibility mode for handleAfterUpdate routing to onValidate() + **/ + public Boolean OldOnUpdateValidateBehaviour {get; private set;} + /** + * True if the base class is checking the users CRUD requirements before invoking trigger methods + **/ + public Boolean EnforcingTriggerCRUDSecurity {get; private set;} + + /** + * Enables reuse of the same Domain instance between before and after trigger phases (subject to recursive scenarios) + **/ + public Boolean TriggerStateEnabled {get; private set;} + + /** + * Default configuration + **/ + public Configuration() + { + EnforcingTriggerCRUDSecurity = true; // Default is true for backwards compatability + TriggerStateEnabled = false; + OldOnUpdateValidateBehaviour = false; // Breaking change, but felt to better practice + } + + /** + * See associated property + **/ + public Configuration enableTriggerState() + { + TriggerStateEnabled = true; + return this; + } + + /** + * See associated property + **/ + public Configuration disableTriggerState() + { + TriggerStateEnabled = false; + return this; + } + + /** + * See associated property + **/ + public Configuration enforceTriggerCRUDSecurity() + { + EnforcingTriggerCRUDSecurity = true; + return this; + } + + /** + * See associated property + **/ + public Configuration disableTriggerCRUDSecurity() + { + EnforcingTriggerCRUDSecurity = false; + return this; + } + + /** + * See associated property + **/ + public Configuration enableOldOnUpdateValidateBehaviour() + { + OldOnUpdateValidateBehaviour = true; + return this; + } + + /** + * See associated property + **/ + public Configuration disableOldOnUpdateValidateBehaviour() + { + OldOnUpdateValidateBehaviour = false; + return this; + } + } + + /** + * General exception class for the domain layer + **/ + public class DomainException extends Exception + { + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public override String error(String message, SObject record) + { + return fflib_SObjectDomain.Errors.error(this, message, record); + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public override String error(String message, SObject record, SObjectField field) + { + return fflib_SObjectDomain.Errors.error(this, message, record, field); + } + + /** + * DEPRECATED, This class has been moved to fflib_SObjects + **/ + public class ErrorFactory + { + private List errorList = new List(); + + private ErrorFactory() + { + + } + + public String error(String message, SObject record) + { + return error(null, message, record); + } + + private String error(fflib_SObjectDomain domain, String message, SObject record) + { + ObjectError objectError = new ObjectError(); + objectError.domain = domain; + objectError.message = message; + objectError.record = record; + errorList.add(objectError); + return message; + } + + public String error(String message, SObject record, SObjectField field) + { + return error(null, message, record, field); + } + + private String error(fflib_SObjectDomain domain, String message, SObject record, SObjectField field) + { + FieldError fieldError = new FieldError(); + fieldError.domain = domain; + fieldError.message = message; + fieldError.record = record; + fieldError.field = field; + errorList.add(fieldError); + return message; + } + + public List getAll() + { + return errorList.clone(); + } + + public void clearAll() + { + errorList.clear(); + } + } + + /** + * DEPRECATED, This class has been moved to fflib_SObjects + **/ + public virtual class FieldError extends ObjectError + { + public SObjectField field; + + public FieldError() + { + + } + } + + /** + * DEPRECATED, This class has been moved to fflib_SObjects + **/ + public virtual class ObjectError extends Error + { + public SObject record; + + public ObjectError() + { + + } + } + + /** + * DEPRECATED, This class has been moved to fflib_SObjects + **/ + public abstract class Error + { + public String message; + public fflib_SObjectDomain domain; + } + + /** + * Provides test context mocking facilities to unit tests testing domain classes + **/ + public class TestFactory + { + public MockDatabase Database = new MockDatabase(); + + private TestFactory() + { + + } + } + + /** + * Class used during Unit testing of Domain classes, can be used (not exclusively) to speed up test execution and focus testing + **/ + public class MockDatabase + { + private Boolean isInsert = false; + private Boolean isUpdate = false; + private Boolean isDelete = false; + private Boolean isUndelete = false; + private List records = new List(); + private Map oldRecords = new Map(); + + private MockDatabase() + { + + } + + private void testTriggerHandler(Type domainClass) + { + // Mock Before + triggerHandler(domainClass, true, false, isInsert, isUpdate, isDelete, isUndelete, records, oldRecords); + + // Mock After + triggerHandler(domainClass, false, true, isInsert, isUpdate, isDelete, isUndelete, records, oldRecords); + } + + public void onInsert(List records) + { + this.isInsert = true; + this.isUpdate = false; + this.isDelete = false; + this.isUndelete = false; + this.records = records; + } + + public void onUpdate(List records, Map oldRecords) + { + this.isInsert = false; + this.isUpdate = true; + this.isDelete = false; + this.records = records; + this.isUndelete = false; + this.oldRecords = oldRecords; + } + + public void onDelete(Map records) + { + this.isInsert = false; + this.isUpdate = false; + this.isDelete = true; + this.isUndelete = false; + this.oldRecords = records; + } + + public void onUndelete(List records) + { + this.isInsert = false; + this.isUpdate = false; + this.isDelete = false; + this.isUndelete = true; + this.records = records; + } + + public Boolean hasRecords() + { + return records!=null && records.size()>0 || oldRecords!=null && oldRecords.size()>0; + } + } + + /** + * Test domain class (ideally this would be in the test class, however Type.newInstance does not see such classes) + **/ + public with sharing class TestSObjectDomain extends fflib_SObjectDomain + { + private String someState; + + public TestSObjectDomain(List sObjectList) + { + // Domain classes are initialised with lists to enforce bulkification throughout + super(sObjectList); + } + + public TestSObjectDomain(List sObjectList, SObjectType sObjectType) + { + // Domain classes are initialised with lists to enforce bulkification throughout + super(sObjectList, sObjectType); + } + + public override void onApplyDefaults() + { + // Not required in production code + super.onApplyDefaults(); + + // Apply defaults to Testfflib_SObjectDomain + for(Opportunity opportunity : (List) Records) + { + opportunity.CloseDate = System.today().addDays(30); + } + } + + public override void onValidate() + { + // Not required in production code + super.onValidate(); + + // Validate Testfflib_SObjectDomain + for(Opportunity opp : (List) Records) + { + if(opp.Type!=null && opp.Type.startsWith('Existing') && opp.AccountId == null) + { + opp.AccountId.addError( error('You must provide an Account for Opportunities for existing Customers.', opp, Opportunity.AccountId) ); + } + } + } + + public override void onValidate(Map existingRecords) + { + // Not required in production code + super.onValidate(existingRecords); + + // Validate changes to Testfflib_SObjectDomain + for(Opportunity opp : (List) Records) + { + Opportunity existingOpp = (Opportunity) existingRecords.get(opp.Id); + if(opp.Type != existingOpp.Type) + { + opp.Type.addError( error('You cannot change the Opportunity type once it has been created.', opp, Opportunity.Type) ); + } + } + } + + public override void onBeforeDelete() + { + // Not required in production code + super.onBeforeDelete(); + + // Validate changes to Testfflib_SObjectDomain + for(Opportunity opp : (List) Records) + { + opp.addError( error('You cannot delete this Opportunity.', opp) ); + } + } + + public override void onAfterUndelete() + { + // Not required in production code + super.onAfterUndelete(); + } + + public override void onBeforeInsert() + { + // Assert this variable is null in the after insert (since this domain class is stateless) + someState = 'This should not survice the trigger after phase'; + } + + public override void onAfterInsert() + { + // This is a stateless domain class, so should not retain anything betweet before and after + System.assertEquals(null, someState); + } + } + + /** + * Typically an inner class to the domain class, supported here for test purposes + **/ + public class TestSObjectDomainConstructor implements fflib_SObjectDomain.IConstructable + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new TestSObjectDomain(sObjectList); + } + } + + /** + * Test domain class (ideally this would be in the test class, however Type.newInstance does not see such classes) + **/ + public with sharing class TestSObjectStatefulDomain + extends fflib_SObjectDomain + { + public String someState; + + public TestSObjectStatefulDomain(List sObjectList) + { + super(sObjectList); + + // Ensure this instance is re-used in the after trigger phase (subject to recursive scenarios) + Configuration.enableTriggerState(); + } + + public override void onBeforeInsert() + { + // This must always be null, as we do not reuse domain instances within recursive scenarios (different record sets) + System.assertEquals(null, someState); + + // Process records + List newOpps = new List(); + for(Opportunity opp : (List) Records) + { + // Set some state sensitive to the incoming records + someState = 'Error on Record ' + opp.Name; + + // Create a new Opportunity record to trigger recursive code path? + if(opp.Name.equals('Test Recursive 1')) + newOpps.add(new Opportunity ( Name = 'Test Recursive 2', Type = 'Existing Account' )); + } + + // If testing recursiving emulate an insert + if(newOpps.size()>0) + { + // This will force recursion and thus validate via the above assert results in a new domain instance + fflib_SObjectDomain.Test.Database.onInsert(newOpps); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectStatefulDomainConstructor.class); + } + } + + public override void onAfterInsert() + { + // Use the state set in the before insert (since this is a stateful domain class) + if(someState!=null) + for(Opportunity opp : (List) Records) + opp.addError(error(someState, opp)); + } + } + + /** + * Typically an inner class to the domain class, supported here for test purposes + **/ + public class TestSObjectStatefulDomainConstructor implements fflib_SObjectDomain.IConstructable + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new TestSObjectStatefulDomain(sObjectList); + } + } + + /** + * Test domain class (ideally this would be in the test class, however Type.newInstance does not see such classes) + **/ + public with sharing class TestSObjectOnValidateBehaviour + extends fflib_SObjectDomain + { + public TestSObjectOnValidateBehaviour(List sObjectList) + { + super(sObjectList); + + // Enable old behaviour based on the test Opportunity name passed in + if(sObjectList[0].Name == 'Test Enable Old Behaviour') + Configuration.enableOldOnUpdateValidateBehaviour(); + } + + public override void onValidate() + { + // Throw exception to give the test somethign to assert on + throw new DomainException('onValidate called'); + } + } + + /** + * Typically an inner class to the domain class, supported here for test purposes + **/ + public class TestSObjectOnValidateBehaviourConstructor implements fflib_SObjectDomain.IConstructable + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new TestSObjectOnValidateBehaviour(sObjectList); + } + } + + /** + * Test domain class (ideally this would be in the test class, however Type.newInstance does not see such classes) + **/ + public with sharing class TestSObjectChangedRecords + extends fflib_SObjectDomain + { + public TestSObjectChangedRecords(List sObjectList) + { + super(sObjectList); + } + } + + /** + * Typically an inner class to the domain class, supported here for test purposes + **/ + public class TestSObjectChangedRecordsConstructor implements fflib_SObjectDomain.IConstructable + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new TestSObjectChangedRecords(sObjectList); + } + } + + /** + * Test domain class (ideally this would be in the test class, however Type.newInstance does not see such classes) + **/ + public with sharing class TestSObjectDisableBehaviour + extends fflib_SObjectDomain + { + public TestSObjectDisableBehaviour(List sObjectList) + { + super(sObjectList); + } + + public override void onAfterInsert() { + // Throw exception to give the test somethign to assert on + throw new DomainException('onAfterInsert called'); + } + + public override void onBeforeInsert() { + // Throw exception to give the test somethign to assert on + throw new DomainException('onBeforeInsert called'); + } + + public override void onAfterUpdate(map existing) { + // Throw exception to give the test somethign to assert on + throw new DomainException('onAfterUpdate called'); + } + + public override void onBeforeUpdate(map existing) { + // Throw exception to give the test somethign to assert on + throw new DomainException('onBeforeUpdate called'); + } + + public override void onAfterDelete() { + // Throw exception to give the test somethign to assert on + throw new DomainException('onAfterDelete called'); + } + + public override void onBeforeDelete() { + // Throw exception to give the test somethign to assert on + throw new DomainException('onBeforeDelete called'); + } + + public override void onAfterUndelete() { + // Throw exception to give the test somethign to assert on + throw new DomainException('onAfterUndelete called'); + } + } + + /** + * Typically an inner class to the domain class, supported here for test purposes + **/ + public class TestSObjectDisableBehaviourConstructor implements fflib_SObjectDomain.IConstructable + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new TestSObjectDisableBehaviour(sObjectList); + } + } +} diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls new file mode 100644 index 00000000000..f9b1c5ea1e7 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls @@ -0,0 +1,494 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Class providing common database query support for abstracting and encapsulating query logic + **/ +public abstract with sharing class fflib_SObjectSelector + implements fflib_ISObjectSelector +{ + /** + * Indicates whether the sObject has the currency ISO code field for organisations which have multi-currency + * enabled. + **/ + private Boolean CURRENCY_ISO_CODE_ENABLED { + get { + if(CURRENCY_ISO_CODE_ENABLED == null){ + CURRENCY_ISO_CODE_ENABLED = describeWrapper.getFieldsMap().keySet().contains('currencyisocode'); + } + return CURRENCY_ISO_CODE_ENABLED; + } + set; + } + + /** + * Should this selector automatically include the FieldSet fields when building queries? + **/ + private Boolean m_includeFieldSetFields = false; + + /** + * Enforce FLS Security + **/ + protected Boolean m_enforceFLS = false; + + /** + * Enforce CRUD Security + **/ + private Boolean m_enforceCRUD = true; + + /** + * Order by field + **/ + private String m_orderBy; + + /** + * Sort the query fields in the select statement (defaults to true, at the expense of performance). + * Switch this off if you need more performant queries. + **/ + private Boolean m_sortSelectFields = true; + + /** + * Describe helper + **/ + private fflib_SObjectDescribe describeWrapper { + get { + if(describeWrapper == null) + describeWrapper = fflib_SObjectDescribe.getDescribe(getSObjectType()); + return describeWrapper; + } + set; + } + /** + * static variables + **/ + private static String DEFAULT_SORT_FIELD = 'CreatedDate'; + private static String SF_ID_FIELD = 'Id'; + + /** + * Implement this method to inform the base class of the SObject (custom or standard) to be queried + **/ + abstract Schema.SObjectType getSObjectType(); + + /** + * Implement this method to inform the base class of the common fields to be queried or listed by the base class methods + **/ + abstract List getSObjectFieldList(); + + /** + * Constructs the Selector with the default settings + **/ + public fflib_SObjectSelector() { } + + /** + * Constructs the Selector + * + * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well + **/ + public fflib_SObjectSelector(Boolean includeFieldSetFields) + { + this(includeFieldSetFields, true, false); + } + + /** + * Constructs the Selector + * + * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well + **/ + public fflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS) + { + this(includeFieldSetFields, enforceCRUD, enforceFLS, true); + } + + /** + * Constructs the Selector + * + * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well + * @param enforceCRUD Enforce CRUD security + * @param enforceFLS Enforce Field Level Security + * @param sortSelectFields Set to false if selecting many columns to skip sorting select fields and improve performance + **/ + public fflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS, Boolean sortSelectFields) + { + m_includeFieldSetFields = includeFieldSetFields; + m_enforceCRUD = enforceCRUD; + m_enforceFLS = enforceFLS; + m_sortSelectFields = sortSelectFields; + } + + /** + * Override this method to provide a list of Fieldsets that can optionally drive inclusion of additional fields in the base queries + **/ + public virtual List getSObjectFieldSetList() + { + return null; + } + + /** + * Override this method to control the default ordering of records returned by the base queries, + * defaults to the name field of the object if it is not encrypted or CreatedDate if there the object has createdDated or Id + **/ + public virtual String getOrderBy() + { + if (m_orderBy == null) + { + Schema.SObjectField nameField = describeWrapper.getNameField(); + if (nameField != null && !nameField.getDescribe().isEncrypted()) + { + m_orderBy = nameField.getDescribe().getName(); + } + else + { + m_orderBy = DEFAULT_SORT_FIELD; + try { + if (describeWrapper.getField(m_orderBy) == null) + { + m_orderBy = SF_ID_FIELD; + } + } + catch(fflib_QueryFactory.InvalidFieldException ex) { + m_orderBy = SF_ID_FIELD; + } + } + } + return m_orderBy; + } + + + /** + * @description Set the selector to enforce FLS Security + **/ + public fflib_SObjectSelector enforceFLS() + { + m_enforceFLS = true; + return this; + } + + /** + * @description Set the selector to automatically include the FieldSet fields when building queries + **/ + public fflib_SObjectSelector includeFieldSetFields() + { + this.m_includeFieldSetFields = true; + return this; + } + + /** + * @description Set the selector to ignore CRUD security + * @return + */ + public fflib_SObjectSelector ignoreCRUD() + { + this.m_enforceCRUD = false; + return this; + } + + public fflib_SObjectSelector unsortedSelectFields() + { + this.m_sortSelectFields = false; + return this; + } + + /** + * Returns True if this Selector instance has been instructed by the caller to include Field Set fields + **/ + public Boolean isIncludeFieldSetFields() + { + return m_includeFieldSetFields; + } + + /** + * Returns True if this Selector is enforcing FLS + **/ + public Boolean isEnforcingFLS() + { + return m_enforceFLS; + } + + /** + * Returns True if this Selector is enforcing CRUD Security + **/ + public Boolean isEnforcingCRUD() + { + return m_enforceCRUD; + } + + /** + * Provides access to the builder containing the list of fields base queries are using, this is demand + * created if one has not already been defined via setFieldListBuilder + * + * @depricated See newQueryFactory + **/ + public fflib_StringBuilder.CommaDelimitedListBuilder getFieldListBuilder() + { + return + new fflib_StringBuilder.CommaDelimitedListBuilder( + new List(newQueryFactory().getSelectedFields())); + } + + /** + * Use this method to override the default FieldListBuilder (created on demand via getFieldListBuilder) with a custom one, + * warning, this will bypass anything getSObjectFieldList or getSObjectFieldSetList returns + * + * @depricated See newQueryFactory + **/ + public void setFieldListBuilder(fflib_StringBuilder.FieldListBuilder fieldListBuilder) + { + // TODO: Consider if given the known use cases for this (dynamic selector optimisation) if it's OK to leave this as a null operation + } + + /** + * Returns in string form a comma delimited list of fields as defined via getSObjectFieldList and optionally getSObjectFieldSetList + * + * @deprecated See newQueryFactory + **/ + public String getFieldListString() + { + return getFieldListBuilder().getStringValue(); + } + + /** + * Returns in string form a comma delimited list of fields as defined via getSObjectFieldList and optionally getSObjectFieldSetList + * @param relation Will prefix fields with the given relation, e.g. MyLookupField__r + * + * @depricated See newQueryFactory + **/ + public String getRelatedFieldListString(String relation) + { + return getFieldListBuilder().getStringValue(relation + '.'); + } + + /** + * Returns the string representation of the SObject this selector represents + **/ + public String getSObjectName() + { + return describeWrapper.getDescribe().getName(); + } + + /** + * Performs a SOQL query, + * - Selecting the fields described via getSObjectFieldsList and getSObjectFieldSetList (if included) + * - From the SObject described by getSObjectType + * - Where the Id's match those provided in the set + * - Ordered by the fields returned via getOrderBy + * @returns A list of SObject's + **/ + public virtual List selectSObjectsById(Set idSet) + { + return Database.query(buildQuerySObjectById()); + } + + /** + * Performs a SOQL query, + * - Selecting the fields described via getSObjectFieldsList and getSObjectFieldSetList (if included) + * - From the SObject described by getSObjectType + * - Where the Id's match those provided in the set + * - Ordered by the fields returned via getOrderBy + * @returns A QueryLocator (typically for use in a Batch Apex job) + **/ + public virtual Database.QueryLocator queryLocatorById(Set idSet) + { + return Database.getQueryLocator(buildQuerySObjectById()); + } + + /** + * Throws an exception if the SObject indicated by getSObjectType is not accessible to the current user (read access) + * + * @deprecated If you utilise the newQueryFactory method this is automatically done for you (unless disabled by the selector) + **/ + public void assertIsAccessible() + { + if(!getSObjectType().getDescribe().isAccessible()) + throw new fflib_SObjectDomain.DomainException( + 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' denied.'); + } + + /** + * Public access for the getSObjectType during Mock registration + * (adding public to the existing method broken base class API backwards compatibility) + **/ + public SObjectType getSObjectType2() + { + return getSObjectType(); + } + + /** + * Public access for the getSObjectType during Mock registration + * (adding public to the existing method broken base class API backwards compatibility1) + **/ + public SObjectType sObjectType() + { + return getSObjectType(); + } + + /** + * Returns a QueryFactory configured with the Selectors object, fields, fieldsets and default order by + **/ + public fflib_QueryFactory newQueryFactory() + { + return newQueryFactory(m_enforceCRUD, m_enforceFLS, true); + } + + /** + * Returns a QueryFactory configured with the Selectors object, fields, fieldsets and default order by + **/ + public fflib_QueryFactory newQueryFactory(Boolean includeSelectorFields) + { + return newQueryFactory(m_enforceCRUD, m_enforceFLS, includeSelectorFields); + } + + /** + * Returns a QueryFactory configured with the Selectors object, fields, fieldsets and default order by + * CRUD and FLS read security will be checked if the corresponding inputs are true (overrides that defined in the selector). + **/ + public fflib_QueryFactory newQueryFactory(Boolean assertCRUD, Boolean enforceFLS, Boolean includeSelectorFields) + { + // Construct QueryFactory around the given SObject + return configureQueryFactory( + new fflib_QueryFactory(getSObjectType2()), + assertCRUD, enforceFLS, includeSelectorFields); + } + + /** + * Adds the selectors fields to the given QueryFactory using the given relationship path as a prefix + * + * // TODO: This should be consistent (ideally) with configureQueryFactory below + **/ + public void configureQueryFactoryFields(fflib_QueryFactory queryFactory, String relationshipFieldPath) + { + // Add fields from selector prefixing the relationship path + for(SObjectField field : getSObjectFieldList()) { + queryFactory.selectField(relationshipFieldPath + '.' + field.getDescribe().getName(), this.getSObjectType()); + } + // Automatically select the CurrencyIsoCode for MC orgs (unless the object is a known exception to the rule) + if(UserInfo.isMultiCurrencyOrganization() && CURRENCY_ISO_CODE_ENABLED){ + queryFactory.selectField(relationshipFieldPath+'.CurrencyIsoCode'); + } + } + + /** + * Adds a subselect QueryFactory based on this selector to the given QueryFactor, returns the parentQueryFactory + **/ + public fflib_QueryFactory addQueryFactorySubselect(fflib_QueryFactory parentQueryFactory) + { + return addQueryFactorySubselect(parentQueryFactory, true); + } + + /** + * Adds a subselect QueryFactory based on this selector to the given QueryFactor + **/ + public fflib_QueryFactory addQueryFactorySubselect(fflib_QueryFactory parentQueryFactory, Boolean includeSelectorFields) + { + fflib_QueryFactory subSelectQueryFactory = + parentQueryFactory.subselectQuery(getSObjectType2()); + return configureQueryFactory( + subSelectQueryFactory, + m_enforceCRUD, + m_enforceFLS, + includeSelectorFields); + } + + /** + * Adds a subselect QueryFactory based on this selector to the given QueryFactor, returns the parentQueryFactory + **/ + public fflib_QueryFactory addQueryFactorySubselect(fflib_QueryFactory parentQueryFactory, String relationshipName) + { + return addQueryFactorySubselect(parentQueryFactory, relationshipName, TRUE); + } + + /** + * Adds a subselect QueryFactory based on this selector to the given QueryFactor + **/ + public fflib_QueryFactory addQueryFactorySubselect(fflib_QueryFactory parentQueryFactory, String relationshipName, Boolean includeSelectorFields) + { + fflib_QueryFactory subSelectQueryFactory = parentQueryFactory.subselectQuery(relationshipName); + return configureQueryFactory(subSelectQueryFactory, m_enforceCRUD, m_enforceFLS, includeSelectorFields); + } + + /** + * Constructs the default SOQL query for this selector, see selectSObjectsById and queryLocatorById + **/ + protected String buildQuerySObjectById() + { + return newQueryFactory().setCondition('id in :idSet').toSOQL(); + } + + /** + * Configures a QueryFactory instance according to the configuration of this selector + **/ + private fflib_QueryFactory configureQueryFactory(fflib_QueryFactory queryFactory, Boolean assertCRUD, Boolean enforceFLS, Boolean includeSelectorFields) + { + // CRUD and FLS security required? + if (assertCRUD) + { + try { + // Leverage QueryFactory for CRUD checking + queryFactory.assertIsAccessible(); + } catch (fflib_SecurityUtils.CrudException e) { + // Marshal exception into DomainException for backwards compatibility + throw new fflib_SObjectDomain.DomainException( + 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' denied.'); + } + } + queryFactory.setEnforceFLS(enforceFLS); + + // Configure the QueryFactory with the Selector fields? + if(includeSelectorFields) + { + // select the Selector fields and Fieldsets and set order + queryFactory.selectFields(getSObjectFieldList()); + + List fieldSetList = getSObjectFieldSetList(); + if(m_includeFieldSetFields && fieldSetList != null) + for(Schema.FieldSet fieldSet : fieldSetList) + queryFactory.selectFieldSet(fieldSet); + + // Automatically select the CurrencyIsoCode for MC orgs (unless the object is a known exception to the rule) + if(UserInfo.isMultiCurrencyOrganization() && CURRENCY_ISO_CODE_ENABLED) + queryFactory.selectField('CurrencyIsoCode'); + } + + // Parse the getOrderBy() + for(String orderBy : getOrderBy().split(',')) + { + List orderByParts = orderBy.trim().split(' '); + String fieldNamePart = orderByParts[0]; + String fieldSortOrderPart = orderByParts.size() > 1 ? orderByParts[1] : null; + fflib_QueryFactory.SortOrder fieldSortOrder = fflib_QueryFactory.SortOrder.ASCENDING; + if(fieldSortOrderPart==null) + fieldSortOrder = fflib_QueryFactory.SortOrder.ASCENDING; + else if(fieldSortOrderPart.equalsIgnoreCase('DESC')) + fieldSortOrder = fflib_QueryFactory.SortOrder.DESCENDING; + else if(fieldSortOrderPart.equalsIgnoreCase('ASC')) + fieldSortOrder = fflib_QueryFactory.SortOrder.ASCENDING; + queryFactory.addOrdering(fieldNamePart, fieldSortOrder, orderBy.containsIgnoreCase('NULLS LAST')); + } + + queryFactory.setSortSelectFields(m_sortSelectFields); + + return queryFactory; + } +} diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls new file mode 100644 index 00000000000..e5b85b32dd0 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls @@ -0,0 +1,933 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Provides an implementation of the Enterprise Application Architecture Unit Of Work, as defined by Martin Fowler + * http://martinfowler.com/eaaCatalog/unitOfWork.html + * + * "When you're pulling data in and out of a database, it's important to keep track of what you've changed; otherwise, + * that data won't be written back into the database. Similarly you have to insert new objects you create and + * remove any objects you delete." + * + * "You can change the database with each change to your object model, but this can lead to lots of very small database calls, + * which ends up being very slow. Furthermore it requires you to have a transaction open for the whole interaction, which is + * impractical if you have a business transaction that spans multiple requests. The situation is even worse if you need to + * keep track of the objects you've read so you can avoid inconsistent reads." + * + * "A Unit of Work keeps track of everything you do during a business transaction that can affect the database. When you're done, + * it figures out everything that needs to be done to alter the database as a result of your work." + * + * In an Apex context this pattern provides the following specific benefits + * - Applies bulkfication to DML operations, insert, update and delete + * - Manages a business transaction around the work and ensures a rollback occurs (even when exceptions are later handled by the caller) + * - Honours dependency rules between records and updates dependent relationships automatically during the commit + * + * Please refer to the testMethod's in this class for example usage + * + * TODO: Need to complete the 100% coverage by covering parameter exceptions in tests + * TODO: Need to add some more test methods for more complex use cases and some unexpected (e.g. registerDirty and then registerDeleted) + * + **/ +public virtual class fflib_SObjectUnitOfWork + implements fflib_ISObjectUnitOfWork +{ + // NOTE: + // If registration methods are added to this class, then they should probably also be added + // to ortoo_DynamicSobjectUnitOfWork + // + @testVisible + protected List m_sObjectTypes = new List(); + + protected Map> m_newListByType = new Map>(); + + protected Map> m_dirtyMapByType = new Map>(); + + protected Map> m_deletedMapByType = new Map>(); + protected Map> m_emptyRecycleBinMapByType = new Map>(); + + protected Map m_relationships = new Map(); + + protected Map> m_publishBeforeListByType = new Map>(); + protected Map> m_publishAfterSuccessListByType = new Map>(); + protected Map> m_publishAfterFailureListByType = new Map>(); + + protected List m_workList = new List(); + + @TestVisible + protected IEmailWork m_emailWork = new SendEmailWork(); + + protected IDML m_dml; + + /** + * Interface describes work to be performed during the commitWork method + **/ + public interface IDoWork + { + void doWork(); + } + + public interface IDML + { + void dmlInsert(List objList); + void dmlUpdate(List objList); + void dmlDelete(List objList); + void eventPublish(List objList); + void emptyRecycleBin(List objList); + } + + public virtual class SimpleDML implements IDML + { + public virtual void dmlInsert(List objList) + { + insert objList; + } + public virtual void dmlUpdate(List objList) + { + update objList; + } + public virtual void dmlDelete(List objList) + { + delete objList; + } + public virtual void eventPublish(List objList) + { + EventBus.publish(objList); + } + public virtual void emptyRecycleBin(List objList) + { + if (objList.isEmpty()) + { + return; + } + + Database.emptyRecycleBin(objList); + } + } + /** + * Constructs a new UnitOfWork to support work against the given object list + * + * @param sObjectTypes A list of objects given in dependency order (least dependent first) + */ + public fflib_SObjectUnitOfWork(List sObjectTypes) + { + this(sObjectTypes,new SimpleDML()); + } + + public fflib_SObjectUnitOfWork(List sObjectTypes, IDML dml) + { + m_sObjectTypes = sObjectTypes.clone(); + + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + // register the type + handleRegisterType(sObjectType); + } + + m_relationships.put(Messaging.SingleEmailMessage.class.getName(), new Relationships()); + + m_dml = dml; + } + + // default implementations for commitWork events + public virtual void onRegisterType(Schema.SObjectType sObjectType) {} + public virtual void onCommitWorkStarting() {} + + public virtual void onPublishBeforeEventsStarting() {} + public virtual void onPublishBeforeEventsFinished() {} + + public virtual void onDMLStarting() {} + public virtual void onDMLFinished() {} + + public virtual void onDoWorkStarting() {} + public virtual void onDoWorkFinished() {} + + public virtual void onPublishAfterSuccessEventsStarting() {} + public virtual void onPublishAfterSuccessEventsFinished() {} + + public virtual void onPublishAfterFailureEventsStarting() {} + public virtual void onPublishAfterFailureEventsFinished() {} + + public virtual void onCommitWorkFinishing() {} + public virtual void onCommitWorkFinished(Boolean wasSuccessful) {} + + /** + * Registers the type to be used for DML operations + * + * @param sObjectType - The type to register + * + */ + protected void handleRegisterType(Schema.SObjectType sObjectType) + { + String sObjectName = sObjectType.getDescribe().getName(); + + // add type to dml operation tracking + m_newListByType.put(sObjectName, new List()); + m_dirtyMapByType.put(sObjectName, new Map()); + m_deletedMapByType.put(sObjectName, new Map()); + m_emptyRecycleBinMapByType.put(sObjectName, new Map()); + m_relationships.put(sObjectName, new Relationships()); + + m_publishBeforeListByType.put(sObjectName, new List()); + m_publishAfterSuccessListByType.put(sObjectName, new List()); + m_publishAfterFailureListByType.put(sObjectName, new List()); + + // give derived class opportunity to register the type + onRegisterType(sObjectType); + } + + /** + * Register a generic piece of work to be invoked during the commitWork phase + **/ + public void registerWork(IDoWork work) + { + m_workList.add(work); + } + + /** + * Registers the given email to be sent during the commitWork + **/ + public void registerEmail(Messaging.Email email) + { + m_emailWork.registerEmail(email); + } + + /** + * Register an deleted record to be removed from the recycle bin during the commitWork method + * + * @param record An deleted record + **/ + public virtual void registerEmptyRecycleBin(SObject record) + { + String sObjectType = record.getSObjectType().getDescribe().getName(); + assertForSupportedSObjectType(m_emptyRecycleBinMapByType, sObjectType); + + m_emptyRecycleBinMapByType.get(sObjectType).put(record.Id, record); + } + + /** + * Register deleted records to be removed from the recycle bin during the commitWork method + * + * @param records Deleted records + **/ + public virtual void registerEmptyRecycleBin(List records) + { + for (SObject record : records) + { + registerEmptyRecycleBin(record); + } + } + + /** + * Register a newly created SObject instance to be inserted when commitWork is called + * + * @param record A newly created SObject instance to be inserted during commitWork + **/ + public virtual void registerNew(SObject record) + { + registerNew(record, null, null); + } + + /** + * Register a list of newly created SObject instances to be inserted when commitWork is called + * + * @param records A list of newly created SObject instances to be inserted during commitWork + **/ + public virtual void registerNew(List records) + { + for (SObject record : records) + { + registerNew(record, null, null); + } + } + + /** + * Register a newly created SObject instance to be inserted when commitWork is called, + * you may also provide a reference to the parent record instance (should also be registered as new separately) + * + * @param record A newly created SObject instance to be inserted during commitWork + * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent + * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) + **/ + public virtual void registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + { + if (record.Id != null) + throw new UnitOfWorkException('Only new records can be registered as new'); + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForNonEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_newListByType, sObjectType); + + m_newListByType.get(sObjectType).add(record); + if (relatedToParentRecord!=null && relatedToParentField!=null) + registerRelationship(record, relatedToParentField, relatedToParentRecord); + } + + /** + * Register a relationship between two records that have yet to be inserted to the database. This information will be + * used during the commitWork phase to make the references only when related records have been inserted to the database. + * + * @param record An existing or newly created record + * @param relatedToField A SObjectField reference to the lookup field that relates the two records together + * @param relatedTo A SObject instance (yet to be committed to the database) + */ + public virtual void registerRelationship(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) + { + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForNonEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_newListByType, sObjectType); + + m_relationships.get(sObjectType).add(record, relatedToField, relatedTo); + } + + /** + * Registers a relationship between a record and a Messaging.Email where the record has yet to be inserted + * to the database. This information will be + * used during the commitWork phase to make the references only when related records have been inserted to the database. + * + * @param email a single email message instance + * @param relatedTo A SObject instance (yet to be committed to the database) + */ + public virtual void registerRelationship( Messaging.SingleEmailMessage email, SObject relatedTo ) + { + m_relationships.get( Messaging.SingleEmailMessage.class.getName() ).add(email, relatedTo); + } + + /** + * Registers a relationship between a record and a lookup value using an external ID field and a provided value. This + * information will be used during the commitWork phase to make the lookup reference requested when inserted to the database. + * + * @param record An existing or newly created record + * @param relatedToField A SObjectField reference to the lookup field that relates the two records together + * @param externalIdField A SObjectField reference to a field on the target SObject that is marked as isExternalId + * @param externalId A Object representing the targeted value of the externalIdField in said lookup + * + * Usage Example: uow.registerRelationship(recordSObject, record_sobject__c.relationship_field__c, lookup_sobject__c.external_id__c, 'abc123'); + * + * Wraps putSObject, creating a new instance of the lookup sobject using the external id field and value. + */ + public virtual void registerRelationship(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) + { + // NOTE: Due to the lack of ExternalID references on Standard Objects, this method can not be provided a standardized Unit Test. - Rick Parker + String sObjectType = record.getSObjectType().getDescribe().getName(); + if(!m_newListByType.containsKey(sObjectType)) + throw new UnitOfWorkException(String.format('SObject type {0} is not supported by this unit of work', new String[] { sObjectType })); + m_relationships.get(sObjectType).add(record, relatedToField, externalIdField, externalId); + } + + /** + * Register an existing record to be updated during the commitWork method + * + * @param record An existing record + **/ + public virtual void registerDirty(SObject record) + { + registerDirty(record, new List()); + } + + /** + * Registers the entire records as dirty or just only the dirty fields if the record was already registered + * + * @param records SObjects to register as dirty + * @param dirtyFields A list of modified fields + */ + public virtual void registerDirty(List records, List dirtyFields) + { + for (SObject record : records) + { + registerDirty(record, dirtyFields); + } + } + + /** + * Registers the entire record as dirty or just only the dirty fields if the record was already registered + * + * @param record SObject to register as dirty + * @param dirtyFields A list of modified fields + */ + public virtual void registerDirty(SObject record, List dirtyFields) + { + if (record.Id == null) + throw new UnitOfWorkException('New records cannot be registered as dirty'); + + SobjectType oSobjectType = record.getSObjectType(); + String sObjectType = oSobjectType.getDescribe().getName(); + + assertForNonEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_dirtyMapByType, sObjectType); + + // If record isn't registered as dirty, or no dirty fields to drive a merge + if ( dirtyFields.isEmpty() ) + { + m_dirtyMapByType.get(sObjectType).put(record.Id, record); + } + else + { + if ( !m_dirtyMapByType.get(sObjectType).containsKey(record.Id) ) + { + // If the record isn't already there, put a new skeleton one in there + m_dirtyMapByType.get(sObjectType).put(record.Id, oSobjectType.newSObject( record.id ) ); + } + + // Update the registered record's fields + SObject registeredRecord = m_dirtyMapByType.get(sObjectType).get(record.Id); + + for (SObjectField dirtyField : dirtyFields) { + registeredRecord.put(dirtyField, record.get(dirtyField)); + } + + m_dirtyMapByType.get(sObjectType).put(record.Id, registeredRecord); + } + } + + /** + * Register an existing record to be updated when commitWork is called, + * you may also provide a reference to the parent record instance (should also be registered as new separately) + * + * @param record A newly created SObject instance to be inserted during commitWork + * @param relatedToParentField A SObjectField reference to the child field that associates the child record with its parent + * @param relatedToParentRecord A SObject instance of the parent record (should also be registered as new separately) + **/ + public virtual void registerDirty(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord) + { + registerDirty(record); + if (relatedToParentRecord!=null && relatedToParentField!=null) + registerRelationship(record, relatedToParentField, relatedToParentRecord); + } + + /** + * Register a list of existing records to be updated during the commitWork method + * + * @param records A list of existing records + **/ + public virtual void registerDirty(List records) + { + for (SObject record : records) + { + this.registerDirty(record); + } + } + + /** + * Register a new or existing record to be inserted/updated during the commitWork method + * + * @param record A new or existing record + **/ + public virtual void registerUpsert(SObject record) + { + if (record.Id == null) + { + registerNew(record, null, null); + } + else + { + registerDirty(record, new List()); + } + } + + /** + * Register a list of mix of new and existing records to be inserted updated during the commitWork method + * + * @param records A list of mix of new and existing records + **/ + public virtual void registerUpsert(List records) + { + for (SObject record : records) + { + this.registerUpsert(record); + } + } + + /** + * Register an existing record to be deleted during the commitWork method + * + * @param record An existing record + **/ + public virtual void registerDeleted(SObject record) + { + if (record.Id == null) + throw new UnitOfWorkException('New records cannot be registered for deletion'); + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForNonEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_deletedMapByType, sObjectType); + + m_deletedMapByType.get(sObjectType).put(record.Id, record); + } + + /** + * Register a list of existing records to be deleted during the commitWork method + * + * @param records A list of existing records + **/ + public virtual void registerDeleted(List records) + { + for (SObject record : records) + { + this.registerDeleted(record); + } + } + + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param records A list of existing records + **/ + public virtual void registerPermanentlyDeleted(List records) + { + this.registerEmptyRecycleBin(records); + this.registerDeleted(records); + } + + /** + * Register a list of existing records to be deleted and removed from the recycle bin during the commitWork method + * + * @param record A list of existing records + **/ + public virtual void registerPermanentlyDeleted(SObject record) + { + this.registerEmptyRecycleBin(record); + this.registerDeleted(record); + } + + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + public virtual void registerPublishBeforeTransaction(SObject record) + { + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_publishBeforeListByType, sObjectType); + + m_publishBeforeListByType.get(sObjectType).add(record); + } + + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param records A list of existing records + **/ + public virtual void registerPublishBeforeTransaction(List records) + { + for (SObject record : records) + { + this.registerPublishBeforeTransaction(record); + } + } + + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + public virtual void registerPublishAfterSuccessTransaction(SObject record) + { + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_publishAfterSuccessListByType, sObjectType); + + m_publishAfterSuccessListByType.get(sObjectType).add(record); + } + + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param records A list of existing records + **/ + public virtual void registerPublishAfterSuccessTransaction(List records) + { + for (SObject record : records) + { + this.registerPublishAfterSuccessTransaction(record); + } + } + /** + * Register a newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param record A newly created SObject (Platform Event) instance to be inserted during commitWork + **/ + public virtual void registerPublishAfterFailureTransaction(SObject record) + { + String sObjectType = record.getSObjectType().getDescribe().getName(); + + assertForEventSObjectType(sObjectType); + assertForSupportedSObjectType(m_publishAfterFailureListByType, sObjectType); + + m_publishAfterFailureListByType.get(sObjectType).add(record); + } + + /** + * Register a list of newly created SObject (Platform Event) instance to be published when commitWork is called + * + * @param records A list of existing records + **/ + public virtual void registerPublishAfterFailureTransaction(List records) + { + for (SObject record : records) + { + this.registerPublishAfterFailureTransaction(record); + } + } + + /** + * Takes all the work that has been registered with the UnitOfWork and commits it to the database + **/ + public void commitWork() + { + Savepoint sp = Database.setSavepoint(); + Boolean wasSuccessful = false; + try + { + doCommitWork(); + wasSuccessful = true; + } + catch (Exception e) + { + Database.rollback(sp); + throw e; + } + finally + { + doAfterCommitWorkSteps(wasSuccessful); + } + } + + private void doCommitWork() + { + onCommitWorkStarting(); + onPublishBeforeEventsStarting(); + publishBeforeEventsStarting(); + onPublishBeforeEventsFinished(); + + onDMLStarting(); + insertDmlByType(); + updateDmlByType(); + deleteDmlByType(); + emptyRecycleBinByType(); + resolveEmailRelationships(); + onDMLFinished(); + + onDoWorkStarting(); + doWork(); + onDoWorkFinished(); + onCommitWorkFinishing(); + } + + private void doAfterCommitWorkSteps(Boolean wasSuccessful) + { + if (wasSuccessful) + { + doAfterCommitWorkSuccessSteps(); + } + else + { + doAfterCommitWorkFailureSteps(); + } + onCommitWorkFinished(wasSuccessful); + } + + private void doAfterCommitWorkSuccessSteps() + { + onPublishAfterSuccessEventsStarting(); + publishAfterSuccessEvents(); + onPublishAfterSuccessEventsFinished(); + } + + private void doAfterCommitWorkFailureSteps() + { + onPublishAfterFailureEventsStarting(); + publishAfterFailureEvents(); + onPublishAfterFailureEventsFinished(); + } + + private void publishBeforeEventsStarting() + { + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + m_dml.eventPublish(m_publishBeforeListByType.get(sObjectType.getDescribe().getName())); + } + } + + private void insertDmlByType() + { + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + m_relationships.get(sObjectType.getDescribe().getName()).resolve(); + m_dml.dmlInsert(m_newListByType.get(sObjectType.getDescribe().getName())); + } + } + + private void updateDmlByType() + { + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + m_dml.dmlUpdate(m_dirtyMapByType.get(sObjectType.getDescribe().getName()).values()); + } + } + + private void deleteDmlByType() + { + Integer objectIdx = m_sObjectTypes.size() - 1; + while (objectIdx >= 0) + { + m_dml.dmlDelete(m_deletedMapByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()).values()); + } + } + + private void emptyRecycleBinByType() + { + Integer objectIdx = m_sObjectTypes.size() - 1; + while (objectIdx >= 0) + { + m_dml.emptyRecycleBin(m_emptyRecycleBinMapByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()).values()); + } + } + + private void resolveEmailRelationships() + { + m_relationships.get(Messaging.SingleEmailMessage.class.getName()).resolve(); + } + + private void doWork() + { + m_workList.add(m_emailWork); + for (IDoWork work : m_workList) + { + work.doWork(); + } + } + + private void publishAfterSuccessEvents() + { + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + m_dml.eventPublish(m_publishAfterSuccessListByType.get(sObjectType.getDescribe().getName())); + } + } + + private void publishAfterFailureEvents() + { + for (Schema.SObjectType sObjectType : m_sObjectTypes) + { + m_dml.eventPublish(m_publishAfterFailureListByType.get(sObjectType.getDescribe().getName())); + } + } + + @TestVisible + private void assertForNonEventSObjectType(String sObjectType) + { + if (sObjectType.length() > 3 && sObjectType.right(3) == '__e') + { + throw new UnitOfWorkException( + String.format( + 'SObject type {0} must use registerPublishBeforeTransaction or ' + + 'registerPublishAfterTransaction methods to be used within this unit of work', + new List { sObjectType } + ) + ); + } + } + + @TestVisible + private void assertForEventSObjectType(String sObjectType) + { + if (sObjectType.length() > 3 && sObjectType.right(3) != '__e') + { + throw new UnitOfWorkException( + String.format( + 'SObject type {0} is invalid for publishing within this unit of work', + new List {sObjectType} + ) + ); + } + } + + @TestVisible + private void assertForSupportedSObjectType(Map theMap, String sObjectType) + { + if (!theMap.containsKey(sObjectType)) + { + throw new UnitOfWorkException( + String.format( + 'SObject type {0} is not supported by this unit of work', + new List { sObjectType } + ) + ); + } + } + + private class Relationships + { + private List m_relationships = new List(); + + public void resolve() + { + // Resolve relationships + for (IRelationship relationship : m_relationships) + { + //relationship.Record.put(relationship.RelatedToField, relationship.RelatedTo.Id); + relationship.resolve(); + } + + } + + public void add(SObject record, Schema.SObjectField relatedToField, Schema.SObjectField externalIdField, Object externalId) + { + if (relatedToField == null) { + throw new UnitOfWorkException('Invalid argument: relatedToField.'); + } + + String relationshipName = relatedToField.getDescribe().getRelationshipName(); + if (String.isBlank(relationshipName)) { + throw new UnitOfWorkException('Invalid argument: relatedToField. Field supplied is not a relationship field.'); + } + + List relatedObjects = relatedToField.getDescribe().getReferenceTo(); + Schema.SObjectType relatedObject = relatedObjects[0]; + + String externalIdFieldName = externalIdField.getDescribe().getName(); + Boolean relatedHasExternalIdField = relatedObject.getDescribe().fields.getMap().keySet().contains(externalIdFieldName.toLowerCase()); + Boolean externalIdFieldIsValid = externalIdField.getDescribe().isExternalId(); + + if (!relatedHasExternalIdField) { + throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied is not a known field on the target sObject.'); + } + + if (!externalIdFieldIsValid) { + throw new UnitOfWorkException('Invalid argument: externalIdField. Field supplied is not a marked as an External Identifier.'); + } + + RelationshipByExternalId relationship = new RelationshipByExternalId(); + relationship.Record = record; + relationship.RelatedToField = relatedToField; + relationship.RelatedTo = relatedObject; + relationship.RelationshipName = relationshipName; + relationship.ExternalIdField = externalIdField; + relationship.ExternalId = externalId; + m_relationships.add(relationship); + } + + public void add(SObject record, Schema.SObjectField relatedToField, SObject relatedTo) + { + // Relationship to resolve + Relationship relationship = new Relationship(); + relationship.Record = record; + relationship.RelatedToField = relatedToField; + relationship.RelatedTo = relatedTo; + m_relationships.add(relationship); + } + + public void add(Messaging.SingleEmailMessage email, SObject relatedTo) + { + EmailRelationship emailRelationship = new EmailRelationship(); + emailRelationship.email = email; + emailRelationship.relatedTo = relatedTo; + m_relationships.add(emailRelationship); + } + } + + private interface IRelationship + { + void resolve(); + } + + private class RelationshipByExternalId implements IRelationship + { + public SObject Record; + public Schema.SObjectField RelatedToField; + public Schema.SObjectType RelatedTo; + public String RelationshipName; + public Schema.SObjectField ExternalIdField; + public Object ExternalId; + + public void resolve() + { + SObject relationshipObject = this.RelatedTo.newSObject(); + relationshipObject.put( ExternalIdField.getDescribe().getName(), this.ExternalId ); + this.Record.putSObject( this.RelationshipName, relationshipObject ); + } + } + + private class Relationship implements IRelationship + { + public SObject Record; + public Schema.SObjectField RelatedToField; + public SObject RelatedTo; + + public void resolve() + { + this.Record.put( this.RelatedToField, this.RelatedTo.Id); + } + } + + private class EmailRelationship implements IRelationship + { + public Messaging.SingleEmailMessage email; + public SObject relatedTo; + + public void resolve() + { + this.email.setWhatId( this.relatedTo.Id ); + } + } + + /** + * UnitOfWork Exception + **/ + public class UnitOfWorkException extends Exception {} + + /** + * Internal implementation of Messaging.sendEmail, see outer class registerEmail method + **/ + public interface IEmailWork extends IDoWork + { + void registerEmail(Messaging.Email email); + } + + private class SendEmailWork implements IEmailWork + { + private List emails; + + public SendEmailWork() + { + this.emails = new List(); + } + + public void registerEmail(Messaging.Email email) + { + this.emails.add(email); + } + + public void doWork() + { + if (emails.size() > 0) Messaging.sendEmail(emails); + } + } +} diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjectUnitOfWork.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls new file mode 100644 index 00000000000..95c48ccf017 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls @@ -0,0 +1,464 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ +public virtual class fflib_SObjects + extends fflib_Objects + implements fflib_ISObjects +{ + + public Schema.DescribeSObjectResult SObjectDescribe {get; private set;} + + /** + * Useful during unit testing to assert at a more granular and robust level for errors raised during the various trigger events + **/ + public static ErrorFactory Errors {get; private set;} + + static + { + Errors = new ErrorFactory(); + } + + /** + * Class constructor + */ + public fflib_SObjects(List records) + { + super(records); + } + + public fflib_SObjects(List records, Schema.SObjectType sObjectType) + { + super(records); + SObjectDescribe = sObjectType.getDescribe(); + } + + public virtual List getRecords() + { + return (List) getObjects(); + } + + public virtual Set getRecordIds() + { + return new Map(getRecords()).keySet(); + } + + public virtual override Object getType() + { + return getSObjectType(); + } + + public virtual SObjectType getSObjectType() + { + return SObjectDescribe.getSObjectType(); + } + + /** + * Adds an error message to the records in the domain + * + * @param message The error message to add to each record + */ + protected void addError(String message) + { + for (SObject record : getRecords()) + { + record.addError(error(message, record)); + } + } + + /** + * Adds an error message to the a field records in the domain + * + * @param field The field where the error should be reported + * @param message The error message to add to the given field on each record + */ + @TestVisible + protected virtual void addError(Schema.SObjectField field, String message) + { + for (SObject record : getRecords()) + { + record.addError(field, error(message, record, field)); + } + } + + /** + * Clear the field value on all the records of the domain + * @param field The field to nullify + */ + @TestVisible + protected virtual void clearField(Schema.SObjectField field) + { + clearFields(new Set{ field }); + } + + /** + * Clear the field values on all the records of the domain + * @param fields The fields to nullify + */ + @TestVisible + protected virtual void clearFields(Set fields) + { + for (SObject record : getRecords()) + { + for (SObjectField field : fields) + { + record.put(field, null); + } + } + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + * + * @param message + * @param record + * + * @return Returns the Error message + **/ + protected virtual String error(String message, SObject record) + { + return Errors.error(this, message, record); + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + * + * @param message + * @param record + * @param field + * + * @return Returns the Error message + **/ + protected virtual String error(String message, SObject record, Schema.SObjectField field) + { + return fflib_SObjects.Errors.error(this, message, record, field); + } + + /** + * @param field The SObjectField reference of the type Id + * + * @return Return a set with all the Id values of the given field + */ + @TestVisible + protected Set getIdFieldValues(Schema.SObjectField field) + { + Set result = new Set(); + for (SObject record : getRecords()) + { + result.add((Id) record.get(field)); + } + return result; + } + + /** + * @param field The SObjectField reference of the type String + * + * @return Return a set with all the String values of the given field + */ + @TestVisible + protected Set getStringFieldValues(Schema.SObjectField field) + { + Set result = new Set(); + for (SObject record : getRecords()) + { + result.add((String) record.get(field)); + } + return result; + } + + /** + * @param field The SObjectField reference + * + * @return Return a set with all the values of the given field + */ + @TestVisible + protected virtual Set getFieldValues(Schema.SObjectField field) + { + Set result = new Set(); + for (SObject record : getRecords()) + { + result.add(record.get(field)); + } + return result; + } + + /** + * @param field The Schema.SObjectField to compare against the given value + * @param value The given value of the records field to include in the return + * + * @return A list with only the SObjects where the given field has the provided value + */ + protected virtual List getRecordsByFieldValue(Schema.SObjectField field, Object value) + { + return getRecordsByFieldValues(field, new Set{value}); + } + + /** + * @param field The Schema.SObjectField to compare against the given value + * @param values The given values of the records field to include in the return + * + * @return A list with only the SObjects where the given field value is part of the provided values + */ + protected virtual List getRecordsByFieldValues(Schema.SObjectField field, Set values) + { + List result = new List(); + for (SObject record : getRecords()) + { + if (values?.contains(record.get(field))) + { + result.add(record); + } + } + return result; + } + + /** + * @param field The Schema.SObjectField to check its value for a Blank value + * + * @return A list with only the SObjects where the given field value is either null or '') + */ + protected virtual List getRecordsWithBlankFieldValues(Schema.SObjectField field) + { + return getRecordsWithBlankFieldValues( + new Set {field} + ); + } + + /** + * @param fields The Schema.SObjectFields to check their value for a Blank value + * + * @return A list with only the SObjects where the at least one given field value is either null or '') + */ + protected virtual List getRecordsWithBlankFieldValues(Set fields) + { + List result = new List(); + for (SObject record : getRecords()) + { + for (SObjectField field : fields) + { + if (String.isNotBlank((String) record.get(field))) continue; + + result.add(record); + break; + } + } + return result; + } + + /** + * @param fields The Schema.SObjectFields to check their value for a Blank value + * + * @return A list with only the SObjects where all given field values are either null or '' + */ + protected virtual List getRecordsWithAllBlankFieldValues(Set fields) + { + List result = new List(); + for (SObject record : getRecords()) + { + Boolean allBlank = true; + for (SObjectField field : fields) + { + if (String.isNotBlank((String) record.get(field))) + { + allBlank = false; + break; + } + } + if (allBlank) result.add(record); + } + return result; + } + + /** + * @param field The Schema.SObjectField to check its value for a Non-Blank value + * + * @return A list with only the SObjects where the given field value is not null or '' + */ + protected virtual List getRecordsWithNotBlankFieldValues(Schema.SObjectField field) + { + return getRecordsWithNotBlankFieldValues( + new Set {field} + ); + } + + /** + * @param fields The Schema.SObjectFields to check their value for a Non-Blank value + * + * @return A list with only the SObjects where the at least one given field value not null or '' + */ + protected virtual List getRecordsWithNotBlankFieldValues(Set fields) + { + List result = new List(); + for (SObject record : getRecords()) + { + for (SObjectField field : fields) + { + if (String.isNotBlank((String) record.get(field))) + { + result.add(record); + break; + } + } + } + return result; + } + + /** + * @param fields The Schema.SObjectFields to check their value for a Non-Blank value + * + * @return A list with only the SObjects where all given field values are not null or '' + */ + protected virtual List getRecordsWithAllNotBlankFieldValues(Set fields) + { + List result = new List(); + for (SObject record : getRecords()) + { + Boolean allNonBlank = true; + for (SObjectField field : fields) + { + if (String.isBlank((String) record.get(field))) + { + allNonBlank = false; + break; + } + } + if (allNonBlank) result.add(record); + } + return result; + } + + /** + * Modifies a value of a field for all records in the domain + * + * @param field The reference to the SObjectField to be modified + * @param value The value to store in the given SObjectField + */ + protected virtual void setFieldValue(Schema.SObjectField field, Object value) + { + for (SObject record : getRecords()) + { + record.put(field, value); + } + } + + /** + * @param fieldToCheck The SObjectField to match the key against in the provided map + * @param fieldToUpdate The SObjectField to store the mapped value when the key matches the value in the fieldToUpdate field + * @param values Map of values to store by the fieldToCheck fields value + */ + protected virtual void setFieldValueByMap( + Schema.SObjectField fieldToCheck, + Schema.SObjectField fieldToUpdate, + Map values) + { + for (SObject record : getRecords()) + { + Object keyValue = record.get(fieldToCheck); + if (values?.containsKey(keyValue)) + { + record.put(fieldToUpdate, values.get(keyValue)); + } + } + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public virtual class ErrorFactory + { + private List errorList = new List(); + + private ErrorFactory() { } + + public String error(String message, SObject record) + { + return error(null, message, record); + } + + public String error(fflib_SObjects domain, String message, SObject record) + { + ObjectError objectError = new ObjectError(); + objectError.domain = domain; + objectError.message = message; + objectError.record = record; + errorList.add(objectError); + return message; + } + + public String error(String message, SObject record, SObjectField field) + { + return error(null, message, record, field); + } + + public String error(fflib_ISObjects domain, String message, SObject record, SObjectField field) + { + FieldError fieldError = new FieldError(); + fieldError.domain = domain; + fieldError.message = message; + fieldError.record = record; + fieldError.field = field; + errorList.add(fieldError); + return message; + } + + public List getAll() + { + return errorList.clone(); + } + + public void clearAll() + { + errorList.clear(); + } + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public virtual class FieldError extends ObjectError + { + public SObjectField field; + + public FieldError() { } + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public virtual class ObjectError extends Error + { + public SObject record; + + public ObjectError() { } + } + + /** + * Ensures logging of errors in the Domain context for later assertions in tests + **/ + public abstract class Error + { + public String message; + public fflib_ISObjects domain; + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SObjects.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls new file mode 100644 index 00000000000..eaa84cc032b --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls @@ -0,0 +1,348 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Utility class for checking FLS/CRUD. NOTE: all "check" methods will throw a SecurityException (or subclass) if the + * user does not have the proper security granted. + **/ +public class fflib_SecurityUtils +{ + @TestVisible + private enum OperationType { CREATE, READ, MODIFY, DEL } //UPDATE and DELETE are reserved words + + /** + * SecurityException is never be thrown directly by fflib_SecurityUtils, instead all + * forms of CRUD and FLD violations throw subclasses of it. It is provided as a convenience + * in the event you wish to handle CRUD and FLS violations the same way (e.g. die and display an error) + **/ + public virtual class SecurityException extends Exception { + protected OperationType m_operation; + protected Schema.SObjectType m_objectType; + } + + /** + * CrudException represents a running user's lack of read/create/update/delete access at a profile (or permission set) + * level. Sharing and field level security issues will never cause this. + **/ + public class CrudException extends SecurityException{ + + private CrudException(OperationType operation, Schema.SObjectType objectType){ + this.m_operation = operation; + this.m_objectType = objectType; + if(operation == OperationType.CREATE) + this.setMessage(System.Label.fflib_security_error_object_not_insertable); + else if(operation == OperationType.READ) + this.setMessage(System.Label.fflib_security_error_object_not_readable); + else if(operation == OperationType.MODIFY) + this.setMessage(System.Label.fflib_security_error_object_not_updateable); + else if(operation == OperationType.DEL) + this.setMessage(System.Label.fflib_security_error_object_not_deletable); + + this.setMessage( + String.format( + this.getMessage(), + new List{ + objectType.getDescribe().getName() + } + ) + ); + } + } + /** + * FlsException represents a running user's lack of field level security to a specific field at a profile (or permission set) level + * Sharing and CRUD security issues will never cause this to be thrown. + **/ + public class FlsException extends SecurityException{ + private Schema.SObjectField m_fieldToken; + + private FlsException(OperationType operation, Schema.SObjectType objectType, Schema.SObjectField fieldToken){ + this.m_operation = operation; + this.m_objectType = objectType; + this.m_fieldToken = fieldToken; + if(operation == OperationType.CREATE) + this.setMessage(System.Label.fflib_security_error_field_not_insertable); + else if(operation == OperationType.READ) + this.setMessage(System.Label.fflib_security_error_field_not_readable); + else if(operation == OperationType.MODIFY) + this.setMessage(System.Label.fflib_security_error_field_not_updateable); + + this.setMessage( + String.format( + this.getMessage(), + new List{ + objectType.getDescribe().getName(), + fieldToken.getDescribe().getName() + } + ) + ); + } + } + + /** + * If set to true all check methods will always return void, and never throw exceptions. + * This should really only be set to true if an app-wide setting to disable in-apex + * FLS and CRUD checks exists and is enabled. + * Per security best practices setting BYPASS should be an a opt-in, and not the default behavior. + **/ + public static Boolean BYPASS_INTERNAL_FLS_AND_CRUD = false; + + /** + * Check{Insert,Read,Update} methods check both FLS and CRUD + **/ + + /** + * Checks both insert FLS and CRUD for the specified object type and fields. + * @exception FlsException if the running user does not have insert rights to any fields in {@code fieldNames}. + * @exception CrudException if the running user does not have insert rights to {@code objType} + **/ + public static void checkInsert(SObjectType objType, List fieldNames) + { + checkObjectIsInsertable(objType); + for (String fieldName : fieldNames) + { + checkFieldIsInsertable(objType, fieldName); + } + } + + /** + * Identical to {@link #checkInsert(SObjectType,List)}, except with SObjectField instead of String field references. + * @exception FlsException if the running user does not have insert rights to any fields in {@code fieldTokens}. + * @exception CrudException if the running user does not have insert rights to {@code objType} + **/ + public static void checkInsert(SObjectType objType, List fieldTokens) + { + checkObjectIsInsertable(objType); + for (SObjectField fieldToken : fieldTokens) + { + checkFieldIsInsertable(objType, fieldToken); + } + } + + /** + * Checks both read FLS and CRUD for the specified object type and fields. + * @exception FlsException if the running user does not have read rights to any fields in {@code fieldNames}. + * @exception CrudException if the running user does not have read rights to {@code objType} + **/ + public static void checkRead(SObjectType objType, List fieldNames) + { + checkObjectIsReadable(objType); + for (String fieldName : fieldNames) + { + checkFieldIsReadable(objType, fieldName); + } + } + + /** + * Identical to {@link #checkRead(SObjectType,List)}, except with SObjectField instead of String field references. + * @exception FlsException if the running user does not have read rights to any fields in {@code fieldTokens}. + * @exception CrudException if the running user does not have read rights to {@code objType} + **/ + public static void checkRead(SObjectType objType, List fieldTokens) + { + checkObjectIsReadable(objType); + for (SObjectField fieldToken : fieldTokens) + { + checkFieldIsReadable(objType, fieldToken); + } + } + + /** + * Checks both update FLS and CRUD for the specified object type and fields. + * @exception FlsException if the running user does not have update rights to any fields in {@code fieldNames}. + * @exception CrudException if the running user does not have update rights to {@code objType} + **/ + public static void checkUpdate(SObjectType objType, List fieldNames) + { + checkObjectIsUpdateable(objType); + for (String fieldName : fieldNames) + { + checkFieldIsUpdateable(objType, fieldName); + } + } + + /** + * Identical to {@link #checkUpdate(SObjectType,List)}, except with SObjectField instead of String field references. + * @exception FlsException if the running user does not have update rights to any fields in {@code fieldTokens}. + * @exception CrudException if the running user does not have update rights to {@code objType} + **/ + public static void checkUpdate(SObjectType objType, List fieldTokens) + { + checkObjectIsUpdateable(objType); + for (SObjectField fieldToken : fieldTokens) + { + checkFieldIsUpdateable(objType, fieldToken); + } + } + + /** + * CheckFieldIs* method check only FLS + **/ + + /** + * Checks insert field level security only (no CRUD) for the specified fields on {@code objType} + * @exception FlsException if the running user does not have insert rights to the {@code fieldName} field. + **/ + public static void checkFieldIsInsertable(SObjectType objType, String fieldName) + { + checkFieldIsInsertable(objType, fflib_SObjectDescribe.getDescribe(objType).getField(fieldName)); + } + + /** + * Identical to {@link #checkFieldIsInsertable(SObjectType,String)}, except with SObjectField instead of String field reference. + * @exception FlsException if the running user does not have insert rights to the {@code fieldName} field. + **/ + public static void checkFieldIsInsertable(SObjectType objType, SObjectField fieldToken) + { + checkFieldIsInsertable(objType, fieldToken.getDescribe()); + } + + /** + * Identical to {@link #checkFieldIsInsertable(SObjectType,String)}, except with DescribeFieldResult instead of String field reference. + * @exception FlsException if the running user does not have insert rights to the {@code fieldName} field. + **/ + public static void checkFieldIsInsertable(SObjectType objType, DescribeFieldResult fieldDescribe) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!fieldDescribe.isCreateable()) + throw new FlsException(OperationType.CREATE, objType, fieldDescribe.getSObjectField()); + } + + /** + * Checks read field level security only (no CRUD) for the specified fields on {@code objType} + * @exception FlsException if the running user does not have read rights to the {@code fieldName} field. + **/ + public static void checkFieldIsReadable(SObjectType objType, String fieldName) + { + checkFieldIsReadable(objType, fflib_SObjectDescribe.getDescribe(objType).getField(fieldName)); + } + + /** + * Identical to {@link #checkFieldIsReadable(SObjectType,String)}, except with SObjectField instead of String field reference. + * @exception FlsException if the running user does not have read rights to the {@code fieldName} field. + **/ + public static void checkFieldIsReadable(SObjectType objType, SObjectField fieldToken) + { + checkFieldIsReadable(objType, fieldToken.getDescribe()); + } + + /** + * Identical to {@link #checkFieldIsReadable(SObjectType,String)}, except with DescribeFieldResult instead of String field reference. + * @exception FlsException if the running user does not have read rights to the {@code fieldName} field. + **/ + public static void checkFieldIsReadable(SObjectType objType, DescribeFieldResult fieldDescribe) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!fieldDescribe.isAccessible()) + throw new FlsException(OperationType.READ, objType, fieldDescribe.getSObjectField()); + } + + + /** + * Checks update field level security only (no CRUD) for the specified fields on {@code objType} + * @exception FlsException if the running user does not have update rights to the {@code fieldName} field. + **/ + public static void checkFieldIsUpdateable(SObjectType objType, String fieldName) + { + checkFieldIsUpdateable(objType, fflib_SObjectDescribe.getDescribe(objType).getField(fieldName)); + } + + /** + * Identical to {@link #checkFieldIsUpdateable(SObjectType,String)}, except with SObjectField instead of String field reference. + * @exception FlsException if the running user does not have update rights to the {@code fieldName} field. + **/ + public static void checkFieldIsUpdateable(SObjectType objType, SObjectField fieldToken) + { + checkFieldIsUpdateable(objType, fieldToken.getDescribe()); + } + + /** + * Identical to {@link #checkFieldIsUpdateable(SObjectType,String)}, except with DescribeFieldResult instead of String field reference. + * @exception FlsException if the running user does not have update rights to the {@code fieldName} field. + **/ + public static void checkFieldIsUpdateable(SObjectType objType, DescribeFieldResult fieldDescribe) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!fieldDescribe.isUpdateable()) + throw new FlsException(OperationType.MODIFY, objType, fieldDescribe.getSObjectField()); + } + + /** + * CheckObjectIs* methods check only CRUD + **/ + + /** + * Checks insert CRUD for the specified object type. + * @exception CrudException if the running user does not have insert rights to the {@code objType} SObject. + **/ + public static void checkObjectIsInsertable(SObjectType objType) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!objType.getDescribe().isCreateable()) + { + throw new CrudException(OperationType.CREATE, objType); + } + } + + /** + * Checks read CRUD for the specified object type. + * @exception CrudException if the running user does not have read rights to the {@code objType} SObject. + **/ + public static void checkObjectIsReadable(SObjectType objType) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!objType.getDescribe().isAccessible()) + throw new CrudException(OperationType.READ, objType); + } + + /** + * Checks update CRUD for the specified object type. + * @exception CrudException if the running user does not have update rights to the {@code objType} SObject. + **/ + public static void checkObjectIsUpdateable(SObjectType objType) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!objType.getDescribe().isUpdateable()) + throw new CrudException(OperationType.MODIFY, objType); + } + + /** + * Checks delete CRUD for the specified object type. + * @exception CrudException if the running user does not have delete rights to the {@code objType} SObject. + **/ + public static void checkObjectIsDeletable(SObjectType objType) + { + if (BYPASS_INTERNAL_FLS_AND_CRUD) + return; + if (!objType.getDescribe().isDeletable()) + throw new CrudException(OperationType.DEL, objType); + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_SecurityUtils.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls new file mode 100644 index 00000000000..cac2e8ab2a0 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls @@ -0,0 +1,157 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + * Helper class, roughly based on the Java version, but subclassed to assist in a number of use cases in this library + * + * NOTE: Aspects of this where developed before recent improvements to String handling, as such could likely be enhanced at this stage. + **/ +public virtual class fflib_StringBuilder +{ + protected List buffer = new List(); + + /** + * Construct an empty StringBuilder + **/ + public fflib_StringBuilder() {} + + /** + * Construct a StringBuilder with the given values + **/ + public fflib_StringBuilder(List values) + { + add(values); + } + + /** + * Add the given values to the StringBuilder + **/ + public virtual void add(List values) + { + buffer.addAll(values); + } + + /** + * Add the given value to the StringBuilder + **/ + public virtual void add(String value) + { + buffer.add(value); + } + + public virtual override String toString() + { + return String.join(buffer, ''); + } + + /** + * Return the state of the StringBuilder + **/ + public virtual String getStringValue() + { + return toString(); + } + + /** + * Subclasses the StringBuilder to produce a comma delimited concatenation of strings + **/ + public virtual with sharing class CommaDelimitedListBuilder extends fflib_StringBuilder + { + String itemPrefix = ''; + String delimiter = ','; + + public CommaDelimitedListBuilder() {} + + public CommaDelimitedListBuilder(List values) + { + super(values); + } + + public void setItemPrefix(String itemPrefix) + { + this.itemPrefix = itemPrefix; + } + + public void setDelimiter(String delimiter) + { + this.delimiter = delimiter; + } + + public String getStringValue(String itemPrefix) + { + setItemPrefix(itemPrefix); + return toString(); + } + + public override String toString() + { + return itemPrefix + String.join(buffer, delimiter + itemPrefix); + } + } + + /** + * Subclasses the StringCommaDelimitedBuilder to accept native SObjectField tokens and optional FieldSet definitions to concatinate when building queries + **/ + public virtual with sharing class FieldListBuilder extends CommaDelimitedListBuilder + { + public FieldListBuilder(List values) + { + this(values, null); + } + + public FieldListBuilder(List values, List fieldSets) + { + // Create a distinct set of fields (or field paths) to select + for(Schema.SObjectField value : values) + add(String.valueOf(value)); // Alternative to value.getDescribe().getName() + + if(fieldSets!=null) + for(Schema.Fieldset fieldSet : fieldSets) + for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) + add(fieldSetMember.getFieldPath()); + } + } + + /** + * Subclasses the FieldListBuilder to auto sense and include when needed the CurrencyIsoCode field in the field list + **/ + public with sharing class MultiCurrencyFieldListBuilder extends FieldListBuilder + { + public MultiCurrencyFieldListBuilder(List values) + { + this(values, null); + } + + public MultiCurrencyFieldListBuilder(List values, List fieldSets) + { + super(values, fieldSets); + + // Dynamically add CurrencyIsoCode field for multi-currency organisations + if(UserInfo.isMultiCurrencyOrganization()) + add('CurrencyIsoCode'); + } + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/classes/fflib_StringBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/main/labels/fflib-Apex-Common-CustomLabels.labels-meta.xml b/subfolders/fflib/sfdx-source/apex-common/main/labels/fflib-Apex-Common-CustomLabels.labels-meta.xml new file mode 100644 index 00000000000..d4002b0a0d3 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/main/labels/fflib-Apex-Common-CustomLabels.labels-meta.xml @@ -0,0 +1,86 @@ + + + + fflib_QueryFactory_crossobject_fieldsets_not_allowed_error + en_US + false + Error when selecting a cross-object fieldsset is not allowed and attempted. + Cross-object fields not allowed and field "{0}" is a cross-object field. + + + fflib_QueryFactory_fieldset_wrong_sobject_error + en_US + false + Error when selecting a field set for a different sobject type. + Field set "{0}" is not for SObject type "{1}" + + + fflib_QueryFactory_recursive_subquery_error + en_US + false + Presented when attempting to nest subqueries. + Invalid call to subselectQuery. You may not add a subselect query to a subselect query. + + + fflib_QueryFactory_subquery_invalid_relationship + en_US + false + Presented when attempting to create a subquery from an invalid relationship. + Invalid call to subselectQuery. Invalid relationship for table. + + + fflib_security_error_field_not_insertable + security,error + en_US + true + fflib_security_error_field_not_insertable + You do not have permission to insert field {1} on {0} + + + fflib_security_error_field_not_readable + security,error + en_US + true + fflib_security_error_field_not_readable + You do not have permission to read the field {1} on {0} + + + fflib_security_error_field_not_updateable + security,error + en_US + true + fflib_security_error_field_not_updateable + You do not have permission to update the field {1} on {0} + + + fflib_security_error_object_not_deletable + en_US + true + fflib_security_error_object_not_deletable + You do not have permission to delete {0} + + + fflib_security_error_object_not_insertable + security,error + en_US + true + fflib_security_error_object_not_insertable + You do not have permission to insert {0} + + + fflib_security_error_object_not_readable + security,error + en_US + true + fflib_security_error_object_not_readable + You do not have permission to read {0} + + + fflib_security_error_object_not_updateable + security,error + en_US + true + fflib_security_error_object_not_updateable + You do not have permission to update {0} + + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls new file mode 100644 index 00000000000..6dc13d47ebc --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls @@ -0,0 +1,829 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private class fflib_ApplicationTest +{ + @IsTest + private static void callingDomainFactoryShouldGiveRegisteredImplsAndMocks() + { + // Registered Accounts domain class by SObject List + Id testAccountId = fflib_IDGenerator.generate(Account.SObjectType); + fflib_IDomain domainObjectAcct = + Domain.newInstance( + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') }); + System.assert(domainObjectAcct instanceof AccountsDomain); + System.assertEquals(testAccountId, getFirstSObject(domainObjectAcct).Id); + + // Registered Accounts domain class by SObject List + testAccountId = fflib_IDGenerator.generate(Account.SObjectType); + domainObjectAcct = + Domain.newInstance( + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') } + , Account.SObjectType); + System.assert(domainObjectAcct instanceof AccountsDomain); + System.assertEquals(testAccountId, getFirstSObject(domainObjectAcct).Id); + + // Registered Opportunities domain class by SObject List + Id testOpportunityId = fflib_IDGenerator.generate(Opportunity.SObjectType); + fflib_IDomain domainObjectOpp = + Domain.newInstance( + new List + { new Opportunity( + Id = testOpportunityId, + Name = 'Test Opportunity') }); + System.assertEquals(testOpportunityId, getFirstSObject(domainObjectOpp).Id); + System.assert(domainObjectOpp instanceof OpportuntiesDomain); + + // Test failure for creating new instance using IConstructable2 + // for domain class that does not support it + testOpportunityId = fflib_IDGenerator.generate(Opportunity.SObjectType); + domainObjectOpp = + Domain.newInstance( + new List + { new Opportunity( + Id = testOpportunityId, + Name = 'Test Opportunity') } + , Opportunity.SObjectType); + System.assertEquals(testOpportunityId, getFirstSObject(domainObjectOpp).Id); + System.assert(domainObjectOpp instanceof OpportuntiesDomain); + + // Given + fflib_ApexMocks mocks = new fflib_ApexMocks(); + mocks.startStubbing(); + fflib_ISObjectDomain domainMock = new fflib_SObjectMocks.SObjectDomain(mocks); + mocks.when(domainMock.sObjectType()).thenReturn(Account.SObjectType); + mocks.stopStubbing(); + Domain.setMock(domainMock); + + // When + domainObjectAcct = + Domain.newInstance( + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') }); + + // Then + System.assert(domainObjectAcct instanceof fflib_SObjectMocks.SObjectDomain); + + // When + domainObjectAcct = + Domain.newInstance( + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') } + , Account.SObjectType); + + // Then + System.assert(domainObjectAcct instanceof fflib_SObjectMocks.SObjectDomain); + } + + private static SObject getFirstSObject(fflib_IDomain domainObjectAcct) + { + return ((List) domainObjectAcct.getObjects())[0]; + } + + @IsTest + private static void callingDomainFactoryWithIdsShouldGiveRegisteredImpls() + { + // Given + fflib_ApexMocks mocks = new fflib_ApexMocks(); + mocks.startStubbing(); + fflib_ISObjectSelector selectorMock = new fflib_SObjectMocks.SObjectSelector(mocks); + Id testAccountId = fflib_IDGenerator.generate(Account.SObjectType); + List accounts = + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') }; + Set accountIds = new Map(accounts).keySet(); + mocks.when(selectorMock.selectSObjectsById(accountIds)).thenReturn(accounts); + mocks.when(selectorMock.sObjectType()).thenReturn(Account.SObjectType); + mocks.stopStubbing(); + + // When + Selector.setMock(selectorMock); + fflib_IDomain domainObjectAcc = Domain.newInstance(new Set { testAccountId }); + + // Then + List assertAccounts = (List) domainObjectAcc.getObjects(); + System.assert(domainObjectAcc instanceof AccountsDomain); + System.assertEquals(testAccountId, getFirstSObject(domainObjectAcc).Id); + System.assertEquals(1, assertAccounts.size()); + System.assertEquals(testAccountId, assertAccounts[0].Id); + System.assertEquals('Test Account', assertAccounts[0].Name); + } + + @IsTest + private static void callingDomainFactoryWithGenericListShouldGiveException() + { + try { + Domain.newInstance(new List()); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Unable to determine SObjectType', e.getMessage()); + } + } + + @IsTest + private static void callingDomainFactoryWithNoSObjectTypeShouldGiveException() + { + try { + Domain.newInstance(new List(), null); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Must specify sObjectType', e.getMessage()); + } + } + + @IsTest + private static void callingDomainFactoryWithInAccessableConstructorShouldGiveException() + { + try { + Domain.newInstance(new List{ new Product2(Name = 'Test Product') }); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Domain constructor class not found for SObjectType Product2', e.getMessage()); + } + + try { + Domain.newInstance(new List{ new Product2(Name = 'Test Product') }, Product2.SObjectType); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Domain constructor class not found for SObjectType Product2', e.getMessage()); + } + } + + @IsTest + private static void callingDomainFactoryWithContructorClassThatDoesNotSupportIConstructableShouldGiveException() + { + try { + Domain.newInstance(new List{ new Contact(LastName = 'TestContactLName') }); + System.assert(false, 'Expected exception'); + } catch (System.TypeException e) { + + System.assert(Pattern.Matches('Invalid conversion from runtime type \\w*\\.?fflib_ApplicationTest\\.ContactsConstructor to \\w*\\.?fflib_IDomainConstructor', + e.getMessage()), 'Exception message did not match the expected pattern: ' + e.getMessage()); + } + + try { + Domain.newInstance(new List{ new Contact(LastName = 'TestContactLName') }, Contact.SObjectType); + System.assert(false, 'Expected exception'); + } catch (System.TypeException e) { + System.assert(Pattern.Matches('Invalid conversion from runtime type \\w*\\.?fflib_ApplicationTest\\.ContactsConstructor to \\w*\\.?fflib_IDomainConstructor', + e.getMessage()), 'Exception message did not match the expected pattern: ' + e.getMessage()); + } + } + + @IsTest + private static void callingUnitOfWorkFactoryShouldGivenStandardImplsAndMockImpls() + { + // Standard behaviour + System.assert(UnitOfWork.newInstance() instanceof fflib_SObjectUnitOfWork); + + // Mocking behaviour + UnitOfWork.setMock(new fflib_SObjectMocks.SObjectUnitOfWork(new fflib_ApexMocks())); + System.assert(UnitOfWork.newInstance() instanceof fflib_SObjectMocks.SObjectUnitOfWork); + } + + @IsTest + private static void callingUnitOfWorkFactoryWithCustomTypesShouldGivenStandardImplsAndMockImpls() + { + // Standard behaviour + System.assert( + UnitOfWork.newInstance( + new List{ Account.SObjectType} + ) instanceof fflib_SObjectUnitOfWork); + + // Mocking behaviour + UnitOfWork.setMock(new fflib_SObjectMocks.SObjectUnitOfWork(new fflib_ApexMocks())); + System.assert( + UnitOfWork.newInstance( + new List{ Account.SObjectType} + ) instanceof fflib_SObjectMocks.SObjectUnitOfWork); + } + + @IsTest + private static void callingServiceFactoryShouldGiveRegisteredImplementationAndMockOnes() + { + // Standard behaviour + System.assert(Service.newInstance(IRegisteredServiceWithStandardName1.class) instanceof RegisteredServiceWithStandardName1Impl, 'ServiceFactory.newInstance will return registered implementations' ) ; + System.assert(Service.newInstance(IRegisteredServiceWithStandardName2.class) instanceof RegisteredServiceWithStandardName2Impl, 'ServiceFactory.newInstance will return registered implementations (2)' ); + System.assert(Service.newInstance(IRegisteredServiceWithNonStandardImplName.class) instanceof RegisteredServiceWithNonStandardNameImplName, 'ServiceFactory.newInstance will return registered implementations (3)' ); + System.assert(Service.newInstance(RegisteredServiceWithNonStandardName.class) instanceof RegisteredServiceWithNonStandardNameImpl, 'ServiceFactory.newInstance will return registered implementations (4)' ); + + // Mocking behaviour + Service.setMock(IRegisteredServiceWithStandardName1.class, new ServiceMock()); + System.assert(Service.newInstance(IRegisteredServiceWithStandardName1.class) instanceof ServiceMock,'ServiceFactory.newInstance, when a mock is registered, will return the mock for that type'); + System.assert(Service.newInstance(IRegisteredServiceWithStandardName2.class) instanceof RegisteredServiceWithStandardName2Impl, 'ServiceFactory.newInstance, when a mock is registered, will still return the standard one for other types (1)'); + System.assert(Service.newInstance(IRegisteredServiceWithNonStandardImplName.class) instanceof RegisteredServiceWithNonStandardNameImplName, 'ServiceFactory.newInstance, when a mock is registered, will still return the standard one for other types (2)'); + System.assert(Service.newInstance(RegisteredServiceWithNonStandardName.class) instanceof RegisteredServiceWithNonStandardNameImpl, 'ServiceFactory.newInstance, when a mock is registered, will still return the standard one for other types (3)'); + } + + @IsTest + private static void callingServiceFactoryShouldGiveDefaultImplementation() + { + // Standard behaviour + System.assert(Service.newInstance(IUnregisteredServiceWithStandardName.class) instanceof UnregisteredServiceWithStandardNameImpl, 'ServiceFactory.newInstance, when given a standard format interface that is not registered, and a standard implentation that exists, will return the standard service' ) ; + + // Mocking behaviour + Service.setMock(IUnregisteredServiceWithStandardName.class, new ServiceMock()); + System.assert(Service.newInstance(IUnregisteredServiceWithStandardName.class) instanceof ServiceMock, 'ServiceFactory.newInstance, when given a standard format interface that is not registered and a mock is set, will return the mock for that type'); + } + + @isTest + private static void serviceFactory_whenAnUnregisteredInterfaceWithNoDefaultImpl_throwsAnException() // NOPMD: Test method name format + { + Test.startTest(); + String exceptionMessage; + try + { + Service.newInstance(IUnregisteredServiceWithNonStandardImpl.class); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert( exceptionMessage.contains( 'No implementation registered for service interface' ), 'serviceFactory, when given a standard format interface that has no default implementation, will throw an exception' ); + System.assert( exceptionMessage.contains( 'and no default implementation found with the name' ), 'serviceFactory, when given a standard format interface that has no default implementation, will throw an exception' ); + } + + @isTest + private static void serviceFactory_whenAnUnregisteredInterfaceWithNoImpl_throwsAnException() // NOPMD: Test method name format + { + Test.startTest(); + String exceptionMessage; + try + { + Service.newInstance(IUnregisteredServiceWithNoImplementation.class); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert( exceptionMessage.contains( 'No implementation registered for service interface' ), 'serviceFactory, when given a standard format interface that has no default implementation, will throw an exception' ); + System.assert( exceptionMessage.contains( 'and no default implementation found with the name' ), 'serviceFactory, when given a standard format interface that has no default implementation, will throw an exception' ); + } + + @isTest + private static void serviceFactory_whenAnUnregisteredInterfaceWithNonStandardName_throwsAnException() // NOPMD: Test method name format + { + Test.startTest(); + String exceptionMessage; + try + { + Service.newInstance(UnregisteredServiceWithNonStandardName.class); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert( exceptionMessage.contains( 'No implementation registered for service interface' ), 'serviceFactory, when given a non standard format interface that is not registered, will throw an exception' ); + System.assert( exceptionMessage.contains( 'and default implementation cannot be determined from the name (not an interface in the standard naming convention (Ixxxx)' ), 'serviceFactory, when given a standard format interface that is not registered, will throw an exception' ); + } + + @isTest + private static void serviceFactory_whenARegisteredInterfaceCannotBeConstructed_throwsAnException() // NOPMD: Test method name format + { + Test.startTest(); + String exceptionMessage = ''; + try + { + Service.newInstance(IRegisteredServiceUnconstructable.class); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert( exceptionMessage.contains( 'Implementation for service interface' ), 'serviceFactory, when given registered service that cannot be constructed, will throw an exception' ); + System.assert( exceptionMessage.contains( 'could not be constructed' ), 'serviceFactory, when given registered service that cannot be constructed, will throw an exception' ); + } + + @isTest + private static void serviceFactory_whenAnUnregisteredInterfaceCannotBeConstructed_throwsAnException() // NOPMD: Test method name format + { + Test.startTest(); + String exceptionMessage = ''; + try + { + Service.newInstance(IUnregisteredServiceUnconstructable.class); + } + catch ( Exception e ) + { + exceptionMessage = e.getMessage(); + } + Test.stopTest(); + + System.assert( exceptionMessage.contains( 'Default implementation for service interface' ), 'serviceFactory, when given unregistered service that cannot be constructed, will throw an exception' ); + System.assert( exceptionMessage.contains( 'could not be constructed' ), 'serviceFactory, when given unregistered service that cannot be constructed, will throw an exception' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInterfaceNameWithNoNs_returnsTrue() // NOPMD: Test method name format + { + System.assertEquals( true, Service.isAServiceInterfaceName( 'IIsAnInterface' ), 'isAServiceInterfaceName, when given a top level interface name with no namespace, returns true' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInterfaceNameWithANs_returnsTrue() // NOPMD: Test method name format + { + System.assertEquals( true, Service.isAServiceInterfaceName( 'namespace.IIsAnInterface' ), 'isAServiceInterfaceName, when given a top level interface name with a namespace, returns true' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInnerInterfaceNameWithNoNs_returnsTrue() // NOPMD: Test method name format + { + System.assertEquals( true, Service.isAServiceInterfaceName( 'TopLevelClass.IIsAnInterface' ), 'isAServiceInterfaceName, when given an inner interface name with no namespace, returns true' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInnerInterfaceNameWithANs_returnsTrue() // NOPMD: Test method name format + { + System.assertEquals( true, Service.isAServiceInterfaceName( 'namespace.TopLevelClass.IIsAnInterface' ), 'isAServiceInterfaceName, when given an inner interface name with a namespace, returns true' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInterfaceNameWithNoNs_returnsFalse() // NOPMD: Test method name format + { + System.assertEquals( false, Service.isAServiceInterfaceName( 'NotAnInterface' ), 'isAServiceInterfaceName, when given a top level non interface name (does not start with I) with no namespace, returns false' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenNotInterfaceNameWithANs_returnsFalse() // NOPMD: Test method name format + { + System.assertEquals( false, Service.isAServiceInterfaceName( 'namespace.NotAnInterface' ), 'isAServiceInterfaceName, when given a top level non interface name (does not start with I) with a namespace, returns false' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInnerInterfaceNameWithNoNs_returnsFalse() // NOPMD: Test method name format + { + System.assertEquals( false, Service.isAServiceInterfaceName( 'TopLevelClass.NotAnInterface' ), 'isAServiceInterfaceName, when given an inner non interface name (does not start with I) with no namespace, returns false' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInnerNonInterfaceNameWithANs_returnsFalse() // NOPMD: Test method name format + { + System.assertEquals( false, Service.isAServiceInterfaceName( 'namespace.TopLevelClass.NotAnInterface' ), 'isAServiceInterfaceName, when given an inner non interface name (does not start with I) with a namespace, returns false' ); + } + + @isTest + private static void isAServiceInterfaceName_whenGivenInnerNonInterfaceNameWithANsAndRestLooksLikeInterface_returnsFalse() // NOPMD: Test method name format + { + System.assertEquals( false, Service.isAServiceInterfaceName( 'Inamespace.ITopLevelClass.NotAnInterface' ), 'isAServiceInterfaceName, when given an inner non interface name (does not start with I) with a namespace, and the other levels look like an interface, returns false' ); + } + + @isTest + private static void buildDefaultServiceName_whenGivenNameWithNoNamespace_returnsTheDefaultServiceName() // NOPMD: Test method name format + { + System.assertEquals( 'ServiceNameImpl', Service.buildDefaultServiceName( 'IServiceName'), 'buildDefaultServiceName, when given an outer class with no namespace, will return a default service name' ); + } + + @isTest + private static void buildDefaultServiceName_whenGivenNameWithANamespace_returnsTheDefaultServiceName() // NOPMD: Test method name format + { + System.assertEquals( 'namespace.ServiceNameImpl', Service.buildDefaultServiceName( 'namespace.IServiceName'), 'buildDefaultServiceName, when given an outer class with a namespace, will return a default service name' ); + } + + @isTest + private static void buildDefaultServiceName_whenGivenInnerClassWithNoNamespace_returnsTheDefaultServiceName() // NOPMD: Test method name format + { + System.assertEquals( 'ParentClass.ServiceNameImpl', Service.buildDefaultServiceName( 'ParentClass.IServiceName'), 'buildDefaultServiceName, when given an inner class with no namespace, will return a default service name' ); + } + + @isTest + private static void buildDefaultServiceName_whenGivenInnerClassWithANamespace_returnsTheDefaultServiceName() // NOPMD: Test method name format + { + System.assertEquals( 'namespace.ParentClass.ServiceNameImpl', Service.buildDefaultServiceName( 'namespace.ParentClass.IServiceName'), 'buildDefaultServiceName, when given an inner class with a namespace, will return a default service name' ); + } + + @IsTest + private static void callingSelectorFactoryShouldGiveRegisteredImpls() + { + // Standard behaviour + System.assert(Selector.newInstance(Account.SObjectType) instanceof AccountsSelector); + System.assert(Selector.newInstance(Opportunity.SObjectType) instanceof OpportuntiesSelector); + try { + Selector.newInstance(User.SObjectType); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Selector class not found for SObjectType User', e.getMessage()); + } + } + + @IsTest + private static void callingSelectorFactorySelectByIdWithEmptyListShouldGiveException() + { + try { + Selector.selectById(null); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Invalid record Id\'s set', e.getMessage()); + } + try { + Selector.selectById(new Set()); + System.assert(false, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Invalid record Id\'s set', e.getMessage()); + } + } + + @IsTest + private static void callingSelectorFactorySelectByIdWithMixedIdTypeListShouldGiveException() + { + try { + Selector.selectById( + new Set { + fflib_IDGenerator.generate(Opportunity.SObjectType), + fflib_IDGenerator.generate(Account.SObjectType) }); + System.assert(true, 'Expected exception'); + } catch (fflib_Application.DeveloperException e) { + System.assertEquals('Unable to determine SObjectType, Set contains Id\'s from different SObject types', e.getMessage()); + } + } + + @IsTest + private static void callingSelectoryFactorySelectByIdShouldReturnResults() + { + // Given + fflib_ApexMocks mocks = new fflib_ApexMocks(); + mocks.startStubbing(); + fflib_ISObjectSelector selectorMock = new fflib_SObjectMocks.SObjectSelector(mocks); + Id testAccountId = fflib_IDGenerator.generate(Account.SObjectType); + List accounts = + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') }; + Set accountIds = new Map(accounts).keySet(); + mocks.when(selectorMock.selectSObjectsById(accountIds)).thenReturn(accounts); + mocks.when(selectorMock.sObjectType()).thenReturn(Account.SObjectType); + mocks.stopStubbing(); + + // When + Selector.setMock(selectorMock); + List assertAccounts = Selector.selectById(accountIds); + + // Then + System.assert(Selector.newInstance(Account.SObjectType) instanceof fflib_SObjectMocks.SObjectSelector); + System.assertEquals(1, assertAccounts.size()); + System.assertEquals(testAccountId, assertAccounts[0].Id); + System.assertEquals('Test Account', assertAccounts[0].Name); + System.assert(Selector.newInstance(Opportunity.SObjectType) instanceof OpportuntiesSelector); + } + + + @IsTest + private static void callingSelectoryFactorySselectByRelationshipPassRelatedIds() + { + // Given + fflib_ApexMocks mocks = new fflib_ApexMocks(); + mocks.startStubbing(); + fflib_ISObjectSelector selectorMock = new fflib_SObjectMocks.SObjectSelector(mocks); + Id testAccountId = fflib_IDGenerator.generate(Account.SObjectType); + Id testOpportunityId = fflib_IDGenerator.generate(Opportunity.SObjectType); + List accounts = + new List + { new Account( + Id = testAccountId, + Name = 'Test Account') }; + Set accountIds = new Map(accounts).keySet(); + mocks.when(selectorMock.selectSObjectsById(accountIds)).thenReturn(accounts); + mocks.when(selectorMock.sObjectType()).thenReturn(Account.SObjectType); + mocks.stopStubbing(); + Selector.setMock(selectorMock); + + // When + List opportunties = + new List + { new Opportunity( + Id = testOpportunityId, + Account = accounts[0], + AccountId = testAccountId, + Name = 'Test Opportunity 1'), + new Opportunity( + Id = testOpportunityId, + Name = 'Test Opportunity 2') }; + List assertAccounts = Selector.selectByRelationship(opportunties, Opportunity.AccountId); + + // Then + System.assert(Selector.newInstance(Account.SObjectType) instanceof fflib_SObjectMocks.SObjectSelector); + System.assertEquals(1, assertAccounts.size()); + System.assertEquals(testAccountId, assertAccounts[0].Id); + System.assertEquals('Test Account', assertAccounts[0].Name); + System.assert(Selector.newInstance(Opportunity.SObjectType) instanceof OpportuntiesSelector); + } + + + @IsTest + private static void callingUnitOfWorkWithCustomDML() + { + // Given a custom DML class and a new record + CustomDML customDML = new CustomDML(); + Account myAccount = new Account(Name = 'Test Account'); + + // When the unit of work is instantiated from the Application Class and the record is registered and commited + fflib_ISObjectUnitOfWork unitOfWork = UnitOfWork.newInstance(customDML); + unitOfWork.registerNew(myAccount); + unitOfWork.commitWork(); + + // Then the Custom DML is used by the unit of Work + System.assert(customDML.isInsertCalled, 'Oops, custom DML was not called'); + } + + @IsTest + private static void callingMockedUnitOfWorkWithCustomDML() + { + // Given a custom DML class and a new record + CustomDML customDML = new CustomDML(); + Account myAccount = new Account(Name = 'Test Account'); + + // When the unit of work is instantiated from the Application Class and the record is registered and commited + UnitOfWork.setMock(new fflib_SObjectMocks.SObjectUnitOfWork(new fflib_ApexMocks())); + fflib_ISObjectUnitOfWork uow = UnitOfWork.newInstance(customDML); + + uow.registerNew(myAccount); + uow.commitWork(); + + // Then the Custom DML should not be used by the unit of Work + System.assert(!customDML.isInsertCalled, 'Oops, custom DML was called'); + } + + @IsTest + private static void callingUnitOfWorkWithCustomObjectTypesAndDML() + { + // Given a custom DML class and a new record + CustomDML customDML = new CustomDML(); + Account myAccount = new Account(Name = 'Test Account'); + + // When the unit of work is instantiated from the Application Class and the record is registered and commited + fflib_ISObjectUnitOfWork unitOfWork = UnitOfWork.newInstance( + new List{ Account.SObjectType }, + customDML + ); + unitOfWork.registerNew(myAccount); + unitOfWork.commitWork(); + + // Then the Custom DML is used by the unit of Work + System.assert(customDML.isInsertCalled, 'Oops, custom DML was not called'); + } + + @IsTest + private static void callingMockedUnitOfWorkWithCustomObjectTypesAndDML() + { + // Given a custom DML class and a new record + CustomDML customDML = new CustomDML(); + Account myAccount = new Account(Name = 'Test Account'); + + // When the unit of work is instantiated from the Application Class and the record is registered and commited + UnitOfWork.setMock(new fflib_SObjectMocks.SObjectUnitOfWork(new fflib_ApexMocks())); + fflib_ISObjectUnitOfWork uow = UnitOfWork.newInstance( + new List{ Account.SObjectType }, + customDML + ); + uow.registerNew(myAccount); + uow.commitWork(); + + // Then the Custom DML should not be used by the unit of Work + System.assert(!customDML.isInsertCalled, 'Oops, custom DML was called'); + } + + public class CustomDML implements fflib_SObjectUnitOfWork.IDML + { + public boolean isInsertCalled = false; + public boolean isUpdateCalled = false; + public boolean isDeleteCalled = false; + public boolean isPublishCalled = false; + public Boolean isEmptyRecycleBinCalled = false; + + public void dmlInsert(List objList){ + this.isInsertCalled = true; + } + public void dmlUpdate(List objList){ + this.isUpdateCalled = true; + } + public void dmlDelete(List objList){ + this.isDeleteCalled = true; + } + public void eventPublish(List objList) + { + this.isPublishCalled = true; + } + public void emptyRecycleBin(List objList) + { + this.isEmptyRecycleBinCalled = true; + } + } + + // Configure and create the ServiceFactory for this Application + public static final fflib_Application.ServiceFactory Service = + new fflib_Application.ServiceFactory( + new Map { + IRegisteredServiceWithStandardName1.class => RegisteredServiceWithStandardName1Impl.class, + IRegisteredServiceWithStandardName2.class => RegisteredServiceWithStandardName2Impl.class, + IRegisteredServiceWithNonStandardImplName.class => RegisteredServiceWithNonStandardNameImplName.class, + RegisteredServiceWithNonStandardName.class => RegisteredServiceWithNonStandardNameImpl.class, + IRegisteredServiceUnconstructable.class => RegisteredServiceUnconstructableImpl.class + }); + + // Configure and create the UnitOfWorkFactory for this Application + public static final fflib_Application.UnitOfWorkFactory UnitOfWork = + new fflib_Application.UnitOfWorkFactory( + new List { + Account.SObjectType, + Opportunity.SObjectType, + OpportunityLineItem.SObjectType }); + + // Configure and create the SelectorFactory for this Application + public static final fflib_Application.SelectorFactory Selector = + new fflib_Application.SelectorFactory( + new Map { + Account.SObjectType => AccountsSelector.class, + Opportunity.SObjectType => OpportuntiesSelector.class }); + + // Configure and create the DomainFactory for this Application + public static final fflib_Application.DomainFactory Domain = + new fflib_Application.DomainFactory( + fflib_ApplicationTest.Selector, + new Map { + Account.SObjectType => AccountsConstructor.class, + Opportunity.SObjectType => OpportuntiesConstructor.class, + Contact.SObjectType => ContactsConstructor.class }); + + public class AccountsDomain extends fflib_SObjectDomain + { + public AccountsDomain(List sObjectList) + { + super(sObjectList); + } + + public AccountsDomain(List sObjectList, SObjectType sObjectType) + { + super(sObjectList, sObjectType); + } + } + + public class AccountsConstructor implements fflib_SObjectDomain.IConstructable2 + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new AccountsDomain(sObjectList); + } + + public fflib_SObjectDomain construct(List sObjectList, SObjectType sObjectType) + { + return new AccountsDomain(sObjectList, sObjectType); + } + } + + public class OpportuntiesDomain extends fflib_SObjectDomain + { + public OpportuntiesDomain(List sObjectList) + { + super(sObjectList); + } + + public OpportuntiesDomain(List sObjectList, SObjectType sObjectType) + { + super(sObjectList, sObjectType); + } + } + + public class OpportuntiesConstructor implements fflib_SObjectDomain.IConstructable2 + { + public fflib_SObjectDomain construct(List sObjectList) + { + return new OpportuntiesDomain(sObjectList); + } + + public fflib_SObjectDomain construct(List sObjectList, SObjectType sObjectType) + { + return new OpportuntiesDomain(sObjectList, sObjectType); + } + } + + public class ContactsDomain extends fflib_SObjectDomain + { + public ContactsDomain(List sObjectList) + { + super(sObjectList); + } + + public ContactsDomain(List sObjectList, SObjectType sObjectType) + { + super(sObjectList, sObjectType); + } + } + + // Intentionally does not support IConstructable or IConstructable2 interfaces in order to support testing + public class ContactsConstructor + { + + } + + class OpportuntiesSelector extends fflib_SObjectSelector + { + public List getSObjectFieldList() + { + return new List { + Opportunity.Name, + Opportunity.Id + }; + } + + public Schema.SObjectType getSObjectType() + { + return Opportunity.sObjectType; + } + } + + class AccountsSelector extends fflib_SObjectSelector + { + public List getSObjectFieldList() + { + return new List { + Account.Name, + Account.Id, + Account.AccountNumber, + Account.AnnualRevenue + }; + } + + public Schema.SObjectType getSObjectType() + { + return Account.sObjectType; + } + } + + public interface IUnregisteredServiceWithStandardName {} + public class UnregisteredServiceWithStandardNameImpl implements IUnregisteredServiceWithStandardName {} + + public interface IUnregisteredServiceWithNoImplementation { } + + public interface UnregisteredServiceWithNonStandardName { } + + public interface IUnregisteredServiceWithNonStandardImpl { } + public class UnregisteredServiceWithNonStandardImplName implements IUnregisteredServiceWithNonStandardImpl {} + + public interface IRegisteredServiceWithStandardName1 {} + public class RegisteredServiceWithStandardName1Impl implements IRegisteredServiceWithStandardName1 {} + public class ServiceMock implements IRegisteredServiceWithStandardName1 {} + + public interface IRegisteredServiceWithStandardName2 {} + public class RegisteredServiceWithStandardName2Impl implements IRegisteredServiceWithStandardName2 {} + + public interface IRegisteredServiceWithNonStandardImplName {} + public class RegisteredServiceWithNonStandardNameImplName implements IRegisteredServiceWithNonStandardImplName {} + + public interface RegisteredServiceWithNonStandardName {} + public class RegisteredServiceWithNonStandardNameImpl implements RegisteredServiceWithNonStandardName {} + + public interface IRegisteredServiceUnconstructable {} + public class RegisteredServiceUnconstructableImpl implements IRegisteredServiceUnconstructable { + public RegisteredServiceUnconstructableImpl( String parameter ) {} + } + + public interface IUnregisteredServiceUnconstructable {} + public class UnregisteredServiceUnconstructableImpl implements IUnregisteredServiceUnconstructable { + public UnregisteredServiceUnconstructableImpl( String parameter ) {} + } + +} diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls-meta.xml new file mode 100644 index 00000000000..bd8e9205ef8 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ApplicationTest.cls-meta.xml @@ -0,0 +1,4 @@ + + + 54.0 + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls new file mode 100644 index 00000000000..91454ef0c36 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls @@ -0,0 +1,198 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private class fflib_ObjectsTest +{ + private static final fflib_ObjectsTest.DataTransferObject TRANSFER_OBJECT_A = new DataTransferObject('Test A'); + private static final fflib_ObjectsTest.DataTransferObject TRANSFER_OBJECT_B = new DataTransferObject('Test B'); + private static final fflib_ObjectsTest.DataTransferObject TRANSFER_OBJECT_C = new DataTransferObject('Test C'); + private static final fflib_ObjectsTest.DataTransferObject TRANSFER_OBJECT_D = new DataTransferObject('Test D'); + + @IsTest + static void itShouldReturnTheCorrectType() + { + fflib_ObjectsTest.Domain dtoDomain = new Domain( + new List + { + TRANSFER_OBJECT_A, + TRANSFER_OBJECT_B + } + ); + + System.assertEquals( + DataTransferObject.class, + dtoDomain.getType(), + 'Wrong domain type' + ); + } + + @IsTest + static void itShouldContainTheObject() + { + fflib_ObjectsTest.DataTransferObject transferObject = TRANSFER_OBJECT_A; + fflib_ObjectsTest.Domain dtoDomain = new Domain( + new List + { + transferObject, + TRANSFER_OBJECT_B + } + ); + + System.assert( + dtoDomain.contains(transferObject), + 'The object should have been part of the domain' + ); + } + + @IsTest + static void itShouldNotContainTheObject() + { + fflib_ObjectsTest.Domain dtoDomain = generateDomain(); + + System.assert( + dtoDomain.containsNot(TRANSFER_OBJECT_D), + 'The object should not have been part of the domain' + ); + } + + @IsTest + static void itShouldNotContainTheObjects() + { + fflib_ObjectsTest.Domain dtoDomain = new Domain( + new List + { + TRANSFER_OBJECT_A, + TRANSFER_OBJECT_B + }); + + System.assert( + dtoDomain.containsNot( + new Set + { + TRANSFER_OBJECT_C, + TRANSFER_OBJECT_D + }), + 'The set of objects should not have been part of the domain' + ); + System.assert( + dtoDomain.containsNot( + new List + { + TRANSFER_OBJECT_C, + TRANSFER_OBJECT_D + }), + 'The list of objects should not have been part of the domain' + ); + } + + @IsTest + static void itShouldHaveAnEmptyDomain() + { + fflib_ObjectsTest.Domain dtoDomain = new Domain(new List()); + System.assert(dtoDomain.isEmpty(), 'Domain should be empty'); + } + + @IsTest + static void itShouldNotBeAnEmptyDomain() + { + fflib_ObjectsTest.Domain dtoDomain = generateDomain(); + System.assert(dtoDomain.isNotEmpty(), 'Domain should not be empty'); + System.assertEquals(3, dtoDomain.size(), 'Incorrect amount of records in the domain'); + } + + @IsTest + static void itShouldContainAllTheObjects() + { + fflib_ObjectsTest.Domain dtoDomain = generateDomain(); + + System.assert( + dtoDomain.containsAll( + new List + { + TRANSFER_OBJECT_A, + TRANSFER_OBJECT_B + } + ), + 'Domain should contain the whole List of objects' + ); + System.assert( + dtoDomain.containsAll( + new Set + { + TRANSFER_OBJECT_A, + TRANSFER_OBJECT_B + } + ), + 'Domain should contain the whole Set of objects' + ); + } + + @IsTest + static void itShouldGetTheObjectType() + { + fflib_Objects domain = new fflib_Objects(new List()); + System.assertEquals(Object.class, domain.getType(), 'Incorrect returned type'); + } + + private static Domain generateDomain() + { + return new Domain(generateDataTransferObjects()); + } + + private static List generateDataTransferObjects() + { + return new List + { + TRANSFER_OBJECT_A, + TRANSFER_OBJECT_B, + TRANSFER_OBJECT_C + }; + } + + private class Domain extends fflib_Objects + { + public Domain(List objects) + { + super(objects); + } + + public override Object getType() + { + return DataTransferObject.class; + } + } + + private class DataTransferObject + { + public String MyProperty { get; set; } + + public DataTransferObject(String property) + { + this.MyProperty = property; + } + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_ObjectsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls new file mode 100644 index 00000000000..42ac0b62507 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls @@ -0,0 +1,826 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@isTest( isParallel = true ) +private class fflib_QueryFactoryTest { + + @isTest + static void fieldSelections(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('firstName'); + qf.selectField(Schema.Contact.SObjectType.fields.lastName); + qf.selectFields( new Set{'acCounTId', 'account.name'} ); + qf.selectFields( new List{'homePhonE','fAX'} ); + qf.selectFields( new List{ Contact.Email, Contact.Title } ); + System.assertEquals(new Set{ + 'FirstName', + 'LastName', + 'AccountId', + 'Account.Name', + 'HomePhone', + 'Fax', + 'Email', + 'Title'}, + qf.getSelectedFields()); + } + + @isTest + static void simpleFieldSelection() { + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('NAMe').selectFields( new Set{'naMe', 'email'}); + String query = qf.toSOQL(); + System.assert( Pattern.matches('SELECT.*Name.*FROM.*',query), 'Expected Name field in query, got '+query); + System.assert( Pattern.matches('SELECT.*Email.*FROM.*',query), 'Expected Name field in query, got '+query); + qf.setLimit(100); + System.assertEquals(100,qf.getLimit()); + System.assert( qf.toSOQL().endsWithIgnoreCase('LIMIT '+qf.getLimit()), 'Failed to respect limit clause:'+qf.toSOQL() ); + } + + @isTest + static void simpleFieldCondition(){ + String whereClause = 'name = \'test\''; + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('email'); + qf.setCondition( whereClause ); + System.assertEquals(whereClause,qf.getCondition()); + String query = qf.toSOQL(); + System.assert(query.endsWith('WHERE name = \'test\''),'Query should have ended with a filter on name, got: '+query); + } + + @isTest + static void iSearchCriteriaCondition(){ + + String whereClause = 'name = \'test\''; + + Amoss_Instance searchCriteriaController = new Amoss_Instance( ISearchCriteria.class ); + searchCriteriaController + .when( 'toSoql' ) + .returns( whereClause ); + + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.setCondition( (ISearchCriteria)searchCriteriaController.getDouble() ); + + System.assertEquals( whereClause, qf.getCondition(), 'setCondition, when given an ISearchCriteria, will set the condition based on the criteria' ); + + String query = qf.toSOQL(); + System.assert( query.endsWith('WHERE name = \'test\''),'Query should have ended with a filter on name, got: '+query); + } + + @isTest + static void iSearchCriteriaCondition_notEvaluatedOnSet(){ + + Amoss_Instance searchCriteriaController = new Amoss_Instance( ISearchCriteria.class ); + searchCriteriaController + .expectsNoCalls(); + + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.setCondition( (ISearchCriteria)searchCriteriaController.getDouble() ); + + searchCriteriaController.verify(); + } + + @isTest + static void iSearchCriteriaCondition_isEvaluatedOnGet(){ + + String whereClause = 'name = \'test\''; + + Amoss_Instance searchCriteriaController = new Amoss_Instance( ISearchCriteria.class ); + searchCriteriaController + .when( 'toSoql' ) + .returns( whereClause ); + + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.setCondition( (ISearchCriteria)searchCriteriaController.getDouble() ); + + qf.getCondition(); + + searchCriteriaController.verify(); + } + + @isTest + static void conditionSetWithStringThenCriteria_usesTheCriteria(){ + + String stringWhereClause = 'name = \'string\''; + String criteriaWhereClause = 'name = \'criteria\''; + + Amoss_Instance searchCriteriaController = new Amoss_Instance( ISearchCriteria.class ); + searchCriteriaController + .when( 'toSoql' ) + .returns( criteriaWhereClause ); + + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.setCondition( stringWhereClause ); + qf.setCondition( (ISearchCriteria)searchCriteriaController.getDouble() ); + + System.assertEquals( criteriaWhereClause, qf.getCondition(), 'setCondition, when given a string then a ISearchCriteria, will set the condition based on the last thing - the criteria' ); + } + + @isTest + static void conditionSetWithCriteriaThenString_usesTheString(){ + + String stringWhereClause = 'name = \'string\''; + String criteriaWhereClause = 'name = \'criteria\''; + + Amoss_Instance searchCriteriaController = new Amoss_Instance( ISearchCriteria.class ); + searchCriteriaController + .when( 'toSoql' ) + .returns( criteriaWhereClause ); + + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.setCondition( (ISearchCriteria)searchCriteriaController.getDouble() ); + qf.setCondition( stringWhereClause ); + + System.assertEquals( stringWhereClause, qf.getCondition(), 'setCondition, when given a ISearchCriteria then string, will set the condition based on the last thing - the string' ); + } + + @isTest + static void duplicateFieldSelection() { + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('NAMe').selectFields( new Set{'naMe', 'email'}); + String query = qf.toSOQL(); + System.assertEquals(1, query.countMatches('Name'), 'Expected one name field in query: '+query ); + } + + @isTest + static void equalityCheck(){ + fflib_QueryFactory qf1 = new fflib_QueryFactory(Contact.SObjectType); + fflib_QueryFactory qf2 = new fflib_QueryFactory(Contact.SObjectType); + System.assertEquals(qf1,qf2); + qf1.selectField('name'); + System.assertNotEquals(qf1,qf2); + qf2.selectField('NAmE'); + System.assertEquals(qf1,qf2); + qf1.selectField('name').selectFields( new Set{ 'NAME', 'name' }).selectFields( new Set{ Contact.Name, Contact.Name} ); + System.assertEquals(qf1,qf2); + } + + @isTest + static void nonReferenceField(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + fflib_QueryFactory.NonReferenceFieldException e; + try{ + qf.selectField('name.title'); + }catch(fflib_QueryFactory.NonReferenceFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e,'Cross-object notation on a non-reference field should throw NonReferenceFieldException.'); + } + + @isTest + static void invalidCrossObjectField(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + fflib_QueryFactory.InvalidFieldException e; + try{ + qf.selectField('account.NOT_A_REAL_FIELD'); + }catch(fflib_QueryFactory.InvalidFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e,'Cross-object notation on a non-reference field should throw NonReferenceFieldException.'); + } + + @isTest + static void invalidFieldTests(){ + List exceptions = new List(); + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + try{ + qf.selectField('Not_a_field'); + }catch(fflib_QueryFactory.InvalidFieldException e){ + exceptions.add(e); + } + try{ + qf.selectFields( new Set{ 'Not_a_field','alsoNotreal'}); + }catch(fflib_QueryFactory.InvalidFieldException e){ + exceptions.add(e); + } + try{ + qf.selectFields( new Set{ null }); + }catch(fflib_QueryFactory.InvalidFieldException e){ + exceptions.add(e); + } + try{ + qf.selectFields( new List{ null, Contact.title }); + }catch(fflib_QueryFactory.InvalidFieldException e){ + exceptions.add(e); + } + System.assertEquals(4,exceptions.size()); + } + + @isTest + static void ordering(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('email'); + qf.setCondition( 'name = \'test\'' ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING) ); + String query = qf.toSOQL(); + + System.assertEquals(2,qf.getOrderings().size()); + System.assertEquals('Name',qf.getOrderings()[0].getField() ); + System.assertEquals(fflib_QueryFactory.SortOrder.DESCENDING,qf.getOrderings()[1].getDirection() ); + + + System.assert( Pattern.matches('SELECT.*Name.*FROM.*',query), 'Expected Name field in query, got '+query); + System.assert( Pattern.matches('SELECT.*Email.*FROM.*',query), 'Expected Name field in query, got '+query); + } + + @isTest + static void setOrdering_ReplacesPreviousOrderingsWithExpectedOrdering(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('email'); + qf.setCondition( 'name = \'test\'' ); + + //test base method with ordeting by OwnerId Descending + qf.setOrdering( new fflib_QueryFactory.Ordering('Contact','OwnerId',fflib_QueryFactory.SortOrder.DESCENDING) ); + + System.assertEquals(1, qf.getOrderings().size(), 'Unexpected order size - setOrder should replace default Orderings'); + System.assertEquals(Contact.OwnerId.getDescribe().getName(), qf.getOrderings()[0].getField(), 'Unexpected order field - should have been resolved from the field OwnerId'); + System.assertEquals(fflib_QueryFactory.SortOrder.DESCENDING, qf.getOrderings()[0].getDirection(), 'Unexpected order direction.'); + + //test method overload with ordering by LastModifiedDate Ascending + qf.setOrdering('LastModifiedDate', fflib_QueryFactory.SortOrder.ASCENDING, true); + + System.assertEquals(1, qf.getOrderings().size(), 'Unexpected order size - setOrder should replace previous Orderings'); + System.assertEquals(Contact.LastModifiedDate.getDescribe().getName(), qf.getOrderings()[0].getField(), 'Unexpected order field - should have been resolved from the field LastModifiedDate'); + System.assertEquals(fflib_QueryFactory.SortOrder.ASCENDING, qf.getOrderings()[0].getDirection(), 'Unexpected order direction.'); + + //test method overload with ordering by CreatedDate Descending + qf.setOrdering(Contact.CreatedDate, fflib_QueryFactory.SortOrder.DESCENDING, true); + + System.assertEquals(1, qf.getOrderings().size(), 'Unexpected order size - setOrder should replace previous Orderings'); + System.assertEquals(Contact.CreatedDate.getDescribe().getName(), qf.getOrderings()[0].getField(), 'Unexpected order field - should have been resolved from the field CreatedDate'); + System.assertEquals(fflib_QueryFactory.SortOrder.DESCENDING, qf.getOrderings()[0].getDirection(), 'Unexpected order direction.'); + + //test method overload with ordering by CreatedBy.Name Descending + qf.setOrdering('CreatedBy.Name', fflib_QueryFactory.SortOrder.DESCENDING); + + System.assertEquals(1, qf.getOrderings().size(), 'Unexpected order size - setOrder should replace previous Orderings'); + System.assertEquals(fflib_QueryFactory.SortOrder.DESCENDING, qf.getOrderings()[0].getDirection(), 'Unexpected order direction.'); + + //test method overload with ordering by Birthdate Ascending + qf.setOrdering(Contact.Birthdate, fflib_QueryFactory.SortOrder.ASCENDING); + + System.assertEquals(1, qf.getOrderings().size(), 'Unexpected order size - setOrder should replace previous Orderings'); + System.assertEquals(Contact.Birthdate.getDescribe().getName(), qf.getOrderings()[0].getField(), 'Unexpected order field - should have been resolved from the field Birthdate'); + System.assertEquals(fflib_QueryFactory.SortOrder.ASCENDING, qf.getOrderings()[0].getDirection(), 'Unexpected order direction.'); + + String query = qf.toSOQL(); + } + + @isTest + static void invalidField_string(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + Exception e; + try{ + qf.selectField('not_a__field'); + }catch(fflib_QueryFactory.InvalidFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidFields_string(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + Exception e; + try{ + qf.selectFields( new List{'not_a__field'} ); + }catch(fflib_QueryFactory.InvalidFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidField_nullToken(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + Exception e; + Schema.SObjectField token = null; + try{ + qf.selectField( token ); + }catch(fflib_QueryFactory.InvalidFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidFields_nullToken(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + Exception e; + List token = new List{ + null + }; + try{ + qf.selectFields( token ); + }catch(fflib_QueryFactory.InvalidFieldException ex){ + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidFields_noQueryField(){ + try { + String path = fflib_QueryFactory.getFieldTokenPath(null); + System.assert(false,'Expected InvalidFieldException; none was thrown'); + } + catch (fflib_QueryFactory.InvalidFieldException ife) { + //Expected + } + catch (Exception e){ + System.assert(false,'Expected InvalidFieldException; ' + e.getTypeName() + ' was thrown instead: ' + e); + } + } + + @isTest + static void queryFieldsNotEquals(){ + String qfld = fflib_QueryFactory.getFieldTokenPath(Contact.Name); + String qfld2 = fflib_QueryFactory.getFieldTokenPath(Contact.LastName); + System.assert(!qfld.equals(qfld2)); + } + + @isTest + static void addChildQueriesWithChildRelationship_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition('name=\'test\'').addOrdering('CreatedDate', fflib_QueryFactory.SortOrder.DESCENDING, true); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + //explicitly assert object accessibility when creating the subselect + qf.subselectQuery('Tasks', true).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert( + Pattern.matches('SELECT.*(SELECT.*FROM Tasks WHERE.*).*FROM Contact WHERE.*', qf.toSOQL()), + 'Incorrect returned query' + ); + } + + @isTest + static void addChildQueriesWithChildRelationshipNoAccessibleCheck_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition('name=\'test\'').addOrdering('CreatedDate', fflib_QueryFactory.SortOrder.DESCENDING, true); + //explicitly assert object accessibility when creating the subselect + qf.subselectQuery('Tasks').selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert( + Pattern.matches('SELECT.*(SELECT.*FROM Tasks WHERE.*).*FROM Contact WHERE.*', qf.toSOQL()), + 'Incorrect returned query' + ); + } + + @isTest + static void addChildQueriesWithChildRelationshipObjCheckIsAccessible_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition('name=\'test\'').addOrdering('CreatedDate', fflib_QueryFactory.SortOrder.DESCENDING, true); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + Schema.ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()){ + //occasionally on some standard objects (Like Contact child of Contact) do not have a relationship name. + //if there is no relationship name, we cannot query on it, so throw an exception. + if (childRow.getRelationshipName() == 'Tasks'){ + relationship = childRow; + } + } + //explicitly assert object accessibility when creating the subselect + qf.subselectQuery(relationship, true).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert( + Pattern.matches('SELECT.*(SELECT.*FROM Tasks WHERE.*).*FROM Contact WHERE.*', qf.toSOQL()), + 'Incorrect returned query' + ); + } + + @isTest + static void addChildQueriesWithChildRelationshipObj_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition( 'name=\'%test%\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING, true); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + Schema.ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()){ + //occasionally on some standard objects (Like Contact child of Contact) do not have a relationship name. + //if there is no relationship name, we cannot query on it, so throw an exception. + if (childRow.getRelationshipName() == 'Tasks'){ + relationship = childRow; + } + } + //explicitly assert object accessibility when creating the subselect + qf.subselectQuery(relationship).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert( + Pattern.matches('SELECT.*(SELECT.*FROM Tasks WHERE.*).*FROM Contact WHERE.*', qf.toSOQL()), + 'Incorrect returned query' + ); + } + + @isTest + static void addChildQueriesWithChildRelationshipNoAccessibleCheck_fail(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition( 'name=\'test\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING, true); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + //explicitly assert object accessibility when creating the subselect + // + Exception e; + try { + qf.subselectQuery('Tas').selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + } catch (fflib_QueryFactory.InvalidSubqueryRelationshipException ex) { + e = ex; + } + System.assertNotEquals(e, null); + } + + @isTest + static void addChildQueries_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name').selectField('Id').setCondition( 'name like \'%test%\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING, true); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + //explicitly assert object accessibility when creating the subselect + qf.subselectQuery(Task.SObjectType, true).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert( + Pattern.matches('SELECT.*(SELECT.*FROM Tasks WHERE.*).*FROM Contact WHERE.*', qf.toSOQL()), + 'Incorrect returned query' + ); + } + + @isTest + static void addChildQuerySameRelationshipAgain_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('Id'); + qf.setCondition( 'name like \'%test%\'' ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering('CreatedBy.Name',fflib_QueryFactory.SortOrder.DESCENDING); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + Schema.ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { + if (childRow.getRelationshipName() == 'Tasks') { + relationship = childRow; + } + } + System.assert(qf.getSubselectQueries() == null); + fflib_QueryFactory childQf = qf.subselectQuery(Task.SObjectType); + childQf.assertIsAccessible(); + childQf.setEnforceFLS(true); + childQf.selectField('Id'); + fflib_QueryFactory childQf2 = qf.subselectQuery(Task.SObjectType); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert(queries.size() == 1); + } + + @isTest + static void addChildQueries_invalidChildRelationship(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('email'); + qf.setCondition( 'name like \'%test%\'' ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering( 'CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); + Schema.DescribeSObjectResult descResult = Account.SObjectType.getDescribe(); + Exception e; + try { + SObjectType invalidType = null; + fflib_QueryFactory childQf = qf.subselectQuery(invalidType); + childQf.selectField('Id'); + } catch (fflib_QueryFactory.InvalidSubqueryRelationshipException ex) { + e = ex; + } + System.assertNotEquals(e, null); + } + + @isTest + static void addChildQueries_invalidChildRelationshipTooDeep(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.selectField('name'); + qf.selectField('email'); + qf.setCondition( 'name like \'%test%\'' ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering('CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + + fflib_QueryFactory childQf = qf.subselectQuery(Task.SObjectType); + childQf.selectField('Id'); + childQf.selectField('Subject'); + Exception e; + try { + fflib_QueryFactory subChildQf = childQf.subselectQuery(Task.SObjectType); + } catch (fflib_QueryFactory.InvalidSubqueryRelationshipException ex) { + e = ex; + } + System.assertNotEquals(e, null); + } + + @isTest + static void checkFieldObjectReadSort_success(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.assertIsAccessible() + .setEnforceFLS(true) + .selectField('createdby.name') + .selectField(Contact.LastModifiedById) + .selectFields(new List{Contact.LastModifiedDate}) + .setEnforceFLS(false) + .selectField(Contact.LastName) + .selectFields(new List{Contact.Id}) + .setCondition( 'name like \'%test%\'' ) + .setEnforceFLS(true) + .selectFields(new Set{Contact.FirstName}) + .addOrdering(new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ) + .addOrdering(Contact.LastModifiedDate,fflib_QueryFactory.SortOrder.DESCENDING) + .addOrdering(Contact.CreatedDate,fflib_QueryFactory.SortOrder.DESCENDING, true); + Set fields = qf.getSelectedFields(); + fflib_QueryFactory.Ordering ordering = new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING); + System.assertEquals('Name',ordering.getField()); + + System.assertEquals(new Set{ + 'CreatedBy.Name', + 'LastModifiedById', + 'LastModifiedDate', + 'LastName', + 'Id', + 'FirstName'}, + fields); + + System.assert(qf.toSOQL().containsIgnoreCase('NULLS LAST')); + } + + @isTest + static void checkObjectRead_fail(){ + User usr = createTestUser_noAccess(); + if (usr != null){ + System.runAs(usr){ + //create a query factory object for Account. + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + Boolean excThrown = false; + try { + //check to see if this record is accessible, it isn't. + qf.assertIsAccessible(); + } catch (fflib_SecurityUtils.CrudException e) { + excThrown = true; + } + System.assert(excThrown); + } + } + } + + @isTest + static void checkFieldRead_fail(){ + User usr = createTestUser_noAccess(); + if (usr != null){ + System.runAs(usr){ + //create a query factory object for Account. + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + Boolean excThrown = false; + try { + //set field to enforce FLS, then try to add a field. + qf.setEnforceFLS(true); + qf.selectField('Name'); + } catch (fflib_SecurityUtils.FlsException e) { + excThrown = true; + } + System.assert(excThrown); + } + } + } + + @isTest + static void queryWith_noFields(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.assertIsAccessible().setEnforceFLS(true).setCondition( 'name like \'%test%\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); + String query = qf.toSOQL(); + System.assert(query.containsIgnoreCase('SELECT Id FROM Contact'),'Expected \'SELECT Id FROM Contact\' in the SOQL, found: ' + query); + } + + @isTest + static void deterministic_toSOQL(){ + fflib_QueryFactory qf1 = new fflib_QueryFactory(User.SObjectType); + fflib_QueryFactory qf2 = new fflib_QueryFactory(User.SObjectType); + for(fflib_QueryFactory qf:new Set{qf1,qf2}){ + qf.selectFields(new List{ + 'Id', + 'FirstName', + 'LastName', + 'CreatedBy.Name', + 'CreatedBy.Manager', + 'LastModifiedBy.Email' + }); + } + String expectedQuery = + 'SELECT CreatedBy.ManagerId, CreatedBy.Name, ' + +'FirstName, Id, LastModifiedBy.Email, LastName ' + +'FROM User'; + System.assertEquals(qf1.toSOQL(), qf2.toSOQL()); + System.assertEquals(expectedQuery, qf1.toSOQL()); + System.assertEquals(expectedQuery, qf2.toSOQL()); + } + + @isTest + static void deepCloneBasicNoChanges() { + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType) + .setLimit(10) + .setCondition('id=12345') + .selectField('Description') + .addOrdering(new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ) + .addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING)) + .setEnforceFLS(true); + + fflib_QueryFactory qf2 = qf.deepClone(); + + System.assertEquals(qf2, qf); + + System.assertEquals(qf.getLimit(), qf2.getLimit()); + System.assertEquals(qf.getCondition(), qf2.getCondition()); + System.assertEquals(qf.toSOQL(), qf2.toSOQL()); + System.assertEquals(qf.getOrderings(), qf2.getOrderings()); + } + + @isTest + static void deepCloneSubqueryNoChanges() { + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType) + .setLimit(10) + .setCondition('id=12345') + .selectField('Description') + .addOrdering(new fflib_QueryFactory.Ordering('Account','Name',fflib_QueryFactory.SortOrder.ASCENDING) ) + .addOrdering( new fflib_QueryFactory.Ordering('Account','Description',fflib_QueryFactory.SortOrder.DESCENDING)) + .setEnforceFLS(true); + + qf.subselectQuery('Contacts', true); + + fflib_QueryFactory qf2 = qf.deepClone(); + + System.assertEquals(qf, qf2); + + System.assertEquals(qf.getLimit(), qf2.getLimit()); + System.assertEquals(qf.getCondition(), qf2.getCondition()); + System.assertEquals(qf.toSOQL(), qf2.toSOQL()); + System.assertEquals(qf.getOrderings(), qf2.getOrderings()); + System.assertEquals(qf.getSubselectQueries(), qf2.getSubselectQueries()); + } + + @isTest + static void deepCloneBasic() { + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType) + .setLimit(10) + .setCondition('id=12345') + .selectField('Description') + .addOrdering(new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ) + .addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING)) + .setEnforceFLS(true); + + + fflib_QueryFactory qf2 = qf.deepClone() + .setLimit(200) + .setCondition('id=54321') + .selectField('Fax') + .addOrdering( new fflib_QueryFactory.Ordering('Contact','Fax',fflib_QueryFactory.SortOrder.ASCENDING)) + .setEnforceFLS(false); + + qf2.getOrderings().remove(0); + + System.assertEquals(10, qf.getLimit()); + System.assertEquals(200, qf2.getLimit()); + + System.assertEquals('id=12345', qf.getCondition()); + System.assertEquals('id=54321', qf2.getCondition()); + + String query = qf.toSOQL(); + String query2 = qf2.toSOQL(); + + System.assert(query.containsIgnoreCase('Fax') == false); + System.assert(query.containsIgnoreCase('Description')); + System.assert(query2.containsIgnoreCase('Description')); + System.assert(query2.containsIgnoreCase('Fax')); + + System.assertEquals(2, qf.getOrderings().size()); + System.assertEquals('Name', qf.getOrderings()[0].getField() ); + System.assertEquals(fflib_QueryFactory.SortOrder.DESCENDING, qf.getOrderings()[1].getDirection()); + + System.assertEquals(2, qf2.getOrderings().size()); + System.assertEquals('Fax', qf2.getOrderings()[1].getField()); + System.assertEquals(fflib_QueryFactory.SortOrder.ASCENDING, qf2.getOrderings()[1].getDirection()); + + } + + @isTest + static void deepCloneSubquery() { + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + qf.subselectQuery('Contacts', true); + + fflib_QueryFactory qf2 = qf.deepClone(); + qf2.subselectQuery('Opportunities', true); + + List subqueries = qf.getSubselectQueries(); + List subqueries2 = qf2.getSubselectQueries(); + + fflib_QueryFactory subquery2_0 = subqueries2.get(0); + + subquery2_0.addOrdering(new fflib_QueryFactory.Ordering('Contact','Name',fflib_QueryFactory.SortOrder.ASCENDING)); + + System.assert(subqueries.size() == 1); + System.assert(subqueries2.size() == 2); + + System.assert(qf.getSubselectQueries().get(0).getOrderings().size() == 0); + System.assert(qf2.getSubselectQueries().get(0).getOrderings().size() == 1); + } + + @isTest + static void testSoql_unsortedSelectFields(){ + //Given + fflib_QueryFactory qf = new fflib_QueryFactory(User.SObjectType); + qf.selectFields(new List{ + 'Id', + 'FirstName', + 'LastName', + 'CreatedBy.Name', + 'CreatedBy.Manager', + 'LastModifiedBy.Email' + }); + + qf.setSortSelectFields(false); + + String orderedQuery = + 'SELECT ' + +'FirstName, Id, LastName, ' //less joins come first, alphabetically + +'CreatedBy.ManagerId, CreatedBy.Name, LastModifiedBy.Email ' //alphabetical on the same number of joins' + +'FROM User'; + + //When + String actualSoql = qf.toSOQL(); + + //Then + System.assertNotEquals(orderedQuery, actualSoql); + } + + + public static User createTestUser_noAccess(){ + User usr; + try { + //look for a profile that does not have access to the Account object + PermissionSet ps = + [SELECT Profile.Id, profile.name + FROM PermissionSet + WHERE IsOwnedByProfile = true + AND Profile.UserType = 'Standard' + AND Id NOT IN (SELECT ParentId + FROM ObjectPermissions + WHERE SObjectType = 'Account' + AND PermissionsRead = true) + LIMIT 1]; + + if (ps != null){ + //create a user with the profile found that doesn't have access to the Account object + usr = new User( + firstName = 'testUsrF', + LastName = 'testUsrL', + Alias = 'tstUsr', + Email = 'testy.test@test.com', + UserName='test'+ Math.random().format()+'user99@test.com', + EmailEncodingKey = 'ISO-8859-1', + LanguageLocaleKey = 'en_US', + TimeZoneSidKey = 'America/Los_Angeles', + LocaleSidKey = 'en_US', + ProfileId = ps.Profile.Id, + IsActive=true + ); + insert usr; + } + } catch (Exception e) { + //do nothing, just return null User because this test case won't work in this org. + return null; + } + return usr; + } +} diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_QueryFactoryTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls new file mode 100644 index 00000000000..1333a3207e0 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls @@ -0,0 +1,183 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/** + This class adapted from https://github.com/capeterson/Apex-Util + Used under a BSD license: https://github.com/capeterson/Apex-Util/blob/master/LICENSE +**/ +@isTest +private class fflib_SObjectDescribeTest { + + /** + * Verify that the different ways of getting your hands on an fflib_SObjectDescribe instance all handle null inputs + * (and blank/empty strings, why not?) by returning null - since there's no possible way to resolve it. + **/ + @isTest + static void getDescribe_badInput(){ + String nullStr = null; //having it as a string var makes for unambiguous selection of overloads + Schema.SObjectType nullType = null; + Schema.DescribeSObjectResult nullDescribe = null; + SObject nullSObject = null; + System.assertEquals(null, fflib_SObjectDescribe.getDescribe(nullStr)); + System.assertEquals(null, fflib_SObjectDescribe.getDescribe('')); + System.assertEquals(null, fflib_SObjectDescribe.getDescribe(' ')); + System.assertEquals(null, fflib_SObjectDescribe.getDescribe(nullType)); + System.assertEquals(null, fflib_SObjectDescribe.getDescribe(nullDescribe)); + System.assertEquals(null, fflib_SObjectDescribe.getDescribe(nullSObject)); + } + + @isTest + static void NamespacedAttributeMap_implementations(){ + fflib_SObjectDescribe.GlobalDescribeMap gdm = fflib_SObjectDescribe.getGlobalDescribe(); + Schema.SObjectType accountObjType = gdm.get('AccOunT'); + System.assertEquals(accountObjType, Account.SobjectType); + System.assertEquals(Schema.getGlobalDescribe().size(), gdm.size()); + + fflib_SObjectDescribe acccountDescribe = fflib_SObjectDescribe.getDescribe(accountObjType); + fflib_SObjectDescribe.FieldsMap fields = acccountDescribe.getFields(); + System.assert( fields.keySet().containsAll(acccountDescribe.getFieldsMap().keySet()) ); + + System.assertEquals(fields.get('name'), Account.SObjectType.fields.name); //behavior of FieldsMap is tested in another method + System.assertEquals(Schema.SObjectType.Account.fields.getMap().size(), fields.size()); + + System.assertEquals(null, fields.get(null), 'Null input should result in null ouput.'); + System.assertEquals(null, fields.get(''), 'Invalid fieldname input should result in null ouput.'); + } + + @isTest + static void FieldsMap(){ + String fakeNamespace = 'fflib_test'; + Map fakeFieldData = new Map{ + 'name__c' => Contact.SObjectType.fields.name, //re-use standard field types since we can't mock them + fakeNamespace+'__name__c' => Account.SObjectType.fields.name, + fakeNamespace+'__otherField__c' => Account.SObjectType.fields.name, + 'createddate' => Contact.SObjectType.fields.CreatedDate + }; + fflib_SObjectDescribe.FieldsMap fields = new fflib_SObjectDescribe.FieldsMap(fakeFieldData); + fields.currentNamespace = fakeNamespace; + System.assertEquals(true, fields.containsKey('name__c') ); + System.assertEquals(true, fields.containsKey(fakeNamespace.toUpperCase()+'__nAMe__c') ); + System.assert(fields.get('NAme__c') === fields.get(fakeNamespace+'__namE__c')); + + System.assert(!fields.keySet(false).contains('otherField__c')); + System.assert(fields.keySet(false).contains(fakeNamespace+'__otherField__c')); + + System.assert(fields.keySet(true).contains('otherField__c')); + System.assert(!fields.keySet(true).contains(fakeNamespace+'__otherField__c')); + + fields.currentNamespace = 'someOtherNamespace'; + System.assertNotEquals(fields.get('name__C'), fields.get(fakeNamespace.capitalize()+'__nAme__c')); + } + + @isTest + static void GlobalDescribeMap(){ + String fakeNamespace = 'fflib_test'; + Map fakeFieldData = new Map{ + 'name__c' => Contact.SObjectType, //re-use stndard object types since we can't mock them + fakeNamespace+'__name__c' => Account.SObjectType, + 'createddate' => Lead.SObjectType + }; + fflib_SObjectDescribe.GlobalDescribeMap gdm = new fflib_SObjectDescribe.GlobalDescribeMap(fakeFieldData); + gdm.currentNamespace = fakeNamespace; + System.assertEquals(true, gdm.containsKey('name__c') ); + System.assertEquals(true, gdm.containsKey(fakeNamespace+'__name__c') ); + System.assert(gdm.get('name__c') === gdm.get(fakeNamespace+'__name__c')); + + gdm.currentNamespace = 'someOtherNamespace'; + System.assertNotEquals(gdm.get('name__c'), gdm.get(fakeNamespace+'__name__c')); + } + + @isTest //Tests all forms of the getDescribe static + static void getAccountDescribes(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe('Account'); + fflib_SObjectDescribe d2 = fflib_SObjectDescribe.getDescribe(Account.SObjectType); + fflib_SObjectDescribe d3 = fflib_SObjectDescribe.getDescribe(Schema.SObjectType.Account); + System.assertEquals('Account', d.getDescribe().getName()); + System.assert( (d === d2 && d2 === d3) ,'All three getDescribe calls should return the same cached instance.'); + } + + @isTest + static void simpleAccountFieldDescribe(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe(Account.SObjectType); + Map fields; + for(integer i = 0; i < 10; i++){ + fields = d.getFieldsMap(); + } + + // Describe Limits removed since Summer ’14. + // https://developer.salesforce.com/releases/release/Summer14/New+Apex+Enhancements + + // Because describe limits are no longer enforced in any API version, this method is no longer available. + // In API version 30.0 and earlier, this method is available but is deprecated. + + // System.assertEquals(1, Limits.getFieldsDescribes() ); + + System.assertEquals(false,fields.isEmpty()); + System.assertEquals(Account.SObjectType, d.getSObjectType()); + } + + @isTest + static void simpleAccountFieldSetDescribe(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe(Account.SObjectType); + Map fields; + for(integer i = 0; i < 10; i++){ + fields = d.getFieldSetsMap(); + } + + // Describe Limits removed since Summer ’14. + // https://developer.salesforce.com/releases/release/Summer14/New+Apex+Enhancements + + // Because describe limits are no longer enforced in any API version, this method is no longer available. + // In API version 30.0 and earlier, this method is available but is deprecated. + + // System.assertEquals(1, Limits.getFieldSetsDescribes() ); + + // no asserts on result size to avoid a requirement on field sets existing + } + + @isTest + static void simpleAccountGetNameField(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe(Account.SObjectType); + Schema.SObjectField nameField = d.getNameField(); + System.assertEquals('Name', nameField.getDescribe().getName()); + } + + @isTest + static void flushCache(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe('Account'); + fflib_SObjectDescribe.flushCache(); + fflib_SObjectDescribe d2 = fflib_SObjectDescribe.getDescribe('Account'); + System.assert(d !== d2, 'Second object should be a fresh instance after a cache flush.' ); + } + + @isTest + static void rawGlobalDescribeCheck(){ + Map systemGd = Schema.getGlobalDescribe(); + Map cachedGd = fflib_SObjectDescribe.getRawGlobalDescribe(); + System.assertEquals(systemGd.size(),cachedGd.size()); + } + +} diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDescribeTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls new file mode 100644 index 00000000000..96555833d3c --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls @@ -0,0 +1,504 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private with sharing class fflib_SObjectDomainTest +{ + @IsTest + private static void testValidationWithoutDML() + { + fflib_SObjectDomain.TestSObjectDomain opps = new fflib_SObjectDomain.TestSObjectDomain(new Opportunity[] { new Opportunity ( Name = 'Test', Type = 'Existing Account' ) } ); + opps.onValidate(); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('You must provide an Account for Opportunities for existing Customers.', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals(Opportunity.AccountId, ((fflib_SObjectDomain.FieldError)fflib_SObjectDomain.Errors.getAll()[0]).field); + + opps = new fflib_SObjectDomain.TestSObjectDomain(new SObject[] { new Opportunity ( Name = 'Test', Type = 'Existing Account' ) }, Opportunity.SObjectType ); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('You must provide an Account for Opportunities for existing Customers.', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals(Opportunity.AccountId, ((fflib_SObjectDomain.FieldError)fflib_SObjectDomain.Errors.getAll()[0]).field); + } + + @IsTest + private static void testInsertValidationFailedWithoutDML() + { + Opportunity opp = new Opportunity ( Name = 'Test', Type = 'Existing Account' ); + System.assertEquals(false, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { opp } ); + System.assertEquals(true, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('You must provide an Account for Opportunities for existing Customers.', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals(Opportunity.AccountId, ((fflib_SObjectDomain.FieldError)fflib_SObjectDomain.Errors.getAll()[0]).field); + } + + @IsTest + private static void testUpdateValidationFailedWithoutDML() + { + Opportunity oldOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + oldOpp.Name = 'Test'; + oldOpp.Type = 'Existing Account'; + Opportunity newOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + newOpp.Name = 'Test'; + newOpp.Type = 'New Account'; + System.assertEquals(false, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + System.assertEquals(true, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('You cannot change the Opportunity type once it has been created.', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals(Opportunity.Type, ((fflib_SObjectDomain.FieldError)fflib_SObjectDomain.Errors.getAll()[0]).field); + } + + @IsTest + private static void testOnBeforeDeleteWithoutDML() + { + Opportunity opp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + opp.Name = 'Test'; + opp.Type = 'Existing Account'; + System.assertEquals(false, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.Test.Database.onDelete(new Map { opp.Id => opp } ); + System.assertEquals(true, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('You cannot delete this Opportunity.', fflib_SObjectDomain.Errors.getAll()[0].message); + } + + @IsTest + private static void testOnAfterUndeleteWithoutDML() + { + Opportunity opp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + opp.Name = 'Test'; + opp.Type = 'Existing Account'; + System.assertEquals(false, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.Test.Database.onUndelete(new list { opp } ); + System.assertEquals(true, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + } + + @IsTest + private static void testObjectSecurity() + { + // Create a user which will not have access to the test object type + User testUser = createChatterExternalUser(); + if(testUser==null) + return; // Abort the test if unable to create a user with low enough acess + System.runAs(testUser) + { + // Test Create object security + Opportunity opp = new Opportunity ( Name = 'Test', Type = 'Existing Account' ); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { opp } ); + try { + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assert(false, 'Expected access denied exception'); + } catch (Exception e) { + System.assertEquals('Permission to create an Opportunity denied.', e.getMessage()); + } + + // Test Update object security + Opportunity existingOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + existingOpp.Name = 'Test'; + existingOpp.Type = 'Existing Account'; + fflib_SObjectDomain.Test.Database.onUpdate(new List { opp }, new Map { opp.Id => opp } ); + try { + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assert(false, 'Expected access denied exception'); + } catch (Exception e) { + System.assertEquals('Permission to update an Opportunity denied.', e.getMessage()); + } + + // Test Delete object security + fflib_SObjectDomain.Test.Database.onDelete(new Map { opp.Id => opp }); + try { + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDomainConstructor.class); + System.assert(false, 'Expected access denied exception'); + } catch (Exception e) { + System.assertEquals('Permission to delete an Opportunity denied.', e.getMessage()); + } + } + } + + @IsTest + private static void testErrorLogging() + { + // Test static helpers for raise none domain object instance errors + Opportunity opp = new Opportunity ( Name = 'Test', Type = 'Existing Account' ); + fflib_SObjectDomain.Errors.error('Error', opp); + fflib_SObjectDomain.Errors.error('Error', opp, Opportunity.Type); + System.assertEquals(2, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('Error', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals('Error', fflib_SObjectDomain.Errors.getAll()[1].message); + System.assertEquals(Opportunity.Type, ((fflib_SObjectDomain.FieldError)fflib_SObjectDomain.Errors.getAll()[1]).field); + fflib_SObjectDomain.Errors.clearAll(); + System.assertEquals(0, fflib_SObjectDomain.Errors.getAll().size()); + } + + @IsTest + private static void testTriggerState() + { + Opportunity opp = new Opportunity ( Name = 'Test', Type = 'Existing Account' ); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { opp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectStatefulDomainConstructor.class); + System.assertEquals(1, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('Error on Record Test', fflib_SObjectDomain.Errors.getAll()[0].message); + } + + @IsTest + private static void testRecursiveTriggerState() + { + Opportunity opp = new Opportunity ( Name = 'Test Recursive 1', Type = 'Existing Account' ); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { opp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectStatefulDomainConstructor.class); + System.assertEquals(2, fflib_SObjectDomain.Errors.getAll().size()); + System.assertEquals('Error on Record Test Recursive 2', fflib_SObjectDomain.Errors.getAll()[0].message); + System.assertEquals('Error on Record Test Recursive 1', fflib_SObjectDomain.Errors.getAll()[1].message); + } + + @IsTest + private static void testOnValidateBehaviorDefault() + { + Opportunity oldOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + oldOpp.Name = 'Test Default Behaviour'; + oldOpp.Type = 'Existing Account'; + Opportunity newOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + newOpp.Name = 'Test Default Behaviour'; + newOpp.Type = 'New Account'; + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectOnValidateBehaviourConstructor.class); + } + + @IsTest + private static void testOnValidateBehaviorOld() + { + Opportunity oldOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + oldOpp.Name = 'Test Enable Old Behaviour'; + oldOpp.Type = 'Existing Account'; + Opportunity newOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + newOpp.Name = 'Test Enable Old Behaviour'; + newOpp.Type = 'New Account'; + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + try { + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectOnValidateBehaviourConstructor.class); + System.assert(false, 'Expected exception'); + } catch (Exception e) { + System.assertEquals('onValidate called', e.getMessage()); + } + } + + @IsTest + private static void testGetChangedFieldsAsStrings() + { + Account acct1 = new Account ( Id = '001E0000006mkRP', Name = 'Test1', AccountNumber = '12345', Site = 'Here'), + acct2 = new Account ( Id = '001E0000006mkRQ', Name = 'Test2', AccountNumber = '54321', Site = 'There'); + System.assertEquals(false, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.Test.Database.onInsert(new Account[] { acct1, acct2 } ); + System.assertEquals(true, fflib_SObjectDomain.Test.Database.hasRecords()); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectChangedRecordsConstructor.class); + Account acct1changed = acct1.clone(), + acct2changed = acct2.clone(); + acct1changed.Name = 'Test1changed'; + fflib_SObjectDomain.Test.Database.onUpdate( + new Account[]{ acct1, acct2}, + new Map{ + acct1changed.Id => acct1, + acct2changed.Id => acct2 + } + ); + } + + + @IsTest + private static void itShouldReturnTheChangedRecordsBySObjectFields() + { + // GIVEN a domain with old and changed records + + Id idLuke = fflib_IDGenerator.generate(Account.SObjectType); + Id idHan = fflib_IDGenerator.generate(Account.SObjectType); + Id idLeia = fflib_IDGenerator.generate(Account.SObjectType); + List oldRecords = new List + { + new Account(Id = idLuke, Name = 'Luke', Description = 'Jedi'), + new Account(Id = idHan, Name = 'Han', Description = 'Pilot'), + new Account(Id = idLeia, Name = 'Leia') + }; + + List newRecords = oldRecords.deepClone(true, true, true); + newRecords.get(0).Name = 'Luke SkyWalker'; + newRecords.get(0).Description = 'Jedi Master'; + newRecords.get(1).Name = 'Han Solo'; + Accounts accounts = new Accounts(newRecords); + accounts.ExistingRecords = new Map(oldRecords); + + // WHEN we create a domain with ExistingRecords and request the changed records + List result = accounts.getChangedRecords( + new Set + { + Account.Name, + Account.Description + } + ); + + // THEN it should only return the changed records + Map resultMap = new Map(result); + System.assertEquals(2, result.size()); + System.assert(resultMap.containsKey(idLuke)); + System.assert(resultMap.containsKey(idHan)); + } + + @IsTest + private static void itShouldReturnTheChangedRecordsByStringFields() + { + // GIVEN a domain with old and changed records + + Id idLuke = fflib_IDGenerator.generate(Account.SObjectType); + Id idHan = fflib_IDGenerator.generate(Account.SObjectType); + Id idLeia = fflib_IDGenerator.generate(Account.SObjectType); + List oldRecords = new List + { + new Account(Id = idLuke, Name = 'Luke', Description = 'Jedi'), + new Account(Id = idHan, Name = 'Han', Description = 'Pilot'), + new Account(Id = idLeia, Name = 'Leia') + }; + + List newRecords = oldRecords.deepClone(true, true, true); + newRecords.get(0).Name = 'Luke SkyWalker'; + newRecords.get(0).Description = 'Jedi Master'; + newRecords.get(1).Name = 'Han Solo'; + Accounts accounts = new Accounts(newRecords); + fflib_SObjectDomain.Test.Database.onUpdate(newRecords, new Map(oldRecords)); + + // WHEN we create a domain with ExistingRecords and request the changed records + List result = accounts.getChangedRecords( + new Set + { + 'Name', + 'Description' + } + ); + + // THEN it should only return the changed records + Map resultMap = new Map(result); + System.assertEquals(2, result.size()); + System.assert(resultMap.containsKey(idLuke)); + System.assert(resultMap.containsKey(idHan)); + } + + + /** + * Create test user + **/ + private static User createChatterExternalUser() + { + // Can only proceed with test if we have a suitable profile - Chatter External license has no access to Opportunity + List testProfiles = [Select Id From Profile where UserLicense.Name='Chatter External' limit 1]; + if(testProfiles.size()!=1) + return null; + + // Can only proceed with test if we can successfully insert a test user + String testUsername = System.now().format('yyyyMMddhhmmss') + '@testorg.com'; + User testUser = new User(Alias = 'test1', Email='testuser1@testorg.com', EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US', LocaleSidKey='en_US', ProfileId = testProfiles[0].Id, TimeZoneSidKey='America/Los_Angeles', UserName=testUsername); + try { + insert testUser; + } catch (Exception e) { + return null; + } + return testUser; + } + + /** + * The following tests that the ability to enable/disable all trigger events works as required + **/ + @IsTest + private static void testDisableTriggerEventsBehaviour() + { + boolean bError = false; + + String sErrorMessage = ''; + + Opportunity oldOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + oldOpp.Name = 'Test'; + oldOpp.Type = 'Existing'; + Opportunity newOpp = (Opportunity) Opportunity.sObjectType.newSObject('006E0000006mkRQ'); + newOpp.Name = 'Test'; + newOpp.Type = 'New'; + + + + // these will be called multiple times making sure the correct error message comes back out + // so... there are alot of tests to do here sadly and remember everything is reversed and you need to run backwards! + // 1 - all disabled + try + { + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).disableAll(); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { newOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + bError = true; + } + + System.AssertEquals(false, bError, 'Error - Trigger events have been fired when they are disabled'); + + //////////////////////////// + // Insert! + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableAfterInsert(); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { newOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + System.Debug('Exception Fired :' + e.getMEssage()); + } + + System.AssertEquals('onAfterInsert called', sErrorMessage, 'Error - After Insert Event is enabled but did not run'); + + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableBeforeInsert(); + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { newOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onBeforeInsert called', sErrorMessage, 'Error - Before Insert Event is enabled but did not run'); + + //////////////////////////// + // Update! + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableAfterUpdate(); + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onAfterUpdate called', sErrorMessage, 'Error - After Update Event is enabled but did not run'); + + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableBeforeUpdate(); + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onBeforeUpdate called', sErrorMessage, 'Error - Before Update Event is enabled but did not run'); + + //////////////////////////// + // Delete! + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableAfterDelete(); + fflib_SObjectDomain.Test.Database.onDelete(new Map { newOpp.Id => newOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onAfterDelete called', sErrorMessage, 'Error - After Delete Event is enabled but did not run'); + + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableBeforeDelete(); + fflib_SObjectDomain.Test.Database.onDelete(new Map { newOpp.Id => newOpp } ); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onBeforeDelete called', sErrorMessage, 'Error - Before Delete Event is enabled but did not run'); + + //////////////////////////// + // Undelete! + try + { + // now lets go after insert and then before + fflib_SObjectDomain.getTriggerEvent(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class).enableAfterUndelete(); + fflib_SObjectDomain.Test.Database.onUndelete(new Opportunity[] { newOpp }); + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectDisableBehaviourConstructor.class); + } + catch (Exception e) + { + sErrorMessage = e.getMessage(); + } + + System.AssertEquals('onAfterUndelete called', sErrorMessage, 'Error - After Undelete Event is enabled but did not run'); + + /* + + fflib_SObjectDomain.Test.Database.onInsert(new Opportunity[] { opp } ); + + + + fflib_SObjectDomain.Test.Database.onUpdate(new Opportunity[] { newOpp }, new Map { newOpp.Id => oldOpp } ); + + fflib_SObjectDomain.Test.Database.onDelete(new Map { opp.Id => opp } ); + + fflib_SObjectDomain.Test.Database.onUndelete(new list { opp } ); + + + try { + fflib_SObjectDomain.triggerHandler(fflib_SObjectDomain.TestSObjectOnValidateBehaviourConstructor.class); + System.assert(false, 'Expected exception'); + } catch (Exception e) { + System.assertEquals('onValidate called', e.getMessage()); + } + */ + } + + + + private class Accounts extends fflib_SObjectDomain + { + public Accounts(List records) + { + super(records); + } + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectDomainTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls new file mode 100644 index 00000000000..c982d9144d4 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls @@ -0,0 +1,558 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private with sharing class fflib_SObjectSelectorTest +{ + + static testMethod void testGetSObjectName() + { + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + system.assertEquals(null, selector.getSObjectFieldSetList()); + system.assertEquals('Account',selector.getSObjectName()); + } + + static testMethod void testSelectSObjectsById() + { + // Inserting in reverse order so that we can test the order by of select + List accountList = new List { + new Account(Name='TestAccount2',AccountNumber='A2',AnnualRevenue=12345.67), + new Account(Name='TestAccount1',AccountNumber='A1',AnnualRevenue=76543.21) }; + insert accountList; + Set idSet = new Set(); + for(Account item : accountList) + idSet.add(item.Id); + + Test.startTest(); + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + List result = (List) selector.selectSObjectsById(idSet); + Test.stopTest(); + + system.assertEquals(2,result.size()); + system.assertEquals('TestAccount2',result[0].Name); + system.assertEquals('A2',result[0].AccountNumber); + system.assertEquals(12345.67,result[0].AnnualRevenue); + system.assertEquals('TestAccount1',result[1].Name); + system.assertEquals('A1',result[1].AccountNumber); + system.assertEquals(76543.21,result[1].AnnualRevenue); + } + + static testMethod void testQueryLocatorById() + { + // Inserting in reverse order so that we can test the order by of select + List accountList = new List { + new Account(Name='TestAccount2',AccountNumber='A2',AnnualRevenue=12345.67), + new Account(Name='TestAccount1',AccountNumber='A1',AnnualRevenue=76543.21) }; + insert accountList; + Set idSet = new Set(); + for(Account item : accountList) + idSet.add(item.Id); + + Test.startTest(); + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + Database.QueryLocator result = selector.queryLocatorById(idSet); + System.Iterator iteratorResult = result.iterator(); + Test.stopTest(); + + System.assert(true, iteratorResult.hasNext()); + Account account = (Account) iteratorResult.next(); + system.assertEquals('TestAccount2',account.Name); + system.assertEquals('A2',account.AccountNumber); + system.assertEquals(12345.67,account.AnnualRevenue); + System.assert(true, iteratorResult.hasNext()); + account = (Account) iteratorResult.next(); + system.assertEquals('TestAccount1',account.Name); + system.assertEquals('A1',account.AccountNumber); + system.assertEquals(76543.21,account.AnnualRevenue); + System.assertEquals(false, iteratorResult.hasNext()); + } + + static testMethod void testAssertIsAccessible() + { + List accountList = new List { + new Account(Name='TestAccount2',AccountNumber='A2',AnnualRevenue=12345.67), + new Account(Name='TestAccount1',AccountNumber='A1',AnnualRevenue=76543.21) }; + insert accountList; + Set idSet = new Set(); + for(Account item : accountList) + idSet.add(item.Id); + + // Create a user which will not have access to the test object type + User testUser = createChatterExternalUser(); + if(testUser==null) + return; // Abort the test if unable to create a user with low enough acess + System.runAs(testUser) + { + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + try + { + List result = (List) selector.selectSObjectsById(idSet); + System.assert(false,'Expected exception was not thrown'); + } + catch(fflib_SObjectDomain.DomainException e) + { + System.assertEquals('Permission to access an Account denied.',e.getMessage()); + } + } + } + + static testMethod void testCRUDOff() + { + List accountList = new List { + new Account(Name='TestAccount2',AccountNumber='A2',AnnualRevenue=12345.67), + new Account(Name='TestAccount1',AccountNumber='A1',AnnualRevenue=76543.21) }; + insert accountList; + Set idSet = new Set(); + for(Account item : accountList) + idSet.add(item.Id); + + // Create a user which will not have access to the test object type + User testUser = createChatterExternalUser(); + if(testUser==null) + return; // Abort the test if unable to create a user with low enough acess + System.runAs(testUser) + { + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(false, false, false, true); + try + { + List result = (List) selector.selectSObjectsById(idSet); + } + catch(fflib_SObjectDomain.DomainException e) + { + System.assert(false,'Did not expect an exception to be thrown'); + } + } + } + + static testMethod void testSOQL() + { + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + String soql = selector.newQueryFactory().toSOQL(); + Pattern p = Pattern.compile('SELECT (.*) FROM Account ORDER BY Name DESC NULLS FIRST , AnnualRevenue ASC NULLS LAST '); + Matcher m = p.matcher(soql); + System.assert(m.matches(), 'Generated SOQL does not match expected pattern. Here is the generated SOQL: ' + soql); + System.assertEquals(1, m.groupCount(), 'Unexpected number of groups captured.'); + String fieldListString = m.group(1); + assertFieldListString(fieldListString, null); + } + + static testMethod void testSOQL_defaultSorting() + { + Testfflib_SObjectSelectorDefaultSorting selector = new Testfflib_SObjectSelectorDefaultSorting(false); + String soql = selector.newQueryFactory().toSOQL(); + Pattern p = Pattern.compile('SELECT (.*) FROM Account ORDER BY Name ASC NULLS FIRST '); + Matcher m = p.matcher(soql); + System.assert(m.matches(), 'Generated SOQL does not match expected pattern. Here is the generated SOQL: ' + soql); + System.assertEquals(1, m.groupCount(), 'Unexpected number of groups captured.'); + String fieldListString = m.group(1); + assertFieldListString(fieldListString, null); + } + + static testMethod void testDefaultConfig() + { + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); + System.assertEquals(false, selector.isEnforcingFLS()); + System.assertEquals(true, selector.isEnforcingCRUD()); + System.assertEquals(false, selector.isIncludeFieldSetFields()); + + System.assertEquals('Account', selector.getSObjectName()); + System.assertEquals(Account.SObjectType, selector.getSObjectType2()); + } + + private static void assertFieldListString(String fieldListString, String prefix) { + String prefixString = (!String.isBlank(prefix)) ? prefix + '.' : ''; + List fieldList = fieldListString.split(',{1}\\s?'); + System.assertEquals(UserInfo.isMultiCurrencyOrganization() ? 5 : 4, fieldList.size()); + Set fieldSet = new Set(); + fieldSet.addAll(fieldList); + String expected = prefixString + 'AccountNumber'; + System.assert(fieldSet.contains(expected), expected + ' missing from field list string: ' + fieldListString); + expected = prefixString + 'AnnualRevenue'; + System.assert(fieldSet.contains(expected), expected + ' missing from field list string: ' + fieldListString); + expected = prefixString + 'Id'; + System.assert(fieldSet.contains(expected), expected + ' missing from field list string: ' + fieldListString); + expected = prefixString + 'Name'; + System.assert(fieldSet.contains(expected), expected + ' missing from field list string: ' + fieldListString); + if (UserInfo.isMultiCurrencyOrganization()) { + expected = prefixString + 'CurrencyIsoCode'; + System.assert(fieldSet.contains(expected), expected + ' missing from field list string: ' + fieldListString); + } + } + + + @isTest + static void testWithoutSorting() + { + //Given + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(false, false, false, false); + fflib_QueryFactory qf = selector.newQueryFactory(); + + Set expectedSelectFields = new Set{ 'Name', 'Id', 'AccountNumber', 'AnnualRevenue' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('CurrencyIsoCode'); + } + + //When + String soql = qf.toSOQL(); + + //Then + Pattern soqlPattern = Pattern.compile('SELECT (.*) FROM Account ORDER BY Name DESC NULLS FIRST , AnnualRevenue ASC NULLS LAST '); + Matcher soqlMatcher = soqlPattern.matcher(soql); + soqlMatcher.matches(); + + List actualSelectFields = soqlMatcher.group(1).deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + // Test case of ordering with NULLS LAST option passed into the ordering method + @isTest + static void testWithOrderingNullsLast() + { + // Build the selector to test with + Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(false, false, false, false); + fflib_QueryFactory qf = selector.newQueryFactory(); + + // Add in the expected fields + Set expectedSelectFields = new Set{ 'Name', 'Id', 'AccountNumber', 'AnnualRevenue' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('CurrencyIsoCode'); + } + + // Generate the SOQL string + String soql = qf.toSOQL(); + + // Assert that the + Pattern soqlPattern = Pattern.compile('SELECT (.*) FROM Account ORDER BY Name DESC NULLS FIRST , AnnualRevenue ASC NULLS LAST '); + Matcher soqlMatcher = soqlPattern.matcher(soql); + system.assert(soqlMatcher.matches(), 'The SOQL should have that expected.'); + } + + @IsTest + static void testConfigureQueryFactoryFields() { + //Given + Testfflib_UserSObjectSelector selector = new Testfflib_UserSObjectSelector(); + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + + Set expectedSelectFields = new Set{ 'Owner.Name', 'Owner.Id', 'Owner.Username', 'Owner.LastLoginDate' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('Owner.CurrencyIsoCode'); + } + + //When + selector.configureQueryFactoryFields(qf, 'Owner'); + + //Then + String soql = qf.toSOQL(); + Pattern soqlPattern = Pattern.compile('SELECT (.*) FROM Account'); + Matcher soqlMatcher = soqlPattern.matcher(soql); + System.assert(soqlMatcher.matches(), 'Generated SOQL does not match expected pattern. Here is the generated SOQL: ' + soql); + + List actualSelectFields = soqlMatcher.group(1).deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + @IsTest + static void testAddQueryFactorySubselect() { + //Given + Testfflib_UserSObjectSelector selector = new Testfflib_UserSObjectSelector(); + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + + Set expectedSelectFields = new Set{ 'Name', 'Id', 'Username', 'LastLoginDate' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('CurrencyIsoCode'); + } + + //When + selector.addQueryFactorySubselect(qf); + + //Then + String soql = qf.toSOQL(); + Pattern soqlPattern = Pattern.compile('SELECT Id, \\(SELECT (.*) FROM Users ORDER BY Name ASC NULLS FIRST \\) +FROM Account'); + Matcher soqlMatcher = soqlPattern.matcher(soql); + System.assert(soqlMatcher.matches(), 'Generated SOQL does not match expected pattern. Here is the generated SOQL: ' + soql); + + List actualSelectFields = soqlMatcher.group(1).deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + @IsTest + static void testAddQueryFactorySubselect2() { + //Given + Testfflib_UserSObjectSelector selector = new Testfflib_UserSObjectSelector(); + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + + Set expectedSelectFields = new Set{ 'Name', 'Id', 'Username', 'LastLoginDate' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('CurrencyIsoCode'); + } + + //When + selector.addQueryFactorySubselect(qf, 'Users'); + + //Then + String soql = qf.toSOQL(); + Pattern soqlPattern = Pattern.compile('SELECT Id, \\(SELECT (.*) FROM Users ORDER BY Name ASC NULLS FIRST \\) +FROM Account'); + Matcher soqlMatcher = soqlPattern.matcher(soql); + System.assert(soqlMatcher.matches(), 'Generated SOQL does not match expected pattern. Here is the generated SOQL: ' + soql); + + List actualSelectFields = soqlMatcher.group(1).deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + @IsTest + static void testGetFieldListString() { + //Given + Testfflib_UserSObjectSelector selector = new Testfflib_UserSObjectSelector(); + fflib_QueryFactory qf = new fflib_QueryFactory(Account.SObjectType); + + Set expectedSelectFields = new Set{ 'Name', 'Id', 'Username', 'LastLoginDate' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('CurrencyIsoCode'); + } + + //When + String fieldListString = selector.getFieldListString(); + + //Then + List actualSelectFields = fieldListString.deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + @IsTest + static void testGetRelatedFieldListString() { + //Given + Testfflib_UserSObjectSelector selector = new Testfflib_UserSObjectSelector(); + + Set expectedSelectFields = new Set{ 'Owner.Name', 'Owner.Id', 'Owner.Username', 'Owner.LastLoginDate' }; + if (UserInfo.isMultiCurrencyOrganization()) + { + expectedSelectFields.add('Owner.CurrencyIsoCode'); + } + + //When + String fieldListString = selector.getRelatedFieldListString('Owner'); + + //Then + List actualSelectFields = fieldListString.deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + + } + + private static void assertEqualsSelectFields(String expectedSelectFields, String actualSelectFields) + { + Set expected = new Set(expectedSelectFields.deleteWhiteSpace().split(',')); + Set actual = new Set(actualSelectFields.deleteWhiteSpace().split(',')); + + System.assertEquals(expected, actual); + } + + private class Testfflib_SObjectSelector extends fflib_SObjectSelector + { + public Testfflib_SObjectSelector() + { + super(); + } + + public Testfflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS, Boolean sortSelectFields) + { + super(includeFieldSetFields, enforceCRUD, enforceFLS, sortSelectFields); + } + + public List getSObjectFieldList() + { + return new List { + Account.Name, + Account.Id, + Account.AccountNumber, + Account.AnnualRevenue + }; + } + + public Schema.SObjectType getSObjectType() + { + return Account.sObjectType; + } + + public override String getOrderBy() + { + return 'Name DESC, AnnualRevenue ASC NULLS LAST'; + } + } + + private class Testfflib_UserSObjectSelector extends fflib_SObjectSelector + { + public Testfflib_UserSObjectSelector() + { + super(); + } + + public List getSObjectFieldList() + { + return new List { + User.Name, + User.Id, + User.Username, + User.LastLoginDate + }; + } + + public Schema.SObjectType getSObjectType() + { + return User.SObjectType; + } + + } + + private class Testfflib_SObjectSelectorDefaultSorting extends fflib_SObjectSelector + { + public Testfflib_SObjectSelectorDefaultSorting(Boolean includeFieldSetFields) + { + super(includeFieldSetFields); + } + + public List getSObjectFieldList() + { + return new List { + Account.Name, + Account.Id, + Account.AccountNumber, + Account.AnnualRevenue + }; + } + + public Schema.SObjectType getSObjectType() + { + return Account.sObjectType; + } + } + + /** + * Create test user + **/ + private static User createChatterExternalUser() + { + // Can only proceed with test if we have a suitable profile - Chatter External license has no access to Opportunity + List testProfiles = [Select Id From Profile where UserLicense.Name='Chatter External' limit 1]; + if(testProfiles.size()!=1) + return null; + + // Can only proceed with test if we can successfully insert a test user + String testUsername = System.now().format('yyyyMMddhhmmss') + '@testorg.com'; + User testUser = new User(Alias = 'test1', Email='testuser1@testorg.com', EmailEncodingKey='UTF-8', LastName='Testing', LanguageLocaleKey='en_US', LocaleSidKey='en_US', ProfileId = testProfiles[0].Id, TimeZoneSidKey='America/Los_Angeles', UserName=testUsername); + try { + insert testUser; + } catch (Exception e) { + return null; + } + return testUser; + } + + @isTest + static void testPolymorphicSelectWithRelatedType() { + //Given + + Testfflib_CampaignMemberSelector cmSelector = new Testfflib_CampaignMemberSelector(); + fflib_QueryFactory qf = cmSelector.newQueryFactory(); + new Testfflib_LeadSelector().configureQueryFactoryFields(qf, 'Lead'); + new Testfflib_UserSelector().configureQueryFactoryFields(qf, 'Lead.Owner'); + + + Set expectedSelectFields = new Set{ + 'Id', 'Status', 'Lead.Id', 'Lead.OwnerId', 'Lead.Owner.Id', 'Lead.Owner.UserRoleId' + }; + if (UserInfo.isMultiCurrencyOrganization()) { + expectedSelectFields.add('CurrencyIsoCode'); + } + + //When + String soql = qf.toSOQL(); + + //Then + Pattern soqlPattern = Pattern.compile('SELECT (.*) FROM CampaignMember ORDER BY CreatedDate ASC NULLS FIRST '); + Matcher soqlMatcher = soqlPattern.matcher(soql); + soqlMatcher.matches(); + + List actualSelectFields = soqlMatcher.group(1).deleteWhiteSpace().split(','); + System.assertEquals(expectedSelectFields, new Set(actualSelectFields)); + } + + private class Testfflib_CampaignMemberSelector extends fflib_SObjectSelector { + public Testfflib_CampaignMemberSelector() { + super(); + } + + public List getSObjectFieldList() { + return new List{ + CampaignMember.Id, + CampaignMember.Status + }; + } + + public Schema.SObjectType getSObjectType() { + return CampaignMember.sObjectType; + } + } + + private class Testfflib_UserSelector extends fflib_SObjectSelector { + public Testfflib_UserSelector() { + super(); + } + + public List getSObjectFieldList() { + return new List{ + User.UserRoleId, + User.Id + }; + } + + public Schema.SObjectType getSObjectType() { + return User.sObjectType; + } + } + + private class Testfflib_LeadSelector extends fflib_SObjectSelector { + public Testfflib_LeadSelector() { + super(); + } + + public List getSObjectFieldList() { + return new List{ + Lead.OwnerId, + Lead.Id + }; + } + + public Schema.SObjectType getSObjectType() { + return Lead.sObjectType; + } + } +} diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectSelectorTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls new file mode 100644 index 00000000000..cf3edd08ba6 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls @@ -0,0 +1,870 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest(IsParallel=true) +private with sharing class fflib_SObjectUnitOfWorkTest +{ + // SObjects (in order of dependency) used by UnitOfWork in tests bellow + private static List MY_SOBJECTS = + new Schema.SObjectType[] { + Product2.SObjectType, + PricebookEntry.SObjectType, + Opportunity.SObjectType, + OpportunityLineItem.SObjectType }; + + @IsTest + private static void testUnitOfWorkEmail() + { + String testRecordName = 'UoW Test Name 1'; + + Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage(); + email.setToAddresses(new List{ 'foobar@test.com' }); + email.setPlainTextBody('See Spot run.'); + + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + + uow.m_emailWork = new Mock_SendEmailWork(); + + Opportunity opp = new Opportunity(); + opp.Name = testRecordName; + opp.StageName = 'Open'; + opp.CloseDate = System.today(); + uow.registerNew( opp ); + + uow.registerEmail( email ); + + uow.registerRelationship( email, opp ); + + uow.commitWork(); + + // assert that mock email functionality was called + System.assert(((Mock_SendEmailWork) uow.m_emailWork).doWorkWasCalled); + + System.assertEquals(1, mockDML.recordsForInsert.size()); + } + + @IsTest + private static void testRegisterNew_ThrowExceptionOnDirtyRecord() + { + // GIVEN an existing record + Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + + // WHEN we register the existing record as new + Boolean exceptionThrown = false; + try + { + unitOfWork.registerNew(opportunity); + } + catch (Exception e) + { + exceptionThrown = true; + System.assertEquals( + 'Only new records can be registered as new', + e.getMessage(), + 'Incorrect exception message thrown' + ); + } + + // THEN it should have thrown an exception + System.assert(exceptionThrown); + } + + @IsTest + private static void testRegisterDirty_ThrowExceptionOnNewRecord() + { + // GIVEN an new record + Opportunity opportunity = new Opportunity(); + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + + // WHEN we register the existing record as new + Boolean exceptionThrown = false; + try + { + unitOfWork.registerDirty(opportunity); + } + catch (Exception e) + { + exceptionThrown = true; + System.assertEquals( + 'New records cannot be registered as dirty', + e.getMessage(), + 'Incorrect exception message thrown' + ); + } + + // THEN it should have thrown an exception + System.assert(exceptionThrown); + } + + @IsTest + private static void testRegisterDeleted() + { + // GIVEN - two existing records + Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); + Product2 product = new Product2(Id = fflib_IDGenerator.generate(Schema.Product2.SObjectType)); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + + // WHEN - we mark the records as deleted + unitOfWork.registerDeleted(new List { opportunity, product }); + unitOfWork.commitWork(); + + // THEN - the dmlDelete action should be invoked + System.assertEquals(new List { opportunity, product }, mockDML.recordsForDelete); + } + + @IsTest + private static void testRegisterPermanentlyDeleted() + { + // GIVEN - two existing records + Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); + Product2 product = new Product2(Id = fflib_IDGenerator.generate(Schema.Product2.SObjectType)); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + + // WHEN - we mark the records as deleted + unitOfWork.registerPermanentlyDeleted(new List { opportunity, product }); + unitOfWork.commitWork(); + + // THEN - the dmlDelete and emptyRecycleBin actions should be invoked + System.assertEquals(new List { opportunity, product }, mockDML.recordsForDelete); + System.assertEquals(new List { opportunity, product }, mockDML.recordsForRecycleBin); + } + + @IsTest + private static void testRegisterEmptyRecycleBin() + { + // GIVEN - an existing record of the recycle bin + Opportunity opportunity = new Opportunity(Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType)); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + + // WHEN - we empty the record from the recycle bin + unitOfWork.registerEmptyRecycleBin(new List{ opportunity }); + unitOfWork.commitWork(); + + // THEN - the emptyRecycleBin action should be invoked + System.assertEquals(1, mockDML.recordsForRecycleBin.size()); + } + + @IsTest + private static void testAssertForNonEventSObjectType() + { + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + unitOfWork.assertForNonEventSObjectType('CustomObject__c'); + } + + @IsTest + private static void testAssertForNonEventSObjectType_ThrowExceptionOnEventObject() + { + Boolean exceptionThrown = false; + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + try + { + unitOfWork.assertForNonEventSObjectType('PlatformEventObject__e'); + } + catch (Exception e) + { + exceptionThrown = true; + System.assert( + e.getMessage().contains('registerPublishBeforeTransaction'), + 'Incorrect exception message thrown' + ); + } + + System.assert(exceptionThrown); + } + + @IsTest + private static void testAssertForEventSObjectType() + { + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + unitOfWork.assertForEventSObjectType('PlatformEventObject__e'); + } + + @IsTest + private static void testAssertForEventSObjectType_ThrowExceptionOnNonEventObject() + { + Boolean exceptionThrown = false; + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + try + { + unitOfWork.assertForEventSObjectType('CustomObject__c'); + } + catch (Exception e) + { + exceptionThrown = true; + System.assert( + e.getMessage().contains('invalid for publishing'), + 'Incorrect exception message thrown' + ); + } + + System.assert(exceptionThrown); + } + + @IsTest + private static void testAssertForSupportedSObjectType_throwExceptionOnUnsupportedType() + { + Boolean exceptionThrown = false; + fflib_SObjectUnitOfWork unitOfWork = new fflib_SObjectUnitOfWork(MY_SOBJECTS); + try + { + unitOfWork.registerNew(new Account()); + } + catch (Exception e) + { + exceptionThrown = true; + System.assert( + e.getMessage().contains('not supported by this unit of work'), + 'Incorrect exception message thrown' + ); + } + + System.assert(exceptionThrown); + } + + /** + * Create uow with new records and commit + * + * Testing: + * + * - Correct events are fired when commitWork completes successfully + * + */ + @IsTest + private static void testDerivedUnitOfWork_CommitSuccess() + { + // Insert Opportunities with UnitOfWork + MockDML mockDML = new MockDML(); + DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS, mockDML); + for(Integer o=0; o<10; o++) + { + Opportunity opp = new Opportunity(); + opp.Name = 'UoW Test Name ' + o; + opp.StageName = 'Open'; + opp.CloseDate = System.today(); + uow.registerNew(new List{opp}); + for(Integer i=0; i{product}); + PricebookEntry pbe = new PricebookEntry(); + pbe.UnitPrice = 10; + pbe.IsActive = true; + pbe.UseStandardPrice = false; + uow.registerNew(pbe, PricebookEntry.Product2Id, product); + OpportunityLineItem oppLineItem = new OpportunityLineItem(); + oppLineItem.Quantity = 1; + oppLineItem.TotalPrice = 10; + uow.registerRelationship(oppLineItem, OpportunityLineItem.PricebookEntryId, pbe); + uow.registerNew(oppLineItem, OpportunityLineItem.OpportunityId, opp); + } + } + uow.commitWork(); + + // Assert Results + System.assertEquals(175, mockDML.recordsForInsert.size(), 'Incorrect of new records'); + + assertEvents(new List { + 'onCommitWorkStarting' + , 'onPublishBeforeEventsStarting' + , 'onPublishBeforeEventsFinished' + , 'onDMLStarting' + , 'onDMLFinished' + , 'onDoWorkStarting' + , 'onDoWorkFinished' + , 'onCommitWorkFinishing' + , 'onPublishAfterSuccessEventsStarting' + , 'onPublishAfterSuccessEventsFinished' + , 'onCommitWorkFinished - true' + } + , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); + } + + /** + * Create uow with data that results in DML Exception + * + * Testing: + * + * - Correct events are fired when commitWork fails during DML processing + * + */ + @IsTest + private static void testDerivedUnitOfWork_CommitDMLFail() + { + // Insert Opportunities with UnitOfWork forcing a failure on DML by not setting 'Name' field + DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS); + Opportunity opp = new Opportunity(); + uow.registerNew(new List{opp}); + Boolean didFail = false; + System.DmlException caughtEx = null; + + try { + uow.commitWork(); + } + catch (System.DmlException dmlex) { + didFail = true; + caughtEx = dmlex; + } + + // Assert Results + System.assertEquals(didFail, true, 'didFail'); + System.assert(caughtEx.getMessage().contains('REQUIRED_FIELD_MISSING'), String.format('Exception message was ', new List { caughtEx.getMessage() })); + + assertEvents(new List { + 'onCommitWorkStarting' + , 'onPublishBeforeEventsStarting' + , 'onPublishBeforeEventsFinished' + , 'onDMLStarting' + , 'onPublishAfterFailureEventsStarting' + , 'onPublishAfterFailureEventsFinished' + , 'onCommitWorkFinished - false' + } + , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); + } + + /** + * Create uow with work that fails + * + * Testing: + * + * - Correct events are fired when commitWork fails during DoWork processing + * + */ + @isTest + private static void testDerivedUnitOfWork_CommitDoWorkFail() + { + // Insert Opportunities with UnitOfWork + MockDML mockDML = new MockDML(); + DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS, mockDML); + Opportunity opp = new Opportunity(); + opp.Name = 'UoW Test Name 1'; + opp.StageName = 'Open'; + opp.CloseDate = System.today(); + uow.registerNew(new List{opp}); + + // register work that will fail during processing + FailDoingWork fdw = new FailDoingWork(); + uow.registerWork(fdw); + + Boolean didFail = false; + FailDoingWorkException caughtEx = null; + + try { + uow.commitWork(); + } + catch (FailDoingWorkException fdwe) { + didFail = true; + caughtEx = fdwe; + } + + // Assert Results + System.assertEquals(didFail, true, 'didFail'); + System.assert(caughtEx.getMessage().contains('Work failed.'), String.format('Exception message was ', new List { caughtEx.getMessage() })); + + assertEvents(new List { + 'onCommitWorkStarting' + , 'onPublishBeforeEventsStarting' + , 'onPublishBeforeEventsFinished' + , 'onDMLStarting' + , 'onDMLFinished' + , 'onDoWorkStarting' + , 'onPublishAfterFailureEventsStarting' + , 'onPublishAfterFailureEventsFinished' + , 'onCommitWorkFinished - false' + } + , uow.getCommitWorkEventsFired(), new Set(MY_SOBJECTS), uow.getRegisteredTypes()); + } + + /** + * Try registering two instances of the same record as dirty. Second register should overwrite first. + * + * Testing: + * + * - Exception is thrown stopping second registration + */ + @IsTest + private static void testRegisterDirty_ExpectReplacement() + { + final Opportunity insertedOpp = new Opportunity( + Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), + Name = 'Original', + StageName = 'Open', + CloseDate = System.today()); + + Opportunity opp = new Opportunity(Id = insertedOpp.Id, Name = 'Never'); + Opportunity opp2 = new Opportunity(Id = insertedOpp.Id, Name = 'Expected'); + + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(opp); + uow.registerDirty(opp2); + uow.commitWork(); + + System.assertEquals(1, mockDML.recordsForUpdate.size()); + System.assertEquals('Expected', mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Name)); + } + + /** + * Try registering a single field as dirty on first call + * + * Testing: + * + * - only that field is updated + */ + @IsTest + private static void testRegisterDirtyOnce_field() { + Opportunity opp = new Opportunity( + Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), + Name = 'test name', + StageName = 'Open', + CloseDate = System.today()); + + Opportunity amountUpdate = new Opportunity(Id = opp.Id, Name = 'ShouldNotAppear', Amount = 250); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(amountUpdate, new List { Opportunity.Amount } ); + uow.commitWork(); + + System.assertEquals(1, mockDML.recordsForUpdate.size()); + + System.assertEquals(true, mockDML.recordsForUpdate.get(0).getPopulatedFieldsAsMap().containsKey( 'Amount' )); + System.assertEquals(false, mockDML.recordsForUpdate.get(0).getPopulatedFieldsAsMap().containsKey( 'Name' )); + + System.assertEquals(amountUpdate.Amount, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Amount)); + + } + /** + * Try registering a single field as dirty. + * + * Testing: + * + * - field is updated + */ + @IsTest + private static void testRegisterDirty_field() { + Opportunity opp = new Opportunity( + Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), + Name = 'test name', + StageName = 'Open', + CloseDate = System.today()); + + Opportunity nameUpdate = new Opportunity(Id = opp.Id, Name = 'UpdateName'); + Opportunity amountUpdate = new Opportunity(Id = opp.Id, Amount = 250); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(nameUpdate); + uow.registerDirty(amountUpdate, new List { Opportunity.Amount } ); + uow.commitWork(); + + System.assertEquals(1, mockDML.recordsForUpdate.size()); + System.assertEquals(nameUpdate.Name, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Name)); + System.assertEquals(amountUpdate.Amount, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Amount)); + } + + /** + * Try registering a single field as dirty on multiple records. + * + */ + @IsTest + private static void testRegisterDirtyRecordsWithDirtyFields() + { + // GIVEN a list of existing records + Opportunity opportunityA = new Opportunity( + Id = fflib_IDGenerator.generate(Opportunity.SObjectType), + Name = 'test name A', + StageName = 'Open', + CloseDate = System.today()); + Opportunity opportunityB = new Opportunity( + Id = fflib_IDGenerator.generate(Opportunity.SObjectType), + Name = 'test name B', + StageName = 'Open', + CloseDate = System.today()); + + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(new List{ opportunityA, opportunityB }); + + // WHEN we register the records again with different fields updated + List recordsWithStageUpdate = new List + { + new Opportunity(Id = opportunityA.Id, StageName = 'Closed'), + new Opportunity(Id = opportunityB.Id, StageName = 'Closed') + }; + List recordsWithAmountUpdate = new List + { + new Opportunity(Id = opportunityA.Id, Amount = 250), + new Opportunity(Id = opportunityB.Id, Amount = 250) + }; + uow.registerDirty(recordsWithStageUpdate, new List { Opportunity.StageName }); + uow.registerDirty(recordsWithAmountUpdate, new List { Opportunity.Amount }); + uow.commitWork(); + + // THEN the records should be registered with both changed values for Amount and StageName + System.assert( + new fflib_MatcherDefinitions.SObjectsWith( + new List>{ + new Map + { + Opportunity.Id => opportunityA.Id, + Opportunity.Amount => 250, + Opportunity.StageName => 'Closed' + }, + new Map + { + Opportunity.Id => opportunityB.Id, + Opportunity.Amount => 250, + Opportunity.StageName => 'Closed' + } + } + ) + .matches(mockDML.recordsForUpdate), + 'Records not registered with the correct values' + ); + } + + /** + * Try registering a single field as dirty on multiple records. + * + */ + @IsTest + private static void testRegisterDirtyRecordsWithDirtyFields_failing() + { + // GIVEN a list of existing records + Opportunity opportunityA = new Opportunity( + Id = fflib_IDGenerator.generate(Opportunity.SObjectType), + Name = 'test name A', + StageName = 'Open', + CloseDate = System.today()); + Opportunity opportunityB = new Opportunity( + Id = fflib_IDGenerator.generate(Opportunity.SObjectType), + Name = 'test name B', + StageName = 'Open', + CloseDate = System.today()); + + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(new List{ opportunityA, opportunityB }); + + // WHEN we register the records again with different fields updated + List recordsWithStageUpdate = new List + { + new Opportunity(Id = opportunityA.Id, StageName = 'Closed'), + new Opportunity(Id = opportunityB.Id, StageName = 'Closed') + }; + List recordsWithAmountUpdate = new List + { + new Opportunity(Id = opportunityA.Id, Amount = 250), + new Opportunity(Id = opportunityB.Id, Amount = 250) + }; + uow.registerDirty(recordsWithStageUpdate, new List { Opportunity.StageName }); + uow.registerDirty(recordsWithAmountUpdate, new List { Opportunity.Amount }); + uow.registerDirty( // Register again the original record, should overwrite the one with the dirty fields + new Opportunity( + Id = opportunityB.Id, + Name = 'test name B', + StageName = 'Open', + CloseDate = System.today()) + ); + uow.commitWork(); + + // THEN only the first record should be registered with both changed values for Amount and StageName and the second should be the original + System.assert( + !new fflib_MatcherDefinitions.SObjectsWith( + new List>{ + new Map + { + Opportunity.Id => opportunityA.Id, + Opportunity.Amount => 250, + Opportunity.StageName => 'Closed' + }, + new Map + { + Opportunity.Id => opportunityB.Id, + Opportunity.Amount => 250, + Opportunity.StageName => 'Closed' + } + } + ) + .matches(mockDML.recordsForUpdate), + 'Not all records should not be registered with the dirty values' + ); + System.assert( + new fflib_MatcherDefinitions.SObjectsWith( + new List>{ + new Map + { + Opportunity.Id => opportunityA.Id, + Opportunity.Amount => 250, + Opportunity.StageName => 'Closed' + }, + new Map + { + Opportunity.Id => opportunityB.Id, + Opportunity.StageName => 'Open' + } + } + ) + .matches(mockDML.recordsForUpdate), + 'The second record should be registered with the original values' + ); + } + + @IsTest + private static void testRegisterUpsert() { + Opportunity existingOpp = new Opportunity( + Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), + Name = 'Existing Opportunity', + StageName = 'Closed', + CloseDate = System.today()); + + Opportunity newOpportunity = new Opportunity(Name = 'New Opportunity', StageName = 'Closed', CloseDate = System.today()); + + Test.startTest(); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerUpsert(new List{existingOpp, newOpportunity}); + uow.commitWork(); + Test.stopTest(); + + System.assertEquals(1, mockDML.recordsForUpdate.size()); + System.assertEquals(1, mockDML.recordsForInsert.size()); + } + + /** + * Assert that actual events exactly match expected events (size, order and name) + * and types match expected types + */ + private static void assertEvents(List expectedEvents, List actualEvents, Set expectedTypes, Set actualTypes) + { + // assert that events match + System.assertEquals(expectedEvents.size(), actualEvents.size(), 'events size'); + for (Integer i = 0; i < expectedEvents.size(); i++) + { + System.assertEquals(expectedEvents[i], actualEvents[i], String.format('Event {0} was not fired in order expected.', new List { expectedEvents[i] })); + } + + // assert that types match + System.assertEquals(expectedTypes.size(), actualTypes.size(), 'types size'); + for (Schema.SObjectType sObjectType :expectedTypes) + { + System.assertEquals(true, actualTypes.contains(sObjectType), String.format('Type {0} was not registered.', new List { sObjectType.getDescribe().getName() })); + } + } + + /** + * DoWork implementation that throws exception during processing + */ + private class FailDoingWork implements fflib_SObjectUnitOfWork.IDoWork + { + public void doWork() + { + throw new FailDoingWorkException('Work failed.'); + } + } + + /** + * Derived unit of work that tracks event notifications and handle registration of type + */ + private class DerivedUnitOfWork extends fflib_SObjectUnitOfWork + { + private List m_commitWorkEventsFired = new List(); + private Set m_registeredTypes = new Set(); + + public List getCommitWorkEventsFired() + { + return m_commitWorkEventsFired.clone(); + } + + public Set getRegisteredTypes() + { + return m_registeredTypes.clone(); + } + + public DerivedUnitOfWork(List sObjectTypes) + { + super(sObjectTypes); + } + + public DerivedUnitOfWork(List sObjectTypes, IDML dml) + { + super(sObjectTypes, dml); + } + + private void addEvent(String event) + { + // events should only be fired one time + // ensure that this event has not been fired already + for (String eventName :m_commitWorkEventsFired) + { + if (event == eventName) + { + throw new DerivedUnitOfWorkException(String.format('Event {0} has already been fired.', new List { event })); + } + } + m_commitWorkEventsFired.add(event); + } + + public override void onRegisterType(Schema.SObjectType sObjectType) + { + if (m_registeredTypes.contains(sObjectType)) + { + throw new DerivedUnitOfWorkException(String.format('Type {0} has already been registered.', new List { sObjectType.getDescribe().getName() })); + } + m_registeredTypes.add(sObjectType); + } + + public override void onCommitWorkStarting() + { + addEvent('onCommitWorkStarting'); + } + + public override void onPublishBeforeEventsStarting() + { + addEvent('onPublishBeforeEventsStarting'); + } + + public override void onPublishBeforeEventsFinished() + { + addEvent('onPublishBeforeEventsFinished'); + } + + public override void onDMLStarting() + { + addEvent('onDMLStarting'); + } + + public override void onDMLFinished() + { + addEvent('onDMLFinished'); + } + + public override void onDoWorkStarting() + { + addEvent('onDoWorkStarting'); + } + + public override void onDoWorkFinished() + { + addEvent('onDoWorkFinished'); + } + + public override void onCommitWorkFinishing() + { + addEvent('onCommitWorkFinishing'); + } + + public override void onPublishAfterSuccessEventsStarting() + { + addEvent('onPublishAfterSuccessEventsStarting'); + } + + public override void onPublishAfterSuccessEventsFinished() + { + addEvent('onPublishAfterSuccessEventsFinished'); + } + + public override void onPublishAfterFailureEventsStarting() + { + addEvent('onPublishAfterFailureEventsStarting'); + } + + public override void onPublishAfterFailureEventsFinished() + { + addEvent('onPublishAfterFailureEventsFinished'); + } + + public override void onCommitWorkFinished(Boolean wasSuccessful) + { + addEvent('onCommitWorkFinished - ' + wasSuccessful); + } + } + + /** + * Mock implementation of fflib_SObjectUnitOfWork.SendEmailWork + **/ + private class Mock_SendEmailWork implements fflib_SObjectUnitOfWork.IEmailWork + { + public Mock_SendEmailWork() + { + } + + public void registerEmail(Messaging.Email email) + { + } + + public void doWork() + { + doWorkWasCalled = true; + // The code in the fflib_SObjectUnitOfWork class + // causes unit test failures in Orgs that do not + // have email enabled. + } + + private Boolean doWorkWasCalled = false; + } + + private class MockDML implements fflib_SObjectUnitOfWork.IDML + { + public List recordsForInsert = new List(); + public List recordsForUpdate = new List(); + public List recordsForDelete = new List(); + public List recordsForRecycleBin = new List(); + public List recordsForEventPublish = new List(); + + public void dmlInsert(List objList) + { + this.recordsForInsert.addAll(objList); + } + + public void dmlUpdate(List objList) + { + this.recordsForUpdate.addAll(objList); + } + + public void dmlDelete(List objList) + { + this.recordsForDelete.addAll(objList); + } + + public void eventPublish(List objList) + { + this.recordsForEventPublish.addAll(objList); + } + + public void emptyRecycleBin(List objList) + { + this.recordsForRecycleBin.addAll(objList); + } + } + + public class DerivedUnitOfWorkException extends Exception {} + public class FailDoingWorkException extends Exception {} +} diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls new file mode 100644 index 00000000000..dd5bd13f0a0 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls @@ -0,0 +1,308 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private class fflib_SObjectsTest +{ + + @IsTest + static void itShouldClearTheField() + { + Account record = new Account(ShippingCountry = 'Holland'); + DomainAccounts domain = new DomainAccounts(new List{ record }); + System.assert(!domain.selectByShippingCountry('Holland').isEmpty(), 'Incorrect test data'); + + domain.clearShippingCountry(); + + System.assert(domain.selectByShippingCountry('Holland').isEmpty(), 'Field should have been nullified'); + } + + @IsTest + static void itShouldReturnTheDomainsType() + { + System.assertEquals( + (Object) Schema.Account.SObjectType, + new DomainAccounts(new List()) + .getType(), + 'Unexpected Domain Type' + ); + System.assertEquals( + Schema.Account.SObjectType, + new DomainAccounts(new List()) + .getSObjectType(), + 'Unexpected Domain SObjectType' + ); + } + + + @IsTest + static void itShouldReturnRecordsIds() + { + SObjectType accountSObjectType = Schema.Account.SObjectType; + Id idA = fflib_IDGenerator.generate(accountSObjectType); + Id idB = fflib_IDGenerator.generate(accountSObjectType); + Id idC = fflib_IDGenerator.generate(accountSObjectType); + + DomainAccounts domain = new DomainAccounts( + new List + { + new Account(Id = idA), + new Account(Id = idB), + new Account(Id = idC) + } + ); + + Set recordIds = new Set {idA, idB, idC}; + System.assert( + domain.getRecordIds().equals(recordIds), + 'The domain should return all the record Ids' + ); + + System.assert( + domain.getIdFieldValues(Schema.Account.Id).equals(recordIds), + 'The domain should return all the record Ids' + ); + } + + @IsTest + static void itShouldReturnRecordsWithFieldValues() + { + DomainAccounts domain = generateDomain(); + + System.assert(domain.selectByShippingCountry('USA').size() == 1); + } + + @IsTest + static void itShouldReturnRecordsWithoutFieldValues() + { + DomainAccounts domain = generateDomain(); + + System.assertEquals(3, domain.selectWithoutShippingCountry().size()); + } + + @IsTest + static void itShouldReturnRecordsWithoutAllFieldValues() + { + DomainAccounts domain = generateDomain(); + + System.assert(domain.selectWithEmptyRecord().size() == 1); + } + + @IsTest + static void itShouldReturnRecordsWithShippingCountry() + { + DomainAccounts domain = generateDomain(); + + System.assert(domain.selectWithShippingCountry().size() == 4); + } + + @IsTest + static void itShouldReturnRecordsWithAllFieldValues() + { + DomainAccounts domain = generateDomain(); + + System.assert(domain.selectPopulatedRecords().size() == 4); + } + + @IsTest + static void itShouldReturnFieldValues() + { + DomainAccounts domain = generateDomain(); + + final Set expected = new Set + { + null, + '', + 'Canada', + 'Ireland', + 'UK', + 'USA' + }; + System.assert( + domain.getStringFieldValues(Schema.Account.ShippingCountry).equals(expected) + ); + + System.assert( + domain.getFieldValues(Schema.Account.ShippingCountry) + .equals(expected) + ); + } + + @IsTest + static void itShouldSetFieldValue() + { + DomainAccounts domain = generateDomain(); + String country = 'Holland'; + domain.setShippingCountry(country); + + System.assert(domain.selectByShippingCountry(country).size() == 7); + } + + + @IsTest + static void itShouldSetFieldValueByCondition() + { + DomainAccounts domain = generateDomain(); + domain.setRatingByShippingCountry( + new Map + { + 'USA' => 'Hot' + } + ); + + System.assert(domain.selectByRating('Hot').size() == 1); + } + + @IsTest + static void testDomainErrorLogging() + { + // Test static helpers for raise none domain object instance errors + final String errorMessage = 'Test Error'; + Account record = new Account(); + DomainAccounts domain = new DomainAccounts(new List{ record }); + domain.addNameError(errorMessage); + + System.assertEquals(1, fflib_SObjects.Errors.getAll().size()); + System.assertEquals(errorMessage, fflib_SObjects.Errors.getAll()[0].message); + System.assertEquals(Account.Name, ((fflib_SObjects.FieldError) fflib_SObjects.Errors.getAll()[0]).field); + } + + @IsTest + static void testErrorLogging() + { + // Test static helpers for raise none domain object instance errors + Opportunity opp = new Opportunity ( Name = 'Test', Type = 'Existing Account' ); + fflib_SObjects.Errors.error('Error', opp); + fflib_SObjects.Errors.error('Error', opp, Opportunity.Type); + System.assertEquals(2, fflib_SObjects.Errors.getAll().size()); + System.assertEquals('Error', fflib_SObjects.Errors.getAll()[0].message); + System.assertEquals('Error', fflib_SObjects.Errors.getAll()[1].message); + System.assertEquals(Opportunity.Type, ((fflib_SObjects.FieldError) fflib_SObjects.Errors.getAll()[1]).field); + fflib_SObjects.Errors.clearAll(); + System.assertEquals(0, fflib_SObjects.Errors.getAll().size()); + } + + private static DomainAccounts generateDomain() + { + DomainAccounts domain = new DomainAccounts( + new List + { + new Account(Name = 'A', ShippingCountry = 'USA'), + new Account(Name = 'B', ShippingCountry = 'Ireland'), + new Account(Name = 'C', ShippingCountry = 'UK'), + new Account(Name = 'D', ShippingCountry = ''), + new Account(Name = 'E'), + new Account(), + new Account(Name = 'G', ShippingCountry = 'Canada') + } + ); + return domain; + } + + + private class DomainAccounts extends fflib_SObjects + { + public DomainAccounts(List records) + { + super(records, Schema.Account.SObjectType); + } + + public List selectByShippingCountry(String country) + { + return (List) getRecordsByFieldValues( + Schema.Account.ShippingCountry, + new Set{ country } + ); + } + + public List selectByRating(String rating) + { + return (List) getRecordsByFieldValue( + Schema.Account.Rating, + rating + ); + } + + public List selectWithoutShippingCountry() + { + return (List) getRecordsWithBlankFieldValues( + Schema.Account.ShippingCountry + ); + } + + public List selectWithShippingCountry() + { + return (List) getRecordsWithNotBlankFieldValues( + Schema.Account.ShippingCountry + ); + } + + public List selectWithEmptyRecord() + { + return (List) getRecordsWithAllBlankFieldValues( + new Set + { + Schema.Account.Name, + Schema.Account.ShippingCountry + } + ); + } + + public List selectPopulatedRecords() + { + return (List) getRecordsWithAllNotBlankFieldValues( + new Set + { + Schema.Account.Name, + Schema.Account.ShippingCountry + } + ); + } + + public void setShippingCountry(String country) + { + setFieldValue(Schema.Account.ShippingCountry, country); + } + + public void setRatingByShippingCountry(Map ratingByCountry) + { + setFieldValueByMap( + Schema.Account.ShippingCountry, + Schema.Account.Rating, + ratingByCountry); + } + + public void addNameError(String message) + { + addError(Schema.Account.Name, message); + } + + public void clearShippingCountry() + { + clearField(Schema.Account.ShippingCountry); + } + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SObjectsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls new file mode 100644 index 00000000000..dea962a7c67 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls @@ -0,0 +1,342 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@isTest +private class fflib_SecurityUtilsTest { + + @TestSetup + static void testSetup() { + // #315 Create a Permission Set that grants "Read" access to Account, Contact and Lead. We will use this in + // Spring '21 orgs that lack the "Read Only" Profile. See: + // https://help.salesforce.com/articleView?id=release-notes.rn_profiles_and_perms_read_only_new.htm&release=230&type=5). + PermissionSet ps = new PermissionSet(Label = 'Read Only Permission Set', Name = 'ReadOnlyPermissionSet'); + insert ps; + + // Grant Read access to the SObjects we use for CRUD tests + List objectPerms = new List(); + objectPerms.add(createObjectPermissions(ps.Id, 'Account', false, true, false, false)); + objectPerms.add(createObjectPermissions(ps.Id, 'Contact', false, true, false, false)); + objectPerms.add(createObjectPermissions(ps.Id, 'Lead', false, true, false, false)); + insert objectPerms; + + // Grant Read/Edit access to the SObject fields we use for FLS tests + List fieldPerms = new List(); + fieldPerms.add(createFieldPermissions(ps.Id, 'Contact', 'Birthdate', true, false)); + fieldPerms.add(createFieldPermissions(ps.Id, 'Contact', 'Email', true, false)); + insert fieldPerms; + } + + static Profile getProfile(String profileName) { + return [SELECT Id, Name FROM Profile WHERE Name = :profileName]; + } + + static ObjectPermissions createObjectPermissions( + Id permSetId, String objectType, Boolean canCreate, Boolean canRead, Boolean canUpdate, Boolean canDelete + ) { + return new ObjectPermissions( + ParentId = permSetId, + SobjectType = objectType, + PermissionsCreate = canCreate, + PermissionsRead = canRead, + PermissionsEdit = canUpdate, + PermissionsDelete = canDelete + ); + } + + static FieldPermissions createFieldPermissions( + Id permSetId, String objectType, String fieldName, Boolean canRead, Boolean canEdit + ) { + return new FieldPermissions( + ParentId = permSetId, + SobjectType = objectType, + Field = objectType + '.' + fieldName, + PermissionsRead = canRead, + PermissionsEdit = canEdit + ); + } + + static User setupTestUser(String profileName){ + Profile p; + Boolean applyReadOnlyPermissionSet = false; + if (profileName == 'Read Only') { + applyReadOnlyPermissionSet = true; + try { + p = getProfile(profileName); + } catch (QueryException ex) { + if (ex.getMessage().contains('List has no rows for assignment to SObject')) { + // #315 If the "Read Only" Profile is absent, then assume it's a Spring '21 org and see if there's a + // "Minimum Access - Salesforce" Profile we can use instead. + p = getProfile('Minimum Access - Salesforce'); + } + } + } else { + p = getProfile(profileName); + } + + //username global uniqueness is still enforced in tests + //make sure we get something unique to avoid issues with parallel tests + String uniqueness = DateTime.now()+':'+Math.random(); + try{ + throw new NullPointerException(); + }catch(Exception e){ + uniqueness += e.getStackTraceString(); //includes the top level test method name without having to pass it + } + User usr = new User( + username=UserInfo.getUserId()+'.'+uniqueness.HashCode()+'@'+UserInfo.getOrganizationId()+'.sfdcOrg', + alias = 'testExec', + email='apextests@example.com', + emailencodingkey='UTF-8', + lastname='Testing', + languagelocalekey='en_US', + localesidkey='en_US', + profileid = p.Id, + timezonesidkey='America/Los_Angeles' + ); + insert usr; + + if (applyReadOnlyPermissionSet) { + // #315 We need to assign the Perm Set to grant Account "Read" access + PermissionSet accountReadPS = [SELECT Id FROM PermissionSet WHERE Name = 'ReadOnlyPermissionSet']; + PermissionSetAssignment psa = new PermissionSetAssignment(AssigneeId = usr.Id, PermissionSetId = accountReadPS.Id); + insert psa; + } + return usr; + } + + @isTest + static void readonly_field_access() { + User testUser = setupTestUser('Read Only'); + System.runAs(testUser){ + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkFieldIsInsertable(Account.SObjectType, 'naMe'); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to insert Account.Name'); + System.assert(ex instanceof fflib_SecurityUtils.FlsException, 'Expected an FlsException, got '+ex.getTypeName()); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkFieldIsReadable(Contact.SObjectType, 'LastNAME'); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertEquals(null, ex, 'Read only profile should be able to read Contact.LastName'); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkFieldIsUpdateable(Lead.SObjectType, 'cOMPANY'); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to update Lead.Company'); + System.assert(ex instanceof fflib_SecurityUtils.FlsException, 'Expected an FlsException, got '+ex.getTypeName()); + } + + fflib_SecurityUtils.BYPASS_INTERNAL_FLS_AND_CRUD = true; + { //no exceptions, despite no rights + fflib_SecurityUtils.checkFieldIsInsertable(Account.SObjectType, 'naMe'); + fflib_SecurityUtils.checkFieldIsReadable(Contact.SObjectType, 'LastNAME'); + fflib_SecurityUtils.checkFieldIsUpdateable(Lead.SObjectType, 'cOMPANY'); + } + } + } + + @isTest + static void readonly_object_access() { + User testUser = setupTestUser('Read Only'); + System.runAs(testUser){ + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkObjectIsInsertable(Account.SObjectType); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to insert Account'); + System.assert(ex instanceof fflib_SecurityUtils.CrudException, 'Expected an CrudException, got '+ex.getTypeName()); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkObjectIsReadable(Contact.SObjectType); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertEquals(null, ex, 'Read only profile should be able to read Contact'); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkObjectIsUpdateable(Lead.SObjectType); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to update Lead'); + System.assert(ex instanceof fflib_SecurityUtils.CrudException, 'Expected an CrudException, got '+ex.getTypeName()); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkObjectIsDeletable(Opportunity.SObjectType); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to delete Opportunity'); + System.assert(ex instanceof fflib_SecurityUtils.CrudException, 'Expected an CrudException, got '+ex.getTypeName()); + } + + fflib_SecurityUtils.BYPASS_INTERNAL_FLS_AND_CRUD = true; + { //no exceptions, despite no rights + fflib_SecurityUtils.checkObjectIsInsertable(Account.SObjectType); + fflib_SecurityUtils.checkObjectIsReadable(Contact.SObjectType); + fflib_SecurityUtils.checkObjectIsUpdateable(Lead.SObjectType); + fflib_SecurityUtils.checkObjectIsDeletable(Opportunity.SObjectType); + } + } + } + + @isTest + static void readonly_objectAndField_access() { + User testUser = setupTestUser('Read Only'); + System.runAs(testUser){ + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkInsert( + Account.SObjectType, + new List{ + 'Name', + 'ParentId', + 'ownerId' + } + ); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to insert Account'); + System.assert(ex instanceof fflib_SecurityUtils.CrudException, 'Expected an CrudException, got '+ex.getTypeName()); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkRead( + Contact.SObjectType, + new List{ + 'LastName', + 'eMaiL', + 'BirthDATE' + } + ); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertEquals(null, ex, 'Read only profile should be able to read Contact'); + } + { + fflib_SecurityUtils.SecurityException ex; + try{ + fflib_SecurityUtils.checkUpdate( + Lead.SObjectType, + new List{ + 'LastName', + 'FirstNAMe', + 'cOMPANY' + } + ); + }catch(fflib_SecurityUtils.SecurityException e){ + ex = e; + } + System.assertNotEquals(null, ex, 'Read only profile should not be able to update Lead'); + System.assert(ex instanceof fflib_SecurityUtils.CrudException, 'Expected an CrudException, got '+ex.getTypeName()); + } + + fflib_SecurityUtils.BYPASS_INTERNAL_FLS_AND_CRUD = true; + { //no exceptions, despite no rights + fflib_SecurityUtils.checkInsert( + Account.SObjectType, + new List{ + 'Name', + 'Type', + 'ownerId' + } + ); + fflib_SecurityUtils.checkRead( + Contact.SObjectType, + new List{ + 'LastName', + 'accountId', + 'ownerId' + } + ); + fflib_SecurityUtils.checkUpdate( + Lead.SObjectType, + new List{ + 'LastName', + 'FirstNAMe', + 'cOMPANY' + } + ); + } + } + } + + @isTest + static void sysadmin_objectAndField_access() { + User testUser = setupTestUser('System Administrator'); + System.runAs(testUser){ + fflib_SecurityUtils.checkInsert( + Account.SObjectType, + new List{ + Account.SObjectType.fields.Name, + Account.SObjectType.fields.ParentId, + Account.SObjectType.fields.ownerId + } + ); + fflib_SecurityUtils.checkRead( + Contact.SObjectType, + new List{ + Contact.SObjectType.fields.LastName, + Contact.SObjectType.fields.accountId, + Contact.SObjectType.fields.ownerId + } + ); + fflib_SecurityUtils.checkUpdate( + Lead.SObjectType, + new List{ + Lead.SObjectType.fields.LastName, + Lead.SObjectType.fields.FirstNAMe, + Lead.SObjectType.fields.cOMPANY + } + ); + } + } + +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_SecurityUtilsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls new file mode 100644 index 00000000000..4b48e377e44 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls @@ -0,0 +1,115 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +@IsTest +private with sharing class fflib_StringBuilderTest +{ + static testMethod void testfflib_StringBuilder1() + { + fflib_StringBuilder sb = new fflib_StringBuilder(); + sb.add('this is a string'); + sb.add(new List{', which is made',' up from\r ','a number of smaller strings', '. 5 in this case!'}); + system.assertEquals(sb.getStringValue(),'this is a string, which is made up from\r a number of smaller strings. 5 in this case!'); + } + + static testMethod void testfflib_StringBuilder2() + { + fflib_StringBuilder sb = new fflib_StringBuilder(new List{'apples',' and ','pears',': stairs. '}); + sb.add('this is a string'); + sb.add(new List{', which is made',' up from\r ','a number of smaller strings', '. 5 in this case!'}); + system.assertEquals(sb.getStringValue(),'apples and pears: stairs. this is a string, which is made up from\r a number of smaller strings. 5 in this case!'); + } + + static testMethod void testCommaDelimitedBuilder1() + { + fflib_StringBuilder.CommaDelimitedListBuilder sb = new fflib_StringBuilder.CommaDelimitedListBuilder(); + sb.add('a'); + sb.add(new List{'b','c','d'}); + system.assertEquals(sb.getStringValue(),'a,b,c,d'); + } + + static testMethod void testCommaDelimitedBuilder2() + { + fflib_StringBuilder.CommaDelimitedListBuilder sb = new fflib_StringBuilder.CommaDelimitedListBuilder(new List{'x','y'}); + sb.add('a'); + sb.add(new List{'b','c','d'}); + system.assertEquals(sb.getStringValue(),'x,y,a,b,c,d'); + } + + static testMethod void testCommanDelimitedBuilderWithItemPrefix() + { + fflib_StringBuilder.CommaDelimitedListBuilder sb = new fflib_StringBuilder.CommaDelimitedListBuilder(new List{'x','y'}); + sb.add('a'); + sb.add(new List{'b','c','d'}); + system.assertEquals(sb.getStringValue('$'),'$x,$y,$a,$b,$c,$d'); + } + + static testMethod void testCommanDelimitedBuilderWithAlternativeDelimiter() + { + fflib_StringBuilder.CommaDelimitedListBuilder sb = new fflib_StringBuilder.CommaDelimitedListBuilder(new List{'x','y'}); + sb.setDelimiter(';'); + sb.add('a'); + sb.add(new List{'b','c','d'}); + system.assertEquals(sb.getStringValue(),'x;y;a;b;c;d'); + } + + static testMethod void testCommanDelimitedBuilderWithAlternativeDelimiterAndPrefix() + { + fflib_StringBuilder.CommaDelimitedListBuilder sb = new fflib_StringBuilder.CommaDelimitedListBuilder(new List{'x','y'}); + sb.setItemPrefix('#'); + sb.setDelimiter(':'); + sb.add('a'); + sb.add(new List{'b','c','d'}); + system.assertEquals(sb.getStringValue(),'#x:#y:#a:#b:#c:#d'); + } + + static testMethod void testFieldListBuilder() + { + List fields = new List { Account.Name, Account.Id, Account.AccountNumber, Account.AccountNumber, Account.AnnualRevenue }; + fflib_StringBuilder.FieldListBuilder sb = new fflib_StringBuilder.FieldListBuilder(fields); + List fieldList = sb.getStringValue().split(','); + Set fieldSet = new Set(fieldList); + system.assertEquals(4, fieldSet.size()); + system.assert(fieldSet.contains('Name')); + system.assert(fieldSet.contains('Id')); + system.assert(fieldSet.contains('AccountNumber')); + system.assert(fieldSet.contains('AnnualRevenue')); + } + + static testMethod void testMultiCurrencyFieldListBuilder() + { + List fields = new List { Account.Name, Account.Id, Account.AccountNumber, Account.AnnualRevenue }; + fflib_StringBuilder.MultiCurrencyFieldListBuilder sb = new fflib_StringBuilder.MultiCurrencyFieldListBuilder(fields); + List fieldList = sb.getStringValue().split(','); + Set fieldSet = new Set(fieldList); + system.assert(fieldSet.contains('Name')); + system.assert(fieldSet.contains('Id')); + system.assert(fieldSet.contains('AccountNumber')); + system.assert(fieldSet.contains('AnnualRevenue')); + if(UserInfo.isMultiCurrencyOrganization()) + system.assert(fieldSet.contains('CurrencyIsoCode')); + } +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/fflib_StringBuilderTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active + diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls b/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls new file mode 100644 index 00000000000..b9e13ab12cb --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls @@ -0,0 +1,237 @@ +/** + * Copyright (c), FinancialForce.com, inc + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +**/ + +/* Generated by apex-mocks-generator version 4.0.0 */ +@isTest +public class fflib_SObjectMocks +{ + public virtual class SObjectDomain implements fflib_ISObjectDomain + { + private fflib_ApexMocks mocks; + + public SObjectDomain(fflib_ApexMocks mocks) + { + this.mocks = mocks; + } + + public Schema.SObjectType sObjectType() + { + return (Schema.SObjectType) mocks.mockNonVoidMethod(this, 'sObjectType', new List {}, new List {}); + } + + public List getRecords() + { + return (List) mocks.mockNonVoidMethod(this, 'getRecords', new List {}, new List {}); + } + + public Object getType() + { + return sObjectType(); + } + + public List getObjects() + { + return getRecords(); + } + } + + public virtual class SObjectSelector implements fflib_ISObjectSelector + { + private fflib_ApexMocks mocks; + + public SObjectSelector(fflib_ApexMocks mocks) + { + this.mocks = mocks; + } + + public Schema.SObjectType sObjectType() + { + return (Schema.SObjectType) mocks.mockNonVoidMethod(this, 'sObjectType', new List {}, new List {}); + } + + public List selectSObjectsById(Set idSet) + { + return (List) mocks.mockNonVoidMethod(this, 'selectSObjectsById', new List {Set.class}, new List {idSet}); + } + } + + public virtual class SObjectUnitOfWork implements fflib_ISObjectUnitOfWork + { + private fflib_ApexMocks mocks; + + public SObjectUnitOfWork(fflib_ApexMocks mocks) + { + this.mocks = mocks; + } + + public void registerNew(SObject record) + { + mocks.mockVoidMethod(this, 'registerNew', new List {SObject.class}, new List {record}); + } + + public void registerNew(List records) + { + mocks.mockVoidMethod(this, 'registerNew', new List {List.class}, new List {records}); + } + + public void registerNew(SObject record, Schema.sObjectField relatedToParentField, SObject relatedToParentRecord) + { + mocks.mockVoidMethod(this, 'registerNew', new List {SObject.class, Schema.sObjectField.class, SObject.class}, new List {record, relatedToParentField, relatedToParentRecord}); + } + + public void registerRelationship(SObject record, Schema.sObjectField relatedToField, SObject relatedTo) + { + mocks.mockVoidMethod(this, 'registerRelationship', new List {SObject.class, Schema.sObjectField.class, SObject.class}, new List {record, relatedToField, relatedTo}); + } + + public void registerRelationship(Messaging.SingleEmailMessage email, SObject relatedTo) + { + mocks.mockVoidMethod(this, 'registerRelationship', new List {Messaging.SingleEmailMessage.class, SObject.class}, new List {email, relatedTo}); + } + + public void registerRelationship(SObject record, Schema.sObjectField relatedToField, Schema.sObjectField externalIdField, Object externalId) + { + mocks.mockVoidMethod(this, 'registerRelationship', new List {SObject.class, Schema.sObjectField.class, Schema.sObjectField.class, Object.class}, new List {record, relatedToField, externalIdField, externalId}); + } + + public void registerDirty(SObject record) + { + mocks.mockVoidMethod(this, 'registerDirty', new List {SObject.class}, new List {record}); + } + + public void registerDirty(List records, List dirtyFields) + { + mocks.mockVoidMethod(this, 'registerDirty', new List { + SObject.class, System.Type.forName('List') + }, new List { + records, dirtyFields + }); + } + + public void registerDirty(SObject record, List dirtyFields) + { + mocks.mockVoidMethod(this, 'registerDirty', new List { + SObject.class, System.Type.forName('List') + }, new List { + record, dirtyFields + }); + } + + public void registerDirty(SObject record, Schema.sObjectField relatedToParentField, SObject relatedToParentRecord) + { + mocks.mockVoidMethod(this, 'registerDirty', new List {SObject.class}, new List {record}); + } + + public void registerDirty(List records) + { + mocks.mockVoidMethod(this, 'registerDirty', new List {List.class}, new List {records}); + } + + public void registerUpsert(SObject record) + { + mocks.mockVoidMethod(this, 'registerUpsert', new List {List.class}, new List {record}); + } + + public void registerEmptyRecycleBin(SObject record) + { + mocks.mockVoidMethod(this, 'registerEmptyRecycleBin', new List {List.class}, new List {record}); + } + + public void registerEmptyRecycleBin(List records) + { + mocks.mockVoidMethod(this, 'registerEmptyRecycleBin', new List {List.class}, new List {records}); + } + + public void registerUpsert(List records) + { + mocks.mockVoidMethod(this, 'registerUpsert', new List {List.class}, new List {records}); + } + + public void registerDeleted(SObject record) + { + mocks.mockVoidMethod(this, 'registerDeleted', new List {SObject.class}, new List {record}); + } + + public void registerDeleted(List records) + { + mocks.mockVoidMethod(this, 'registerDeleted', new List {List.class}, new List {records}); + } + + public void registerPermanentlyDeleted(SObject record) + { + mocks.mockVoidMethod(this, 'registerPermanentlyDeleted', new List {SObject.class}, new List {record}); + } + + public void registerPermanentlyDeleted(List records) + { + mocks.mockVoidMethod(this, 'registerPermanentlyDeleted', new List {SObject.class}, new List {records}); + } + + public void registerPublishBeforeTransaction(SObject record) + { + mocks.mockVoidMethod(this, 'registerPublishBeforeTransaction', new List {SObject.class}, new List {record}); + } + + public void registerPublishBeforeTransaction(List records) + { + mocks.mockVoidMethod(this, 'registerPublishBeforeTransaction', new List {List.class}, new List {records}); + } + + public void registerPublishAfterSuccessTransaction(SObject record) + { + mocks.mockVoidMethod(this, 'registerPublishAfterSuccessTransaction', new List {SObject.class}, new List {record}); + } + public void registerPublishAfterSuccessTransaction(List records) + { + mocks.mockVoidMethod(this, 'registerPublishAfterSuccessTransaction', new List {List.class}, new List {records}); + } + public void registerPublishAfterFailureTransaction(SObject record) + { + mocks.mockVoidMethod(this, 'registerPublishAfterFailureTransaction', new List {SObject.class}, new List {record}); + } + public void registerPublishAfterFailureTransaction(List records) + { + mocks.mockVoidMethod(this, 'registerPublishAfterFailureTransaction', new List {List.class}, new List {records}); + } + + + public void commitWork() + { + mocks.mockVoidMethod(this, 'commitWork', new List {}, new List {}); + } + + public void registerWork(fflib_SObjectUnitOfWork.IDoWork work) + { + mocks.mockVoidMethod(this, 'registerWork', new List {fflib_SObjectUnitOfWork.IDoWork.class}, new List {work}); + } + + public void registerEmail(Messaging.Email email) + { + mocks.mockVoidMethod(this, 'registerEmail', new List {Messaging.Email.class}, new List {email}); + } + } + +} \ No newline at end of file diff --git a/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls-meta.xml b/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls-meta.xml new file mode 100644 index 00000000000..40d67933d00 --- /dev/null +++ b/subfolders/fflib/sfdx-source/apex-common/test/classes/mocks/fflib_SObjectMocks.cls-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + Active +