diff --git a/README.md b/README.md index 4e952f14..75b6c4ed 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,104 @@ -# Nebula Query & Search -A lightweight Apex library for easily building dynamic SOQL queries & SOSL searches

-[![Travis CI](https://img.shields.io/travis/jongpie/NebulaLogger/master.svg)](https://travis-ci.org/jongpie/NebulaLogger) +# Nebula Query & Search for Salesforce Apex +[![Travis CI](https://img.shields.io/travis/jongpie/NebulaQueryAndSearch/master.svg)](https://travis-ci.org/jongpie/NebulaQueryAndSearch) Deploy to Salesforce +A dynamic SOQL query & SOSL search library for for Salesforce Apex

+ ## Features -* Easily add a field if the field meets the category specified, using the Soql.FieldCategory enum -* Easily add any fields that are accessible, updateable, standard or custom, using the Soql.FieldCategory enum +* Provides chainable builder methods for dyanmically building queries & searches in APex +* Easily add fields to a query based on field level security * Easily add fields from a field set * Automatically adds the parent name field for any lookup/master-detail fields * Adds translations for picklist fields & record types by calling includeLabels() * Adds localized formatting for number, date, datetime, time, or currency fields by calling includeFormattedValues() * Leverage query scope to filter results +* Enable query & search caching by simple calling cacheResults() * Reuse your dynamic SOQL queries to quickly build dynamic SOSL searches -## SOQL Query Examples +## Overview +There are 3 main builder classes + +   | SobjectQueryBuilder | AggregateQueryBuilder | SearchBuilder +------- | --------------------|-----------------------|-------------- +Super Class | Soql.cls (Queries) | Soql.cls (Queries) | Sosl.cls (Searches) | - +Action | Queries an Sobject | Queries an Sobject | Searches 1 or more Sobjects +Returns | `Sobject` or `List` | `AggregateResult` or `List` | `Sobject`, `List` or `List>` + +## SOQL Sobject Query Examples **Basic Usage:** Query an object & return the object's ID and display name field (typically the 'Name' field, but some objects use other fields, like Task.Subject and Case.CaseNumber). Since no filters have been added, this query would also return all accounts. ``` -List accounts = new Soql(Schema.Account.SobjectType).getQueryResults(); +List accounts = new SobjectQueryBuilder(Schema.Account.SobjectType).getResults(); ``` **Advanced Usage:** Query an object & leverage the query builder methods. The order of the builder methods does not matter - you can arrange the calls to these methods in any order that you prefer. ``` -Soql accountQuery = new Soql(Schema.Account) // Query the account object - .addField(Schema.Account.ParentId) // Include the ParentId field, using SObjectField. The current user must have at least read access to the field - .addField(Schema.Account.Type, Soql.FieldCategory.UPDATEABLE) // Include the Type field if the current user has access to update it - .addFields(Soql.FieldCategory.CUSTOM) // Include all custom fields - Soql.cls only includes fields that are accessible to the user - .addFields(myAccountFieldSet) // Include all fields in a field set that are accessible to the user - .removeField(Schema.Account.My_custom_Field__c) // remove a custom field - .usingScope(Soql.Scope.MINE) // Set the query scope - .filterWhere(Schema.Account.CreatedDate, '=', new Soql.DateLiteral('LAST_WEEK')) // Filter on the created date, using a date literal - .orderBy(Schema.Account.Type) // Order by a field API name - sort order/nulls defaults to 'Type ASC NULLS FIRST' - .orderBy(Account.Name, Soql.SortOrder.ASCENDING) // Order by, using SObjectField & sort order - .orderBy(Account.AnnualRevenue, Soql.SortOrder.DESCENDING, false) // Order by, using SObjectField, sort order and nulls sort order - .limitCount(100) // Limit the results to 100 records - .includeLabels() // Include labels/translations for any picklist fields or record types. These are aliased using the convention 'FieldName__c_Label' - .includeFormattedValues() // Include formatted values for any number, date, time, or currency fields - .cacheResults() // When enabled, the query results are internally cached - any subsequent calls for getQueryResults() will returned cached results instead of executing the query again - .offset(25); // Skip the first 25 results +SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType) // Query the account object + .addField(Schema.Account.ParentId) // Include the ParentId field, using SObjectField. The current user must have at least read access to the field + .addField(Schema.Account.Type, Soql.FieldCategory.UPDATEABLE) // Include the Type field if the current user has access to update it + .addFields(Soql.FieldCategory.CUSTOM) // Include all custom fields - only fields that are accessible to the user are included + .addFieldSet(Schema.Account.MyFieldSet) // Include all fields in a field set that are accessible to the user + .removeField(Schema.Account.My_Custom_Field__c) // remove a custom field + .usingScope(Soql.Scope.MINE) // Set the query scope + .filterWhere(Schema.Account.CreatedDate, '=', new Soql.DateLiteral('LAST_WEEK')) // Filter on the created date, using a date literal + .orderBy(Schema.Account.Type) // Order by a field API name - sort order/nulls defaults to 'Type ASC NULLS FIRST' + .orderBy(Account.Name, Soql.SortOrder.ASCENDING) // Order by, using SObjectField & sort order + .orderBy(Account.AnnualRevenue, Soql.SortOrder.DESCENDING, false) // Order by, using SObjectField, sort order and nulls sort order + .limitTo(100) // Limit the results to 100 records + .includeLabels() // Include labels/translations for any picklist fields or record types. These are aliased using the convention 'FieldName__c_Label' + .includeFormattedValues() // Include formatted values for any number, date, time, or currency fields + .cacheResults() // When enabled, the query results are internally cached - any subsequent calls for getResults() will returned cached results instead of executing the query again + .offsetBy(25); // Skip the first 25 results // Execute the query and store the results in the 'accounts' variable -List accounts = accountQuery.getQueryResults(); +List accounts = accountQuery.getResults(); + +/****** Resulting output ******* +SELECT Id, MyCustomDateField__c, MyCustomPicklistField__c, Name, + format(MyCustomDateField__c) MyCustomDateField__c__Formatted, + toLabel(MyCustomPicklistField__c) MyCustomPicklistField__c__Label +FROM Account +USING SCOPE MINE +WHERE CreatedDate = LAST_WEEK +ORDER BY Type ASC NULLS FIRST, Name ASC NULLS FIRST, AnnualRevenue DESC NULLS LAST LIMIT 100 OFFSET 25 +*******************************/ + +System.debug(accountQuery.getQuery()); ``` ## SOSL Search Examples **Basic Usage:** Search a single object ``` -Soql userQuery = new Soql(Schema.User.SobjectType); // Create an instance of Soql for an Sobject - you can include additional fields, filters, etc -Sosl userSearch = new Sosl('my search term', userQuery); // Create a new Sosl instance with a search term & instance of Soql -List userSearchResults = userSearch.getFirstSearchResults(); // Sosl returns a list of lists of sobjects - getFirstSearchResults() returns the first list +SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType); // Create an instance of SobjectQueryBuilder for an Sobject - you can include additional fields, filters, etc +SearchBuilder userSearch = new SearchBuilder('my search term', userQuery); // Create a new SearchBuilder instance with a search term & instance of SobjectQueryBuilder +List userSearchResults = userSearch.getFirstResults(); // SearchBuilder returns a list of lists of sobjects - getFirstResults() returns the first list + +/****** Resulting output ******* +FIND 'my search term' IN ALL FIELDS RETURNING User(Id, Name) +*******************************/ + +System.debug(userSearch.getSearch()); ``` **Advanced Usage:** Search several objects ``` -Soql accountQuery = new Soql(Schema.Account.SobjectType); // Create an instance of Soql for the Account object -Soql contactQuery = new Soql(Schema.Contact.SobjectType); // Create an instance of Soql for the Contact object -Soql leadQuery = new Soql(Schema.Lead.SobjectType); // Create an instance of Soql for the Lead object -List queries = new List{accountQuery, contactQuery, leadQuery}; // Add the Soql queries to a list +SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType); // Create an instance of SobjectQueryBuilder for the Account object +SobjectQueryBuilder contactQuery = new SobjectQueryBuilder(Schema.Contact.SobjectType); // Create an instance of SobjectQueryBuilder for the Contact object +SobjectQueryBuilder leadQuery = new SobjectQueryBuilder(Schema.Lead.SobjectType); // Create an instance of SobjectQueryBuilder for the Lead object +List queries = new List{contactQuery, accountQuery, leadQuery}; // Add the SobjectQueryBuilder queries to a list + +SearchBuilder mySearch = new SearchBuilder('my search term', queries); // Create a new SearchBuilder instance with a search term & the list of SobjectQueryBuilder queries +List> searchResults = mySearch.getResults(); // Returns all search results + +/****** Resulting output ******* +FIND 'my search term' IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, Name), Lead(Id, Name) +*******************************/ -Sosl mySearch = new Sosl('my search term', queries); // Create a new Sosl instance with a search term & the list of Soql queries -List> searchResults = mySearch.getSearchResults(); // Returns all search results +System.debug(mySearch.getSearch()); ``` \ No newline at end of file diff --git a/src/classes/AggregateQueryBuilder.cls b/src/classes/AggregateQueryBuilder.cls new file mode 100644 index 00000000..e7eecccb --- /dev/null +++ b/src/classes/AggregateQueryBuilder.cls @@ -0,0 +1,270 @@ +/****************************************************************************************************** +* This file is part of the Nebula Query & Search project, released under the MIT License. * +* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * +******************************************************************************************************/ +public class AggregateQueryBuilder extends Soql { + + private Soql.GroupingDimension groupingDimension; + private List aggregateFields; + private List havingConditions; + + public AggregateQueryBuilder(Schema.SobjectType sobjectType) { + super(sobjectType, false); + + this.aggregateFields = new List(); + this.havingConditions = new List(); + } + + public AggregateQueryBuilder groupByField(Schema.SobjectField field) { + return this.groupByFields(new List{field}); + } + + public AggregateQueryBuilder groupByField(Soql.QueryField queryField) { + return this.groupByFields(new List{queryField}); + } + + public AggregateQueryBuilder groupByFields(List fields) { + List queryFields = new List(); + for(Schema.SobjectField field : fields) { + queryFields.add(new Soql.QueryField(field)); + } + return this.groupByFields(queryFields); + } + + public AggregateQueryBuilder groupByFields(List queryFields) { + super.doAddFields(queryFields, null); + return this.setHasChanged(); + } + + public AggregateQueryBuilder groupByFieldSet(Schema.FieldSet fieldSet) { + List queryFields = new List(); + for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) { + queryFields.add(new Soql.QueryField(this.sobjectType, fieldSetMember.getFieldPath())); + } + return this.groupByFields(queryFields); + } + + public AggregateQueryBuilder usingGroupingDimension(Soql.GroupingDimension groupingDimension) { + this.groupingDimension = groupingDimension; + return this.setHasChanged(); + } + + public AggregateQueryBuilder addAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field) { + return this.addAggregate(aggregateFunction, field, null); + } + + public AggregateQueryBuilder addAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field, String fieldAlias) { + return this.addAggregate(aggregateFunction, new Soql.QueryField(field), fieldAlias); + } + + public AggregateQueryBuilder addAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField) { + return this.addAggregate(aggregateFunction, queryField, null); + } + + public AggregateQueryBuilder addAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField, String fieldAlias) { + this.aggregateFields.add(new AggregateField(this.getSobjectType(), aggregateFunction, queryField, fieldAlias)); + return this.setHasChanged(); + } + + public AggregateQueryBuilder havingAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field, Soql.Operator operator, Object value) { + return this.havingAggregate(aggregateFunction, new Soql.QueryField(field), operator, value); + } + + public AggregateQueryBuilder havingAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField, Soql.Operator operator, Object value) { + this.havingConditions.add(aggregateFunction.name() + '(' + queryField + ') ' + Soql.getOperatorValue(operator) + ' ' + value); + return this.setHasChanged(); + } + + public AggregateQueryBuilder filterWhere(Schema.SobjectField field, Soql.Operator operator, Object value) { + return this.filterWhere(new Soql.QueryField(field), operator, value); + } + + public AggregateQueryBuilder filterWhere(Soql.QueryField queryField, Soql.Operator operator, Object value) { + return this.filterWhere(new Soql.QueryFilter(queryField, operator, value)); + } + + public AggregateQueryBuilder filterWhere(Soql.QueryFilter filter) { + return this.filterWhere(new List{filter}); + } + + public AggregateQueryBuilder filterWhere(List filters) { + super.doFilterWhere(filters); + return this.setHasChanged(); + } + + public AggregateQueryBuilder orFilterWhere(List filters) { + super.doOrFilterWhere(filters); + return this.setHasChanged(); + } + + public AggregateQueryBuilder orderByField(Schema.SobjectField field) { + return this.orderByField(field, null); + } + + public AggregateQueryBuilder orderByField(Soql.QueryField queryField) { + return this.orderByField(queryField, null); + } + + public AggregateQueryBuilder orderByField(Schema.SobjectField field, Soql.SortOrder sortOrder) { + return this.orderByField(field, sortOrder, null); + } + + public AggregateQueryBuilder orderByField(Soql.QueryField queryField, Soql.SortOrder sortOrder) { + return this.orderByField(queryField, sortOrder, null); + } + + public AggregateQueryBuilder orderByField(Schema.SobjectField field, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + return this.orderByField(new Soql.QueryField(field), sortOrder, sortNullsFirst); + } + + public AggregateQueryBuilder orderByField(Soql.QueryField queryField, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + super.doOrderBy(queryField, sortOrder, sortNullsFirst); + return this.setHasChanged(); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field) { + return this.orderByAggregate(aggregateFunction, field, null); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field, Soql.SortOrder sortOrder) { + return this.orderByAggregate(aggregateFunction, field, sortOrder, null); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Schema.SobjectField field, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + return this.orderByAggregate(aggregateFunction, new Soql.QueryField(field), sortOrder, sortNullsFirst); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField) { + return this.orderByAggregate(aggregateFunction, queryField, null); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField, Soql.SortOrder sortOrder) { + return this.orderByAggregate(aggregateFunction, queryField, sortOrder, null); + } + + public AggregateQueryBuilder orderByAggregate(Soql.Aggregate aggregateFunction, Soql.QueryField queryField, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + super.doOrderBy(aggregateFunction.name() + '(' + queryField + ')', sortOrder, sortNullsFirst); + return this.setHasChanged(); + } + + public AggregateQueryBuilder limitTo(Integer numberOfRecords) { + super.doLimitTo(numberOfRecords); + return this.setHasChanged(); + } + + public AggregateQueryBuilder offsetBy(Integer offset) { + super.doOffsetBy(offset); + return this.setHasChanged(); + } + + public AggregateQueryBuilder cacheResults() { + super.doCacheResults(); + return this; + } + + public override String getQuery() { + if(this.query != null && !this.hasChanged) return this.query; + + String queryFieldString = super.doGetQueryFieldString(); + String aggregateQueryFieldString = this.getAggregateQueryFieldString(); + String aggregateFieldDelimiter = !String.isEmpty(queryFieldString) && !String.isEmpty(aggregateQueryFieldString) ? ', ' : ''; + + String combinedFieldsString = queryFieldString + aggregateFieldDelimiter + aggregateQueryFieldString; + if(String.isBlank(combinedFieldsString)) { + Schema.SobjectField idField = this.getSobjectType().getDescribe().fields.getMap().get('Id'); + combinedFieldsString = new AggregateField(this.getSobjectType(), Soql.Aggregate.COUNT, new Soql.QueryField(idField), null).toString(); + } + + this.query = 'SELECT ' + combinedFieldsString + + ' FROM ' + this.sobjectType + + super.doGetUsingScopeString() + + super.doGetWhereClauseString() + + this.getGroupByString() + + this.getHavingString() + + super.doGetOrderByString() + + super.doGetLimitCountString() + + super.doGetOffetString(); + + System.debug(LoggingLevel.FINEST, this.query); + return this.query; + } + + public Integer getResultCount() { + String countQuery = 'SELECT COUNT()' + + ' FROM ' + this.sobjectType + + super.doGetUsingScopeString() + + super.doGetWhereClauseString() + + this.getGroupByString() + + this.getHavingString() + + super.doGetOrderByString() + + super.doGetLimitCountString() + + super.doGetOffetString(); + return Database.countQuery(countQuery); + } + + public AggregateResult getFirstResult() { + return (AggregateResult)super.doGetFirstResult(); + } + + public List getResults() { + return (List)super.doGetResults(); + } + + private AggregateQueryBuilder setHasChanged() { + this.hasChanged = true; + return this; + } + + private String getAggregateQueryFieldString() { + if(this.aggregateFields.isEmpty()) return ''; + + List aggregateFieldStrings = new List(); + for(AggregateQueryBuilder.AggregateField aggregatedField : this.aggregateFields) { + aggregateFieldStrings.add(aggregatedField.toString()); + } + aggregateFieldStrings.sort(); + return String.join(aggregateFieldStrings, ', '); + } + + private String getGroupByString() { + String queryFieldString = super.doGetQueryFieldString(); + + String groupByTextString = ' GROUP BY '; + String groupingDimensionClosingString = ''; + if(this.groupingDimension != null) { + groupByTextString += this.groupingDimension.name() + '('; + groupingDimensionClosingString = ')'; + } + + return String.isEmpty(queryFieldString) ? '' : groupByTextString + queryFieldString + groupingDimensionClosingString; + } + + private String getHavingString() { + return this.havingConditions.isEmpty() ? '' : ' HAVING ' + String.join(this.havingConditions, ', '); + } + + private class AggregateField { + + private Schema.SobjectType sobjectType; + private String aggregateFieldPath; + + public AggregateField(Schema.SobjectType sobjectType, Soql.Aggregate aggregateFunction, Soql.QueryField queryField, String fieldAlias) { + this.sobjectType = sobjectType; + this.aggregateFieldPath = this.getAggregateFieldPath(aggregateFunction, queryField, fieldAlias); + } + + public override String toString() { + return this.aggregateFieldPath; + } + + private String getAggregateFieldPath(Soql.Aggregate aggregateFunction, Soql.QueryField queryField, String fieldAlias) { + String fieldApiName = queryField.getDescribe().getName(); + fieldAlias = !String.isEmpty(fieldAlias) ? String.escapeSingleQuotes(fieldAlias) : aggregateFunction.name() + '__' + fieldApiName; + + // Example: MIN(Schema.Lead.MyField__c) is auto-aliased to MyField__c__MIN + return aggregateFunction.name() + '(' + fieldApiName + ') ' + fieldAlias; + } + + } + +} \ No newline at end of file diff --git a/src/classes/Soql_Tests.cls-meta.xml b/src/classes/AggregateQueryBuilder.cls-meta.xml similarity index 80% rename from src/classes/Soql_Tests.cls-meta.xml rename to src/classes/AggregateQueryBuilder.cls-meta.xml index fec71a26..800e53cf 100644 --- a/src/classes/Soql_Tests.cls-meta.xml +++ b/src/classes/AggregateQueryBuilder.cls-meta.xml @@ -1,5 +1,5 @@ - 42.0 + 43.0 Active diff --git a/src/classes/AggregateQueryBuilder_Tests.cls b/src/classes/AggregateQueryBuilder_Tests.cls new file mode 100644 index 00000000..eaf8f9b5 --- /dev/null +++ b/src/classes/AggregateQueryBuilder_Tests.cls @@ -0,0 +1,194 @@ +/****************************************************************************************************** +* This file is part of the Nebula Query & Search project, released under the MIT License. * +* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * +******************************************************************************************************/ +@isTest +private class AggregateQueryBuilder_Tests { + + @isTest + static void it_should_be_usable_after_construction() { + // Query builders should be usable as soon as it's constructed - it should be able to execute a query with some default values + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType); + + Test.startTest(); + + AggregateResult result = (AggregateResult)aggregateQueryBuilder.getFirstResult(); + + Test.stopTest(); + } + + @isTest + static void it_should_return_results_when_filtering() { + String expectedQueryString = 'SELECT Type FROM Opportunity WHERE AccountId != null GROUP BY Type'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType) + .groupByField(Schema.Opportunity.Type) + .filterWhere(new Soql.QueryFilter(Schema.Opportunity.AccountId, Soql.Operator.NOT_EQUAL_TO, null)); + + Test.startTest(); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + + Test.stopTest(); + } + + @isTest + static void it_should_return_results_when_filtering_with_an_or_statement() { + String expectedQueryString = 'SELECT Type, COUNT(Id) COUNT__Id FROM Account WHERE (AccountNumber = null OR Type = null) AND ParentId != null GROUP BY Type'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Account.SobjectType) + .groupByField(Schema.Account.Type) + .filterWhere(new Soql.QueryFilter(Schema.Account.ParentId, Soql.Operator.NOT_EQUAL_TO, null)) + .orFilterWhere(new List{ + new Soql.QueryFilter(Schema.Account.Type, Soql.Operator.EQUALS, null), + new Soql.QueryFilter(Schema.Account.AccountNumber, Soql.Operator.EQUALS, null) + }) + .addAggregate(Soql.Aggregate.COUNT, Schema.Account.Id); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + } + + @isTest + static void it_should_cache_results() { + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType); + aggregateQueryBuilder.cacheResults(); + + Test.startTest(); + + System.assertEquals(0, Limits.getQueries()); + for(Integer i = 0; i < 3; i++) { + aggregateQueryBuilder.getResults(); + } + + System.assertEquals(1, Limits.getQueries()); + + Test.stopTest(); + } + + @isTest + static void it_should_group_by_cube() { + String expectedQueryString = 'SELECT Type, StageName, SUM(Amount) SUM__Amount FROM Opportunity GROUP BY CUBE(Type, StageName)'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType) + .groupByField(Schema.Opportunity.Type) + .groupByField(Schema.Opportunity.StageName) + .addAggregate(Soql.Aggregate.SUM, Schema.Opportunity.Amount) + .usingGroupingDimension(Soql.GroupingDimension.CUBE); + + Test.startTest(); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + + Test.stopTest(); + } + + @isTest + static void it_should_group_by_rollup() { + String expectedQueryString = 'SELECT Type, StageName, SUM(Amount) SUM__Amount FROM Opportunity GROUP BY ROLLUP(Type, StageName)'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType) + .groupByField(Schema.Opportunity.Type) + .groupByField(Schema.Opportunity.StageName) + .addAggregate(Soql.Aggregate.SUM, Schema.Opportunity.Amount) + .usingGroupingDimension(Soql.GroupingDimension.ROLLUP); + + Test.startTest(); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + + Test.stopTest(); + } + + @isTest + static void it_should_group_by_having_aggregate() { + String expectedQueryString = 'SELECT Name, COUNT(Id) COUNT__Id FROM Account GROUP BY Name HAVING COUNT(Id) > 2'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Account.SobjectType) + .groupByField(Account.Name) + .addAggregate(Soql.Aggregate.COUNT, Account.Id) + .havingAggregate(Soql.Aggregate.COUNT, Account.Id, Soql.Operator.GREATER_THAN, 2); + + Test.startTest(); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + + Test.stopTest(); + } + + @isTest + static void it_should_group_by_a_date_function() { + String expectedQueryString = 'SELECT CALENDAR_MONTH(CloseDate), COUNT(Id) COUNT__Id FROM Opportunity GROUP BY CALENDAR_MONTH(CloseDate)'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType) + .groupByField(new Soql.QueryField(Soql.DateFunction.CALENDAR_MONTH, Schema.Opportunity.CloseDate)) + .addAggregate(Soql.Aggregate.COUNT, Opportunity.Id); + + Test.startTest(); + + System.assertEquals(expectedQueryString, aggregateQueryBuilder.getQuery()); + List results = aggregateQueryBuilder.getResults(); + + Test.stopTest(); + } + + @isTest + static void it_should_build_an_ridiculous_query_string() { + String expectedQueryString = 'SELECT Account.Type, StageName, AVG(Amount) AVG__Amount, COUNT(AccountId) COUNT__AccountId,' + + ' COUNT_DISTINCT(AccountId) COUNT_DISTINCT__AccountId, COUNT_DISTINCT(OwnerId) COUNT_DISTINCT__OwnerId, COUNT_DISTINCT(Type) COUNT_DISTINCT__Type,' + + ' MAX(CreatedDate) MAX__CreatedDate, MIN(CreatedDate) MIN__CreatedDate, SUM(Amount) SUM__Amount' + + ' FROM Opportunity' + + ' WHERE AccountId != null' + + ' GROUP BY Account.Type, StageName' + + ' ORDER BY Account.Type ASC NULLS FIRST, StageName ASC NULLS FIRST, SUM(Amount) ASC NULLS FIRST,' + + ' MIN(CloseDate) DESC NULLS FIRST, MAX(Account.LastActivityDate) ASC NULLS FIRST' + + ' LIMIT 100' + + ' OFFSET 0'; + + AggregateQueryBuilder aggregateQueryBuilder = new AggregateQueryBuilder(Schema.Opportunity.SobjectType) + .addAggregate(Soql.Aggregate.MAX, Schema.Opportunity.CreatedDate) + .addAggregate(Soql.Aggregate.AVG, Schema.Opportunity.Amount) + .addAggregate(Soql.Aggregate.COUNT_DISTINCT, Schema.Opportunity.OwnerId) + .addAggregate(Soql.Aggregate.MIN, Schema.Opportunity.CreatedDate) + .groupByField(new Soql.QueryField(new List{ + Schema.Opportunity.AccountId, Schema.Account.Type + })) + .addAggregate(Soql.Aggregate.SUM, Schema.Opportunity.Amount) + .groupByField(Schema.Opportunity.StageName) + .addAggregate(Soql.Aggregate.COUNT, Schema.Opportunity.AccountId) + .addAggregate(Soql.Aggregate.COUNT_DISTINCT, Schema.Opportunity.AccountId) + .addAggregate(Soql.Aggregate.COUNT_DISTINCT, new Soql.QueryField(new List{ + Schema.Opportunity.AccountId, Schema.Account.Type + })) + .orderByField(new Soql.QueryField(new List{ + Schema.Opportunity.AccountId, Schema.Account.Type + })) + .orderByField(Schema.Opportunity.StageName) + .orderByAggregate(Soql.Aggregate.SUM, Schema.Opportunity.Amount) + .orderByAggregate(Soql.Aggregate.MIN, Schema.Opportunity.CloseDate, Soql.SortOrder.DESCENDING) + .orderByAggregate( + Soql.Aggregate.MAX, + new Soql.QueryField(new List{Schema.Opportunity.AccountId, Schema.Account.LastActivityDate}) + ) + .filterWhere(Schema.Opportunity.AccountId, Soql.Operator.NOT_EQUAL_TO, null) + .limitTo(100) + .offsetBy(0); + + Test.startTest(); + + String returnedQueryString = aggregateQueryBuilder.getQuery(); + + Test.stopTest(); + + System.assertEquals(expectedQueryString, returnedQueryString); + + // Verify that the query can be executed + List results = Database.query(returnedQueryString); + } + + +} \ No newline at end of file diff --git a/src/classes/Sosl_Tests.cls-meta.xml b/src/classes/AggregateQueryBuilder_Tests.cls-meta.xml similarity index 80% rename from src/classes/Sosl_Tests.cls-meta.xml rename to src/classes/AggregateQueryBuilder_Tests.cls-meta.xml index fec71a26..800e53cf 100644 --- a/src/classes/Sosl_Tests.cls-meta.xml +++ b/src/classes/AggregateQueryBuilder_Tests.cls-meta.xml @@ -1,5 +1,5 @@ - 42.0 + 43.0 Active diff --git a/src/classes/SearchBuilder.cls b/src/classes/SearchBuilder.cls new file mode 100644 index 00000000..542fd817 --- /dev/null +++ b/src/classes/SearchBuilder.cls @@ -0,0 +1,92 @@ +/****************************************************************************************************** +* This file is part of the Nebula Query & Search project, released under the MIT License. * +* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * +******************************************************************************************************/ +public class SearchBuilder extends Sosl { + + public SearchBuilder(String searchTerm, SobjectQueryBuilder sobjectQuery) { + super(searchTerm, sobjectQuery); + } + + public SearchBuilder(String searchTerm, List sobjectQueries) { + super(searchTerm, sobjectQueries); + } + + public SearchBuilder inSearchGroup(Sosl.SearchGroup searchGroup) { + this.searchGroup = searchGroup; + return this.setHasChanged(); + } + + public SearchBuilder withDataCategory(Schema.DataCategory dataCategory, Sosl.DataCategoryLocation dataCategoryLocation, Schema.DataCategory childDataCategory) { + return this.withDataCategory(dataCategory, dataCategoryLocation, new List{childDataCategory}); + } + + public SearchBuilder withDataCategory(Schema.DataCategory dataCategory, Sosl.DataCategoryLocation dataCategoryLocation, List childDataCategories) { + List childDataCategoryApiNames = new List(); + for(Schema.DataCategory childDataCategory : childDataCategories) { + childDataCategoryApiNames.add(childDataCategory.getName()); + } + this.withDataCategoryClauses.add(dataCategory.getName() + ' ' + dataCategoryLocation + ' (' + String.join(childDataCategoryApiNames, ', ') + ')'); + return this.setHasChanged(); + } + + public SearchBuilder withHighlight() { + this.withClauses.add('HIGHLIGHT'); + return this.setHasChanged(); + } + + public SearchBuilder withSnippet(Integer targetLength) { + this.withClauses.add('SNIPPET (target_length=' + targetLength + ')'); + return this.setHasChanged(); + } + + public SearchBuilder withSpellCorrection() { + this.withClauses.add('SPELL_CORRECTION = true'); + return this.setHasChanged(); + } + + public SearchBuilder updateArticleReporting(Sosl.ArticleReporting articleReporting) { + this.articleReporting = articleReporting; + return this.setHasChanged(); + } + + public SearchBuilder cacheResults() { + this.cacheResults = true; + return this.setHasChanged(); + } + + public override String getSearch() { + if(this.searchQuery != null && !this.hasChanged) return this.searchQuery; + + this.searchQuery = 'FIND \'' + this.searchTerm + '\'' + + super.doGetSearchGroupString() + + super.doGetReturningSobjectsString() + + super.doGetWithClauseString() + + super.doGetUpdateArticleReportingString(); + + // Change hasChanged to false so that subsequent calls to getSearchQuery() use the cached search query string + // If additional builder methods are later called, the builder methods will set hasChanged = true + this.hasChanged = false; + + System.debug(LoggingLevel.FINEST, this.searchQuery); + return this.searchQuery; + } + + public Sobject getFirstResult() { + return super.doGetFirstResult(); + } + + public List getFirstResults() { + return super.doGetFirstResults(); + } + + public List> getResults() { + return super.doGetResults(); + } + + private SearchBuilder setHasChanged() { + this.hasChanged = true; + return this; + } + +} \ No newline at end of file diff --git a/src/classes/SearchBuilder.cls-meta.xml b/src/classes/SearchBuilder.cls-meta.xml new file mode 100644 index 00000000..800e53cf --- /dev/null +++ b/src/classes/SearchBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 43.0 + Active + diff --git a/src/classes/Sosl_Tests.cls b/src/classes/SearchBuilder_Tests.cls similarity index 61% rename from src/classes/Sosl_Tests.cls rename to src/classes/SearchBuilder_Tests.cls index 646a4ebd..2e974b0c 100644 --- a/src/classes/Sosl_Tests.cls +++ b/src/classes/SearchBuilder_Tests.cls @@ -1,44 +1,55 @@ /****************************************************************************************************** -* This file is part of the Nebula Framework project, released under the MIT License. * +* This file is part of the Nebula Query & Search project, released under the MIT License. * * See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * ******************************************************************************************************/ @isTest -private class Sosl_Tests { +private class SearchBuilder_Tests { + + @isTest + static void it_should_return_first_result_for_a_single_sobject_type() { + String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)'; + + SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), userQuery); + + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + User userSearchResult = (User)userSearch.getFirstResult(); + } @isTest static void it_should_return_results_for_a_single_sobject_type() { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)'; - Soql userQuery = new Soql(Schema.User.SobjectType); - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), userQuery); + SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), userQuery); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest static void it_should_return_results_for_multiple_sobject_types() { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING Account(Id, Name), User(Id, Name)'; - List queries = new List{ - new Soql(Schema.User.SobjectType), - new Soql(Schema.Account.SobjectType) + List queries = new List{ + new SobjectQueryBuilder(Schema.User.SobjectType), + new SobjectQueryBuilder(Schema.Account.SobjectType) }; - Sosl search = new Sosl(UserInfo.getUserEmail(), queries); + SearchBuilder search = new SearchBuilder(UserInfo.getUserEmail(), queries); - System.assertEquals(expectedSearchQueryString, search.getSearchQuery()); - List> searchResults = search.getSearchResults(); + System.assertEquals(expectedSearchQueryString, search.getSearch()); + List> searchResults = search.getResults(); } @isTest static void it_should_return_results_with_highlight_enabled() { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name) WITH HIGHLIGHT'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.withHighlight(); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest @@ -47,11 +58,11 @@ private class Sosl_Tests { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)' + ' WITH SNIPPET (target_length=' + snippetTargetLength + ')'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.withSnippet(snippetTargetLength); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest @@ -59,11 +70,11 @@ private class Sosl_Tests { Integer snippetTargetLength = 10; String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN EMAIL FIELDS RETURNING User(Id, Name)'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.inSearchGroup(Sosl.SearchGroup.EMAIL_FIELDS); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = (List)userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = (List)userSearch.getFirstResults(); } @isTest @@ -71,11 +82,11 @@ private class Sosl_Tests { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)' + ' WITH SPELL_CORRECTION = true'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.withSpellCorrection(); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest @@ -87,11 +98,11 @@ private class Sosl_Tests { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)' + ' UPDATE TRACKING'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.updateArticleReporting(Sosl.ArticleReporting.TRACKING); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest @@ -103,23 +114,23 @@ private class Sosl_Tests { String expectedSearchQueryString = 'FIND \'' + UserInfo.getUserEmail() + '\' IN ALL FIELDS RETURNING User(Id, Name)' + ' UPDATE VIEWSTAT'; - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), new Soql(Schema.User.SobjectType)); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), new SobjectQueryBuilder(Schema.User.SobjectType)); userSearch.updateArticleReporting(Sosl.ArticleReporting.VIEWSTAT); - System.assertEquals(expectedSearchQueryString, userSearch.getSearchQuery()); - List userSearchResults = userSearch.getFirstSearchResults(); + System.assertEquals(expectedSearchQueryString, userSearch.getSearch()); + List userSearchResults = userSearch.getFirstResults(); } @isTest static void it_should_cache_search_results_when_enabled() { Integer loops = 4; - Soql userQuery = new Soql(Schema.User.SobjectType); - Sosl userSearch = new Sosl(UserInfo.getUserEmail(), userQuery); + SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType); + SearchBuilder userSearch = new SearchBuilder(UserInfo.getUserEmail(), userQuery); // First, verify that caching is not enabled by default System.assertEquals(0, Limits.getSoslQueries()); for(Integer i=0; i < loops; i++) { - userSearch.getSearchResults(); + userSearch.getResults(); } System.assertEquals(loops, Limits.getSoslQueries()); @@ -127,7 +138,7 @@ private class Sosl_Tests { userSearch.cacheResults(); for(Integer i=0; i < loops; i++) { - userSearch.getSearchResults(); + userSearch.getResults(); } System.assertEquals(1, Limits.getSoslQueries()); diff --git a/src/classes/SearchBuilder_Tests.cls-meta.xml b/src/classes/SearchBuilder_Tests.cls-meta.xml new file mode 100644 index 00000000..800e53cf --- /dev/null +++ b/src/classes/SearchBuilder_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 43.0 + Active + diff --git a/src/classes/SobjectQueryBuilder.cls b/src/classes/SobjectQueryBuilder.cls new file mode 100644 index 00000000..624533cb --- /dev/null +++ b/src/classes/SobjectQueryBuilder.cls @@ -0,0 +1,416 @@ +/****************************************************************************************************** +* This file is part of the Nebula Query & Search project, released under the MIT License. * +* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * +******************************************************************************************************/ +public class SobjectQueryBuilder extends Soql { + + private String displayFieldApiName; + private List childRelationshipQueries; + private Boolean forReference, forUpdate, forView, includeLabels, includeFormattedValues; + + public SobjectQueryBuilder(Schema.SobjectType sobjectType) { + super(sobjectType, true); + + this.displayFieldApiName = this.getDisplayFieldApiName(this.sobjectType); + this.childRelationshipQueries = new List(); + this.forReference = false; + this.forUpdate = false; + this.forView = false; + this.includeLabels = false; + this.includeFormattedValues = false; + + this.addDefaultFields(); + } + + public SobjectQueryBuilder addField(Schema.SobjectField field) { + return this.addField(field, null); + } + + public SobjectQueryBuilder addField(Schema.SobjectField field, Soql.FieldCategory fieldCategory) { + return this.addFields(new List{field}, fieldCategory); + } + + public SobjectQueryBuilder addField(Soql.QueryField queryField) { + return this.addField(queryField, null); + } + + public SobjectQueryBuilder addField(Soql.QueryField queryField, Soql.FieldCategory fieldCategory) { + return this.addFields(new List{queryField}, fieldCategory); + } + + public SobjectQueryBuilder addFields(List fields) { + return this.addFields(fields, null); + } + + public SobjectQueryBuilder addFields(List fields, Soql.FieldCategory fieldCategory) { + List queryFields = new List(); + for(Schema.SobjectField field : fields) { + queryFields.add(new Soql.QueryField(field)); + } + return this.addFields(queryFields, fieldCategory); + } + + public SobjectQueryBuilder addFields(List queryFields) { + return this.addFields(queryFields, null); + } + + public SobjectQueryBuilder addFields(Soql.FieldCategory fieldCategory) { + List queryFields = new List(); + for(Schema.SobjectField field : this.sobjectDescribe.fields.getMap().values()) { + queryFields.add(new Soql.QueryField(field)); + } + return this.addFields(queryFields, fieldCategory); + } + + public SobjectQueryBuilder addFields(List queryFields, Soql.FieldCategory fieldCategory) { + super.doAddFields(queryFields, fieldCategory); + return this.setHasChanged(); + } + + public SobjectQueryBuilder addFieldSet(Schema.FieldSet fieldSet) { + return this.addFieldSet(fieldSet, null); + } + + public SobjectQueryBuilder addFieldSet(Schema.FieldSet fieldSet, Soql.FieldCategory fieldCategory) { + List queryFields = new List(); + for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) { + queryFields.add(new Soql.QueryField(this.sobjectType, fieldSetMember.getFieldPath())); + } + return this.addFields(queryFields, fieldCategory); + } + + public SobjectQueryBuilder includeLabels() { + this.includeLabels = true; + return this.setHasChanged(); + } + + public SobjectQueryBuilder includeFormattedValues() { + this.includeFormattedValues = true; + return this.setHasChanged(); + } + + public SobjectQueryBuilder removeField(Schema.SobjectField field) { + return this.removeFields(new List{field}); + } + + public SobjectQueryBuilder removeField(Soql.QueryField queryField) { + return this.removeFields(new List{queryField}); + } + + public SobjectQueryBuilder removeFields(Schema.FieldSet fieldSet) { + List queryFields = new List(); + for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) { + queryFields.add(new Soql.QueryField(this.getSobjectType(), fieldSetMember.getFieldPath())); + } + return this.removeFields(queryFields); + } + + public SobjectQueryBuilder removeFields(List fields) { + List queryFields = new List(); + for(Schema.SobjectField field : fields) { + queryFields.add(new Soql.QueryField(field)); + } + return this.removeFields(queryFields); + } + + public SobjectQueryBuilder removeFields(List queryFields) { + super.doRemoveFields(queryFields); + return this.setHasChanged(); + } + + public SobjectQueryBuilder includeRelatedRecords(Schema.SobjectField childToParentRelationshipField, SobjectQueryBuilder relatedSobjectQuery) { + this.childRelationshipQueries.add(relatedSobjectQuery.getRelatedRecordsQuery(childToParentRelationshipField)); + return this.setHasChanged(); + } + + public SobjectQueryBuilder usingScope(Scope scope) { + super.doUsingScope(scope); + return this.setHasChanged(); + } + + public SobjectQueryBuilder filterWhere(Schema.SobjectField field, Soql.Operator operator, Object value) { + return this.filterWhere(new Soql.QueryField(field), operator, value); + } + + public SobjectQueryBuilder filterWhere(Soql.QueryField queryField, Soql.Operator operator, Object value) { + return this.filterWhere(new Soql.QueryFilter(queryField, operator, value)); + } + + public SobjectQueryBuilder filterWhere(Soql.QueryFilter filter) { + return this.filterWhere(new List{filter}); + } + + public SobjectQueryBuilder filterWhere(List filters) { + super.doFilterWhere(filters); + return this.setHasChanged(); + } + + public SobjectQueryBuilder orFilterWhere(List filters) { + super.doOrFilterWhere(filters); + return this.setHasChanged(); + } + + public SobjectQueryBuilder orderByField(Schema.SobjectField field) { + return this.orderByField(new Soql.QueryField(field)); + } + + public SobjectQueryBuilder orderByField(Soql.QueryField queryField) { + return this.orderByField(queryField, null); + } + + public SobjectQueryBuilder orderByField(Schema.SobjectField field, Soql.SortOrder sortOrder) { + return this.orderByField(field, sortOrder, null); + } + + public SobjectQueryBuilder orderByField(Soql.QueryField queryField, Soql.SortOrder sortOrder) { + return this.orderByField(queryField, sortOrder, null); + } + + public SobjectQueryBuilder orderByField(Schema.SobjectField field, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + return this.orderByField(new Soql.QueryField(field), sortOrder, sortNullsFirst); + } + + public SobjectQueryBuilder orderByField(Soql.QueryField queryField, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + super.doOrderBy(queryField, sortOrder, sortNullsFirst); + return this.setHasChanged(); + } + + public SobjectQueryBuilder limitTo(Integer numberOfRecords) { + super.doLimitTo(numberOfRecords); + return this.setHasChanged(); + } + + public SobjectQueryBuilder offsetBy(Integer offset) { + super.doOffsetBy(offset); + return this.setHasChanged(); + } + + public SobjectQueryBuilder forReference() { + this.forReference = true; + return this.setHasChanged(); + } + + public SobjectQueryBuilder forUpdate() { + this.forUpdate = true; + return this.setHasChanged(); + } + + public SobjectQueryBuilder forView() { + this.forView = true; + return this.setHasChanged(); + } + + public SobjectQueryBuilder cacheResults() { + super.doCacheResults(); + return this; + } + + public override String getQuery() { + if(this.query != null && !this.hasChanged) return this.query; + + String queryFieldString = this.getQueryFieldString(); + String childRelationshipsQueryFieldString = this.getChildRelationshipsQueryFieldString(); + String childRelationshipDelimiter = !String.isEmpty(queryFieldString) && !String.isEmpty(childRelationshipsQueryFieldString) ? ', ' : ''; + + this.query = 'SELECT ' + queryFieldString + + childRelationshipDelimiter + childRelationshipsQueryFieldString + + ' FROM ' + this.sobjectType + + super.doGetUsingScopeString() + + super.doGetWhereClauseString() + + super.doGetOrderByString() + + super.doGetLimitCountString() + + super.doGetOffetString() + + this.getForReferenceString() + + this.getForUpdateString() + + this.getForViewString(); + + // Change hasChanged to false so that subsequent calls to getQuery() use the cached query string + // If additional builder methods are later called, the builder methods will set hasChanged = true + this.hasChanged = false; + + System.debug(LoggingLevel.FINEST, this.query); + return this.query; + } + + public String getRelatedRecordsQuery(Schema.SobjectField childToParentRelationshipField) { + Schema.SobjectType parentSobjectType = childToParentRelationshipField.getDescribe().getReferenceTo()[0]; + + // Get the relationship name + String childRelationshipName; + for(Schema.ChildRelationship childRelationship : parentSobjectType.getDescribe().getChildRelationships()) { + if(childRelationship.getField() != childToParentRelationshipField) continue; + + childRelationshipName = childRelationship.getRelationshipName(); + } + + String childQuery = '(SELECT ' + super.doGetQueryFieldString() + + ' FROM ' + childRelationshipName + + super.doGetUsingScopeString() + + super.doGetWhereClauseString() + + super.doGetOrderByString() + + super.doGetLimitCountString() + + ')'; + + System.debug(LoggingLevel.FINEST, childQuery); + return childQuery; + } + + public String getSearchQuery() { + String sobjectTypeOptions = super.doGetQueryFieldString() + + super.doGetWhereClauseString() + + super.doGetOrderByString() + + super.doGetLimitCountString(); + + // If we have any sobject-specific options, then wrap the options in parentheses + sobjectTypeOptions = String.isEmpty(sobjectTypeOptions) ? '' : '(' + sobjectTypeOptions + ')'; + + String searchQuery = this.getSobjectType() + sobjectTypeOptions; + + System.debug(LoggingLevel.FINEST, searchQuery); + return searchQuery; + } + + public Sobject getFirstResult() { + return super.doGetFirstResult(); + } + + public List getResults() { + return super.doGetResults(); + } + + private void addDefaultFields() { + Map fieldMap = this.getSobjectType().getDescribe().fields.getMap(); + this.addField(fieldMap.get('Id')); + if(!String.isBlank(this.displayFieldApiName)) this.addField(fieldMap.get(this.displayFieldApiName)); + } + + private SobjectQueryBuilder setHasChanged() { + this.hasChanged = true; + return this; + } + + private String getQueryFieldString() { + Set distinctFieldApiNamesToQuery = new Set(); + for(Soql.QueryField queryField : this.includedQueryFieldsAndCategory.keySet()) { + Soql.FieldCategory fieldCategory = this.includedQueryFieldsAndCategory.get(queryField); + + List fieldsToQuery = this.getFieldsToQuery(queryField, fieldCategory); + if(!fieldsToQuery.isEmpty()) distinctFieldApiNamesToQuery.addAll(fieldsToQuery); + } + + + // Remove an excluded field paths + for(Soql.QueryField excludedQueryField : this.excludedQueryFields) { + distinctFieldApiNamesToQuery.remove(excludedQueryField.toString()); + } + + List fieldApiNamesToQuery = new List(distinctFieldApiNamesToQuery); + fieldApiNamesToQuery.sort(); + return String.join(fieldApiNamesToQuery, ', '); + } + + private String getDisplayFieldApiName(Schema.SobjectType sobjectType) { + // There are several commonly used names for the display field name - typically, Name + // The order of the field names has been sorted based on number of objects in a new dev org with that field + List possibleDisplayFieldApiNames = new List{ + 'Name', 'DeveloperName', 'ApiName', 'Title', 'Subject', 'AssetRelationshipNumber', + 'CaseNumber', 'ContractNumber', 'Domain', 'FriendlyName', 'FunctionName', 'Label', 'LocalPart', + 'OrderItemNumber', 'OrderNumber', 'SolutionName', 'TestSuiteName' + }; + Map fieldMap = sobjectType.getDescribe().fields.getMap(); + for(String fieldApiName : possibleDisplayFieldApiNames) { + Schema.SobjectField field = fieldMap.get(fieldApiName); + + if(field == null) continue; + + Schema.DescribeFieldResult fieldDescribe = field.getDescribe(); + if(fieldDescribe.isNameField()) return fieldDescribe.getName(); + } + + return null; + } + + private String getParentObjectNameField(Schema.DescribeFieldResult fieldDescribe) { + String relationshipName = fieldDescribe.getRelationshipName(); + Schema.SobjectType parentSobjectType = fieldDescribe.getReferenceTo()[0]; + String nameField = this.getDisplayFieldApiName(parentSobjectType); + + if(relationshipName == null) return null; + else if(nameField == null) return null; + else return relationshipName + '.' + nameField; + } + + private List getFieldsToQuery(Soql.QueryField queryField, Soql.FieldCategory fieldCat) { + //List fieldsToReturn = super.doGetFieldsToQuery(queryField, fieldCat); + List fieldsToReturn = new List(); + + if(fieldCat == null) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.ACCESSIBLE && !queryField.getDescribe().isAccessible()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.UPDATEABLE && !queryField.getDescribe().isUpdateable()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.STANDARD && queryField.getDescribe().isCustom()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.CUSTOM && !queryField.getDescribe().isCustom()) return fieldsToReturn; + + fieldsToReturn.add(queryField.toString()); + + // If the field has picklist options, then it can be translated + if(this.includeLabels && !queryField.getDescribe().getPickListValues().isEmpty()) { + fieldsToReturn.add(this.getFieldToLabel(queryField.getDescribe().getName())); + } + + // If the field is a number, date, time, or currency, it can be formatted + List supportedTypesForFormatting = new List{ + Schema.DisplayType.CURRENCY, Schema.DisplayType.DATE, Schema.DisplayType.DATETIME, Schema.DisplayType.DOUBLE, + Schema.DisplayType.INTEGER, Schema.DisplayType.PERCENT, Schema.DisplayType.TIME + }; + if(this.includeFormattedValues && supportedTypesForFormatting.contains(queryField.getDescribe().getType())) { + fieldsToReturn.add(this.getFieldFormattedValue(queryField.getDescribe().getName())); + } + + // If the field is a lookup, then we need to get the name field from the parent object + if(queryField.getDescribe().getType().name() == 'Reference') { + if(queryField.getDescribe().isNamePointing()) { + String fieldPath = queryField.getFieldPath(); + Integer indx = fieldPath.lastIndexOf(queryField.getDescribe().getName()); + String parentTypeFieldPath = fieldPath.substring(0, indx) + queryField.getDescribe().getRelationshipName() + '.Type'; + fieldsToReturn.add(parentTypeFieldPath); + } + + String parentNameField = this.getParentObjectNameField(queryField.getDescribe()); + if(parentNameField != null) { + fieldsToReturn.add(parentNameField); + // Record type names can be translated, so include the translation + if(this.includeLabels && queryField.toString() == 'RecordTypeId') fieldsToReturn.add(this.getFieldToLabel(parentNameField)); + } + } + + return fieldsToReturn; + } + + private String getChildRelationshipsQueryFieldString() { + if(this.childRelationshipQueries.isEmpty()) return ''; + + this.childRelationshipQueries.sort(); + return String.join(this.childRelationshipQueries, ', '); + } + + private String getFieldToLabel(String fieldApiName) { + return 'toLabel(' + fieldApiName + ') ' + fieldApiName.replace('.', '_') + '__Label'; + } + + private String getFieldFormattedValue(String fieldApiName) { + return 'format(' + fieldApiName + ') ' + fieldApiName.replace('.', '_') + '__Formatted'; + } + + private String getForReferenceString() { + return !this.forReference ? '' : ' FOR REFERENCE'; + } + + private String getForUpdateString() { + return !this.forUpdate ? '' : ' FOR UPDATE'; + } + + private String getForViewString() { + return !this.forView ? '' : ' FOR VIEW'; + } + +} \ No newline at end of file diff --git a/src/classes/SobjectQueryBuilder.cls-meta.xml b/src/classes/SobjectQueryBuilder.cls-meta.xml new file mode 100644 index 00000000..800e53cf --- /dev/null +++ b/src/classes/SobjectQueryBuilder.cls-meta.xml @@ -0,0 +1,5 @@ + + + 43.0 + Active + diff --git a/src/classes/SobjectQueryBuilder_Tests.cls b/src/classes/SobjectQueryBuilder_Tests.cls new file mode 100644 index 00000000..899eb65a --- /dev/null +++ b/src/classes/SobjectQueryBuilder_Tests.cls @@ -0,0 +1,163 @@ +/****************************************************************************************************** +* This file is part of the Nebula Query & Search project, released under the MIT License. * +* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * +******************************************************************************************************/ +@isTest +private class SobjectQueryBuilder_Tests { + + @isTest + static void it_should_return_results_for_a_simple_query() { + String expectedQueryString = 'SELECT Id, Name FROM Account'; + + SobjectQueryBuilder simpleAccountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType); + + System.assertEquals(expectedQueryString, simpleAccountQuery.getQuery()); + List accounts = simpleAccountQuery.getResults(); + } + + @isTest + static void it_should_return_results_for_an_advanced_query() { + Datetime now = System.now(); + + String expectedQueryString = 'SELECT Alias, Email, Id, IsActive, Profile.Name, ProfileId' + + ' FROM User USING SCOPE MINE' + + ' WHERE CreatedDate <= LAST_WEEK' + + ' AND Email != null' + + ' AND IsActive = true' + + ' AND LastLoginDate >= LAST_N_DAYS:3' + + ' AND LastModifiedDate <= ' + now.format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'', 'Greenwich Mean Time') + + ' AND Profile.Id != \'' + UserInfo.getProfileId() + '\'' + + ' ORDER BY Profile.CreatedBy.LastModifiedDate ASC NULLS FIRST, Name ASC NULLS FIRST, Email ASC NULLS FIRST' + + ' LIMIT 100 OFFSET 1 FOR VIEW'; + + List fieldsToQuery = new List{Schema.User.IsActive, Schema.User.Alias}; + + SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType) + .addFields(fieldsToQuery) + .addField(Schema.User.ProfileId) + .addField(Schema.User.Email, Soql.FieldCategory.UPDATEABLE) + .removeField(new Soql.QueryField(Schema.User.Name)) + .removeField(Schema.User.UserRoleId) + .includeLabels() + .includeFormattedValues() + .usingScope(Soql.Scope.MINE) + .filterWhere(Schema.User.IsActive, Soql.Operator.EQUALS, true) + .filterWhere(new Soql.QueryField(Schema.User.SobjectType, 'Profile.Id'), Soql.Operator.NOT_EQUAL_TO, UserInfo.getProfileId()) + .filterWhere(Schema.User.LastModifiedDate, Soql.Operator.LESS_THAN_OR_EQUAL_TO, now) + .filterWhere(Schema.User.LastLoginDate, Soql.Operator.GREATER_THAN_OR_EQUAL_TO, new Soql.DateLiteral(Soql.RelativeDateLiteral.LAST_N_DAYS, 3)) + .filterWhere(Schema.User.CreatedDate, Soql.Operator.LESS_THAN_OR_EQUAL_TO, new Soql.DateLiteral(Soql.FixedDateLiteral.LAST_WEEK)) + .filterWhere(Schema.User.Email, Soql.Operator.NOT_EQUAL_TO, null) + .orderByField(new Soql.QueryField(Schema.User.SobjectType, 'Profile.CreatedBy.LastModifiedDate')) + .orderByField(Schema.User.Name, Soql.SortOrder.ASCENDING) + .orderByField(Schema.User.Email) + .limitTo(100) + .offsetBy(1) + .forView(); + + System.assertEquals(expectedQueryString, userQuery.getQuery()); + List users = userQuery.getResults(); + } + + @isTest + static void it_should_return_results_and_include_grandparent_query_field() { + String expectedQueryString = 'SELECT Id, Name, Parent.Owner.Name FROM Account'; + + List fieldChain = new List{ + Schema.Account.ParentId, Schema.Account.OwnerId, Schema.User.Name + }; + Soql.QueryField queryField = new Soql.QueryField(fieldChain); + + SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType); + accountQuery.addField(queryField); + + System.assertEquals(expectedQueryString, accountQuery.getQuery()); + List accounts = accountQuery.getResults(); + } + + @isTest + static void it_should_return_results_and_not_include_sobject_type_for_monomorphic_field() { + String expectedQueryString = 'SELECT Id, Name, Owner.Name, OwnerId FROM Account'; + + SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType) + .addField(new Soql.QueryField(Schema.Account.OwnerId)); + + System.assertEquals(expectedQueryString, accountQuery.getQuery()); + List accounts = accountQuery.getResults(); + } + + @isTest + static void it_should_return_results_and_include_sobject_type_for_polymorphic_field() { + String expectedQueryString = 'SELECT Id, Name, Owner.Name, Owner.Type, OwnerId FROM Lead'; + + SobjectQueryBuilder leadQuery = new SobjectQueryBuilder(Schema.Lead.SobjectType) + .addField(new Soql.QueryField(Schema.Lead.OwnerId)); + + System.assertEquals(expectedQueryString, leadQuery.getQuery()); + List leads = leadQuery.getResults(); + } + + @isTest + static void it_should_return_results_and_include_related_records() { + String expectedQueryString = 'SELECT Id, Name, Type, (SELECT Email, Id, Name FROM Contacts) FROM Account'; + + SobjectQueryBuilder contactQuery = new SobjectQueryBuilder(Schema.Contact.SobjectType) + .addField(Schema.Contact.Email); + + SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType) + .includeRelatedRecords(Schema.Contact.AccountId, contactQuery) + .addField(new Soql.QueryField(Schema.Account.Type)); + + System.assertEquals(expectedQueryString, accountQuery.getQuery()); + List accounts = accountQuery.getResults(); + } + + @isTest + static void it_should_return_results_when_filtering_with_an_or_statement() { + String expectedQueryString = 'SELECT Id, Name FROM Account WHERE ParentId != null AND (AccountNumber = null OR Type = null)'; + + SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType) + .addField(Schema.Account.AnnualRevenue) + .orFilterWhere(new List{ + new Soql.QueryFilter(Schema.Account.Type, Soql.Operator.EQUALS, null), + new Soql.QueryFilter(Schema.Account.AccountNumber, Soql.Operator.EQUALS, null) + }) + .filterWhere(new Soql.QueryFilter(Schema.Account.ParentId, Soql.Operator.NOT_EQUAL_TO, null)); + List accounts = accountQuery.getResults(); + } + + @isTest + static void it_should_return_results_when_filtering_with_iso_currency() { + // If multi-currency isn't enabled, then we cannot use IsoCurrency, so skip running this test + if(!UserInfo.isMultiCurrencyOrganization()) return; + + // If multi-currency is enabled, then execute the test + SobjectQueryBuilder accountQuery = new SobjectQueryBuilder(Schema.Account.SobjectType) + .addField(Schema.Account.AnnualRevenue) + .filterWhere(Schema.Account.AnnualRevenue, Soql.Operator.LESS_THAN, new Soql.IsoCurrency('USD', 100)); + List accounts = accountQuery.getResults(); + } + + @isTest + static void it_should_cache_query_results_when_enabled() { + Integer loops = 4; + SobjectQueryBuilder userQuery = new SobjectQueryBuilder(Schema.User.SobjectType).limitTo(1); + + // First, verify that caching is not enabled by default + System.assertEquals(0, Limits.getQueries()); + for(Integer i = 0; i < loops; i++) { + userQuery.getResults(); + } + System.assertEquals(loops, Limits.getQueries()); + + Test.startTest(); + + userQuery.cacheResults(); + for(Integer i = 0; i < loops; i++) { + userQuery.getResults(); + } + System.assertEquals(1, Limits.getQueries()); + + Test.stopTest(); + } + +} \ No newline at end of file diff --git a/src/classes/SobjectQueryBuilder_Tests.cls-meta.xml b/src/classes/SobjectQueryBuilder_Tests.cls-meta.xml new file mode 100644 index 00000000..800e53cf --- /dev/null +++ b/src/classes/SobjectQueryBuilder_Tests.cls-meta.xml @@ -0,0 +1,5 @@ + + + 43.0 + Active + diff --git a/src/classes/Soql.cls b/src/classes/Soql.cls index 2848d06c..e13c2070 100644 --- a/src/classes/Soql.cls +++ b/src/classes/Soql.cls @@ -1,394 +1,207 @@ /****************************************************************************************************** -* This file is part of the Nebula Framework project, released under the MIT License. * +* This file is part of the Nebula Query & Search project, released under the MIT License. * * See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * ******************************************************************************************************/ -public class Soql implements Comparable { +public abstract class Soql implements Comparable { public enum Aggregate { AVG, COUNT, COUNT_DISTINCT, MAX, MIN, SUM } + public enum GroupingDimension { CUBE, ROLLUP } public enum FieldCategory { ACCESSIBLE, UPDATEABLE, STANDARD, CUSTOM, IGNORE_FLS } + public enum Operator { + EQUALS, NOT_EQUAL_TO, + GREATER_THAN, GREATER_THAN_OR_EQUAL_TO, + LESS_THAN, LESS_THAN_OR_EQUAL_TO, + IS_IN, IS_NOT_IN, + INCLUDES, EXCLUDES, + IS_LIKE, IS_NOT_LIKE + } public enum Scope { EVERYTHING, DELEGATED, TEAM, MINE, MY_TERRITORY, MY_TEAM_TERRITORY } public enum SortOrder { ASCENDING, DESCENDING } - - private static final FieldCategory DEFAULT_FIELD_CATEGORY = FieldCategory.ACCESSIBLE; - private static final SortOrder DEFAULT_SORT_ORDER = SortOrder.ASCENDING; - private static final Boolean DEFAULT_NULLS_SORT_ORDER_FIRST = true; - - private static Map> cachedQueryResultsByHashCode = new Map>(); - - private Schema.SobjectType sobjectType; - private Schema.DescribeSobjectResult sobjectDescribe; - private String displayFieldApiName, query; - private Map includedQueryFieldsAndCategory; - private List aggregatedFields; - private Set excludedQueryFields; - private Scope scope; - private List whereFilters, orderByFieldApiNames; - private Integer limitCount; - private Integer offset; - private Boolean hasChanged, forReference, forUpdate, forView, cacheResults, includeLabels, includeFormattedValues; - - public Soql(Schema.SobjectType sobjectType) { - this.sobjectType = sobjectType; - this.displayFieldApiName = this.getDisplayFieldApiName(this.sobjectType); + public enum DateFunction { + HOUR_IN_DAY, + DAY_ONLY, DAY_IN_MONTH, DAY_IN_WEEK, DAY_IN_YEAR, + WEEK_IN_MONTH, WEEK_IN_YEAR, + FISCAL_MONTH, FISCAL_QUARTER, FISCAL_YEAR, + CALENDAR_MONTH, CALENDAR_QUARTER, CALENDAR_YEAR + } + public enum FixedDateLiteral { + YESTERDAY, TODAY, TOMORROW, + LAST_WEEK, THIS_WEEK, NEXT_WEEK, + LAST_MONTH, THIS_MONTH, NEXT_MONTH, + LAST_90_DAYS, NEXT_90_DAYS, + LAST_QUARTER, THIS_QUARTER, NEXT_QUARTER, + LAST_FISCAL_QUARTER, THIS_FISCAL_QUARTER, NEXT_FISCAL_QUARTER, + LAST_YEAR, THIS_YEAR, NEXT_YEAR, + LAST_FISCAL_YEAR, THIS_FISCAL_YEAR, NEXT_FISCAL_YEAR + } + public enum RelativeDateLiteral { + N_DAYS_AGO, LAST_N_DAYS, NEXT_N_DAYS, + LAST_N_WEEKS, NEXT_N_WEEKS, + LAST_N_MONTHS, NEXT_N_MONTHS, + LAST_N_QUARTERS, NEXT_N_QUARTERS, + LAST_N_FISCAL_QUARTERS, NEXT_N_FISCAL_QUARTERS, + LAST_N_YEARS, NEXT_N_YEARS, + LAST_N_FISCAL_YEARS, NEXT_N_FISCAL_YEARS + } + + private static final Soql.FieldCategory DEFAULT_FIELD_CATEGORY = Soql.FieldCategory.ACCESSIBLE; + private static final Soql.SortOrder DEFAULT_SORT_ORDER = Soql.SortOrder.ASCENDING; + private static final Boolean DEFAULT_NULLS_SORT_ORDER_FIRST = true; + + private static Map> cachedResultsByHashCode = new Map>(); + + public static String getOperatorValue(Soql.Operator operator) { + switch on operator { + when EQUALS { return '='; } + when NOT_EQUAL_TO { return '!='; } + when GREATER_THAN { return '>'; } + when GREATER_THAN_OR_EQUAL_TO { return '>='; } + when LESS_THAN { return '<'; } + when LESS_THAN_OR_EQUAL_TO { return '<='; } + when IS_IN { return 'IN'; } + when IS_NOT_IN { return 'NOT IN'; } + when INCLUDES { return 'INCLUDES'; } + when EXCLUDES { return 'EXCLUDES'; } + when IS_LIKE { return 'LIKE'; } + when IS_NOT_LIKE { return 'NOT LIKE'; } + when else { return null; } + } + } + + protected String query; + protected Schema.SobjectType sobjectType; + protected Schema.DescribeSobjectResult sobjectDescribe; + protected Map includedQueryFieldsAndCategory; + protected Set excludedQueryFields; + protected Scope scope; + protected List whereFilters, orderByFieldApiNames; + protected Integer limitCount; + protected Integer offset; + protected Boolean hasChanged, sortQueryFields; + + protected Boolean cacheResults; + + protected Soql(Schema.SobjectType sobjectType, Boolean sortQueryFields) { + this.sobjectType = sobjectType; + this.sortQueryFields = sortQueryFields; this.sobjectDescribe = this.sobjectType.getDescribe(); - this.includedQueryFieldsAndCategory = new Map(); - this.aggregatedFields = new List(); + this.includedQueryFieldsAndCategory = new Map(); this.excludedQueryFields = new Set(); this.whereFilters = new List(); this.orderByFieldApiNames = new List(); - this.forReference = false; - this.forUpdate = false; - this.forView = false; this.cacheResults = false; - this.includeLabels = false; - this.includeFormattedValues = false; this.hasChanged = false; } - public Soql addField(Schema.SobjectField field) { - return this.addField(field, DEFAULT_FIELD_CATEGORY); - } - - public Soql addField(Schema.SobjectField field, Soql.FieldCategory fieldCategory) { - return this.addFields(new List{field}, fieldCategory); - } - - public Soql addField(Soql.QueryField queryField) { - return this.addField(queryField, DEFAULT_FIELD_CATEGORY); - } - - public Soql addField(Soql.QueryField queryField, Soql.FieldCategory fieldCategory) { - return this.addFields(new List{queryField}, fieldCategory); - } - - public Soql addFields(Schema.FieldSet fieldSet) { - return this.addFields(FieldSet, DEFAULT_FIELD_CATEGORY); + public Schema.SobjectType getSobjectType() { + return this.sobjectType; } - public Soql addFields(Schema.FieldSet fieldSet, FieldCategory fieldCategory) { - List queryFields = new List(); - for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) { - queryFields.add(new Soql.QueryField(this.sobjectType, fieldSetMember.getFieldPath())); - } - return this.addFields(queryFields, fieldCategory); - } + public abstract String getQuery(); - public Soql addFields(List fields) { - return this.addFields(fields, DEFAULT_FIELD_CATEGORY); - } + public Integer compareTo(Object compareTo) { + String currentSobjectApiName = String.valueOf(this.getSobjectType()); + Soql soqlToCompareTo = (Soql)compareTo; + String compareToSobjectApiName = String.valueOf(soqlToCompareTo.getSobjectType()); - public Soql addFields(List fields, Soql.FieldCategory fieldCategory) { - List queryFields = new List(); - for(Schema.SobjectField field : fields) { - queryFields.add(new Soql.QueryField(field)); - } - return this.addFields(queryFields, fieldCategory); + if(currentSobjectApiName == compareToSobjectApiName) return 0; + else if(currentSobjectApiName > compareToSobjectApiName) return 1; + else return -1; } - public Soql addFields(List queryFields) { - return this.addFields(queryFields, DEFAULT_FIELD_CATEGORY); + protected void doCacheResults() { + this.cacheResults = true; } - public Soql addFields(List queryFields, Soql.FieldCategory fieldCategory) { + protected void doAddFields(List queryFields, Soql.FieldCategory fieldCategory) { + if(fieldCategory == null) fieldCategory = DEFAULT_FIELD_CATEGORY; for(Soql.QueryField queryField : queryFields) { this.includedQueryFieldsAndCategory.put(queryField, fieldCategory); } - return this; - } - - public Soql addFields(Soql.FieldCategory fieldCategory) { - List queryFields = new List(); - for(Schema.SobjectField field : this.sobjectDescribe.fields.getMap().values()) { - queryFields.add(new Soql.QueryField(field)); - } - return this.addFields(queryFields, DEFAULT_FIELD_CATEGORY); - } - - public Soql aggregateField(Schema.SobjectField field, Soql.Aggregate aggregateFunction) { - this.aggregatedFields.add(new Soql.AggregateField(field, aggregateFunction)); - return this.setHasChanged(); - } - - public Soql removeField(Schema.SobjectField field) { - return this.removeFields(new List{field}); - } - - public Soql removeField(Soql.QueryField queryField) { - return this.removeFields(new List{queryField}); - } - - public Soql removeFields(Schema.FieldSet fieldSet) { - List queryFields = new List(); - for(Schema.FieldSetMember fieldSetMember : fieldSet.getFields()) { - queryFields.add(new Soql.QueryField(this.sobjectType, fieldSetMember.getFieldPath())); - } - return this.removeFields(queryFields); + this.doSetHasChanged(); } - public Soql removeFields(List fields) { - List queryFields = new List(); - for(Schema.SobjectField field : fields) { - queryFields.add(new Soql.QueryField(field)); - } - return this.removeFields(queryFields); - } - - public Soql removeFields(List queryFields) { + protected void doRemoveFields(List queryFields) { this.excludedQueryFields.addAll(queryFields); - return this.setHasChanged(); } - public Soql usingScope(Scope scope) { + protected void doUsingScope(Scope scope) { this.scope = scope; - return this.setHasChanged(); } - public Soql filterWhere(Schema.SobjectField field, String operator, Object value) { - Soql.QueryField queryField = new QueryField(new List{field}); - return this.filterWhere(queryField, operator, value); - } + protected void doFilterWhere(List filters) { + if(filters == null || filters.isEmpty()) return; - public Soql filterWhere(Soql.QueryField queryField, String operator, Object value) { - return this.filterWhere(new QueryFilter(queryField, operator, value)); + for(Soql.QueryFilter filter : filters) this.whereFilters.add(filter.toString()); + this.doSetHasChanged(); } - public Soql filterWhere(QueryFilter filter) { - return this.filterWhere(new List{filter}); - } - - public Soql filterWhere(List filters) { - for(QueryFilter filter : filters) this.whereFilters.add(filter.toString()); - return this.setHasChanged(); - } - - public Soql orderBy(Schema.SobjectField field) { - return this.orderBy(field, DEFAULT_SORT_ORDER, DEFAULT_NULLS_SORT_ORDER_FIRST); - } + protected void doOrFilterWhere(List filters) { + if(filters == null || filters.isEmpty()) return; - public Soql orderBy(Schema.SobjectField field, SortOrder sortOrder) { - return this.orderBy(field, sortOrder, DEFAULT_NULLS_SORT_ORDER_FIRST); - } + filters.sort(); - public Soql orderBy(Schema.SobjectField field, SortOrder sortOrder, Boolean sortNullsFirst) { - return this.orderBy(new QueryField(field), sortOrder, sortNullsFirst); + List orFilterPieces = new List(); + for(Soql.QueryFilter filter : filters) orFilterPieces.add(filter.toString()); + this.whereFilters.add('(' + String.join(orFilterPieces, ' OR ') + ')'); + this.doSetHasChanged(); } - public Soql orderBy(Soql.QueryField queryField) { - return this.orderBy(queryField, DEFAULT_SORT_ORDER, DEFAULT_NULLS_SORT_ORDER_FIRST); + protected void doOrderBy(Soql.QueryField queryField, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + this.doOrderBy(queryField.toString(), sortOrder, sortNullsFirst); } - public Soql orderBy(Soql.QueryField queryField, SortOrder sortOrder) { - return this.orderBy(queryField, sortOrder, DEFAULT_NULLS_SORT_ORDER_FIRST); - } + protected void doOrderBy(String fieldPath, Soql.SortOrder sortOrder, Boolean sortNullsFirst) { + if(sortOrder == null) sortOrder = DEFAULT_SORT_ORDER; + if(sortNullsFirst == null) sortNullsFirst = DEFAULT_NULLS_SORT_ORDER_FIRST; - public Soql orderBy(Soql.QueryField queryField, SortOrder sortOrder, Boolean sortNullsFirst) { String sortOrderString = sortOrder == Soql.SortOrder.ASCENDING ? 'ASC' : 'DESC'; String nullsSortOrder = sortNullsFirst ? 'FIRST' : 'LAST'; - this.orderByFieldApiNames.add(queryField + ' ' + sortOrderString + ' NULLS ' + nullsSortOrder); - return this.setHasChanged(); + this.orderByFieldApiNames.add(fieldPath + ' ' + sortOrderString + ' NULLS ' + nullsSortOrder); } - public Soql limitCount(Integer limitCount) { - this.limitCount = limitCount; - return this.setHasChanged(); + protected void doLimitTo(Integer numberOfRecords) { + this.limitCount = numberOfRecords; } - public Soql offset(Integer offset) { + protected void doOffsetBy(Integer offset) { this.offset = offset; - return this.setHasChanged(); - } - - public Soql forReference() { - this.forReference = true; - return this.setHasChanged(); - } - - public Soql forUpdate() { - this.forUpdate = true; - return this.setHasChanged(); - } - - public Soql forView() { - this.forView = true; - return this.setHasChanged(); - } - - public Soql includeLabels() { - this.includeLabels = true; - return this.setHasChanged(); - } - - public Soql includeFormattedValues() { - this.includeFormattedValues = true; - return this.setHasChanged(); - } - - public Soql cacheResults() { - this.cacheResults = true; - return this.setHasChanged(); - } - - public virtual String getQuery() { - if(this.query != null && !this.hasChanged) return this.query; - - String queryFieldString = this.getQueryFieldString(); - String aggregateQueryFieldString = this.getAggregateQueryFieldString(); - String fieldDelimiter = !String.isEmpty(queryFieldString) && !String.isEmpty(aggregateQueryFieldString) ? ', ' : ''; - - this.query = 'SELECT ' + queryFieldString + fieldDelimiter + aggregateQueryFieldString - + ' FROM ' + this.sobjectType - + this.getUsingScopeString() - + this.getWhereClauseString() - + this.getGroupByString() - + this.getOrderByString() - + this.getLimitCountString() - + this.getOffetString() - + this.getForReferenceString() - + this.getForUpdateString() - + this.getForViewString(); - - // Change hasChanged to false so that subsequent calls to getQuery() use the cached query string - // If additional builder methods are later called, the builder methods will set hasChanged = true - this.hasChanged = false; - - System.debug(LoggingLevel.FINEST, this.query); - return this.query; - } - - public String getSearchQuery() { - String sobjectTypeOptions = this.getQueryFieldString() - + this.getWhereClauseString() - + this.getOrderByString() - + this.getLimitCountString(); - - // If we have any sobject-specific options, then wrap the options in parentheses - sobjectTypeOptions = String.isEmpty(sobjectTypeOptions) ? '' : '(' + sobjectTypeOptions + ')'; - - String searchQuery = this.getSobjectType() + sobjectTypeOptions; - System.debug(LoggingLevel.FINEST, searchQuery); - return searchQuery; } - public Sobject getFirstQueryResult() { - return this.getQueryResults()[0]; + protected Sobject doGetFirstResult() { + List results = this.doGetResults(); + return results == null || results.isEmpty() ? null : results[0]; } - public List getQueryResults() { - if(this.cacheResults) return this.getCachedQuery(); + protected List doGetResults() { + if(this.cacheResults) return this.getCachedResults(); else return Database.query(this.getQuery()); } - public Schema.SobjectType getSobjectType() { - return this.sobjectType; - } - - public Integer compareTo(Object compareTo) { - String currentSobjectApiName = String.valueOf(this.getSobjectType()); - Soql soqlToCompareTo = (Soql)compareTo; - String compareToSobjectApiName = String.valueOf(soqlToCompareTo.getSobjectType()); - - if(currentSobjectApiName == compareToSobjectApiName) return 0; - else if(currentSobjectApiName > compareToSobjectApiName) return 1; - else return -1; - } - - private Soql setHasChanged() { - this.hasChanged = true; - return this; - } - - private String getDisplayFieldApiName(Schema.SobjectType sobjectType) { - // There are several commonly used names for the display field name - typically, Name - // The order of the field names has been sorted based on number of objects in a new dev org with that field - List possibleDisplayFieldApiNames = new List{ - 'Name', 'DeveloperName', 'ApiName', 'Title', 'Subject', 'AssetRelationshipNumber', - 'CaseNumber', 'ContractNumber', 'Domain', 'FriendlyName', 'FunctionName', 'Label', 'LocalPart', - 'OrderItemNumber', 'OrderNumber', 'SolutionName', 'TestSuiteName' - }; - Map fieldMap = sobjectType.getDescribe().fields.getMap(); - for(String fieldApiName : possibleDisplayFieldApiNames) { - Schema.SobjectField field = fieldMap.get(fieldApiName); - - if(field == null) continue; - - Schema.DescribeFieldResult fieldDescribe = field.getDescribe(); - if(fieldDescribe.isNameField()) return fieldDescribe.getName(); - } - - return null; - } - - private String getParentObjectNameField(Schema.DescribeFieldResult fieldDescribe) { - String relationshipName = fieldDescribe.getRelationshipName(); - Schema.SobjectType parentSobjectType = fieldDescribe.getReferenceTo()[0]; - String nameField = this.getDisplayFieldApiName(parentSobjectType); - - if(relationshipName == null) return null; - else if(nameField == null) return null; - else return relationshipName + '.' + nameField; - } - - private String getFieldToLabel(String fieldApiName) { - return 'toLabel(' + fieldApiName + ') ' + fieldApiName.replace('.', '_') + '__Label'; - } - - private String getFieldFormattedValue(String fieldApiName) { - return 'format(' + fieldApiName + ') ' + fieldApiName.replace('.', '_') + '__Formatted'; - } - - private List getFieldsToQuery(QueryField queryField, FieldCategory fieldCat) { + protected List doGetFieldsToQuery(Soql.QueryField queryField, Soql.FieldCategory fieldCat) { List fieldsToReturn = new List(); if(fieldCat == null) return fieldsToReturn; - else if(fieldCat == FieldCategory.ACCESSIBLE && !queryField.getDescribe().isAccessible()) return fieldsToReturn; - else if(fieldCat == FieldCategory.UPDATEABLE && !queryField.getDescribe().isUpdateable()) return fieldsToReturn; - else if(fieldCat == FieldCategory.STANDARD && queryField.getDescribe().isCustom()) return fieldsToReturn; - else if(fieldCat == FieldCategory.CUSTOM && !queryField.getDescribe().isCustom()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.ACCESSIBLE && !queryField.getDescribe().isAccessible()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.UPDATEABLE && !queryField.getDescribe().isUpdateable()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.STANDARD && queryField.getDescribe().isCustom()) return fieldsToReturn; + else if(fieldCat == Soql.FieldCategory.CUSTOM && !queryField.getDescribe().isCustom()) return fieldsToReturn; fieldsToReturn.add(queryField.toString()); - // If the field has picklist options, then it can be translated - if(this.includeLabels && !queryField.getDescribe().getPickListValues().isEmpty()) { - fieldsToReturn.add(this.getFieldToLabel(queryField.getDescribe().getName())); - } - - // If the field is a number, date, time, or currency, it can be formatted - List supportedTypesForFormat = new List{ - Schema.DisplayType.CURRENCY, Schema.DisplayType.DATE, Schema.DisplayType.DATETIME, Schema.DisplayType.DOUBLE, - Schema.DisplayType.INTEGER, Schema.DisplayType.PERCENT, Schema.DisplayType.TIME - }; - if(this.includeFormattedValues && supportedTypesForFormat.contains(queryField.getDescribe().getType())) { - fieldsToReturn.add(this.getFieldFormattedValue(queryField.getDescribe().getName())); - } - - // If the field is a lookup, then we need to get the name field from the parent object - if(queryField.getDescribe().getType().name() == 'Reference') { - String parentNameField = this.getParentObjectNameField(queryField.getDescribe()); - if(parentNameField != null) { - fieldsToReturn.add(parentNameField); - // Record type names can be translated, so include the translation - if(this.includeLabels && queryField.toString() == 'RecordTypeId') fieldsToReturn.add(this.getFieldToLabel(parentNameField)); - } - } - return fieldsToReturn; } - private String getQueryFieldString() { + protected String doGetQueryFieldString() { Set distinctFieldApiNamesToQuery = new Set(); - for(QueryField queryField : this.includedQueryFieldsAndCategory.keySet()) { - FieldCategory fieldCategory = this.includedQueryFieldsAndCategory.get(queryField); + for(Soql.QueryField queryField : this.includedQueryFieldsAndCategory.keySet()) { + Soql.FieldCategory fieldCategory = this.includedQueryFieldsAndCategory.get(queryField); - List fieldsToQuery = this.getFieldsToQuery(queryField, fieldCategory); + List fieldsToQuery = this.doGetFieldsToQuery(queryField, fieldCategory); if(!fieldsToQuery.isEmpty()) distinctFieldApiNamesToQuery.addAll(fieldsToQuery); } - // If the query is NOT an aggregate query, then add the Id & display name fields automatically - if(this.aggregatedFields.isEmpty()) { - distinctFieldApiNamesToQuery.add('Id'); - distinctFieldApiNamesToQuery.add(this.displayFieldApiName); - } // Remove an excluded field paths for(Soql.QueryField excludedQueryField : this.excludedQueryFields) { @@ -396,91 +209,56 @@ public class Soql implements Comparable { } List fieldApiNamesToQuery = new List(distinctFieldApiNamesToQuery); - if(this.aggregatedFields.isEmpty()) fieldApiNamesToQuery.sort(); + if(this.sortQueryFields) fieldApiNamesToQuery.sort(); return String.join(fieldApiNamesToQuery, ', '); } - private String getAggregateQueryFieldString() { - if(this.aggregatedFields.isEmpty()) return ''; - - List aggregatedFieldStrings = new List(); - for(Soql.AggregateField aggregatedField : this.aggregatedFields) { - aggregatedFieldStrings.add(aggregatedField.toString()); - } - return String.join(aggregatedFieldStrings, ', '); - } - - private String getUsingScopeString() { + protected String doGetUsingScopeString() { return this.scope == null ? '' : ' USING SCOPE ' + this.scope.name(); } - private String getWhereClauseString() { + protected String doGetWhereClauseString() { + this.whereFilters.sort(); return this.whereFilters.isEmpty() ? '' : ' WHERE ' + String.join(this.whereFilters, ' AND '); } - private String getGroupByString() { - // TODO might need a better way to track if the query is a standard query or aggregate - String queryFieldString = this.getQueryFieldString(); - return String.isEmpty(queryFieldString) || this.aggregatedFields.isEmpty() ? '' : ' GROUP BY ' + queryFieldString; - } - - private String getOrderByString() { + protected String doGetOrderByString() { return this.orderByFieldApiNames.isEmpty() ? '' : ' ORDER BY ' + String.join(this.orderByFieldApiNames, ', '); } - private String getLimitCountString() { + protected String doGetLimitCountString() { return this.limitCount == null ? '' : ' LIMIT ' + this.limitCount; } - private String getOffetString() { + protected String doGetOffetString() { return this.offset == null ? '' : ' OFFSET ' + this.offset; } - private String getForReferenceString() { - return !this.forReference ? '' : ' FOR REFERENCE'; - } - - private String getForUpdateString() { - return !this.forUpdate ? '' : ' FOR UPDATE'; - } - - private String getForViewString() { - return !this.forView ? '' : ' FOR VIEW'; + private void doSetHasChanged() { + this.hasChanged = true; } - private List getCachedQuery() { + private List getCachedResults() { String query = this.getQuery(); Integer hashCode = query.hashCode(); - Boolean isCached = cachedQueryResultsByHashCode.containsKey(hashCode); - if(!isCached) cachedQueryResultsByHashCode.put(hashCode, Database.query(query)); + Boolean isCached = cachedResultsByHashCode.containsKey(hashCode); + if(!isCached) cachedResultsByHashCode.put(hashCode, Database.query(query)); // Always return a deep clone so the original cached version is never modified - return cachedQueryResultsByHashCode.get(hashCode).deepClone(true, true, true); - } - - private class AggregateField { - private String aggregateField; - public AggregateField(Schema.SobjectField field, Soql.Aggregate aggregateFunction) { - String fieldApiName = field.getDescribe().getName(); - String fieldAlias = fieldApiName + '__' + aggregateFunction.name(); - - // Alias: MIN(Schema.Lead.MyField__c) is auto-aliased to MyField__c__MIN - this.aggregateField = aggregateFunction.name() + '(' + fieldApiName + ') ' + fieldAlias; - } - public override String toString() { - return this.aggregateField; - } + return cachedResultsByHashCode.get(hashCode).deepClone(true, true, true); } - public class SoqlException extends Exception {} - public class DateLiteral { private String dateLiteral; - public DateLiteral(String dateLiteral) { - this.dateLiteral = String.escapeSingleQuotes(dateLiteral); + public DateLiteral(FixedDateLiteral fixedDateLiteral) { + this.dateLiteral = fixedDateLiteral.name(); + } + + public DateLiteral(RelativeDateLiteral relativeDateLiteral, Integer n) { + this.dateLiteral = relativeDateLiteral.name() + ':' + n; } public override String toString() { @@ -504,103 +282,67 @@ public class Soql implements Comparable { } - public virtual class QueryArgument { + public class QueryField { - private String value; + private final String queryFieldPath; + private final Schema.DescribeFieldResult fieldDescribe; - public QueryArgument(Object valueToFormat) { - this.value = this.formatObjectForQueryString(valueToFormat); + public QueryField(Schema.SobjectType sobjectType, String queryFieldPath) { + this.fieldDescribe = this.getLastFieldDescribe(sobjectType, queryFieldPath); + this.queryFieldPath = queryFieldPath; } - public override String toString() { - return this.value; + public QueryField(Schema.SobjectField field) { + this(new List{field}); } - private String formatObjectForQueryString(Object valueToFormat) { - if(valueToFormat == null) return null; - else if(valueToFormat instanceOf List) return this.convertListToQueryString((List)valueToFormat); - else if(valueToFormat instanceOf Set) return this.convertSetToQueryString(valueToFormat); - else if(valueToFormat instanceOf Map) return this.convertMapToQueryString(valueToFormat); - else if(valueToFormat instanceOf Date) return String.valueOf((Date)valueToFormat).left(10); - else if(valueToFormat instanceOf Datetime) { - Datetime datetimeValue = (Datetime)valueToFormat; - return datetimeValue.format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'', 'Greenwich Mean Time'); - } - else if(valueToFormat instanceOf Sobject) { - Sobject record = (Sobject)valueToFormat; - return this.wrapInSingleQuotes(((Sobject)valueToFormat).Id); - } - else if(valueToFormat instanceOf String) { - // Escape single quotes to prevent SOQL/SOSL injection - String stringArgument = String.escapeSingleQuotes((String)valueToFormat); - return this.wrapInSingleQuotes(stringArgument); - } - else return String.valueOf(valueToFormat); + public QueryField(List fieldChain) { + this.fieldDescribe = this.getLastFieldDescribe(fieldChain); + this.queryFieldPath = this.getQueryField(fieldChain); } - private String wrapInSingleQuotes(String input) { - input = input.trim(); - if(input.left(1) != '\'') input = '\'' + input; - if(input.right(1) != '\'') input = input + '\''; - return input; + public QueryField(Soql.DateFunction dateFunction, Schema.SobjectField field) { + this(dateFunction, field, false); } - private String convertListToQueryString(List valueList) { - List parsedValueList = new List(); - for(Object value : valueList) { - parsedValueList.add(this.formatObjectForQueryString(value)); - } - return '(' + String.join(parsedValueList, ', ') + ')'; + public QueryField(Soql.DateFunction dateFunction, Schema.SobjectField field, Boolean convertTimeZone) { + this(dateFunction, new List{field}, convertTimeZone); } - private String convertSetToQueryString(Object valueSet) { - String unformattedString = String.valueOf(valueSet).replace('{', '').replace('}', ''); - List parsedValueList = new List(); - for(String collectionItem : unformattedString.split(',')) { - parsedValueList.add(this.formatObjectForQueryString(collectionItem)); - } - return '(' + String.join(parsedValueList, ', ') + ')'; + public QueryField(Soql.DateFunction dateFunction, List fieldChain) { + this(dateFunction, fieldChain, false); } - private String convertMapToQueryString(Object valueMap) { - Map m = (Map)JSON.deserializeUntyped(JSON.serialize(valueMap)); - return this.convertSetToQueryString(m.keySet()); + public QueryField(Soql.DateFunction dateFunction, List fieldChain, Boolean convertTimeZone) { + this.fieldDescribe = this.getLastFieldDescribe(fieldChain); + this.queryFieldPath = this.getDateFunctionFieldPath(dateFunction, fieldChain, convertTimeZone); } - } - - public class QueryField { - - private final String queryField; - private final Schema.DescribeFieldResult fieldDescribe; - - public QueryField(Schema.SobjectType sobjectType, String queryField) { - this.fieldDescribe = this.getLastFieldDescribe(sobjectType, queryField); - this.queryField = queryField; + public override String toString() { + return this.queryFieldPath; } - public QueryField(Schema.SobjectField field) { - this(new List{field}); + public Schema.DescribeFieldResult getDescribe() { + return this.fieldDescribe; } - public QueryField(List fields) { - this.fieldDescribe = this.getLastFieldDescribe(fields); - this.queryField = this.getQueryField(fields); + public String getFieldPath() { + return this.queryFieldPath; } - public override String toString() { - return this.queryField; - } + private String getDateFunctionFieldPath(Soql.DateFunction dateFunction, List fieldChain, Boolean convertTimeZone) { + String fieldPath = !convertTimeZone ? this.getQueryField(fieldChain) : 'convertTimeZone(' + this.getQueryField(fieldChain) + ')'; - public Schema.DescribeFieldResult getDescribe() { - return this.fieldDescribe; + return dateFunction.name() + '(' + fieldPath + ')'; } private Schema.DescribeFieldResult getLastFieldDescribe(Schema.SobjectType sobjectType, String queryField) { Schema.SobjectType currentSobjectType = sobjectType; + List fields = new List(); List queryFieldPieces = queryField.split('\\.'); - Integer lastFieldIndex = queryFieldPieces.size() - 1; + Integer lastFieldIndex = queryFieldPieces.size() <= 1 ? 0 : queryFieldPieces.size() - 1; + for(Integer i = 0; i < queryFieldPieces.size(); i++) { String queryFieldPiece = queryFieldPieces[i]; @@ -640,27 +382,121 @@ public class Soql implements Comparable { public class QueryFilter implements Comparable { - private String value; + private Soql.QueryField queryField; + private Soql.Operator operator; + private Object value; + private String formattedValue; + private String filterString; - public QueryFilter(Schema.SobjectField field, String operator, Object value) { + public QueryFilter(Schema.SobjectField field, Soql.Operator operator, Object value) { this(new QueryField(field), operator, value); } - public QueryFilter(QueryField queryField, String operator, Object value) { - this.value = queryField + ' ' + operator + ' ' + new QueryArgument(value); + public QueryFilter(QueryField queryField, Soql.Operator operator, Object value) { + this.queryField = queryField; + this.operator = operator; + this.value = value; + this.formattedValue = new QueryArgument(value).toString(); + + this.filterString = queryField + ' ' + Soql.getOperatorValue(operator) + ' ' + formattedValue; } public Integer compareTo(Object compareTo) { QueryFilter compareToQueryFilter = (QueryFilter)compareTo; + if(this.toString() == compareToQueryFilter.toString()) return 0; else if(this.toString() > compareToQueryFilter.toString()) return 1; else return -1; } + public Soql.QueryField getQueryField() { + return this.queryField; + } + + public Soql.Operator getOperator() { + return this.operator; + } + + public Object getValue() { + return this.value; + } + + public Object getFormattedValue() { + return this.formattedValue; + } + + public override String toString() { + return this.filterString; + } + + } + + public class SoqlException extends Exception {} + + private class QueryArgument { + + private String value; + + public QueryArgument(Object valueToFormat) { + this.value = this.formatObjectForQueryString(valueToFormat); + } + public override String toString() { return this.value; } + private String formatObjectForQueryString(Object valueToFormat) { + if(valueToFormat == null) return null; + else if(valueToFormat instanceOf List) return this.convertListToQueryString((List)valueToFormat); + else if(valueToFormat instanceOf Set) return this.convertSetToQueryString(valueToFormat); + else if(valueToFormat instanceOf Map) return this.convertMapToQueryString(valueToFormat); + else if(valueToFormat instanceOf Date) return String.valueOf((Date)valueToFormat).left(10); + else if(valueToFormat instanceOf Datetime) { + Datetime datetimeValue = (Datetime)valueToFormat; + return datetimeValue.format('yyyy-MM-dd\'T\'HH:mm:ss\'Z\'', 'Greenwich Mean Time'); + } + else if(valueToFormat instanceOf Sobject) { + Sobject record = (Sobject)valueToFormat; + return this.wrapInSingleQuotes(((Sobject)valueToFormat).Id); + } + else if(valueToFormat instanceOf String) { + // Escape single quotes to prevent SOQL/SOSL injection + String unformattedString = (String)valueToFormat; + String stringArgument = String.escapeSingleQuotes(unformattedString.trim()); + return this.wrapInSingleQuotes(stringArgument); + } + else return String.valueOf(valueToFormat); + } + + private String wrapInSingleQuotes(String input) { + input = input.trim(); + if(input.left(1) != '\'') input = '\'' + input; + if(input.right(1) != '\'') input = input + '\''; + return input; + } + + private String convertListToQueryString(List valueList) { + List parsedValueList = new List(); + for(Object value : valueList) { + parsedValueList.add(this.formatObjectForQueryString(value)); + } + return '(' + String.join(parsedValueList, ', ') + ')'; + } + + private String convertSetToQueryString(Object valueSet) { + String unformattedString = String.valueOf(valueSet).replace('{', '').replace('}', ''); + List parsedValueList = new List(); + for(String collectionItem : unformattedString.split(',')) { + parsedValueList.add(this.formatObjectForQueryString(collectionItem)); + } + return '(' + String.join(parsedValueList, ', ') + ')'; + } + + private String convertMapToQueryString(Object valueMap) { + Map untypedMap = (Map)Json.deserializeUntyped(Json.serialize(valueMap)); + return this.convertSetToQueryString(untypedMap.keySet()); + } + } } \ No newline at end of file diff --git a/src/classes/Soql.cls-meta.xml b/src/classes/Soql.cls-meta.xml index fec71a26..800e53cf 100644 --- a/src/classes/Soql.cls-meta.xml +++ b/src/classes/Soql.cls-meta.xml @@ -1,5 +1,5 @@ - 42.0 + 43.0 Active diff --git a/src/classes/Soql_Tests.cls b/src/classes/Soql_Tests.cls deleted file mode 100644 index f92fc4dc..00000000 --- a/src/classes/Soql_Tests.cls +++ /dev/null @@ -1,118 +0,0 @@ -/****************************************************************************************************** -* This file is part of the Nebula Framework project, released under the MIT License. * -* See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * -******************************************************************************************************/ -@isTest -private class Soql_Tests { - - @isTest - static void it_should_return_results_for_a_simple_query() { - String expectedQueryString = 'SELECT Id, Name FROM Account'; - - Soql simpleAccountQuery = new Soql(Schema.Account.SobjectType); - - System.assertEquals(expectedQueryString, simpleAccountQuery.getQuery()); - List accounts = simpleAccountQuery.getQueryResults(); - } - - @isTest - static void it_should_return_results_for_an_advanced_query() { - Datetime now = System.now(); - - // The fields are conditionally added to the query based on the current user's permissions - // To keep the test simpler for now, the rest of the query (excluding the 'SELECT ') is validated - String expectedPartialQueryString = 'FROM User USING SCOPE MINE WHERE IsActive = true' - + ' AND Profile.Id != \'' + UserInfo.getProfileId() + '\' AND LastModifiedDate <= ' + now - + ' AND CreatedDate <= LAST_WEEK AND Email != null' - + ' ORDER BY Profile.Name ASC NULLS FIRST, Name ASC NULLS FIRST, Email ASC NULLS FIRST LIMIT 100 OFFSET 1 FOR VIEW'; - - List fieldsToQuery = new List{Schema.User.IsActive, Schema.User.Alias}; - - Soql userQuery = new Soql(Schema.User.SobjectType) - .addFields(fieldsToQuery) - .addField(Schema.User.ProfileId) - .addField(Schema.User.Email, Soql.FieldCategory.UPDATEABLE) - .addFields(Soql.FieldCategory.STANDARD) - .removeField(new Soql.QueryField(Schema.User.Name)) - .removeField(Schema.User.UserRoleId) - .includeLabels() - .includeFormattedValues() - .usingScope(Soql.Scope.MINE) - .filterWhere(Schema.User.IsActive, '=', true) - .filterWhere(new Soql.QueryField(Schema.User.SobjectType, 'Profile.Id'), '!=', UserInfo.getProfileId()) - .filterWhere(Schema.User.LastModifiedDate, '<=', now) - .filterWhere(Schema.User.CreatedDate, '<=', new Soql.DateLiteral('LAST_WEEK')) - .filterWhere(Schema.User.Email, '!=', null) - .orderBy(new Soql.QueryField(Schema.User.SobjectType, 'Profile.CreatedBy.LastModifiedDate')) - .orderBy(Schema.User.Name, Soql.SortOrder.ASCENDING) - .orderBy(Schema.User.Email) - .limitCount(100) - .offset(1) - .forView(); - - //System.assert(userQuery.getQuery().endsWith(expectedPartialQueryString), Json.serialize(userQuery.getQuery())); //TODO finish implementing this assert - List users = userQuery.getQueryResults(); - } - - @isTest - static void it_should_return_results_for_an_aggregate_query() { - Soql aggregateAccountQuery = new Soql(Schema.User.SobjectType) - .addField(Schema.User.ProfileId) - .aggregateField(Schema.User.CreatedDate, Soql.Aggregate.MAX) - .aggregateField(Schema.User.CreatedDate, Soql.Aggregate.MIN) - .aggregateField(Schema.User.Email, Soql.Aggregate.COUNT); - List results = aggregateAccountQuery.getQueryResults(); - } - - @isTest - static void it_should_return_results_and_include_grandparent_query_field() { - String expectedQueryString = 'SELECT Id, Name, Parent.Owner.Name FROM Account'; - - List fieldChain = new List{ - Schema.Account.ParentId, Schema.Account.OwnerId, Schema.User.Name - }; - Soql.QueryField queryField = new Soql.QueryField(fieldChain); - - Soql accountQuery = new Soql(Schema.Account.SobjectType); - accountQuery.addField(queryField); - - System.assertEquals(expectedQueryString, accountQuery.getQuery()); - List accounts = accountQuery.getQueryResults(); - } - - @isTest - static void it_should_return_results_when_filtering_with_iso_currency() { - // If multi-currency isn't enabled, then we cannot use IsoCurrency, so skip running this test - if(!UserInfo.isMultiCurrencyOrganization()) return; - - // If multi-currency is enabled, then execute the test - Soql accountQuery = new Soql(Schema.Account.SobjectType) - .addField(Schema.Account.AnnualRevenue) - .filterWhere(Schema.Account.AnnualRevenue, '<', new Soql.IsoCurrency('USD', 100)); - List accounts = accountQuery.getQueryResults(); - } - - @isTest - static void it_should_cache_query_results_when_enabled() { - Integer loops = 4; - Soql userQuery = new Soql(Schema.User.SobjectType).limitCount(1); - - // First, verify that caching is not enabled by default - System.assertEquals(0, Limits.getQueries()); - for(Integer i=0; i < loops; i++) { - userQuery.getQueryResults(); - } - System.assertEquals(loops, Limits.getQueries()); - - Test.startTest(); - - userQuery.cacheResults(); - for(Integer i=0; i < loops; i++) { - userQuery.getQueryResults(); - } - System.assertEquals(1, Limits.getQueries()); - - Test.stopTest(); - } - -} \ No newline at end of file diff --git a/src/classes/Sosl.cls b/src/classes/Sosl.cls index a2fd8538..5924982d 100644 --- a/src/classes/Sosl.cls +++ b/src/classes/Sosl.cls @@ -1,143 +1,81 @@ /****************************************************************************************************** -* This file is part of the Nebula Framework project, released under the MIT License. * +* This file is part of the Nebula Query & Search project, released under the MIT License. * * See LICENSE file or go to https://github.com/jongpie/NebulaQueryAndSearch for full license details. * ******************************************************************************************************/ -public class Sosl { +public abstract class Sosl { public enum ArticleReporting { TRACKING, VIEWSTAT } - public enum Clause { HIGHLIGHT, SPELL_CORRECTION } public enum DataCategoryLocation { AT, ABOVE, BELOW, ABOVE_OR_BELOW } public enum SearchGroup { ALL_FIELDS, NAME_FIELDS, EMAIL_FIELDS, PHONE_FIELDS, SIDEBAR_FIELDS } private static Map>> cachedSearchResultsByHashCode = new Map>>(); - private String searchQuery, searchTerm; - private ArticleReporting articleReporting; - private List withClauses, withDataCategoryClauses; - private SearchGroup searchGroup; - private List searchQueries; - private Boolean cacheResults, hasChanged; + protected String searchQuery, searchTerm; + protected List sobjectQueries; + protected Set sobjectTypes; + protected Boolean cacheResults, hasChanged; + protected Sosl.ArticleReporting articleReporting; + protected List withClauses, withDataCategoryClauses; + protected Sosl.SearchGroup searchGroup; - public Sosl(String searchTerm, Soql query) { - this(searchTerm, new List{query}); + protected Sosl(String searchTerm, SobjectQueryBuilder sobjectQuery) { + this(searchTerm, new List{sobjectQuery}); } - public Sosl(String searchTerm, List queries) { - this.searchTerm = searchTerm; - this.searchQueries = queries; + protected Sosl(String searchTerm, List sobjectQueries) { + this.searchTerm = String.escapeSingleQuotes(searchTerm); + this.sobjectQueries = sobjectQueries; this.cacheResults = false; + this.hasChanged = false; this.searchGroup = Sosl.SearchGroup.ALL_FIELDS; this.withClauses = new List(); this.withDataCategoryClauses = new List(); } - public Sosl inSearchGroup(SearchGroup searchGroup) { - this.searchGroup = searchGroup; - return this.setHasChanged(); - } - - public Sosl withDataCategory(Schema.DataCategory dataCategory, Sosl.DataCategoryLocation dataCategoryLocation, Schema.DataCategory childDataCategory) { - return this.withDataCategory(dataCategory, dataCategoryLocation, new List{childDataCategory}); - } + public Set getSobjectTypes() { + if(this.sobjectTypes != null) return this.sobjectTypes; - public Sosl withDataCategory(Schema.DataCategory dataCategory, Sosl.DataCategoryLocation dataCategoryLocation, List childDataCategories) { - List childDataCategoryApiNames = new List(); - for(Schema.DataCategory childDataCategory : childDataCategories) { - childDataCategoryApiNames.add(childDataCategory.getName()); + this.sobjectTypes = new Set(); + for(SobjectQueryBuilder query : this.sobjectQueries) { + this.sobjectTypes.add(query.getSobjectType()); } - this.withDataCategoryClauses.add(dataCategory.getName() + ' ' + dataCategoryLocation + ' (' + String.join(childDataCategoryApiNames, ', ') + ')'); - return this.setHasChanged(); - } - - public Sosl withHighlight() { - this.withClauses.add('HIGHLIGHT'); - return this.setHasChanged(); - } - - public Sosl withSnippet(Integer targetLength) { - this.withClauses.add('SNIPPET (target_length=' + targetLength + ')'); - return this.setHasChanged(); - } - - public Sosl withSpellCorrection() { - this.withClauses.add('SPELL_CORRECTION = true'); - return this.setHasChanged(); + return this.sobjectTypes; } - public Sosl updateArticleReporting(Sosl.ArticleReporting articleReporting) { - this.articleReporting = articleReporting; - return this.setHasChanged(); - } + public abstract String getSearch(); - public Sosl cacheResults() { - this.cacheResults = true; - return this.setHasChanged(); + protected Sobject doGetFirstResult() { + List> results = this.doGetResults(); + return results.isEmpty() || results[0].isEmpty() ? null : results[0][0]; } - public String getSearchQuery() { - if(this.searchQuery != null && !this.hasChanged) return this.searchQuery; - - this.searchQuery = 'FIND \'' + this.searchTerm + '\'' - + this.getSearchGroupString() - + this.getReturningSobjectsString() - + this.getWithClauseString() - + this.getUpdateArticleReportingString(); - - // Change hasChanged to false so that subsequent calls to getSearchQuery() use the cached search query string - // If additional builder methods are later called, the builder methods will set hasChanged = true - this.hasChanged = false; - - System.debug(LoggingLevel.FINEST, this.searchQuery); - return this.searchQuery; + protected List doGetFirstResults() { + List> results = this.doGetResults(); + return results.isEmpty() ? null : results[0]; } - public List getFirstSearchResults() { - return this.getSearchResults()[0]; + protected List> doGetResults() { + if(this.cacheResults) return this.getCachedResults(); + else return Search.query(this.getSearch()); } - public List> getSearchResults() { - if(this.cacheResults) return this.getCachedQuery(); - else return Search.query(this.getSearchQuery()); - } - - private Sosl setHasChanged() { - this.hasChanged = true; - return this; - } - - private List> getCachedQuery() { - String query = this.getSearchQuery(); - Integer hashCode = query.hashCode(); - - Boolean isCached = cachedSearchResultsByHashCode.containsKey(hashCode); - if(!isCached) cachedSearchResultsByHashCode.put(hashCode, Search.query(query)); - - // Always return a deep clone so the original cached version is never modified - List> cachedResults = cachedSearchResultsByHashCode.get(hashCode); - List> deepClonedResults = new List>(); - for(List cachedListOfResults : cachedResults) { - deepClonedResults.add(cachedListOfResults.deepClone(true, true, true)); - } - return deepClonedResults; - } - - private String getSearchGroupString() { + protected String doGetSearchGroupString() { return ' IN ' + this.searchGroup.name().replace('_', ' '); } - private String getReturningSobjectsString() { - if(this.searchQueries.isEmpty()) return ''; + protected String doGetReturningSobjectsString() { + if(this.sobjectQueries.isEmpty()) return ''; List queryStrings = new List(); - this.searchQueries.sort(); - for(Soql query : this.searchQueries) { + this.sobjectQueries.sort(); + for(SobjectQueryBuilder query : this.sobjectQueries) { queryStrings.add(query.getSearchQuery()); } return ' RETURNING ' + String.join(queryStrings, ', '); } - private String getWithClauseString() { + protected String doGetWithClauseString() { List combinedWithClauses = new List(this.withClauses); if(!this.withDataCategoryClauses.isEmpty()) { String withDataCategoryClausesString = 'DATA CATEGORY ' + String.join(withDataCategoryClauses, ' AND '); @@ -147,8 +85,24 @@ public class Sosl { return this.withClauses.isEmpty() ? '' : ' WITH ' + String.join(this.withClauses, ' WITH '); } - private String getUpdateArticleReportingString() { + protected String doGetUpdateArticleReportingString() { return this.articleReporting == null ? '' : ' UPDATE ' + this.articleReporting.name(); } + private List> getCachedResults() { + String searchQuery = this.getSearch(); + Integer hashCode = searchQuery.hashCode(); + + Boolean isCached = cachedSearchResultsByHashCode.containsKey(hashCode); + if(!isCached) cachedSearchResultsByHashCode.put(hashCode, Search.query(searchQuery)); + + // Always return a deep clone so the original cached version is never modified + List> cachedResults = cachedSearchResultsByHashCode.get(hashCode); + List> deepClonedResults = new List>(); + for(List cachedListOfResults : cachedResults) { + deepClonedResults.add(cachedListOfResults.deepClone(true, true, true)); + } + return deepClonedResults; + } + } \ No newline at end of file diff --git a/src/classes/Sosl.cls-meta.xml b/src/classes/Sosl.cls-meta.xml index fec71a26..800e53cf 100644 --- a/src/classes/Sosl.cls-meta.xml +++ b/src/classes/Sosl.cls-meta.xml @@ -1,5 +1,5 @@ - 42.0 + 43.0 Active