From ed6df6f0e58613cd2ca9b50d30bc1e7cccd08513 Mon Sep 17 00:00:00 2001 From: Alan Morey Date: Sun, 6 Apr 2014 01:06:09 -0700 Subject: [PATCH 01/32] Refactored StringBuilder to use a List as a buffer and use String.join() to build the result string more efficiently than string concatenation. Introduced new method setDelimiter on CommaDelimitedListBuilder to support a custom delimiter when building a string. The delimiter defaults to a comma but can be changed as required. --- fflib/src/classes/fflib_StringBuilder.cls | 41 +++++++----- fflib/src/classes/fflib_StringBuilderTest.cls | 63 +++++++++++++------ 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/fflib/src/classes/fflib_StringBuilder.cls b/fflib/src/classes/fflib_StringBuilder.cls index 44859b1f16c..786be27ef59 100644 --- a/fflib/src/classes/fflib_StringBuilder.cls +++ b/fflib/src/classes/fflib_StringBuilder.cls @@ -31,7 +31,7 @@ **/ public virtual with sharing class fflib_StringBuilder { - protected String m_stringValue; + protected List buffer = new List(); /** * Construct an empty StringBuilder @@ -51,8 +51,7 @@ public virtual with sharing class fflib_StringBuilder **/ public virtual void add(List values) { - for(String value : values) - add(value); + buffer.addAll(values); } /** @@ -60,7 +59,12 @@ public virtual with sharing class fflib_StringBuilder **/ public virtual void add(String value) { - m_stringValue = ( m_stringValue==null ? value : m_stringValue + value ); + buffer.add(value); + } + + public virtual override String toString() + { + return String.join(buffer, ''); } /** @@ -68,15 +72,17 @@ public virtual with sharing class fflib_StringBuilder **/ public virtual String getStringValue() { - return m_stringValue; + return toString(); } - /** * Subclasses the StringBuilder to produce a comma delimited contactination of strings **/ public virtual with sharing class CommaDelimitedListBuilder extends fflib_StringBuilder { + String itemPrefix = ''; + String delimiter = ','; + public CommaDelimitedListBuilder() {} public CommaDelimitedListBuilder(List values) @@ -84,19 +90,25 @@ public virtual with sharing class fflib_StringBuilder super(values); } - public virtual override void add(String value) + public void setItemPrefix(String itemPrefix) { - m_stringValue = ( m_stringValue==null ? '{0}' + value : m_stringValue + ',{0}' + value ); + this.itemPrefix = itemPrefix; } - public override String getStringValue() + public void setDelimiter(String delimiter) { - return getStringValue(''); + this.delimiter = delimiter; } public String getStringValue(String itemPrefix) { - return m_stringValue==null ? null : String.format(m_stringValue,new List{itemPrefix}); + setItemPrefix(itemPrefix); + return toString(); + } + + public override String toString() + { + return itemPrefix + String.join(buffer, delimiter + itemPrefix); } } @@ -113,14 +125,13 @@ public virtual with sharing class fflib_StringBuilder public FieldListBuilder(List values, List fieldSets) { // Create a distinct set of fields (or field paths) to select - Set selectFields = new Set(); for(Schema.SObjectField value : values) - selectFields.add(value.getDescribe().getName()); + add(String.valueOf(value)); // Alternative to value.getDescribe().getName() + if(fieldSets!=null) for(Schema.Fieldset fieldSet : fieldSets) for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) - selectFields.add(fieldSetMember.getFieldPath()); - add(new List(selectFields)); + add(fieldSetMember.getFieldPath()); } } diff --git a/fflib/src/classes/fflib_StringBuilderTest.cls b/fflib/src/classes/fflib_StringBuilderTest.cls index 3e7b3460928..4b48e377e44 100644 --- a/fflib/src/classes/fflib_StringBuilderTest.cls +++ b/fflib/src/classes/fflib_StringBuilderTest.cls @@ -27,40 +27,67 @@ @IsTest private with sharing class fflib_StringBuilderTest { - static testMethod void testfflib_StringBuilder1() - { + 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() - { + 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() - { + 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() - { + 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() - { + 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(','); @@ -70,10 +97,10 @@ private with sharing class fflib_StringBuilderTest system.assert(fieldSet.contains('Id')); system.assert(fieldSet.contains('AccountNumber')); system.assert(fieldSet.contains('AnnualRevenue')); - } + } - static testMethod void testMultiCurrencyFieldListBuilder() - { + 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(','); @@ -84,5 +111,5 @@ private with sharing class fflib_StringBuilderTest system.assert(fieldSet.contains('AnnualRevenue')); if(UserInfo.isMultiCurrencyOrganization()) system.assert(fieldSet.contains('CurrencyIsoCode')); - } + } } \ No newline at end of file From 8cb8c54129a9a66b2f56355d28a186e0812204a9 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sat, 19 Apr 2014 11:20:35 +0100 Subject: [PATCH 02/32] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e1e5cf53bb..8a0fdb2fc62 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -fflib-apex-common -================= +Apex Enterprise Patterns +======================== **[Deploy to Salesforce](https://githubsfdeploy.herokuapp.com/app/githubdeploy/financialforcedev/fflib-apex-common)** From 327d11969787692832c0fc8ad68fc98bd6f2c69a Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Wed, 23 Apr 2014 14:21:11 +0100 Subject: [PATCH 03/32] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a0fdb2fc62..595a052f0c1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -Apex Enterprise Patterns -======================== +FinancialForce Common Apex Lib +============================== **[Deploy to Salesforce](https://githubsfdeploy.herokuapp.com/app/githubdeploy/financialforcedev/fflib-apex-common)** From f533444bc1969f222a009d0eed1923928143f825 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Thu, 22 May 2014 10:48:07 +0100 Subject: [PATCH 04/32] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 595a052f0c1..f4f1bcb8f9a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ FinancialForce Common Apex Lib **[Deploy to Salesforce](https://githubsfdeploy.herokuapp.com/app/githubdeploy/financialforcedev/fflib-apex-common)** +Comming Soon! +============= + +Watch this space for exciting updates and new components to this libray from us! + This Library ============ From 4b53e28fb9d2a75f84b71a1828d809ff80c0be1e Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sat, 24 May 2014 10:17:45 +0100 Subject: [PATCH 05/32] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f4f1bcb8f9a..c79259e2e41 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ FinancialForce Common Apex Lib **[Deploy to Salesforce](https://githubsfdeploy.herokuapp.com/app/githubdeploy/financialforcedev/fflib-apex-common)** +See here for [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensmate-templates-and-apex-enterprise-patterns/) + Comming Soon! ============= @@ -38,4 +40,5 @@ I'm proud to have been given the opportunity to run a more detailed look at thes **Other Related Blogs** - [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/) From ec45ada13232acd69277daa8c8f3dacd2d1e29df Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sun, 1 Jun 2014 09:44:07 +0100 Subject: [PATCH 06/32] Initial commit of new QueryFactory, SecurityUtils and SObjectDescribe Also upgraded classes to API 30.0 --- fflib/src/classes/fflib_QueryFactory.cls | 429 ++++++++++++++++++ .../classes/fflib_QueryFactory.cls-meta.xml | 5 + fflib/src/classes/fflib_QueryFactoryTest.cls | 401 ++++++++++++++++ .../fflib_QueryFactoryTest.cls-meta.xml | 5 + fflib/src/classes/fflib_SObjectDescribe.cls | 305 +++++++++++++ .../fflib_SObjectDescribe.cls-meta.xml | 5 + .../src/classes/fflib_SObjectDescribeTest.cls | 131 ++++++ .../fflib_SObjectDescribeTest.cls-meta.xml | 5 + fflib/src/classes/fflib_SObjectDomain.cls | 187 +++++++- .../classes/fflib_SObjectDomain.cls-meta.xml | 2 +- fflib/src/classes/fflib_SObjectDomainTest.cls | 21 + .../fflib_SObjectDomainTest.cls-meta.xml | 2 +- fflib/src/classes/fflib_SObjectSelector.cls | 25 +- .../fflib_SObjectSelector.cls-meta.xml | 2 +- .../fflib_SObjectSelectorTest.cls-meta.xml | 2 +- fflib/src/classes/fflib_SObjectUnitOfWork.cls | 58 ++- .../fflib_SObjectUnitOfWork.cls-meta.xml | 2 +- .../fflib_SObjectUnitOfWorkTest.cls-meta.xml | 2 +- fflib/src/classes/fflib_SecurityUtils.cls | 348 ++++++++++++++ .../classes/fflib_SecurityUtils.cls-meta.xml | 5 + fflib/src/classes/fflib_SecurityUtilsTest.cls | 267 +++++++++++ .../fflib_SecurityUtilsTest.cls-meta.xml | 5 + .../classes/fflib_StringBuilder.cls-meta.xml | 2 +- .../fflib_StringBuilderTest.cls-meta.xml | 2 +- fflib/src/labels/CustomLabels.labels | 58 +++ fflib/src/package.xml | 12 +- 26 files changed, 2255 insertions(+), 33 deletions(-) create mode 100644 fflib/src/classes/fflib_QueryFactory.cls create mode 100644 fflib/src/classes/fflib_QueryFactory.cls-meta.xml create mode 100644 fflib/src/classes/fflib_QueryFactoryTest.cls create mode 100644 fflib/src/classes/fflib_QueryFactoryTest.cls-meta.xml create mode 100644 fflib/src/classes/fflib_SObjectDescribe.cls create mode 100644 fflib/src/classes/fflib_SObjectDescribe.cls-meta.xml create mode 100644 fflib/src/classes/fflib_SObjectDescribeTest.cls create mode 100644 fflib/src/classes/fflib_SObjectDescribeTest.cls-meta.xml create mode 100644 fflib/src/classes/fflib_SecurityUtils.cls create mode 100644 fflib/src/classes/fflib_SecurityUtils.cls-meta.xml create mode 100644 fflib/src/classes/fflib_SecurityUtilsTest.cls create mode 100644 fflib/src/classes/fflib_SecurityUtilsTest.cls-meta.xml create mode 100644 fflib/src/labels/CustomLabels.labels diff --git a/fflib/src/classes/fflib_QueryFactory.cls b/fflib/src/classes/fflib_QueryFactory.cls new file mode 100644 index 00000000000..1373c1e2941 --- /dev/null +++ b/fflib/src/classes/fflib_QueryFactory.cls @@ -0,0 +1,429 @@ +/** + * Copyright (c) 2014, 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. +**/ + +/** + * QueryFactor 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 relativley 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 heavilly + * 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. + * + * Subselect Queries are supported with the subselectQuery method. + * 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 guideance 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;} + private Set fields; + private String conditionExpression; + private Integer limitCount; + private Integer offset; + private List order; + /** + * 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 QueryField getFieldToken(String fieldName){ + QueryField result; + if(!fieldName.contains('.')){ //single field + Schema.SObjectField token = fflib_SObjectDescribe.getDescribe(table).getField(fieldName); + if(token == null) + throw new InvalidFieldException(fieldName,this.table); + result = new QueryField(table,token); + }else{ //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); + if(token != null && i.hasNext() && token.getDescribe().getSOAPType() == Schema.SOAPType.ID){ + lastSObjectType = token.getDescribe().getReferenceTo()[0]; //if it's polymorphic doesn't matter which one we get + fieldPath.add(token); + }else if(token != null && !i.hasNext()){ + fieldPath.add(token); + }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.'); + } + + } + result = new QueryField(this.table, fieldPath); + } + return result; + } + + /** + * 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).toString() == this.toString(); + } + + /** + * Construct a new fflib_QueryFactory instance with no options other than the FROM caluse. + * 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(); + } + /** + * 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; + } + /** + * 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( getFieldToken(fieldName) ); + return this; + } + /** + * Selects a field, avoiding the possible ambiguitiy 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); + fields.add( new QueryField(table,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){ + Set toAdd = new Set(); + for(String fieldName:fieldNames) + toAdd.add( getFieldToken(fieldName) ); + fields.addAll(toAdd); + 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){ + Set toAdd = new Set(); + for(String fieldName:fieldNames) + toAdd.add( getFieldToken(fieldName) ); + fields.addAll(toAdd); + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(Schema.SObjectField)} multiple times. + * @param fieldNames 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(); + this.fields.add( new QueryField(table, token) ); + } + return this; + } + /** + * Selects multiple fields. This acts the same as calling {@link #selectField(Schema.SObjectField)} multiple times. + * @param fieldNames 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(); + this.fields.add( new QueryField(table, token) ); + } + return this; + } + /** + * @see #selectFieldSet(Schema.FieldSet,Boolean) + **/ + public fflib_QueryFactory selectFieldSet(Schema.FieldSet fieldSet){ + return selectFieldSet(fieldSet,true); + } + /** + * This is equivielent 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( getFieldToken(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; + 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 caluse 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 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; + } + /** + * @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; + } + /** + * 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 + **/ + public fflib_QueryFactory subselectQuery(ChildRelationship relationship){ + if (this.subselectQueryMap == null){ + this.subselectQueryMap = new Map(); + } + if (this.subselectQueryMap.containsKey(relationship)){ + return subselectQueryMap.get(relationship); + } + if (this.relationship != null){ + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. You may not add a subselect query to a subselect query.'); + } else if (!isRelationshipValid(relationship)) { + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. Invalid relationship for table.'); + } + fflib_QueryFactory subselectQuery = new fflib_QueryFactory(relationship); + 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; + } + /** + * When a subselect is being added, check to make sure that the relationship on that object + * is valid for the parent's object type (table) + **/ + private boolean isRelationshipValid(Schema.ChildRelationship relationship){ + for (Schema.ChildRelationship childRow : table.getDescribe().getChildRelationships()) { + if (childRow.getRelationshipName() == relationship.getRelationshipName()) { + return true; + } + } + return false; + } + + /** + * 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 '; + for(QueryField field:fields){ + result += field + ', '; + } + if (subselectQueryMap != null && !subselectQueryMap.isEmpty()) { + for (fflib_QueryFactory childRow : subselectQueryMap.values()) { + result += ' (' + childRow.toSOQL() + '), '; + } + } + result = result.substring(0,result.length()-2) + ' FROM ' + (relationship != null ? relationship.getRelationshipName() : table.getDescribe().getName()); + if(conditionExpression != null) + 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; + return result; + } + + public class Ordering{ + private SortOrder direction; + private boolean nullsFirst; + private Schema.SObjectField 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.direction = direction; + this.field = field; + } + public Schema.SObjectField getField(){ + return field; + } + public SortOrder getDirection(){ + return direction; + } + public String toSOQL(){ + return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC'); + } + } + + @testVisible + private class QueryField{ + List fields; + + @testVisible + private QueryField(Schema.SObjectType table, List fields){ + if(fields == null || fields.size() == 0) + throw new InvalidFieldException(null, table); + this.fields = fields.clone(); //don't let clients mutate after setting! + } + @testVisible + private QueryField(Schema.SObjectType table, Schema.SObjectField field){ + if(field == null) + throw new InvalidFieldException(null, table); + fields = new List{ field }; + } + public override String toString(){ + String result = ''; + Iterator i = fields.iterator(); + while(i.hasNext()){ + String fieldName = i.next().getDescribe().getName(); + if(fieldName.endsWithIgnoreCase('Id') && i.hasNext()) + fieldName = fieldName.removeEndIgnoreCase('Id'); + if(fieldName.endsWithIgnoreCase('__c') && i.hasNext()) + fieldName = fieldName.removeEndIgnoreCase('__c')+'__r'; + result += fieldName + (i.hasNext() ? '.' :''); + } + return result; + } + public integer hashCode(){ + return String.valueOf(this.fields).hashCode(); + } + public boolean equals(Object obj){ + if(!(obj instanceof QueryField)) + return false; + if( String.valueOf(((QueryField) obj).fields) != String.valueOf(this.fields)) + return false; + Set objFields = new Set(); + objFields.addAll( ((QueryField)obj).fields ); + objFields.retainAll(this.fields); + objFields.removeAll(this.fields); + return objFields.size() == 0; + } + } + + 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{} + +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_QueryFactory.cls-meta.xml b/fflib/src/classes/fflib_QueryFactory.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_QueryFactory.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_QueryFactoryTest.cls b/fflib/src/classes/fflib_QueryFactoryTest.cls new file mode 100644 index 00000000000..b2e0a862948 --- /dev/null +++ b/fflib/src/classes/fflib_QueryFactoryTest.cls @@ -0,0 +1,401 @@ +/** + * Copyright (c) 2014, 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_QueryFactoryTest { + @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 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 } ); + } + + @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 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(Contact.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 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_noQueryFields(){ + Exception e; + List sObjectFields = new List(); + try { + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, sObjectFields); + } catch (Exception ex) { + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidFields_noQueryField(){ + Exception e; + Schema.SObjectField sObjectField; + try { + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, sObjectField); + } catch (Exception ex) { + e = ex; + } + System.assertNotEquals(null,e); + } + + @isTest + static void invalidFields_queryFieldsNotEquals(){ + Exception e; + Schema.SObjectField sObjectField; + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, Contact.Name); + fflib_QueryFactory.QueryField qfld2 = new fflib_QueryFactory.QueryField(Contact.SObjectType, Contact.LastName); + System.assert(!qfld.equals(qfld2)); + } + + @isTest + static void queryIdFieldNotEquals(){ + //this is the equivalent of calling setField('account.name'), where table = Contact + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, new List{ + Schema.Contact.SObjectType.fields.AccountId, + Schema.Account.SObjectType.fields.name + }); + String fldString = qfld.toString(); + } + + @isTest + static void queryIdFieldNotEqualsWrongObjType(){ + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, new List{ + Schema.Contact.SObjectType.fields.AccountId}); + System.assert(!qfld.equals(new Contact())); + } + + @isTest + static void addChildQueries_success(){ + Account acct = new Account(); + acct.Name = 'testchildqueriesacct'; + insert acct; + Contact cont = new Contact(); + cont.FirstName = 'test'; + cont.LastName = 'test'; + cont.AccountId = acct.Id; + insert cont; + Task tsk = new Task(); + tsk.WhoId = cont.Id; + tsk.Subject = 'test'; + tsk.ActivityDate = System.today(); + insert tsk; + 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( new fflib_QueryFactory.Ordering('Contact','CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING) ); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { + if (childRow.getRelationshipName() == 'Tasks') { + relationship = childRow; + } + } + fflib_QueryFactory childQf = qf.subselectQuery(relationship); + childQf.selectField('Id'); + childQf.selectField('Subject'); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + String query = qf.toSOQL(); + List contacts = Database.query(query); + System.assert(contacts != null && contacts.size() == 1); + System.assert(contacts[0].Tasks.size() == 1); + System.assert(contacts[0].Tasks[0].Subject == 'test'); + } + + @isTest + static void addChildQuerySameRelationshipAgain_success(){ + Account acct = new Account(); + acct.Name = 'testchildqueriesacct'; + insert acct; + Contact cont = new Contact(); + cont.FirstName = 'test'; + cont.LastName = 'test'; + cont.AccountId = acct.Id; + insert cont; + Task tsk = new Task(); + tsk.WhoId = cont.Id; + tsk.Subject = 'test'; + tsk.ActivityDate = System.today(); + insert tsk; + 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( new fflib_QueryFactory.Ordering('Contact','CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING) ); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { + if (childRow.getRelationshipName() == 'Tasks') { + relationship = childRow; + } + } + System.assert(qf.getSubselectQueries() == null); + fflib_QueryFactory childQf = qf.subselectQuery(relationship); + fflib_QueryFactory childQf2 = qf.subselectQuery(relationship); + List queries = qf.getSubselectQueries(); + System.assert(queries != null); + System.assert(queries.size() == 1); + } + + @isTest + static void addChildQueries_invalidChildRelationship(){ + Account acct = new Account(); + acct.Name = 'testchildqueriesacct'; + insert acct; + Contact cont = new Contact(); + cont.FirstName = 'test'; + cont.LastName = 'test'; + cont.AccountId = acct.Id; + insert cont; + 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( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING) ); + Schema.DescribeSObjectResult descResult = Account.SObjectType.getDescribe(); + ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { + if (childRow.getRelationshipName() == 'Contacts') { + relationship = childRow; + } + } + Exception e; + try { + fflib_QueryFactory childQf = qf.subselectQuery(relationship); + childQf.selectField('Id'); + } catch (Exception ex) { + e = ex; + } + System.assertNotEquals(e, null); + System.assert(e.getMessage().containsIgnoreCase('Invalid relationship')); + } + + @isTest + static void addChildQueries_invalidChildRelationshipTooDeep(){ + Account acct = new Account(); + acct.Name = 'testchildqueriesacct'; + insert acct; + Contact cont = new Contact(); + cont.FirstName = 'test'; + cont.LastName = 'test'; + cont.AccountId = acct.Id; + insert cont; + 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( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING) ); + Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); + ChildRelationship relationship; + for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { + if (childRow.getRelationshipName() == 'Tasks') { + relationship = childRow; + } + } + fflib_QueryFactory childQf = qf.subselectQuery(relationship); + childQf.selectField('Id'); + childQf.selectField('Subject'); + Exception e; + try { + fflib_QueryFactory subChildQf = childQf.subselectQuery(relationship); + } catch (Exception ex) { + e = ex; + } + System.assertNotEquals(e, null); + System.assert(e.getMessage().containsIgnoreCase('invalid')); + System.assert(e.getMessage().containsIgnoreCase('subselect query')); + } +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_QueryFactoryTest.cls-meta.xml b/fflib/src/classes/fflib_QueryFactoryTest.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_QueryFactoryTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls b/fflib/src/classes/fflib_SObjectDescribe.cls new file mode 100644 index 00000000000..0d2fdcfb3ca --- /dev/null +++ b/fflib/src/classes/fflib_SObjectDescribe.cls @@ -0,0 +1,305 @@ +/** + * Copyright (c) 2014, 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.DescribeSObjectResult describe { //lazy load - keep this leightweight 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'); + if(instanceCache.containsKey( String.valueOf(token) )) + throw new DuplicateDescribeException(token + ' is already in the describe cache'); + this.token = token; + instanceCache.put( String.valueOf(token).toLowerCase() , this); + } + + //public instace methods + /** + * 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 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 consutrcted + * 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){ + 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){ + 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){ + 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){ + 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 sublasses 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 courtesey 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 contructor 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){ + String preferredValue = ((implyNamespace ? currentNamespace+'__' : '') + name).toLowerCase(); + if(values.containsKey(preferredValue)){ + return values.get(preferredValue); + }else if(implyNamespace){ + return values.get(name); + }else{ + return null; + } + } + public virtual Boolean containsKey(String name){ + return this.containsKey(name, true); + } + public virtual Boolean containsKey(String name, Boolean implyNamespace){ + String preferredValue = ((implyNamespace ? currentNamespace+'__' : '') + name).toLowerCase(); + return ( + values.containsKey(preferredValue) || + implyNamespace && values.containsKey(name) + ); + } + public virtual Integer size(){ + return values.size(); + } + public virtual Set keySet(){ + 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 @testVisbile annotation to force exception cases. + public class InvalidDescribeException extends DescribeException{} +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls-meta.xml b/fflib/src/classes/fflib_SObjectDescribe.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_SObjectDescribe.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_SObjectDescribeTest.cls b/fflib/src/classes/fflib_SObjectDescribeTest.cls new file mode 100644 index 00000000000..b999d209146 --- /dev/null +++ b/fflib/src/classes/fflib_SObjectDescribeTest.cls @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2014, 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 { + + @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()); + } + + @isTest + static void FieldsMap(){ + String fakeNamespace = 'fflib_test'; + Map fakeFieldData = new Map{ + 'name__c' => Contact.SObjectType.fields.name, //re-use stndard field types since we can't mock them + fakeNamespace+'__name__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+'__name__c') ); + System.assert(fields.get('name__c') === fields.get(fakeNamespace+'__name__c')); + + fields.currentNamespace = 'someOtherNamespace'; + System.assertNotEquals(fields.get('name__c'), fields.get(fakeNamespace+'__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(); + } + System.assertEquals(1, Limits.getFieldsDescribes() ); + System.assertEquals(false,fields.isEmpty()); + } + + @isTest + static void simpleAccountFieldSetDescribe(){ + fflib_SObjectDescribe d = fflib_SObjectDescribe.getDescribe(Account.SObjectType); + Map fields; + for(integer i = 0; i < 10; i++){ + fields = d.getFieldSetsMap(); + } + System.assertEquals(1, Limits.getFieldSetsDescribes() ); + //no asserts on result size to avoid a requirement on field sets existing + } + + @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()); + } + +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectDescribeTest.cls-meta.xml b/fflib/src/classes/fflib_SObjectDescribeTest.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_SObjectDescribeTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_SObjectDomain.cls b/fflib/src/classes/fflib_SObjectDomain.cls index 68a1160a66a..da35855b538 100644 --- a/fflib/src/classes/fflib_SObjectDomain.cls +++ b/fflib/src/classes/fflib_SObjectDomain.cls @@ -60,11 +60,18 @@ public virtual with sharing class fflib_SObjectDomain **/ public static TestFactory Test {get; private set;} + /** + * Retains instances of domain classes implementing ITriggerStateful + **/ + private static Map> TriggerStateByClass; + static { Errors = new ErrorFactory(); Test = new TestFactory(); + + TriggerStateByClass = new Map>(); } /** @@ -189,32 +196,52 @@ public virtual with sharing class fflib_SObjectDomain onAfterDelete(); } + /** + * Interface used to aid the triggerHandler in constructing instances of Domain classes + **/ public interface IConstructable { fflib_SObjectDomain construct(List sObjectList); } + /** + * Interface used to indicate the same instance of the Domain class should be shared between + * trigger invocations, e.g. between before and after trigger invocations, also see + * getTriggerInstance method + **/ + public interface ITriggerStateful { } + + /** + * 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) - { - // Construct the domain class constructor class - String domainClassName = domainClass.getName(); - Type constructableClass = domainClassName.endsWith('Constructor') ? Type.forName(domainClassName) : Type.forName(domainClassName+'.Constructor'); - IConstructable constructor = (IConstructable) constructableClass.newInstance(); - + { // 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(constructor); + Test.Database.testTriggerHandler(domainClass); } else { // Process the runtime Apex Trigger context - triggerHandler(constructor, + triggerHandler(domainClass, Trigger.isBefore, Trigger.isAfter, Trigger.isInsert, @@ -228,22 +255,70 @@ public virtual with sharing class fflib_SObjectDomain /** * Calls the applicable override methods such as beforeInsert, beforeUpdate etc. based on a Trigger context **/ - private static void triggerHandler(IConstructable domainConstructor, Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, List newRecords, Map oldRecordsMap) + private static void triggerHandler(Type domainClass, Boolean isBefore, Boolean isAfter, Boolean isInsert, Boolean isUpdate, Boolean isDelete, 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()); + + // Should this instance be reused on the next trigger invocation? + if(domainObject instanceof ITriggerStateful) + // Push this instance onto the stack to be popped during the after phase + pushTriggerInstance(domainClass, domainObject); + } + + // Invoke the applicable handler if(isBefore) { - if(isInsert) domainConstructor.construct(newRecords).handleBeforeInsert(); - else if(isUpdate) domainConstructor.construct(newRecords).handleBeforeUpdate(oldRecordsMap); - else if(isDelete) domainConstructor.construct(oldRecordsMap.values()).handleBeforeDelete(); + if(isInsert) domainObject.handleBeforeInsert(); + else if(isUpdate) domainObject.handleBeforeUpdate(oldRecordsMap); + else if(isDelete) domainObject.handleBeforeDelete(); } else { - if(isInsert) domainConstructor.construct(newRecords).handleAfterInsert(); - else if(isUpdate) domainConstructor.construct(newRecords).handleAfterUpdate(oldRecordsMap); - else if(isDelete) domainConstructor.construct(oldRecordsMap.values()).handleAfterDelete(); + if(isInsert) domainObject.handleAfterInsert(); + else if(isUpdate) domainObject.handleAfterUpdate(oldRecordsMap); + else if(isDelete) domainObject.handleAfterDelete(); } } + /** + * 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.Records = records; + return domain; + } + + /** + * General exception class for the domain layer + **/ public class DomainException extends Exception { } @@ -382,13 +457,13 @@ public virtual with sharing class fflib_SObjectDomain } - private void testTriggerHandler(IConstructable domainConstructor) + private void testTriggerHandler(Type domainClass) { // Mock Before - triggerHandler(domainConstructor, true, false, isInsert, isUpdate, isDelete, records, oldRecords); + triggerHandler(domainClass, true, false, isInsert, isUpdate, isDelete, records, oldRecords); // Mock After - triggerHandler(domainConstructor, false, true, isInsert, isUpdate, isDelete, records, oldRecords); + triggerHandler(domainClass, false, true, isInsert, isUpdate, isDelete, records, oldRecords); } public void onInsert(List records) @@ -427,6 +502,8 @@ public virtual with sharing class fflib_SObjectDomain **/ 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 @@ -487,6 +564,18 @@ public virtual with sharing class fflib_SObjectDomain opp.addError( error('You cannot delete this Opportunity.', opp) ); } } + + 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); + } } /** @@ -498,5 +587,65 @@ public virtual with sharing class fflib_SObjectDomain { 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 + implements fflib_SObjectDomain.ITriggerStateful + { + public String someState; + + public TestSObjectStatefulDomain(List sObjectList) + { + super(sObjectList); + } + + 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); + } + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectDomain.cls-meta.xml b/fflib/src/classes/fflib_SObjectDomain.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectDomain.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectDomain.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SObjectDomainTest.cls b/fflib/src/classes/fflib_SObjectDomainTest.cls index bed07c2f4a5..41a21c22cb0 100644 --- a/fflib/src/classes/fflib_SObjectDomainTest.cls +++ b/fflib/src/classes/fflib_SObjectDomainTest.cls @@ -139,6 +139,27 @@ private with sharing class fflib_SObjectDomainTest 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); + } + /** * Create test user **/ diff --git a/fflib/src/classes/fflib_SObjectDomainTest.cls-meta.xml b/fflib/src/classes/fflib_SObjectDomainTest.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectDomainTest.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectDomainTest.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index 13f585ee5fd..42a238bf732 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -53,7 +53,7 @@ public abstract with sharing class fflib_SObjectSelector **/ public fflib_SObjectSelector() { - this(false); + this(false); } /** @@ -63,7 +63,7 @@ public abstract with sharing class fflib_SObjectSelector **/ public fflib_SObjectSelector(Boolean includeFieldSetFields) { - m_includeFieldSetFields = includeFieldSetFields; + m_includeFieldSetFields = includeFieldSetFields; } /** @@ -71,7 +71,7 @@ public abstract with sharing class fflib_SObjectSelector **/ public virtual List getSObjectFieldSetList() { - return null; + return null; } /** @@ -82,6 +82,14 @@ public abstract with sharing class fflib_SObjectSelector return 'Name'; } + /** + * Returns True if this Selector instance has been instructed by the caller to include Field Set fields + **/ + public Boolean isIncludeFieldSetFields() + { + return m_includeFieldSetFields; + } + /** * 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 @@ -171,12 +179,21 @@ public abstract with sharing class fflib_SObjectSelector 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.'); } + /** + * Public acccess for the getSObjectType during Mock registration + * (adding public to the existing method broken base class API backwards compatability) + **/ + public SObjectType getSObjectType2() + { + return getSObjectType(); + } + /** * Constructs the default SOQL query for this selector, see selectSObjectsById and queryLocatorById **/ private String buildQuerySObjectById() { return String.format('SELECT {0} FROM {1} WHERE id in :idSet ORDER BY {2}', - new List{getFieldListString(),getSObjectName(),getOrderBy()}); + new List{getFieldListString(),getSObjectName(),getOrderBy()}); } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectSelector.cls-meta.xml b/fflib/src/classes/fflib_SObjectSelector.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectSelector.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls-meta.xml b/fflib/src/classes/fflib_SObjectSelectorTest.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SObjectUnitOfWork.cls b/fflib/src/classes/fflib_SObjectUnitOfWork.cls index df0f2c9be8e..aa5a450472b 100644 --- a/fflib/src/classes/fflib_SObjectUnitOfWork.cls +++ b/fflib/src/classes/fflib_SObjectUnitOfWork.cls @@ -51,7 +51,7 @@ * TODO: Need to add some more test methods for more complex use cases and some unexpected (e.g. registerDirty and then registerDeleted) * **/ -public class fflib_SObjectUnitOfWork +public virtual class fflib_SObjectUnitOfWork { private List m_sObjectTypes = new List(); @@ -62,7 +62,19 @@ public class fflib_SObjectUnitOfWork private Map> m_deletedListByType = new Map>(); private Map m_relationships = new Map(); + + private List m_workList = new List(); + + private SendEmailWork m_emailWork = new SendEmailWork(); + /** + * Interface describes work to be performed during the commitWork method + **/ + public interface IDoWork + { + void doWork(); + } + /** * Constructs a new UnitOfWork to support work against the given object list * @@ -79,6 +91,24 @@ public class fflib_SObjectUnitOfWork m_deletedListByType.put(sObjectType.getDescribe().getName(), new List()); m_relationships.put(sObjectType.getDescribe().getName(), new Relationships()); } + + m_workList.add(m_emailWork); + } + + /** + * Register a generic peace 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); } /** @@ -179,6 +209,9 @@ public class fflib_SObjectUnitOfWork Integer objectIdx = m_sObjectTypes.size() - 1; while(objectIdx>=0) delete m_deletedListByType.get(m_sObjectTypes[objectIdx--].getDescribe().getName()); + // Generic work + for(IDoWork work : m_workList) + work.doWork(); } catch (Exception e) { @@ -222,4 +255,27 @@ public class fflib_SObjectUnitOfWork * UnitOfWork Exception **/ public class UnitOfWorkException extends Exception {} + + /** + * Internal implementation of Messaging.sendEmail, see outer class registerEmail method + **/ + private class SendEmailWork implements IDoWork + { + private List emails; + + public SendEmailWork() + { + this.emails = new List(); + } + + public void registerEmail(Messaging.Email email) + { + this.emails.add(email); + } + + public void doWork() + { + Messaging.sendEmail(emails); + } + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectUnitOfWork.cls-meta.xml b/fflib/src/classes/fflib_SObjectUnitOfWork.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectUnitOfWork.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectUnitOfWork.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml b/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml +++ b/fflib/src/classes/fflib_SObjectUnitOfWorkTest.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_SecurityUtils.cls b/fflib/src/classes/fflib_SecurityUtils.cls new file mode 100644 index 00000000000..2aa075f7f40 --- /dev/null +++ b/fflib/src/classes/fflib_SecurityUtils.cls @@ -0,0 +1,348 @@ +/** + * Copyright (c) 2014, 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 conveneience + * 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 uder 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 uder 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 uder 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 uder 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/fflib/src/classes/fflib_SecurityUtils.cls-meta.xml b/fflib/src/classes/fflib_SecurityUtils.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_SecurityUtils.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_SecurityUtilsTest.cls b/fflib/src/classes/fflib_SecurityUtilsTest.cls new file mode 100644 index 00000000000..65d072a8020 --- /dev/null +++ b/fflib/src/classes/fflib_SecurityUtilsTest.cls @@ -0,0 +1,267 @@ +/** + * Copyright (c) 2014, 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 { + static User setupTestUser(String 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 + } + Profile p = [SELECT id, Name FROM Profile WHERE Name = :profileName]; + User result = 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 result; + return result; + } + + @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', + 'accountId', + 'ownerId' + } + ); + }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/fflib/src/classes/fflib_SecurityUtilsTest.cls-meta.xml b/fflib/src/classes/fflib_SecurityUtilsTest.cls-meta.xml new file mode 100644 index 00000000000..04433daec1f --- /dev/null +++ b/fflib/src/classes/fflib_SecurityUtilsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 30.0 + Active + diff --git a/fflib/src/classes/fflib_StringBuilder.cls-meta.xml b/fflib/src/classes/fflib_StringBuilder.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_StringBuilder.cls-meta.xml +++ b/fflib/src/classes/fflib_StringBuilder.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/classes/fflib_StringBuilderTest.cls-meta.xml b/fflib/src/classes/fflib_StringBuilderTest.cls-meta.xml index b211a092826..04433daec1f 100644 --- a/fflib/src/classes/fflib_StringBuilderTest.cls-meta.xml +++ b/fflib/src/classes/fflib_StringBuilderTest.cls-meta.xml @@ -1,5 +1,5 @@ - 28.0 + 30.0 Active diff --git a/fflib/src/labels/CustomLabels.labels b/fflib/src/labels/CustomLabels.labels new file mode 100644 index 00000000000..6fc85864f89 --- /dev/null +++ b/fflib/src/labels/CustomLabels.labels @@ -0,0 +1,58 @@ + + + + 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/fflib/src/package.xml b/fflib/src/package.xml index f57737bc370..2871bab5613 100644 --- a/fflib/src/package.xml +++ b/fflib/src/package.xml @@ -1,15 +1,25 @@ + fflib_QueryFactory + fflib_QueryFactoryTest + fflib_SObjectDescribe + fflib_SObjectDescribeTest fflib_SObjectDomain fflib_SObjectDomainTest fflib_SObjectSelector fflib_SObjectSelectorTest fflib_SObjectUnitOfWork fflib_SObjectUnitOfWorkTest + fflib_SecurityUtils + fflib_SecurityUtilsTest fflib_StringBuilder fflib_StringBuilderTest ApexClass - 28.0 + + * + CustomLabels + + 24.0 From 6c2b4c79afe7dd69db728cead10dd4750e50cbf1 Mon Sep 17 00:00:00 2001 From: Chris Peterson Date: Tue, 3 Jun 2014 14:37:37 -0700 Subject: [PATCH 07/32] Removed internal FFDC google doc from QueryFactory Doc was specific to a larger theme of field set strategy and not particularly useful outside of FinancialForce. It also wasn't visible publicly. --- fflib/src/classes/fflib_QueryFactory.cls | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fflib/src/classes/fflib_QueryFactory.cls b/fflib/src/classes/fflib_QueryFactory.cls index 1373c1e2941..58674f64dbd 100644 --- a/fflib/src/classes/fflib_QueryFactory.cls +++ b/fflib/src/classes/fflib_QueryFactory.cls @@ -45,9 +45,6 @@ * - 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 guideance 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} @@ -426,4 +423,4 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr public class NonReferenceFieldException extends Exception{} public class InvalidSubqueryRelationshipException extends Exception{} -} \ No newline at end of file +} From 98a3fa94f95f601058c92848276defdf73dddb99 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Fri, 20 Jun 2014 16:24:50 +0100 Subject: [PATCH 08/32] fixes issue #8 in fflib-apex-common --- fflib/src/classes/fflib_SObjectSelectorTest.cls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index df9e88e9f23..046c54446c1 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -40,7 +40,7 @@ private with sharing class fflib_SObjectSelectorTest system.assert(fieldSet.contains('AnnualRevenue')); if(UserInfo.isMultiCurrencyOrganization()) system.assert(fieldSet.contains('CurrencyIsoCode')); - system.assertEquals('myprefix.Name,myprefix.AccountNumber,myprefix.Id,myprefix.AnnualRevenue', selector.getRelatedFieldListString('myprefix')); + system.assertEquals('myprefix.Name,myprefix.Id,myprefix.AccountNumber,myprefix.AnnualRevenue', selector.getRelatedFieldListString('myprefix')); } static testMethod void testGetSObjectName() @@ -172,4 +172,4 @@ private with sharing class fflib_SObjectSelectorTest } return testUser; } -} \ No newline at end of file +} From 4da11f4cf860682b57f4fee8aca6da9b239642d4 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Fri, 27 Jun 2014 17:41:14 +0100 Subject: [PATCH 09/32] Updated QueryFactory and Selector base class - Added FLS support to the QueryFactory - Rewrote the Selector base class to use QueryFactory --- fflib/src/classes/fflib_QueryFactory.cls | 213 ++++++- fflib/src/classes/fflib_QueryFactoryTest.cls | 77 +-- fflib/src/classes/fflib_SObjectSelector.cls | 557 +++++++++++------- .../src/classes/fflib_SObjectSelectorTest.cls | 84 ++- fflib/src/classes/fflib_StringBuilder.cls | 2 +- fflib/src/labels/CustomLabels.labels | 28 + fflib/src/package.xml | 1 + 7 files changed, 690 insertions(+), 272 deletions(-) diff --git a/fflib/src/classes/fflib_QueryFactory.cls b/fflib/src/classes/fflib_QueryFactory.cls index 58674f64dbd..f705d1dc26d 100644 --- a/fflib/src/classes/fflib_QueryFactory.cls +++ b/fflib/src/classes/fflib_QueryFactory.cls @@ -59,6 +59,19 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr private Integer limitCount; private Integer offset; private List order; + /** + * each item in sortExpressions contains the field and the direction (ascending or descending) + * use the addSort method to add fields to sort by. the sort fields + * appear in the SOQL query in the order they are added to the query. + **/ + private List sortExpressions; + /** + /* 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; + /** * 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 @@ -74,6 +87,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr Schema.SObjectField token = fflib_SObjectDescribe.getDescribe(table).getField(fieldName); if(token == null) throw new InvalidFieldException(fieldName,this.table); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(this.table, token); result = new QueryField(table,token); }else{ //traversing FK relationship(s) List fieldPath = new List(); @@ -82,6 +97,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr while(i.hasNext()){ String field = i.next(); Schema.SObjectField token = fflib_SObjectDescribe.getDescribe(lastSObjectType).getField(field); + if (token != null && enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(lastSObjectType, token); if(token != null && i.hasNext() && token.getDescribe().getSOAPType() == Schema.SOAPType.ID){ lastSObjectType = token.getDescribe().getReferenceTo()[0]; //if it's polymorphic doesn't matter which one we get fieldPath.add(token); @@ -93,7 +110,6 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr else throw new NonReferenceFieldException(lastSObjectType+'.'+field+' is not a lookup or master-detail field but is used in a cross-object query field.'); } - } result = new QueryField(this.table, fieldPath); } @@ -121,6 +137,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr this.table = table; fields = new Set(); order = new List(); + sortExpressions = new List(); + enforceFLS = false; } /** * Construct a new fflib_QueryFactory instance with no options other than the FROM clause and the relationship. @@ -133,6 +151,27 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr 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 assertCRUD(){ + 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; + } + /** * 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. @@ -151,6 +190,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr 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( new QueryField(table,field) ); return this; } @@ -184,7 +225,9 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr public fflib_QueryFactory selectFields(Set fields){ for(Schema.SObjectField token:fields){ if(token == null) - throw new InvalidFieldException(); + throw new InvalidFieldException(); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(table, token); this.fields.add( new QueryField(table, token) ); } return this; @@ -198,6 +241,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr for(Schema.SObjectField token:fields){ if(token == null) throw new InvalidFieldException(); + if (enforceFLS) + fflib_SecurityUtils.checkFieldIsReadable(table, token); this.fields.add( new QueryField(table, token) ); } return this; @@ -216,10 +261,25 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr **/ 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+'"'); + throw new InvalidFieldSetException( + String.format( + System.label.fflib_QueryFactory_fieldset_wrong_sobject_error, + new List{ + fieldSet.getName(), + table.getDescribe().getName() + } + ) + ); 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.'); + throw new InvalidFieldSetException( + String.format( + System.label.fflib_QueryFactory_crossobject_fieldsets_not_allowed_error, + new List{ + field.getFieldPath() + } + ) + ); fields.add( getFieldToken(field.getFieldPath()) ); } return this; @@ -263,24 +323,41 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr 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. + * @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){ + return setSubselectQuery(getChildRelationship(related)); + } + /** * 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 **/ - public fflib_QueryFactory subselectQuery(ChildRelationship relationship){ + public fflib_QueryFactory setSubselectQuery(ChildRelationship relationship){ + if (this.relationship != null){ + throw new InvalidSubqueryRelationshipException(System.label.fflib_QueryFactory_recursive_subquery_error); + } if (this.subselectQueryMap == null){ this.subselectQueryMap = new Map(); } if (this.subselectQueryMap.containsKey(relationship)){ return subselectQueryMap.get(relationship); } - if (this.relationship != null){ - throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. You may not add a subselect query to a subselect query.'); - } else if (!isRelationshipValid(relationship)) { - throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. Invalid relationship for table.'); - } + fflib_QueryFactory subselectQuery = new fflib_QueryFactory(relationship); subselectQueryMap.put(relationship, subSelectQuery); return subSelectQuery; @@ -294,18 +371,51 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr } return null; } + /** - * When a subselect is being added, check to make sure that the relationship on that object - * is valid for the parent's object type (table) + * Get the ChildRelationship from the Table for the object type passed in. + * @param objType The object type of the child relationship to get **/ - private boolean isRelationshipValid(Schema.ChildRelationship relationship){ - for (Schema.ChildRelationship childRow : table.getDescribe().getChildRelationships()) { - if (childRow.getRelationshipName() == relationship.getRelationshipName()) { - return true; - } + 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; + } } - return false; - } + throw new InvalidSubqueryRelationshipException(System.label.fflib_QueryFactory_subquery_invalid_relationship); + } + + /** + * 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 SortOrder the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory addSort(String fieldName, SortOrder direction){ + sortExpressions.add(new SortExpression(getFieldToken(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. + * @param field The SObjectfield to sort. This can only be a direct reference. + * @param SortOrder the direction to be sorted on (ASCENDING or DESCENDING) + **/ + public fflib_QueryFactory addSort(SObjectField field, SortOrder direction){ + sortExpressions.add(new SortExpression(new QueryField(table, field), direction)); + return this; + } /** * Convert the values provided to this instance into a full SOQL string for use with Database.query @@ -313,23 +423,46 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr **/ public String toSOQL(){ String result = 'SELECT '; - for(QueryField field:fields){ - result += field + ', '; - } - if (subselectQueryMap != null && !subselectQueryMap.isEmpty()) { - for (fflib_QueryFactory childRow : subselectQueryMap.values()) { + //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{ + for(QueryField field:fields){ + result += field + ', '; + } + } + if(subselectQueryMap != null && !subselectQueryMap.isEmpty()){ + for (fflib_QueryFactory childRow : subselectQueryMap.values()){ result += ' (' + childRow.toSOQL() + '), '; } } result = result.substring(0,result.length()-2) + ' FROM ' + (relationship != null ? relationship.getRelationshipName() : table.getDescribe().getName()); if(conditionExpression != null) result += ' WHERE '+conditionExpression; + /** + * check both the order and sortexpressions for fields to order by. the order list uses an older method of sorting (addOrdering method), but does not work well with sorting related object fields. + * newer method uses sortExpressions list (addSort method), which work with all fields and can handle multiple sorts expressions (ie. order by last name desc, first name asc) + * both methods may be used together on a single query factory + **/ if(order.size() > 0){ result += ' ORDER BY '; for(Ordering o:order) result += o.toSOQL() +', '; result = result.substring(0,result.length()-2); } + + if (sortExpressions.size() > 0){ + if (order.size() == 0){ + result += ' ORDER BY '; + } else{ + result += ', '; + } + for(SortExpression o:sortExpressions) + result += o.toSOQL() +', '; + result = result.substring(0,result.length()-2); + } + System.debug('resultaft='+result); if(limitCount != null) result += ' LIMIT '+limitCount; return result; @@ -365,10 +498,36 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr } } - @testVisible - private class QueryField{ + @TestVisible + private class SortExpression{ + SortOrder direction; + QueryField field; + public SortExpression(QueryField field, SortOrder direction){ + this.field = field; + this.direction = direction; + } + public String toSOQL(){ + return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC'); + } + } + + public class QueryField{ List fields; + /** + * The first field in the path to to field being queried + **/ + public SObjectField getBaseField(){ + return fields[0]; + } + + /** + * The full list of fields representing the path to the field being queried + **/ + public List getFieldPath(){ + return fields.clone(); + } + @testVisible private QueryField(Schema.SObjectType table, List fields){ if(fields == null || fields.size() == 0) @@ -423,4 +582,4 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr public class NonReferenceFieldException extends Exception{} public class InvalidSubqueryRelationshipException extends Exception{} -} +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_QueryFactoryTest.cls b/fflib/src/classes/fflib_QueryFactoryTest.cls index b2e0a862948..15efe2063de 100644 --- a/fflib/src/classes/fflib_QueryFactoryTest.cls +++ b/fflib/src/classes/fflib_QueryFactoryTest.cls @@ -206,13 +206,14 @@ private class fflib_QueryFactoryTest { System.assertNotEquals(null,e); } + //Constructing a queryfield with no fields should never happen, but queryfield protects against it anyways @isTest static void invalidFields_noQueryFields(){ Exception e; List sObjectFields = new List(); try { fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, sObjectFields); - } catch (Exception ex) { + } catch (fflib_QueryFactory.InvalidFieldException ex) { e = ex; } System.assertNotEquals(null,e); @@ -271,21 +272,11 @@ private class fflib_QueryFactoryTest { tsk.Subject = 'test'; tsk.ActivityDate = System.today(); insert tsk; + 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( new fflib_QueryFactory.Ordering('Contact','CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING) ); + qf.selectField('name').selectField('Id').setCondition( 'name like \'%test%\'' ).addSort('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); - ChildRelationship relationship; - for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { - if (childRow.getRelationshipName() == 'Tasks') { - relationship = childRow; - } - } - fflib_QueryFactory childQf = qf.subselectQuery(relationship); - childQf.selectField('Id'); - childQf.selectField('Subject'); + qf.subselectQuery(Task.SObjectType).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); List queries = qf.getSubselectQueries(); System.assert(queries != null); String query = qf.toSOQL(); @@ -314,7 +305,7 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('Id'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING) ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort('CreatedBy.Name',fflib_QueryFactory.SortOrder.DESCENDING); Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); ChildRelationship relationship; for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { @@ -323,8 +314,8 @@ private class fflib_QueryFactoryTest { } } System.assert(qf.getSubselectQueries() == null); - fflib_QueryFactory childQf = qf.subselectQuery(relationship); - fflib_QueryFactory childQf2 = qf.subselectQuery(relationship); + fflib_QueryFactory childQf = qf.subselectQuery(Task.SObjectType); + fflib_QueryFactory childQf2 = qf.subselectQuery(Task.SObjectType); List queries = qf.getSubselectQueries(); System.assert(queries != null); System.assert(queries.size() == 1); @@ -344,17 +335,11 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('email'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING) ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort( 'CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); Schema.DescribeSObjectResult descResult = Account.SObjectType.getDescribe(); - ChildRelationship relationship; - for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { - if (childRow.getRelationshipName() == 'Contacts') { - relationship = childRow; - } - } Exception e; try { - fflib_QueryFactory childQf = qf.subselectQuery(relationship); + fflib_QueryFactory childQf = qf.subselectQuery(Contact.SObjectType); childQf.selectField('Id'); } catch (Exception ex) { e = ex; @@ -377,25 +362,47 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('email'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addOrdering( new fflib_QueryFactory.Ordering('Contact','CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING) ); + qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort('CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); - ChildRelationship relationship; - for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { - if (childRow.getRelationshipName() == 'Tasks') { - relationship = childRow; - } - } - fflib_QueryFactory childQf = qf.subselectQuery(relationship); + + fflib_QueryFactory childQf = qf.subselectQuery(Task.SObjectType); childQf.selectField('Id'); childQf.selectField('Subject'); Exception e; try { - fflib_QueryFactory subChildQf = childQf.subselectQuery(relationship); + fflib_QueryFactory subChildQf = childQf.subselectQuery(Task.SObjectType); } catch (Exception ex) { - e = ex; + e = ex; } System.assertNotEquals(e, null); System.assert(e.getMessage().containsIgnoreCase('invalid')); System.assert(e.getMessage().containsIgnoreCase('subselect query')); } + + @isTest + static void checkFieldObjectRead(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.assertCRUD() + .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%\'' ) + .selectFields(new Set{Contact.FirstName}) + .addOrdering(new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ) + .addSort(Contact.LastModifiedDate,fflib_QueryFactory.SortOrder.DESCENDING); + qf.getSelectedFields(); + qf.toSOQL(); + } + + @isTest + static void queryWithNoFields(){ + fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); + qf.assertCRUD().setEnforceFLS(true).setCondition( 'name like \'%test%\'' ).addSort('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); + String query = qf.toSOQL(); + System.assert(query.containsIgnoreCase('Id FROM')); + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index 42a238bf732..de6407e571e 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -1,199 +1,360 @@ -/** - * 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 -{ - /** - * This overrides the Multi Currency handling, preventing it from injecting the CurrencyIsoCode field for certain System objects that don't ever support it - **/ - private static Set STANDARD_WITHOUT_CURRENCYISO = new Set { 'AsyncApexJob' }; - - private fflib_StringBuilder.FieldListBuilder m_fieldListBuilder; - - private Boolean m_includeFieldSetFields; - - /** - * 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, defaults to not including any FieldSet fields automatically - **/ - public fflib_SObjectSelector() - { - this(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) - { - m_includeFieldSetFields = includeFieldSetFields; - } - - /** - * 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 Name - **/ - public virtual String getOrderBy() - { - return 'Name'; - } - - /** - * Returns True if this Selector instance has been instructed by the caller to include Field Set fields - **/ - public Boolean isIncludeFieldSetFields() - { - return m_includeFieldSetFields; - } - - /** - * 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 - **/ - public fflib_StringBuilder.FieldListBuilder getFieldListBuilder() - { - // Demand create the FieldListBuilder unless one has already been set? - if(m_fieldListBuilder == null) - m_fieldListBuilder = STANDARD_WITHOUT_CURRENCYISO.contains(getSObjectType().getDescribe().getName()) ? - new fflib_StringBuilder.FieldListBuilder( - getSObjectFieldList(), m_includeFieldSetFields ? getSObjectFieldSetList() : null) : - new fflib_StringBuilder.MultiCurrencyFieldListBuilder( - getSObjectFieldList(), m_includeFieldSetFields ? getSObjectFieldSetList() : null); - - return m_fieldListBuilder; - } - - /** - * 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 - **/ - public void setFieldListBuilder(fflib_StringBuilder.FieldListBuilder fieldListBuilder) - { - m_fieldListBuilder = fieldListBuilder; - } - - /** - * Returns in string form a comma delimted list of fields as defined via getSObjectFieldList and optionally getSObjectFieldSetList - **/ - public String getFieldListString() - { - return getFieldListBuilder().getStringValue(); - } - - /** - * Returns in string form a comma delimted list of fields as defined via getSObjectFieldList and optionally getSObjectFieldSetList - * @param relation Will prefix fields with the given relation, e.g. MyLookupField__r - **/ - public String getRelatedFieldListString(String relation) - { - return getFieldListBuilder().getStringValue(relation + '.'); - } - - /** - * Returns the string representaiton of the SObject this selector represents - **/ - public String getSObjectName() - { - return getSObjectType().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 List selectSObjectsById(Set idSet) - { - assertIsAccessible(); - 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 Database.QueryLocator queryLocatorById(Set idSet) - { - assertIsAccessible(); - return Database.getQueryLocator(buildQuerySObjectById()); - } - - /** - * Throws an exception if the SObject indicated by getSObjectType is not accessible to the current user (read access) - **/ - public void assertIsAccessible() - { - if(!getSObjectType().getDescribe().isAccessible()) - throw new fflib_SObjectDomain.DomainException( - 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.'); - } - - /** - * Public acccess for the getSObjectType during Mock registration - * (adding public to the existing method broken base class API backwards compatability) - **/ - public SObjectType getSObjectType2() - { - return getSObjectType(); - } - - /** - * Constructs the default SOQL query for this selector, see selectSObjectsById and queryLocatorById - **/ - private String buildQuerySObjectById() - { - return String.format('SELECT {0} FROM {1} WHERE id in :idSet ORDER BY {2}', - new List{getFieldListString(),getSObjectName(),getOrderBy()}); - } +/** + * 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 +{ + /** + * This overrides the Multi Currency handling, preventing it from injecting the CurrencyIsoCode fie ld for certain System objects that don't ever support it + **/ + private static Set STANDARD_WITHOUT_CURRENCYISO = new Set { 'AsyncApexJob' }; + + /** + * Should this selector automatically include the FieldSet fields when building queries? + **/ + private Boolean m_includeFieldSetFields; + + /** + * Enforce FLS Security + **/ + private Boolean m_enforceFLS; + + /** + * Enforce CRUD Security + **/ + private Boolean m_enforceCRUD; + + /** + * 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, defaults to not including any FieldSet fields automatically + **/ + public fflib_SObjectSelector() + { + this(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) + { + 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) + { + m_includeFieldSetFields = includeFieldSetFields; + m_enforceCRUD = enforceCRUD; + m_enforceFLS = enforceFLS; + } + + /** + * 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 Name + **/ + public virtual String getOrderBy() + { + return 'Name'; + } + + /** + * 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.FieldListBuilder getFieldListBuilder() + { + List sObjectFields = new List(); + for(fflib_QueryFactory.QueryField queryField : newQueryFactory().getSelectedFields()) + sObjectFields.add(queryField.getBaseField()); + return new fflib_StringBuilder.FieldListBuilder(sObjectFields); + } + + /** + * 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 optomisation) if it's OK to leave this as a null operation + } + + /** + * Returns in string form a comma delimted list of fields as defined via getSObjectFieldList and optionally getSObjectFieldSetList + * + * @depricated See newQueryFactory + **/ + public String getFieldListString() + { + return getFieldListBuilder().getStringValue(); + } + + /** + * Returns in string form a comma delimted 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 representaiton of the SObject this selector represents + **/ + public String getSObjectName() + { + return getSObjectType().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 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 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) + * + * @depricated 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() + ' dennied.'); + } + + /** + * Public acccess for the getSObjectType during Mock registration + * (adding public to the existing method broken base class API backwards compatability) + **/ + public SObjectType getSObjectType2() + { + 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 consistant (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()); + // Automatically select the CurrencyIsoCode for MC orgs (unless the object is a known exception to the rule) + if(Userinfo.isMultiCurrencyOrganization() && + !STANDARD_WITHOUT_CURRENCYISO.contains(getSObjectType().getDescribe().getName())) + 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); + } + + /** + * Constructs the default SOQL query for this selector, see selectSObjectsById and queryLocatorById + **/ + private 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.assertCRUD(); + } catch (fflib_SecurityUtils.CrudException e) { + // Marshal exception into DomainException for backwards compatability + throw new fflib_SObjectDomain.DomainException( + 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.'); + } + } + queryFactory.setEnforceFLS(enforceFLS); + + // Configure the QueryFactory with the Selector fields? + if(includeSelectorFields) + { + // select the Selector fields and Fieldsets and set order + queryFactory.selectFields(new Set(getSObjectFieldList())); + if(m_includeFieldSetFields) + for(Schema.FieldSet fieldSet : getSObjectFieldSetList()) + queryFactory.selectFieldSet(fieldSet); + + // Automatically select the CurrencyIsoCode for MC orgs (unless the object is a known exception to the rule) + if(Userinfo.isMultiCurrencyOrganization() && + !STANDARD_WITHOUT_CURRENCYISO.contains(getSObjectType().getDescribe().getName())) + queryFactory.selectField('CurrencyIsoCode'); + } + + // Parse the getOrderBy() + for(String orderBy : getOrderBy().split(',')) + { + // TODO: Handle NULLS FIRST and NULLS LAST, http://www.salesforce.com/us/developer/docs/soql_sosl/Content/sforce_api_calls_soql_select_orderby.htm + 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.addSort(fieldNamePart, fieldSortOrder); + } + + return queryFactory; + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index df9e88e9f23..12e5dcf1958 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -67,12 +67,12 @@ private with sharing class fflib_SObjectSelectorTest Test.stopTest(); system.assertEquals(2,result.size()); - system.assertEquals('TestAccount1',result[0].Name); - system.assertEquals('A1',result[0].AccountNumber); - system.assertEquals(76543.21,result[0].AnnualRevenue); - system.assertEquals('TestAccount2',result[1].Name); - system.assertEquals('A2',result[1].AccountNumber); - system.assertEquals(12345.67,result[1].AnnualRevenue); + 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() @@ -94,14 +94,14 @@ private with sharing class fflib_SObjectSelectorTest System.assert(true, iteratorResult.hasNext()); Account account = (Account) iteratorResult.next(); - system.assertEquals('TestAccount1',account.Name); - system.assertEquals('A1',account.AccountNumber); - system.assertEquals(76543.21,account.AnnualRevenue); - System.assert(true, iteratorResult.hasNext()); - 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()); } @@ -133,9 +133,66 @@ private with sharing class fflib_SObjectSelectorTest } } } + + 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); + 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(); + System.assertEquals('SELECT AccountNumber, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC', selector.newQueryFactory().toSOQL()); + } + + 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('Name,AccountNumber,Id,AnnualRevenue', selector.getFieldListBuilder().getStringValue()); + System.assertEquals('Name,AccountNumber,Id,AnnualRevenue', selector.getFieldListString()); + System.assertEquals('LookupField__r.Name,LookupField__r.AccountNumber,LookupField__r.Id,LookupField__r.AnnualRevenue', selector.getRelatedFieldListString('LookupField__r')); + System.assertEquals('Account', selector.getSObjectName()); + System.assertEquals(Account.SObjectType, selector.getSObjectType2()); + } private class Testfflib_SObjectSelector extends fflib_SObjectSelector { + public Testfflib_SObjectSelector() + { + super(); + } + + public Testfflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS) + { + super(includeFieldSetFields, enforceCRUD, enforceFLS); + } + public List getSObjectFieldList() { return new List { @@ -150,6 +207,11 @@ private with sharing class fflib_SObjectSelectorTest { return Account.sObjectType; } + + public override String getOrderBy() + { + return 'Name DESC, AnnualRevenue ASC'; + } } /** diff --git a/fflib/src/classes/fflib_StringBuilder.cls b/fflib/src/classes/fflib_StringBuilder.cls index 786be27ef59..a20bea3d076 100644 --- a/fflib/src/classes/fflib_StringBuilder.cls +++ b/fflib/src/classes/fflib_StringBuilder.cls @@ -29,7 +29,7 @@ * * NOTE: Aspects of this where developed before recent improvements to String handling, as such could likely be enhanced at this stage. **/ -public virtual with sharing class fflib_StringBuilder +public virtual class fflib_StringBuilder { protected List buffer = new List(); diff --git a/fflib/src/labels/CustomLabels.labels b/fflib/src/labels/CustomLabels.labels index 6fc85864f89..d4002b0a0d3 100644 --- a/fflib/src/labels/CustomLabels.labels +++ b/fflib/src/labels/CustomLabels.labels @@ -1,5 +1,33 @@ + + 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 diff --git a/fflib/src/package.xml b/fflib/src/package.xml index 2871bab5613..007ecee8024 100644 --- a/fflib/src/package.xml +++ b/fflib/src/package.xml @@ -7,6 +7,7 @@ fflib_SObjectDescribeTest fflib_SObjectDomain fflib_SObjectDomainTest + fflib_SObjectDomainTestCases fflib_SObjectSelector fflib_SObjectSelectorTest fflib_SObjectUnitOfWork From 0491e294c891695f99fcf9a4f5695fe2357505b8 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Fri, 27 Jun 2014 18:20:50 +0100 Subject: [PATCH 10/32] Added new Configuration property - Ability to enable/disable default CRUD security - Ability to enable/disable Trigger state mode --- fflib/src/classes/fflib_SObjectDomain.cls | 96 +++++++++++++++++++---- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectDomain.cls b/fflib/src/classes/fflib_SObjectDomain.cls index da35855b538..93470f423ce 100644 --- a/fflib/src/classes/fflib_SObjectDomain.cls +++ b/fflib/src/classes/fflib_SObjectDomain.cls @@ -49,6 +49,11 @@ public virtual with sharing class fflib_SObjectDomain * Derived from the records provided during construction, provides the native describe for the standard or custom object **/ public Schema.DescribeSObjectResult SObjectDescribe {get; private set;} + + /** + * Exposes the configuration for this domain class instance + **/ + public Configuration Configuration {get; private set;} /** * Useful during unit testign to assert at a more granular and robust level for errors raised during the various trigger events @@ -61,7 +66,7 @@ public virtual with sharing class fflib_SObjectDomain public static TestFactory Test {get; private set;} /** - * Retains instances of domain classes implementing ITriggerStateful + * Retains instances of domain classes implementing trigger stateful **/ private static Map> TriggerStateByClass; @@ -79,9 +84,12 @@ public virtual with sharing class fflib_SObjectDomain **/ public fflib_SObjectDomain(List sObjectList) { - Records = sObjectList.clone(); // Ensure the domain class has its own copy of the data - + // Ensure the domain class has its own copy of the data + Records = sObjectList.clone(); + // Capture SObjectType describe for this domain class SObjectDescribe = Records.getSObjectType().getDescribe(); + // Configure the Domain object instance + Configuration = new Configuration(); } /** @@ -161,7 +169,7 @@ public virtual with sharing class fflib_SObjectDomain **/ public void handleAfterInsert() { - if(!SObjectDescribe.isCreateable()) + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isCreateable()) throw new DomainException('Permission to create an ' + SObjectDescribe.getName() + ' denied.'); onValidate(); @@ -174,8 +182,8 @@ public virtual with sharing class fflib_SObjectDomain * @throws DomainException if the current user context is not able to update records **/ public void handleAfterUpdate(Map existingRecords) - { - if(!SObjectDescribe.isUpdateable()) + { + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isUpdateable()) throw new DomainException('Permission to udpate an ' + SObjectDescribe.getName() + ' denied.'); onValidate(); @@ -190,7 +198,7 @@ public virtual with sharing class fflib_SObjectDomain **/ public void handleAfterDelete() { - if(!SObjectDescribe.isDeletable()) + if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isDeletable()) throw new DomainException('Permission to delete an ' + SObjectDescribe.getName() + ' denied.'); onAfterDelete(); @@ -204,13 +212,6 @@ public virtual with sharing class fflib_SObjectDomain fflib_SObjectDomain construct(List sObjectList); } - /** - * Interface used to indicate the same instance of the Domain class should be shared between - * trigger invocations, e.g. between before and after trigger invocations, also see - * getTriggerInstance method - **/ - public interface ITriggerStateful { } - /** * For Domain classes implementing the ITriggerStateful interface returns the instance * of the domain class being shared between trigger invocations, returns null if @@ -272,7 +273,7 @@ public virtual with sharing class fflib_SObjectDomain else if(isDelete) domainObject = domainConstructor.construct(oldRecordsMap.values()); // Should this instance be reused on the next trigger invocation? - if(domainObject instanceof ITriggerStateful) + if(domainObject.Configuration.TriggerStateEnabled) // Push this instance onto the stack to be popped during the after phase pushTriggerInstance(domainClass, domainObject); } @@ -316,6 +317,67 @@ public virtual with sharing class fflib_SObjectDomain return domain; } + /** + * Fluent style Configuration system for Domain class creation + **/ + public class Configuration + { + /** + * 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; + } + + /** + * 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; + } + } + /** * General exception class for the domain layer **/ @@ -594,13 +656,15 @@ public virtual with sharing class fflib_SObjectDomain **/ public with sharing class TestSObjectStatefulDomain extends fflib_SObjectDomain - implements fflib_SObjectDomain.ITriggerStateful { 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() From 08dd3e1c5047e91260e297038a31088315226499 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sat, 28 Jun 2014 09:47:57 +0100 Subject: [PATCH 11/32] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c79259e2e41..9d6da5c80cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -FinancialForce Common Apex Lib -============================== +FinancialForce Apex Common +========================== **[Deploy to Salesforce](https://githubsfdeploy.herokuapp.com/app/githubdeploy/financialforcedev/fflib-apex-common)** From d70246a63efb500f2d43a212944d1119c471fb75 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sat, 28 Jun 2014 14:28:18 +0100 Subject: [PATCH 12/32] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d6da5c80cc..71ffdcea122 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ FinancialForce Apex Common See here for [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensmate-templates-and-apex-enterprise-patterns/) -Comming Soon! -============= +Updates +======= -Watch this space for exciting updates and new components to this libray from us! +- **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/financialforcedev/fflib-apex-common/tree/fls-support-experiment) supporting automated FLS checking, see [README](https://github.com/financialforcedev/fflib-apex-common/tree/fls-support-experiment#expirimental-crud-and-fls-support) for more details. This Library ============ From 42995cda15663e947c946d6bb914d5867d148b97 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Sun, 29 Jun 2014 11:30:30 +0100 Subject: [PATCH 13/32] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71ffdcea122..18d9cf2aa28 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Updates 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. While also leading up to an updated presentation given at **Dreamforce 2013**. 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/financialforcedev/fflib-apex-common-samplecode). +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/financialforcedev/fflib-apex-common-samplecode). ![Alt text](/images/patternsturning.png "Optional title") @@ -28,8 +28,8 @@ Dreamforce Session and Slides View slides for the **Dreamforce 2013** session [here](https://docs.google.com/file/d/0B6brfGow3cD8RVVYc1dCX2s0S1E/edit) and a video recording of the session [here](http://www.youtube.com/watch?v=qlq46AEAlLI). -Latest Article Series on Developer Force.com --------------------------------------------- +Documentation +------------- I'm proud to have been given the opportunity to run a more detailed look at these patterns on developer.force.com. @@ -42,4 +42,5 @@ I'm proud to have been given the opportunity to run a more detailed look at thes - [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/) From 4c74c8418a98696d5f96eecff77572c36f65abef Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 2 Jul 2014 12:19:38 +0100 Subject: [PATCH 14/32] Update package.xml Removed fflib_SObjectDomainTestCases which is not present in the repo and therefore prevents you deploying using the 'Deploy to Salesforce' link. --- fflib/src/package.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/fflib/src/package.xml b/fflib/src/package.xml index 007ecee8024..2871bab5613 100644 --- a/fflib/src/package.xml +++ b/fflib/src/package.xml @@ -7,7 +7,6 @@ fflib_SObjectDescribeTest fflib_SObjectDomain fflib_SObjectDomainTest - fflib_SObjectDomainTestCases fflib_SObjectSelector fflib_SObjectSelectorTest fflib_SObjectUnitOfWork From 9a294340a3521583b9ab33a0c1eb7d1d1576017d Mon Sep 17 00:00:00 2001 From: Tyler Mowbrey Date: Thu, 3 Jul 2014 14:18:37 -0700 Subject: [PATCH 15/32] updated denied spelling errors --- fflib/src/classes/fflib_SObjectSelector.cls | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index de6407e571e..1e9b66dbf03 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -218,7 +218,7 @@ public abstract with sharing class fflib_SObjectSelector { if(!getSObjectType().getDescribe().isAccessible()) throw new fflib_SObjectDomain.DomainException( - 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.'); + 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' denied.'); } /** @@ -318,7 +318,7 @@ public abstract with sharing class fflib_SObjectSelector } catch (fflib_SecurityUtils.CrudException e) { // Marshal exception into DomainException for backwards compatability throw new fflib_SObjectDomain.DomainException( - 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.'); + 'Permission to access an ' + getSObjectType().getDescribe().getName() + ' denied.'); } } queryFactory.setEnforceFLS(enforceFLS); @@ -357,4 +357,4 @@ public abstract with sharing class fflib_SObjectSelector return queryFactory; } -} \ No newline at end of file +} From 4aa8e8935a7287767b2ca8d3f2634f5ab8faa08a Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Tue, 8 Jul 2014 20:20:40 +0100 Subject: [PATCH 16/32] Update fflib_SObjectSelector.cls Changed the default value of getOrderBy() as not all objects have a 'Name' field (e.g. OpportunityContactRole, CampaignMember). --- fflib/src/classes/fflib_SObjectSelector.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index 1e9b66dbf03..240b20b42d5 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -102,7 +102,7 @@ public abstract with sharing class fflib_SObjectSelector **/ public virtual String getOrderBy() { - return 'Name'; + return 'CreatedDate'; } /** From 1b4ae15d96da6460567cb70c613777cf745e7aa8 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Tue, 8 Jul 2014 20:23:00 +0100 Subject: [PATCH 17/32] Update fflib_SObjectSelectorTest.cls Fixed test failures (hopefully the diff makes sense this time): System.AssertException: Assertion Failed: Expected: Permission to access an Account dennied., Actual: Permission to access an Account denied. Class.fflib_SObjectSelectorTest.testAssertIsAccessible: line 132, column 1 System.AssertException: Assertion Failed: Expected: Name,AccountNumber,Id,AnnualRevenue, Actual: AccountNumber,AnnualRevenue,Id,Name Class.fflib_SObjectSelectorTest.testDefaultConfig: line 177, column 1 System.AssertException: Assertion Failed: Expected: myprefix.Name,myprefix.Id,myprefix.AccountNumber,myprefix.AnnualRevenue, Actual: myprefix.AccountNumber,myprefix.AnnualRevenue,myprefix.Id,myprefix.Name Class.fflib_SObjectSelectorTest.testGetFieldListString: line 43, column 1 --- fflib/src/classes/fflib_SObjectSelectorTest.cls | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index 9219bbc7475..e9d9345f75c 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -129,7 +129,7 @@ private with sharing class fflib_SObjectSelectorTest } catch(fflib_SObjectDomain.DomainException e) { - System.assertEquals('Permission to access an Account dennied.',e.getMessage()); + System.assertEquals('Permission to access an Account denied.',e.getMessage()); } } } @@ -197,8 +197,8 @@ private with sharing class fflib_SObjectSelectorTest { return new List { Account.Name, - Account.Id, Account.AccountNumber, + Account.Id, Account.AnnualRevenue }; } From 2e61885d6a135632f0d7ab95e5a6adfcb29327b7 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Tue, 8 Jul 2014 20:35:21 +0100 Subject: [PATCH 18/32] Update fflib_SObjectSelectorTest.cls Reverted changes in last commit and fixed tests properly. --- fflib/src/classes/fflib_SObjectSelectorTest.cls | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index e9d9345f75c..1e75801c7c5 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -40,7 +40,7 @@ private with sharing class fflib_SObjectSelectorTest system.assert(fieldSet.contains('AnnualRevenue')); if(UserInfo.isMultiCurrencyOrganization()) system.assert(fieldSet.contains('CurrencyIsoCode')); - system.assertEquals('myprefix.Name,myprefix.Id,myprefix.AccountNumber,myprefix.AnnualRevenue', selector.getRelatedFieldListString('myprefix')); + system.assertEquals('myprefix.AccountNumber,myprefix.AnnualRevenue,myprefix.Id,myprefix.Name', selector.getRelatedFieldListString('myprefix')); } static testMethod void testGetSObjectName() @@ -174,9 +174,9 @@ private with sharing class fflib_SObjectSelectorTest System.assertEquals(false, selector.isEnforcingFLS()); System.assertEquals(true, selector.isEnforcingCRUD()); System.assertEquals(false, selector.isIncludeFieldSetFields()); - System.assertEquals('Name,AccountNumber,Id,AnnualRevenue', selector.getFieldListBuilder().getStringValue()); - System.assertEquals('Name,AccountNumber,Id,AnnualRevenue', selector.getFieldListString()); - System.assertEquals('LookupField__r.Name,LookupField__r.AccountNumber,LookupField__r.Id,LookupField__r.AnnualRevenue', selector.getRelatedFieldListString('LookupField__r')); + System.assertEquals('AccountNumber,AnnualRevenue,Id,Name', selector.getFieldListBuilder().getStringValue()); + System.assertEquals('AccountNumber,AnnualRevenue,Id,Name', selector.getFieldListString()); + System.assertEquals('LookupField__r.AccountNumber,LookupField__r.AnnualRevenue,LookupField__r.Id,LookupField__r.Name', selector.getRelatedFieldListString('LookupField__r')); System.assertEquals('Account', selector.getSObjectName()); System.assertEquals(Account.SObjectType, selector.getSObjectType2()); } @@ -197,8 +197,8 @@ private with sharing class fflib_SObjectSelectorTest { return new List { Account.Name, - Account.AccountNumber, Account.Id, + Account.AccountNumber, Account.AnnualRevenue }; } From 0a42cdaf48747d8edb32a88e66bdc24e1862bfee Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 9 Jul 2014 16:51:39 +0100 Subject: [PATCH 19/32] Update fflib_SObjectDescribe.cls Added getNameField() method. --- fflib/src/classes/fflib_SObjectDescribe.cls | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls b/fflib/src/classes/fflib_SObjectDescribe.cls index 0d2fdcfb3ca..e90684c199a 100644 --- a/fflib/src/classes/fflib_SObjectDescribe.cls +++ b/fflib/src/classes/fflib_SObjectDescribe.cls @@ -105,6 +105,26 @@ public class fflib_SObjectDescribe { } return result; } + + /** + * Returns the field where isNameField() is true (if any); otherwise returns null + **/ + public Schema.SObjectField getNameField() + { + Schema.SObjectField 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. **/ @@ -302,4 +322,4 @@ public class fflib_SObjectDescribe { public abstract class DescribeException extends Exception{} public class DuplicateDescribeException extends DescribeException{} //Test coverage for this requires APIv28's @testVisbile annotation to force exception cases. public class InvalidDescribeException extends DescribeException{} -} \ No newline at end of file +} From 073df3b7c661f0ba4194dc77a8dd14c0d7eed533 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 9 Jul 2014 16:52:38 +0100 Subject: [PATCH 20/32] Update fflib_SObjectDescribeTest.cls Added test method for fflib_SObjectDescribe.getNameField(). --- fflib/src/classes/fflib_SObjectDescribeTest.cls | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fflib/src/classes/fflib_SObjectDescribeTest.cls b/fflib/src/classes/fflib_SObjectDescribeTest.cls index b999d209146..5d0bdb21fd8 100644 --- a/fflib/src/classes/fflib_SObjectDescribeTest.cls +++ b/fflib/src/classes/fflib_SObjectDescribeTest.cls @@ -112,6 +112,13 @@ private class fflib_SObjectDescribeTest { 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(){ @@ -128,4 +135,4 @@ private class fflib_SObjectDescribeTest { System.assertEquals(systemGd.size(),cachedGd.size()); } -} \ No newline at end of file +} From a0b28a43b703b5fe03a16db40d101832b11c7305 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 9 Jul 2014 17:11:30 +0100 Subject: [PATCH 21/32] Update fflib_SObjectSelector.cls Added a lazy loaded fflib_SObjectDescribe property to handle internal describe calls, updated getOrderBy() to use the name field of the object if present, otherwise the CreatedDate field. --- fflib/src/classes/fflib_SObjectSelector.cls | 44 +++++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index 240b20b42d5..bb78fd993d0 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -48,7 +48,42 @@ public abstract with sharing class fflib_SObjectSelector * Enforce CRUD Security **/ private Boolean m_enforceCRUD; - + + /** + * Describe helper + **/ + private fflib_SObjectDescribe describeWrapper + { + get + { + if(describeWrapper == null) + describeWrapper = fflib_SObjectDescribe.getDescribe(getSObjectType()); + return describeWrapper; + } + set; + } + + /** + * Order by field + **/ + private String orderBy + { + get + { + if(orderBy == null) + { + orderBy = 'CreatedDate'; + + if(describeWrapper.getNameField() != null) + { + orderBy = describeWrapper.getNameField().getDescribe().getName(); + } + } + return orderBy; + } + set; + } + /** * Implement this method to inform the base class of the SObject (custom or standard) to be queried **/ @@ -98,11 +133,12 @@ public abstract with sharing class fflib_SObjectSelector } /** - * Override this method to control the default ordering of records returned by the base queries, defaults to Name + * Override this method to control the default ordering of records returned by the base queries, + * defaults to the name field of the object or CreatedDate if there is none **/ public virtual String getOrderBy() { - return 'CreatedDate'; + return orderBy; } /** @@ -180,7 +216,7 @@ public abstract with sharing class fflib_SObjectSelector **/ public String getSObjectName() { - return getSObjectType().getDescribe().getName(); + return describeWrapper.getDescribe().getName(); } /** From b795cfd06e2e9a90586d8ca5331d3f964960b871 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 9 Jul 2014 17:14:22 +0100 Subject: [PATCH 22/32] Update fflib_SObjectDescribe.cls Changed getNameField() to use a lazy loaded private property. --- fflib/src/classes/fflib_SObjectDescribe.cls | 25 ++++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls b/fflib/src/classes/fflib_SObjectDescribe.cls index e90684c199a..cb06a87ff06 100644 --- a/fflib/src/classes/fflib_SObjectDescribe.cls +++ b/fflib/src/classes/fflib_SObjectDescribe.cls @@ -72,6 +72,20 @@ public class fflib_SObjectDescribe { } set; } + private Schema.SObjectField nameField { + get{ + if(nameField == null) { + for(Schema.SObjectField field : wrappedFields.values()) { + if(field.getDescribe().isNameField()) { + nameField = field; + break; + } + } + } + return nameField; + } + set; + } private fflib_SObjectDescribe(Schema.SObjectType token){ if(token == null) throw new InvalidDescribeException('Invalid SObject type: null'); @@ -111,17 +125,6 @@ public class fflib_SObjectDescribe { **/ public Schema.SObjectField getNameField() { - Schema.SObjectField nameField = null; - - for(Schema.SObjectField field : wrappedFields.values()) - { - if(field.getDescribe().isNameField()) - { - nameField = field; - break; - } - } - return nameField; } From 66a6922076707f94826b69e1a94c112407ce7f9d Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Wed, 9 Jul 2014 17:19:04 +0100 Subject: [PATCH 23/32] Update fflib_SObjectSelector.cls Minor formatting changes to conform with the code style of the rest of the library. --- fflib/src/classes/fflib_SObjectSelector.cls | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index bb78fd993d0..498fbfd9a6a 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -52,10 +52,8 @@ public abstract with sharing class fflib_SObjectSelector /** * Describe helper **/ - private fflib_SObjectDescribe describeWrapper - { - get - { + private fflib_SObjectDescribe describeWrapper { + get { if(describeWrapper == null) describeWrapper = fflib_SObjectDescribe.getDescribe(getSObjectType()); return describeWrapper; @@ -66,16 +64,11 @@ public abstract with sharing class fflib_SObjectSelector /** * Order by field **/ - private String orderBy - { - get - { - if(orderBy == null) - { + private String orderBy { + get { + if(orderBy == null) { orderBy = 'CreatedDate'; - - if(describeWrapper.getNameField() != null) - { + if(describeWrapper.getNameField() != null) { orderBy = describeWrapper.getNameField().getDescribe().getName(); } } From 93d58eea4872c9866f6d2a4f45633098a2df6f37 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Thu, 10 Jul 2014 10:45:52 +0100 Subject: [PATCH 24/32] Update fflib_SObjectDescribe.cls Removed the nameField property and put it inline in getNameField(). --- fflib/src/classes/fflib_SObjectDescribe.cls | 24 +++++++++------------ 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls b/fflib/src/classes/fflib_SObjectDescribe.cls index cb06a87ff06..fc957b860fa 100644 --- a/fflib/src/classes/fflib_SObjectDescribe.cls +++ b/fflib/src/classes/fflib_SObjectDescribe.cls @@ -39,6 +39,7 @@ public class fflib_SObjectDescribe { //internal implementation details private Schema.SObjectType token; + private String nameField; private Schema.DescribeSObjectResult describe { //lazy load - keep this leightweight until we need more data get{ if(describe == null) @@ -72,20 +73,7 @@ public class fflib_SObjectDescribe { } set; } - private Schema.SObjectField nameField { - get{ - if(nameField == null) { - for(Schema.SObjectField field : wrappedFields.values()) { - if(field.getDescribe().isNameField()) { - nameField = field; - break; - } - } - } - return nameField; - } - set; - } + private fflib_SObjectDescribe(Schema.SObjectType token){ if(token == null) throw new InvalidDescribeException('Invalid SObject type: null'); @@ -125,6 +113,14 @@ public class fflib_SObjectDescribe { **/ public Schema.SObjectField getNameField() { + if(nameField == null) { + for(Schema.SObjectField field : wrappedFields.values()) { + if(field.getDescribe().isNameField()) { + nameField = field; + break; + } + } + } return nameField; } From de11840d3f76b0d79f64841551f1cfb717ba2cdf Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Thu, 10 Jul 2014 10:48:48 +0100 Subject: [PATCH 25/32] Update fflib_SObjectSelector.cls Removed orderBy property and put inline in getOrderBy(). --- fflib/src/classes/fflib_SObjectSelector.cls | 29 +++++++++------------ 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index 498fbfd9a6a..e4cb0ab6ce9 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -49,6 +49,11 @@ public abstract with sharing class fflib_SObjectSelector **/ private Boolean m_enforceCRUD; + /** + * Order by field + **/ + private String m_orderBy; + /** * Describe helper **/ @@ -61,22 +66,6 @@ public abstract with sharing class fflib_SObjectSelector set; } - /** - * Order by field - **/ - private String orderBy { - get { - if(orderBy == null) { - orderBy = 'CreatedDate'; - if(describeWrapper.getNameField() != null) { - orderBy = describeWrapper.getNameField().getDescribe().getName(); - } - } - return orderBy; - } - set; - } - /** * Implement this method to inform the base class of the SObject (custom or standard) to be queried **/ @@ -131,7 +120,13 @@ public abstract with sharing class fflib_SObjectSelector **/ public virtual String getOrderBy() { - return orderBy; + if(m_orderBy == null) { + m_orderBy = 'CreatedDate'; + if(describeWrapper.getNameField() != null) { + m_orderBy = describeWrapper.getNameField().getDescribe().getName(); + } + } + return m_orderBy; } /** From cc356c39ab5fc5ac5df64d1d19355969ad127833 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Thu, 10 Jul 2014 10:55:10 +0100 Subject: [PATCH 26/32] Update fflib_SObjectDescribe.cls Changed the type of nameField from String to Schema.SObjectField so that the code actually compiles. --- fflib/src/classes/fflib_SObjectDescribe.cls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fflib/src/classes/fflib_SObjectDescribe.cls b/fflib/src/classes/fflib_SObjectDescribe.cls index fc957b860fa..79d40fb5ed9 100644 --- a/fflib/src/classes/fflib_SObjectDescribe.cls +++ b/fflib/src/classes/fflib_SObjectDescribe.cls @@ -39,7 +39,7 @@ public class fflib_SObjectDescribe { //internal implementation details private Schema.SObjectType token; - private String nameField; + private Schema.SObjectField nameField; private Schema.DescribeSObjectResult describe { //lazy load - keep this leightweight until we need more data get{ if(describe == null) From 3d9e43554cb1671f795bc1f39d7661a2d1765b07 Mon Sep 17 00:00:00 2001 From: Alex Tennant Date: Thu, 17 Jul 2014 10:30:32 +0100 Subject: [PATCH 27/32] Update fflib_SObjectSelectorTest.cls Updated testGetFieldListString(), testSOQL() and testDefaultConfig() so that they pass on Multicurrency orgs. --- .../src/classes/fflib_SObjectSelectorTest.cls | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index 1e75801c7c5..c758e0fe94f 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -33,14 +33,17 @@ private with sharing class fflib_SObjectSelectorTest Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); List fieldList = selector.getFieldListString().split(','); Set fieldSet = new Set(fieldList); - system.assertEquals(4, fieldSet.size()); + system.assertEquals(Userinfo.isMultiCurrencyOrganization() ? 5 : 4, fieldSet.size()); 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')); - system.assertEquals('myprefix.AccountNumber,myprefix.AnnualRevenue,myprefix.Id,myprefix.Name', selector.getRelatedFieldListString('myprefix')); + + String relatedFieldListString = Userinfo.isMultiCurrencyOrganization() ? 'myprefix.AccountNumber,myprefix.CurrencyIsoCode,myprefix.AnnualRevenue,myprefix.Id,myprefix.Name' + : 'myprefix.AccountNumber,myprefix.AnnualRevenue,myprefix.Id,myprefix.Name'; + system.assertEquals(relatedFieldListString, selector.getRelatedFieldListString('myprefix')); } static testMethod void testGetSObjectName() @@ -165,7 +168,9 @@ private with sharing class fflib_SObjectSelectorTest static testMethod void testSOQL() { Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); - System.assertEquals('SELECT AccountNumber, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC', selector.newQueryFactory().toSOQL()); + String soql = Userinfo.isMultiCurrencyOrganization() ? 'SELECT AccountNumber, CurrencyIsoCode, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC' + : 'SELECT AccountNumber, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC'; + System.assertEquals(soql, selector.newQueryFactory().toSOQL()); } static testMethod void testDefaultConfig() @@ -174,9 +179,16 @@ private with sharing class fflib_SObjectSelectorTest System.assertEquals(false, selector.isEnforcingFLS()); System.assertEquals(true, selector.isEnforcingCRUD()); System.assertEquals(false, selector.isIncludeFieldSetFields()); - System.assertEquals('AccountNumber,AnnualRevenue,Id,Name', selector.getFieldListBuilder().getStringValue()); - System.assertEquals('AccountNumber,AnnualRevenue,Id,Name', selector.getFieldListString()); - System.assertEquals('LookupField__r.AccountNumber,LookupField__r.AnnualRevenue,LookupField__r.Id,LookupField__r.Name', selector.getRelatedFieldListString('LookupField__r')); + + String fieldListString = Userinfo.isMultiCurrencyOrganization() ? 'AccountNumber,CurrencyIsoCode,AnnualRevenue,Id,Name' + : 'AccountNumber,AnnualRevenue,Id,Name'; + System.assertEquals(fieldListString, selector.getFieldListBuilder().getStringValue()); + System.assertEquals(fieldListString, selector.getFieldListString()); + + String relatedFieldListString = Userinfo.isMultiCurrencyOrganization() ? 'LookupField__r.AccountNumber,LookupField__r.CurrencyIsoCode,LookupField__r.AnnualRevenue,LookupField__r.Id,LookupField__r.Name' + : 'LookupField__r.AccountNumber,LookupField__r.AnnualRevenue,LookupField__r.Id,LookupField__r.Name'; + System.assertEquals(relatedFieldListString, selector.getRelatedFieldListString('LookupField__r')); + System.assertEquals('Account', selector.getSObjectName()); System.assertEquals(Account.SObjectType, selector.getSObjectType2()); } From f3fd27f403f2326c9f59004ac1af9d711cc8cf6b Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Fri, 18 Jul 2014 18:32:48 +0100 Subject: [PATCH 28/32] Updated QueryFactory class Minor API change relating to sorting vs ordering --- fflib/src/classes/fflib_QueryFactory.cls | 189 +++++++++++------- fflib/src/classes/fflib_QueryFactoryTest.cls | 144 ++++++++++--- fflib/src/classes/fflib_SObjectSelector.cls | 8 +- .../src/classes/fflib_SObjectSelectorTest.cls | 6 +- 4 files changed, 240 insertions(+), 107 deletions(-) diff --git a/fflib/src/classes/fflib_QueryFactory.cls b/fflib/src/classes/fflib_QueryFactory.cls index f705d1dc26d..fc40cf49b62 100644 --- a/fflib/src/classes/fflib_QueryFactory.cls +++ b/fflib/src/classes/fflib_QueryFactory.cls @@ -36,6 +36,9 @@ * 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 sort expression(s), use one of the addOrdering methods. If not specified, the "NULLS FIRST" keywords + * will be included by default. + * * Subselect Queries are supported with the subselectQuery method. * 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 @@ -45,6 +48,9 @@ * - 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 guideance 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} @@ -54,6 +60,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * 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; private Integer limitCount; @@ -61,10 +68,9 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr private List order; /** * each item in sortExpressions contains the field and the direction (ascending or descending) - * use the addSort method to add fields to sort by. the sort fields + * use the addOrdering method to add fields to sort by. the sort fields * appear in the SOQL query in the order they are added to the query. **/ - private List sortExpressions; /** /* 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 @@ -89,7 +95,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr throw new InvalidFieldException(fieldName,this.table); if (enforceFLS) fflib_SecurityUtils.checkFieldIsReadable(this.table, token); - result = new QueryField(table,token); + result = new QueryField(token); }else{ //traversing FK relationship(s) List fieldPath = new List(); Schema.sObjectType lastSObjectType = table; @@ -111,7 +117,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr throw new NonReferenceFieldException(lastSObjectType+'.'+field+' is not a lookup or master-detail field but is used in a cross-object query field.'); } } - result = new QueryField(this.table, fieldPath); + result = new QueryField(fieldPath); } return result; } @@ -137,9 +143,9 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr this.table = table; fields = new Set(); order = new List(); - sortExpressions = 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. @@ -156,7 +162,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * This method checks to see if the User has Read Access on {@link #table}. * Asserts true if User has access. **/ - public fflib_QueryFactory assertCRUD(){ + public fflib_QueryFactory assertIsAccessible(){ fflib_SecurityUtils.checkObjectIsReadable(table); return this; } @@ -177,10 +183,10 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * 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){ + public fflib_QueryFactory selectField(String fieldName){ fields.add( getFieldToken(fieldName) ); return this; - } + } /** * Selects a field, avoiding the possible ambiguitiy of String API names. * @see #selectField(String) @@ -192,7 +198,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr throw new InvalidFieldException(null,this.table); if (enforceFLS) fflib_SecurityUtils.checkFieldIsReadable(table, field); - fields.add( new QueryField(table,field) ); + fields.add( new QueryField(field) ); return this; } /** @@ -200,9 +206,11 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * @param fieldNames the Set of field API names to select. **/ public fflib_QueryFactory selectFields(Set fieldNames){ + List fieldList = new List(); Set toAdd = new Set(); - for(String fieldName:fieldNames) + for(String fieldName:fieldNames){ toAdd.add( getFieldToken(fieldName) ); + } fields.addAll(toAdd); return this; } @@ -228,7 +236,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr throw new InvalidFieldException(); if (enforceFLS) fflib_SecurityUtils.checkFieldIsReadable(table, token); - this.fields.add( new QueryField(table, token) ); + this.fields.add( new QueryField(token) ); } return this; } @@ -243,7 +251,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr throw new InvalidFieldException(); if (enforceFLS) fflib_SecurityUtils.checkFieldIsReadable(table, token); - this.fields.add( new QueryField(table, token) ); + this.fields.add( new QueryField(token) ); } return this; } @@ -261,25 +269,10 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr **/ public fflib_QueryFactory selectFieldSet(Schema.FieldSet fieldSet, Boolean allowCrossObject){ if(fieldSet.getSObjectType() != table) - throw new InvalidFieldSetException( - String.format( - System.label.fflib_QueryFactory_fieldset_wrong_sobject_error, - new List{ - fieldSet.getName(), - table.getDescribe().getName() - } - ) - ); + 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( - String.format( - System.label.fflib_QueryFactory_crossobject_fieldsets_not_allowed_error, - new List{ - field.getFieldPath() - } - ) - ); + throw new InvalidFieldSetException('Cross-object fields not allowed and field "'+field.getFieldPath()+'"" is a cross-object field.'); fields.add( getFieldToken(field.getFieldPath()) ); } return this; @@ -338,7 +331,18 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * @param related The related object type **/ public fflib_QueryFactory subselectQuery(SObjectType related){ - return setSubselectQuery(getChildRelationship(related)); + 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. + * @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){ + return setSubselectQuery(getChildRelationship(related), assertIsAccessible); } /** @@ -347,9 +351,9 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * @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 setSubselectQuery(ChildRelationship relationship){ + private fflib_QueryFactory setSubselectQuery(ChildRelationship relationship, Boolean assertIsAccessible){ if (this.relationship != null){ - throw new InvalidSubqueryRelationshipException(System.label.fflib_QueryFactory_recursive_subquery_error); + 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(); @@ -359,9 +363,11 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr } fflib_QueryFactory subselectQuery = new fflib_QueryFactory(relationship); + 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. **/ @@ -384,7 +390,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr return childRow; } } - throw new InvalidSubqueryRelationshipException(System.label.fflib_QueryFactory_subquery_invalid_relationship); + throw new InvalidSubqueryRelationshipException('Invalid call to subselectQuery. Invalid relationship for table '+table + ' and objtype='+objType); } /** @@ -396,9 +402,49 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * were added in. * @param fieldName The string value of the field to be sorted on * @param SortOrder 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(getFieldToken(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 SortOrder 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(new QueryField(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 SortOrder the direction to be sorted on (ASCENDING or DESCENDING) **/ - public fflib_QueryFactory addSort(String fieldName, SortOrder direction){ - sortExpressions.add(new SortExpression(getFieldToken(fieldName), direction)); + public fflib_QueryFactory addOrdering(String fieldName, SortOrder direction){ + order.add( + new Ordering(getFieldToken(fieldName), direction) + ); return this; } @@ -409,11 +455,15 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * 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 SortOrder the direction to be sorted on (ASCENDING or DESCENDING) **/ - public fflib_QueryFactory addSort(SObjectField field, SortOrder direction){ - sortExpressions.add(new SortExpression(new QueryField(table, field), direction)); + public fflib_QueryFactory addOrdering(SObjectField field, SortOrder direction){ + order.add( + new Ordering(new QueryField(field), direction) + ); return this; } @@ -442,7 +492,7 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr result += ' WHERE '+conditionExpression; /** * check both the order and sortexpressions for fields to order by. the order list uses an older method of sorting (addOrdering method), but does not work well with sorting related object fields. - * newer method uses sortExpressions list (addSort method), which work with all fields and can handle multiple sorts expressions (ie. order by last name desc, first name asc) + * newer method uses sortExpressions list (addOrdering method), which work with all fields and can handle multiple sorts expressions (ie. order by last name desc, first name asc) * both methods may be used together on a single query factory **/ if(order.size() > 0){ @@ -452,17 +502,6 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr result = result.substring(0,result.length()-2); } - if (sortExpressions.size() > 0){ - if (order.size() == 0){ - result += ' ORDER BY '; - } else{ - result += ', '; - } - for(SortExpression o:sortExpressions) - result += o.toSOQL() +', '; - result = result.substring(0,result.length()-2); - } - System.debug('resultaft='+result); if(limitCount != null) result += ' LIMIT '+limitCount; return result; @@ -470,8 +509,8 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr public class Ordering{ private SortOrder direction; - private boolean nullsFirst; - private Schema.SObjectField field; + private boolean nullsLast; + private QueryField field; public Ordering(String sobjType, String fieldName, SortOrder direction){ this( @@ -484,32 +523,41 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr * Once constructed it's properties may not be modified. **/ public Ordering(Schema.SObjectField field, SortOrder direction){ + this(field, direction, false); //SOQL docs state NULLS FIRST is default behavior + } + public Ordering(Schema.SObjectField field, SortOrder direction, Boolean nullsLast){ + this(new QueryField(field), direction, nullsLast); + } + @testVisible + private Ordering(QueryField field, SortOrder direction){ + this(field, direction, false); + } + @testVisible + private Ordering(QueryField field, SortOrder direction, Boolean nullsLast){ this.direction = direction; this.field = field; + this.nullsLast = nullsLast; } + /** + * @deprecated + * Use of this method is discouraged. Only the first field of any cross-object fields is returned. + * Use getFields() instead. + **/ public Schema.SObjectField getField(){ - return field; + System.debug(LoggingLevel.WARN, 'fflib_QueryFactory.Ordering.getField is deprecated and should not be used.'); + return field.getBaseField(); + } + public List getFields(){ + return this.field.getFieldPath(); } public SortOrder getDirection(){ return direction; } public String toSOQL(){ - return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC'); + return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC') + (nullsLast ? ' NULLS LAST ' : ' NULLS FIRST '); } } - @TestVisible - private class SortExpression{ - SortOrder direction; - QueryField field; - public SortExpression(QueryField field, SortOrder direction){ - this.field = field; - this.direction = direction; - } - public String toSOQL(){ - return field + ' ' + (direction == SortOrder.ASCENDING ? 'ASC' : 'DESC'); - } - } public class QueryField{ List fields; @@ -529,15 +577,15 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr } @testVisible - private QueryField(Schema.SObjectType table, List fields){ + private QueryField(List fields){ if(fields == null || fields.size() == 0) - throw new InvalidFieldException(null, table); + throw new InvalidFieldException('Invalid field: null'); this.fields = fields.clone(); //don't let clients mutate after setting! } @testVisible - private QueryField(Schema.SObjectType table, Schema.SObjectField field){ + private QueryField(Schema.SObjectField field){ if(field == null) - throw new InvalidFieldException(null, table); + throw new InvalidFieldException('Invalid field: null'); fields = new List{ field }; } public override String toString(){ @@ -580,6 +628,5 @@ public class fflib_QueryFactory { //No explicit sharing declaration - inherit fr } public class InvalidFieldSetException extends Exception{} public class NonReferenceFieldException extends Exception{} - public class InvalidSubqueryRelationshipException extends Exception{} - + public class InvalidSubqueryRelationshipException extends Exception{} } \ No newline at end of file diff --git a/fflib/src/classes/fflib_QueryFactoryTest.cls b/fflib/src/classes/fflib_QueryFactoryTest.cls index 15efe2063de..cce98205274 100644 --- a/fflib/src/classes/fflib_QueryFactoryTest.cls +++ b/fflib/src/classes/fflib_QueryFactoryTest.cls @@ -55,7 +55,7 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('email'); qf.setCondition( whereClause ); - System.assertEquals(whereClause,qf.getCondition()); + 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); } @@ -167,7 +167,7 @@ private class fflib_QueryFactoryTest { static void invalidFields_string(){ fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); qf.selectField('name'); - Exception e; + Exception e; try{ qf.selectFields( new List{'not_a__field'} ); }catch(fflib_QueryFactory.InvalidFieldException ex){ @@ -206,14 +206,13 @@ private class fflib_QueryFactoryTest { System.assertNotEquals(null,e); } - //Constructing a queryfield with no fields should never happen, but queryfield protects against it anyways @isTest static void invalidFields_noQueryFields(){ Exception e; List sObjectFields = new List(); try { - fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, sObjectFields); - } catch (fflib_QueryFactory.InvalidFieldException ex) { + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(sObjectFields); + } catch (Exception ex) { e = ex; } System.assertNotEquals(null,e); @@ -224,7 +223,7 @@ private class fflib_QueryFactoryTest { Exception e; Schema.SObjectField sObjectField; try { - fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, sObjectField); + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(sObjectField); } catch (Exception ex) { e = ex; } @@ -235,15 +234,15 @@ private class fflib_QueryFactoryTest { static void invalidFields_queryFieldsNotEquals(){ Exception e; Schema.SObjectField sObjectField; - fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, Contact.Name); - fflib_QueryFactory.QueryField qfld2 = new fflib_QueryFactory.QueryField(Contact.SObjectType, Contact.LastName); + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.Name); + fflib_QueryFactory.QueryField qfld2 = new fflib_QueryFactory.QueryField(Contact.LastName); System.assert(!qfld.equals(qfld2)); } @isTest static void queryIdFieldNotEquals(){ //this is the equivalent of calling setField('account.name'), where table = Contact - fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, new List{ + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(new List{ Schema.Contact.SObjectType.fields.AccountId, Schema.Account.SObjectType.fields.name }); @@ -252,7 +251,7 @@ private class fflib_QueryFactoryTest { @isTest static void queryIdFieldNotEqualsWrongObjType(){ - fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(Contact.SObjectType, new List{ + fflib_QueryFactory.QueryField qfld = new fflib_QueryFactory.QueryField(new List{ Schema.Contact.SObjectType.fields.AccountId}); System.assert(!qfld.equals(new Contact())); } @@ -274,13 +273,13 @@ private class fflib_QueryFactoryTest { insert tsk; fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); - qf.selectField('name').selectField('Id').setCondition( 'name like \'%test%\'' ).addSort('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); + qf.selectField('name').selectField('Id').setCondition( 'name like \'%test%\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING, true); Schema.DescribeSObjectResult descResult = Contact.SObjectType.getDescribe(); - qf.subselectQuery(Task.SObjectType).selectField('Id').selectField('Subject').setCondition(' IsDeleted = false '); + //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); - String query = qf.toSOQL(); - List contacts = Database.query(query); + List contacts = Database.query(qf.toSOQL()); System.assert(contacts != null && contacts.size() == 1); System.assert(contacts[0].Tasks.size() == 1); System.assert(contacts[0].Tasks[0].Subject == 'test'); @@ -305,7 +304,7 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('Id'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort('CreatedBy.Name',fflib_QueryFactory.SortOrder.DESCENDING); + 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(); ChildRelationship relationship; for (Schema.ChildRelationship childRow : descResult.getChildRelationships()) { @@ -315,6 +314,9 @@ private class fflib_QueryFactoryTest { } 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); @@ -335,17 +337,16 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('email'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort( 'CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); + 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 { fflib_QueryFactory childQf = qf.subselectQuery(Contact.SObjectType); childQf.selectField('Id'); - } catch (Exception ex) { + } catch (fflib_QueryFactory.InvalidSubqueryRelationshipException ex) { e = ex; } System.assertNotEquals(e, null); - System.assert(e.getMessage().containsIgnoreCase('Invalid relationship')); } @isTest @@ -362,7 +363,7 @@ private class fflib_QueryFactoryTest { qf.selectField('name'); qf.selectField('email'); qf.setCondition( 'name like \'%test%\'' ); - qf.addOrdering( new fflib_QueryFactory.Ordering('Contact','name',fflib_QueryFactory.SortOrder.ASCENDING) ).addSort('CreatedDATE',fflib_QueryFactory.SortOrder.DESCENDING); + 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); @@ -371,18 +372,16 @@ private class fflib_QueryFactoryTest { Exception e; try { fflib_QueryFactory subChildQf = childQf.subselectQuery(Task.SObjectType); - } catch (Exception ex) { + } catch (fflib_QueryFactory.InvalidSubqueryRelationshipException ex) { e = ex; } System.assertNotEquals(e, null); - System.assert(e.getMessage().containsIgnoreCase('invalid')); - System.assert(e.getMessage().containsIgnoreCase('subselect query')); } @isTest - static void checkFieldObjectRead(){ + static void checkFieldObjectReadSort_success(){ fflib_QueryFactory qf = new fflib_QueryFactory(Contact.SObjectType); - qf.assertCRUD() + qf.assertIsAccessible() .setEnforceFLS(true) .selectField('createdby.name') .selectField(Contact.LastModifiedById) @@ -391,18 +390,105 @@ private class fflib_QueryFactoryTest { .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) ) - .addSort(Contact.LastModifiedDate,fflib_QueryFactory.SortOrder.DESCENDING); - qf.getSelectedFields(); - qf.toSOQL(); + .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); + ordering.getFields(); + for (fflib_QueryFactory.QueryField qfRow : fields) { + SObjectField fld = qfRow.getBaseField(); + List flds = qfRow.getFieldPath(); + break; + } + 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 queryWithNoFields(){ + 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.assertCRUD().setEnforceFLS(true).setCondition( 'name like \'%test%\'' ).addSort('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); + qf.assertIsAccessible().setEnforceFLS(true).setCondition( 'name like \'%test%\'' ).addOrdering('CreatedDate',fflib_QueryFactory.SortOrder.DESCENDING); String query = qf.toSOQL(); System.assert(query.containsIgnoreCase('Id FROM')); } + + 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; + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectSelector.cls b/fflib/src/classes/fflib_SObjectSelector.cls index e4cb0ab6ce9..dce11d20da6 100644 --- a/fflib/src/classes/fflib_SObjectSelector.cls +++ b/fflib/src/classes/fflib_SObjectSelector.cls @@ -65,7 +65,7 @@ public abstract with sharing class fflib_SObjectSelector } set; } - + /** * Implement this method to inform the base class of the SObject (custom or standard) to be queried **/ @@ -338,7 +338,7 @@ public abstract with sharing class fflib_SObjectSelector { try { // Leverage QueryFactory for CRUD checking - queryFactory.assertCRUD(); + queryFactory.assertIsAccessible(); } catch (fflib_SecurityUtils.CrudException e) { // Marshal exception into DomainException for backwards compatability throw new fflib_SObjectDomain.DomainException( @@ -376,9 +376,9 @@ public abstract with sharing class fflib_SObjectSelector fieldSortOrder = fflib_QueryFactory.SortOrder.DESCENDING; else if(fieldSortOrderPart.equalsIgnoreCase('ASC')) fieldSortOrder = fflib_QueryFactory.SortOrder.ASCENDING; - queryFactory.addSort(fieldNamePart, fieldSortOrder); + queryFactory.addOrdering(fieldNamePart, fieldSortOrder); } return queryFactory; } -} +} \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectSelectorTest.cls b/fflib/src/classes/fflib_SObjectSelectorTest.cls index c758e0fe94f..3dd99c59484 100644 --- a/fflib/src/classes/fflib_SObjectSelectorTest.cls +++ b/fflib/src/classes/fflib_SObjectSelectorTest.cls @@ -168,8 +168,8 @@ private with sharing class fflib_SObjectSelectorTest static testMethod void testSOQL() { Testfflib_SObjectSelector selector = new Testfflib_SObjectSelector(); - String soql = Userinfo.isMultiCurrencyOrganization() ? 'SELECT AccountNumber, CurrencyIsoCode, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC' - : 'SELECT AccountNumber, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC, AnnualRevenue ASC'; + String soql = Userinfo.isMultiCurrencyOrganization() ? 'SELECT AccountNumber, CurrencyIsoCode, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC NULLS FIRST , AnnualRevenue ASC NULLS FIRST ' + : 'SELECT AccountNumber, AnnualRevenue, Id, Name FROM Account ORDER BY Name DESC NULLS FIRST , AnnualRevenue ASC NULLS FIRST '; System.assertEquals(soql, selector.newQueryFactory().toSOQL()); } @@ -246,4 +246,4 @@ private with sharing class fflib_SObjectSelectorTest } return testUser; } -} +} \ No newline at end of file From e3cf9d3b508c61feaa0052e8e6104131042e5683 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Wed, 23 Jul 2014 18:40:07 +0100 Subject: [PATCH 29/32] Change in default behaviour for handleAfterUpdate - By default the onValidate() method will no longer be called during an after update (it will still be called for on after insert) - Only the onValidate(existingRecords); will now be called during a on after update. - For those dependent on the original behaviour add the line Configuration. enableOldOnUpdateValidateBehaviour(); into your constructor code. --- fflib/src/classes/fflib_SObjectDomain.cls | 63 ++++++++++++++++++- fflib/src/classes/fflib_SObjectDomainTest.cls | 31 +++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/fflib/src/classes/fflib_SObjectDomain.cls b/fflib/src/classes/fflib_SObjectDomain.cls index 93470f423ce..a15f05019bb 100644 --- a/fflib/src/classes/fflib_SObjectDomain.cls +++ b/fflib/src/classes/fflib_SObjectDomain.cls @@ -185,8 +185,9 @@ public virtual with sharing class fflib_SObjectDomain { if(Configuration.EnforcingTriggerCRUDSecurity && !SObjectDescribe.isUpdateable()) throw new DomainException('Permission to udpate an ' + SObjectDescribe.getName() + ' denied.'); - - onValidate(); + + if(Configuration.OldOnUpdateValidateBehaviour) + onValidate(); onValidate(existingRecords); onAfterUpdate(existingRecords); } @@ -322,6 +323,10 @@ public virtual with sharing class fflib_SObjectDomain **/ public class Configuration { + /** + * Backwards compatability 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 **/ @@ -339,6 +344,7 @@ public virtual with sharing class fflib_SObjectDomain { EnforcingTriggerCRUDSecurity = true; // Default is true for backwards compatability TriggerStateEnabled = false; + OldOnUpdateValidateBehaviour = false; // Breaking change, but felt to better practice } /** @@ -376,6 +382,24 @@ public virtual with sharing class fflib_SObjectDomain EnforcingTriggerCRUDSecurity = false; return this; } + + /** + * See associated property + **/ + public Configuration enableOldOnUpdateValidateBehaviour() + { + OldOnUpdateValidateBehaviour = true; + return this; + } + + /** + * See associated property + **/ + public Configuration disableOldOnUpdateValidateBehaviour() + { + OldOnUpdateValidateBehaviour = false; + return this; + } } /** @@ -711,5 +735,38 @@ public virtual with sharing class fflib_SObjectDomain { 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); + } + } } \ No newline at end of file diff --git a/fflib/src/classes/fflib_SObjectDomainTest.cls b/fflib/src/classes/fflib_SObjectDomainTest.cls index 41a21c22cb0..df3125ae189 100644 --- a/fflib/src/classes/fflib_SObjectDomainTest.cls +++ b/fflib/src/classes/fflib_SObjectDomainTest.cls @@ -159,6 +159,37 @@ private with sharing class fflib_SObjectDomainTest 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()); + } + } /** * Create test user From b8f68c24a483657841a76f186d1fc694ba729ef8 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Wed, 23 Jul 2014 18:45:00 +0100 Subject: [PATCH 30/32] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 18d9cf2aa28..bba4a9c825d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ See here for [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensm Updates ======= +- **July 2014**, **IMPORTANT CHANGE**, prior 23rd July, 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/financialforcedev/fflib-apex-common/tree/fls-support-experiment) supporting automated FLS checking, see [README](https://github.com/financialforcedev/fflib-apex-common/tree/fls-support-experiment#expirimental-crud-and-fls-support) for more details. From 291961c054d87321452accf3405286db7a058bf9 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Wed, 23 Jul 2014 18:48:14 +0100 Subject: [PATCH 31/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bba4a9c825d..ba945fe4558 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ See here for [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensm Updates ======= -- **July 2014**, **IMPORTANT CHANGE**, prior 23rd July, 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. +- **July 2014**, **IMPORTANT CHANGE**, prior 23rd July, 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/financialforcedev/fflib-apex-common/tree/fls-support-experiment) supporting automated FLS checking, see [README](https://github.com/financialforcedev/fflib-apex-common/tree/fls-support-experiment#expirimental-crud-and-fls-support) for more details. From d13f39d599689acba68a93d6799602a7cc537aa3 Mon Sep 17 00:00:00 2001 From: Andrew Fawcett Date: Wed, 23 Jul 2014 18:48:43 +0100 Subject: [PATCH 32/32] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ba945fe4558..4d4a0bbd231 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ See here for [MavensMate Templates](http://andyinthecloud.com/2014/05/23/mavensm Updates ======= -- **July 2014**, **IMPORTANT CHANGE**, prior 23rd July, 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. +- **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/financialforcedev/fflib-apex-common/tree/fls-support-experiment) supporting automated FLS checking, see [README](https://github.com/financialforcedev/fflib-apex-common/tree/fls-support-experiment#expirimental-crud-and-fls-support) for more details.