From bea65e6c6707f4de338e19f9f95865bf1144b2ff Mon Sep 17 00:00:00 2001 From: Piotr Gajek Date: Tue, 10 Oct 2023 16:36:12 +0200 Subject: [PATCH] Release/v3.0.0 (#86) Release/v3.0.0 --- force-app/main/default/classes/SOQL.cls | 233 +++++++++++++++---- force-app/main/default/classes/SOQL_Test.cls | 143 +++++++++++- website/docs/api/soql.md | 122 +++++++++- website/docs/docs/design.md | 1 + 4 files changed, 451 insertions(+), 48 deletions(-) diff --git a/force-app/main/default/classes/SOQL.cls b/force-app/main/default/classes/SOQL.cls index 5dc1a585..f270545e 100644 --- a/force-app/main/default/classes/SOQL.cls +++ b/force-app/main/default/classes/SOQL.cls @@ -65,6 +65,8 @@ public virtual inherited sharing class SOQL implements Queryable { Queryable count(); Queryable count(SObjectField field); Queryable count(SObjectField field, String alias); + // GROUPING + Queryable grouping(SObjectField field, String alias); // USING SCOPE Queryable delegatedScope(); Queryable mineScope(); @@ -81,6 +83,7 @@ public virtual inherited sharing class SOQL implements Queryable { // GROUP BY Queryable groupBy(SObjectField field); Queryable groupByRollup(SObjectField field); + Queryable groupByCube(SObjectField field); // ORDER BY Queryable orderBy(String field); Queryable orderBy(String field, String direction); @@ -123,6 +126,10 @@ public virtual inherited sharing class SOQL implements Queryable { List toList(); List toAggregated(); Map toMap(); + Map toMap(SObjectField keyField); + Map toMap(SObjectField keyField, SObjectField valueField); + Map> toAggregatedMap(SObjectField keyField); + Map> toAggregatedMap(SObjectField keyField, SObjectField valueField); Database.QueryLocator toQueryLocator(); } @@ -213,9 +220,9 @@ public virtual inherited sharing class SOQL implements Queryable { public interface InnerJoin { InnerJoin of(SObjectType ofObject); - + // SELECT InnerJoin with(SObjectField field); - + // WHERE InnerJoin whereAre(FilterGroup filterGroup); InnerJoin whereAre(Filter filter); } @@ -257,8 +264,8 @@ public virtual inherited sharing class SOQL implements Queryable { public SOQL(String ofObject) { binder = new Binder(); - executor = new Executor(); builder = new QueryBuilder(ofObject); + executor = new Executor(ofObject, builder); } public SOQL with(SObjectField field) { @@ -346,6 +353,11 @@ public virtual inherited sharing class SOQL implements Queryable { return this; } + public SOQL grouping(SObjectField field, String alias) { + builder.fields.grouping(field, alias); + return this; + } + public SOQL delegatedScope() { builder.scope.delegated(); return this; @@ -403,11 +415,19 @@ public virtual inherited sharing class SOQL implements Queryable { public SOQL groupBy(SObjectField field) { builder.groupBy.with(field); + builder.fields.withAggregatedField(field); return this; } public SOQL groupByRollup(SObjectField field) { builder.groupBy.rollup(field); + builder.fields.withAggregatedField(field); + return this; + } + + public SOQL groupByCube(SObjectField field) { + builder.groupBy.cube(field); + builder.fields.withAggregatedField(field); return this; } @@ -526,22 +546,23 @@ public virtual inherited sharing class SOQL implements Queryable { public Set toValuesOf(SObjectField fieldToExtract) { // https://salesforce.stackexchange.com/questions/393308/get-a-list-of-one-column-from-a-soql-result + builder.fields.clearAllFields(); // other fields not needed return new Map(with(fieldToExtract, 'Id').groupBy(fieldToExtract).toAggregated()).keySet(); } public Integer toInteger() { - if (builder.fields.areCountsEmpty()) { + if (!builder.fields.hasCount()) { count(); } - return executor.toInteger(builder.toString(), binder.getBindingMap()); + return executor.toInteger(); } public SObject toObject() { - return executor.toObject(builder.toString(), binder.getBindingMap()); + return executor.toObject(); } public List toList() { - return executor.toList(builder.toString(), binder.getBindingMap()); + return executor.toList(); } public List toAggregated() { @@ -549,11 +570,33 @@ public virtual inherited sharing class SOQL implements Queryable { } public Map toMap() { - return new Map(toList()); + return executor.toMap(); + } + + public Map toMap(SObjectField keyField) { + with(keyField); + return executor.toMap(keyField); + } + + public Map toMap(SObjectField keyField, SObjectField valueField) { + builder.fields.clearAllFields(); // other fields not needed + with(keyField, valueField); + return executor.toMap(keyField, valueField); + } + + public Map> toAggregatedMap(SObjectField keyField) { + with(keyField); + return executor.toAggregatedMap(keyField); + } + + public Map> toAggregatedMap(SObjectField keyField, SObjectField valueField) { + builder.fields.clearAllFields(); // other fields not needed + with(keyField, valueField); + return executor.toAggregatedMap(keyField, valueField); } public Database.QueryLocator toQueryLocator() { - return executor.toQueryLocator(builder.toString(), binder.getBindingMap()); + return executor.toQueryLocator(); } public SOQL byId(SObject record) { @@ -684,10 +727,9 @@ public virtual inherited sharing class SOQL implements Queryable { private class QFields implements QueryClause { private Set fields = new Set(); - private Set counts = new Set(); + private Set aggregatedFields = new Set(); public void count() { - // COUNT() must be the only element in the SELECT list. count('COUNT()'); } @@ -697,20 +739,32 @@ public virtual inherited sharing class SOQL implements Queryable { public void count(SObjectField field, String alias) { count('COUNT(' + field + ') ' + alias); + fields.add('COUNT(' + field + ') ' + alias); + } + + private void count(String count) { + withAggregatedField(count); + fields.add(count); } - public void count(String countSoql) { - // Clear all default fields to avoid "Field must be grouped or aggregated" - clearAllFields(); - counts.add(countSoql); + public void grouping(SObjectField field, String alias) { + withAggregatedField('GROUPING(' + field + ') ' + alias); + fields.add('GROUPING(' + field + ') ' + alias); } public void with(SObjectField field, String alias) { - // Only aggregate expressions use field aliasing. Clear all default fields to avoid "Field must be grouped or aggregated" - clearAllFields(); + withAggregatedField(field + ' ' + alias); fields.add(field + ' ' + alias); } + public void withAggregatedField(SObjectField field) { + withAggregatedField(field.getDescribe().getName()); + } + + public void withAggregatedField(String field) { + aggregatedFields.add(field); + } + public void with(String stringFields) { // To avoid field duplicates in query fields.addAll(stringFields.deleteWhitespace().split(',')); @@ -740,21 +794,30 @@ public virtual inherited sharing class SOQL implements Queryable { fields.clear(); } - public Boolean areCountsEmpty() { - return counts.isEmpty(); + public Boolean hasCount() { + return !aggregatedFields.isEmpty(); } public override String toString() { - if (fields.isEmpty() && counts.isEmpty()) { + removeNotAggregatedFieldsFromAggregateSoql(); + + if (fields.isEmpty()) { return 'SELECT Id'; } - List selectStatement = new List(); - - selectStatement.addAll(counts); - selectStatement.addAll(fields); + return 'SELECT ' + String.join(fields, ', '); + } - return 'SELECT ' + String.join(selectStatement, ', '); + public void removeNotAggregatedFieldsFromAggregateSoql() { + if (aggregatedFields.isEmpty()) { + return; + } + // Clear not grouped or aggregated fields to avoid "Field must be grouped or aggregated" error + for (String field : fields) { + if (!aggregatedFields.contains(field)) { + fields.remove(field); + } + } } } @@ -1237,10 +1300,10 @@ public virtual inherited sharing class SOQL implements Queryable { public override String toString() { if (skipBinding) { - return String.format(wrapper, new List { field + ' ' + comperator + ' ' + value }); + return String.format(wrapper, new List{ field + ' ' + comperator + ' ' + value }); } - return String.format(wrapper, new List { field + ' ' + comperator + ' :' + binder.bind(value) }); + return String.format(wrapper, new List{ field + ' ' + comperator + ' :' + binder.bind(value) }); } } @@ -1274,17 +1337,34 @@ public virtual inherited sharing class SOQL implements Queryable { private class QGroupBy implements QueryClause { private Set groupByFields = new Set(); + private String groupByFunction = ''; public void with(SObjectField field) { + setGroupByFunction('{0}'); groupByFields.add(field.getDescribe().getName()); } public void rollup(SObjectField field) { - groupByFields.add('ROLLUP(' + field + ')'); + setGroupByFunction('ROLLUP({0})'); + groupByFields.add(field.getDescribe().getName()); + } + + public void cube(SObjectField field) { + setGroupByFunction('CUBE({0})'); + groupByFields.add(field.getDescribe().getName()); + } + + public void setGroupByFunction(String newGroupByFunction) { + if (String.isNotEmpty(groupByFunction) && groupByFunction != newGroupByFunction) { + QueryException e = new QueryException(); + e.setMessage('You cant use GROUP BY, GROUP BY ROLLUP and GROUP BY CUBE in the same query.'); + throw e; + } + this.groupByFunction = newGroupByFunction; } public override String toString() { - return 'GROUP BY ' + String.join(groupByFields, ', '); + return 'GROUP BY ' + String.format(groupByFunction, new List{ String.join(groupByFields, ', ') }); } } @@ -1316,12 +1396,12 @@ public virtual inherited sharing class SOQL implements Queryable { private String sortingOrder = 'ASC'; private String nullsOrder = 'FIRST'; - public QOrderBy with(SObjectField field) { - return with(field.getDescribe().getName()); + public void with(SObjectField field) { + with(field.getDescribe().getName()); } - public QOrderBy with(String relationshipName, SObjectField field) { - return with(relationshipName + '.' + field); + public void with(String relationshipName, SObjectField field) { + with(relationshipName + '.' + field); } public QOrderBy with(String field) { @@ -1333,9 +1413,8 @@ public virtual inherited sharing class SOQL implements Queryable { sortingOrder('DESC'); } - public QOrderBy sortingOrder(String direction) { + public void sortingOrder(String direction) { sortingOrder = direction; - return this; } public void nullsLast() { @@ -1402,7 +1481,6 @@ public virtual inherited sharing class SOQL implements Queryable { public String bind(Object value) { bindIndex++; binding.put('v' + bindIndex, value); - return 'v' + bindIndex; } @@ -1445,6 +1523,13 @@ public virtual inherited sharing class SOQL implements Queryable { private DatabaseQuery sharingExecutor = new InheritedSharing(); private AccessType accessType; private String mockId; + private String ofObject; + private QueryBuilder builder; + + public Executor(String ofObject, QueryBuilder builder) { + this.ofObject = ofObject; + this.builder = builder; + } public void withSharing() { sharingExecutor = new WithSharing(); @@ -1466,8 +1551,8 @@ public virtual inherited sharing class SOQL implements Queryable { mockId = id; } - public SObject toObject(String query, Map binding) { - List records = toList(query, binding); + public SObject toObject() { + List records = toList(); if (records.size() > 1) { QueryException e = new QueryException(); @@ -1482,31 +1567,89 @@ public virtual inherited sharing class SOQL implements Queryable { return records[0]; } - public List toList(String query, Map binding) { + public List toList() { if (mock.hasMock(mockId)) { return mock.getSObjectsMock(mockId); } if (accessType == null) { - return sharingExecutor.toSObjects(query, binding, accessMode); + return sharingExecutor.toSObjects(builder.toString(), binder.getBindingMap(), accessMode); } return Security.stripInaccessible( accessType, - sharingExecutor.toSObjects(query, binding, accessMode) + sharingExecutor.toSObjects(builder.toString(), binder.getBindingMap(), accessMode) ).getRecords(); } - public Integer toInteger(String query, Map binding) { + public Map toMap() { + Map recordPerId = (Map) Type.forName('Map').newInstance(); + recordPerId.putAll(toList()); + return recordPerId; + } + + public Map toMap(SObjectField keyField) { + Map recordPerCustomKey = (Map) Type.forName('Map').newInstance(); + + for (SObject record : toList()) { + recordPerCustomKey.put(String.valueOf(record.get(keyField)), record); + } + + return recordPerCustomKey; + } + + public Map toMap(SObjectField keyField, SObjectField valueField) { + Map customValuePerCustomKey = new Map(); + + for (SObject record : toList()) { + customValuePerCustomKey.put(String.valueOf(record.get(keyField)), String.valueOf(record.get(valueField))); + } + + return customValuePerCustomKey; + } + + public Map> toAggregatedMap(SObjectField keyField) { + Map> recordsPerCustomKey = (Map>) Type.forName('Map>').newInstance(); + + for (SObject record : toList()) { + String key = String.valueOf(record.get(keyField)); + + if (!recordsPerCustomKey.containsKey(key)) { + recordsPerCustomKey.put(key, new List()); + } + + recordsPerCustomKey.get(key).add(record); + } + + return recordsPerCustomKey; + } + + public Map> toAggregatedMap(SObjectField keyField, SObjectField valueField) { + Map> customValuesPerCustomKey = new Map>(); + + for (SObject record : toList()) { + String key = String.valueOf(record.get(keyField)); + + if (!customValuesPerCustomKey.containsKey(key)) { + customValuesPerCustomKey.put(key, new List()); + } + + customValuesPerCustomKey.get(key).add(String.valueOf(record.get(valueField))); + } + + return customValuesPerCustomKey; + } + + public Integer toInteger() { if (mock.hasCountMock(mockId)) { return mock.getCountMock(mockId); } - return sharingExecutor.toInteger(query, binding, accessMode); + return sharingExecutor.toInteger(builder.toString(), binder.getBindingMap(), accessMode); } - public Database.QueryLocator toQueryLocator(String query, Map binding) { - return Database.getQueryLocatorWithBinds(query, binding, accessMode); + public Database.QueryLocator toQueryLocator() { + return Database.getQueryLocatorWithBinds(builder.toString(), binder.getBindingMap(), accessMode); } } diff --git a/force-app/main/default/classes/SOQL_Test.cls b/force-app/main/default/classes/SOQL_Test.cls index 40d634bf..677f90f9 100644 --- a/force-app/main/default/classes/SOQL_Test.cls +++ b/force-app/main/default/classes/SOQL_Test.cls @@ -62,6 +62,22 @@ private class SOQL_Test { Assert.areEqual('SELECT COUNT(Name) names FROM Account', soql); } + @IsTest + static void grouping() { + // Test + String soql = SOQL.of(Lead.SObjectType) + .with(Lead.LeadSource, Lead.Rating) + .grouping(Lead.LeadSource, 'grpLS') + .grouping(Lead.Rating, 'grpRating') + .count(Lead.Name, 'cnt') + .groupByRollup(Lead.LeadSource) + .groupByRollup(Lead.Rating) + .toString(); + + // Verify + Assert.areEqual('SELECT LeadSource, Rating, GROUPING(LeadSource) grpLS, GROUPING(Rating) grpRating, COUNT(Name) cnt FROM Lead GROUP BY ROLLUP(LeadSource, Rating)', soql); + } + @IsTest static void withField() { // Test @@ -1297,6 +1313,54 @@ private class SOQL_Test { Assert.areEqual('SELECT COUNT(Name) cnt, LeadSource FROM Lead GROUP BY ROLLUP(LeadSource)', soql); } + @IsTest + static void groupByCube() { + // Test + String soql = SOQL.of(Account.SObjectType) + .with(Account.Type) + .groupByCube(Account.Type) + .toString(); + + // Verify + Assert.areEqual('SELECT Type FROM Account GROUP BY CUBE(Type)', soql); + } + + @IsTest + static void differentGroupByFunctionsException() { + // Setup + Exception queryException = null; + + // Test + try { + String soql = SOQL.of(Account.SObjectType) + .with(Account.Type) + .groupBy(Account.Type) + .groupByCube(Account.Type) + .toString(); + } catch(Exception e) { + queryException = e; + } + + // Verify + Assert.areEqual( + 'You cant use GROUP BY, GROUP BY ROLLUP and GROUP BY CUBE in the same query.', + queryException.getMessage() + ); + } + + @IsTest + static void groupByWithDefaultFields() { + // Test + String soql = SOQL.of(Lead.SObjectType) + .with(Lead.FirstName, Lead.LastName, Lead.Email) + .with(Lead.LeadSource) + .groupBy(Lead.LeadSource) + .toString(); + + // Verify + Assert.areEqual('SELECT LeadSource FROM Lead GROUP BY LeadSource', soql); + } + @IsTest static void orderByString() { // Test @@ -1831,15 +1895,92 @@ private class SOQL_Test { List accounts = insertAccounts(); // Test - Map result = SOQL.of(Account.SObjectType).toMap(); + Map result = (Map) SOQL.of(Account.SObjectType).toMap(); // Verify Assert.areEqual(accounts.size(), result.size()); + for (Account acc : accounts) { Assert.isNotNull(result.get(acc.Id)); } } + @IsTest + static void toMapWithCustomKey() { + // Setup + List accounts = insertAccounts(); + + // Test + Map result = (Map) SOQL.of(Account.SObjectType).toMap(Account.Name); + + // Verify + Assert.areEqual(accounts.size(), result.size()); + + for (Account acc : accounts) { + Assert.isNotNull(result.get(acc.Name)); + } + } + + @IsTest + static void toMapWithCustomKeyAndCustomValue() { + // Setup + List accounts = insertAccounts(); + + // Test + Map result = SOQL.of(Account.SObjectType).toMap(Account.Name, Account.Id); + + // Verify + Assert.areEqual(accounts.size(), result.size()); + + for (Account acc : accounts) { + Assert.isNotNull(result.get(acc.Name)); + } + } + + @IsTest + static void toAggregatedMapWithCustomKey() { + // Setup + List accounts = insertAccounts(); + + // Test + Map> result = SOQL.of(Account.SObjectType).toAggregatedMap(Account.Name); + + // Verify + Assert.areEqual(accounts.size(), result.size()); + + for (Account acc : accounts) { + Assert.isFalse(result.get(acc.Name).isEmpty()); + } + } + + @IsTest + static void toAggregatedMapWithEmptyCustomKey() { + // Setup + insertAccounts(); + + // Test + Map> result = SOQL.of(Account.SObjectType).toAggregatedMap(Account.Industry); + + // Verify + Assert.areEqual(1, result.size()); // grouped by empty Industry + } + + @IsTest + static void toAggregatedMapWithCustomKeyAndCustomValue() { + // Setup + List accounts = insertAccounts(); + + // Test + Map> result = SOQL.of(Account.SObjectType).toAggregatedMap(Account.Name, Account.Id); + + // Verify + Assert.areEqual(accounts.size(), result.size()); + + for (Account acc : accounts) { + Assert.isFalse(result.get(acc.Name).isEmpty()); + } + } + @IsTest static void toQueryLocator() { // Test diff --git a/website/docs/api/soql.md b/website/docs/api/soql.md index 78cbb19e..f3fd609e 100644 --- a/website/docs/api/soql.md +++ b/website/docs/api/soql.md @@ -38,6 +38,10 @@ The following are methods for `SOQL`. - [`count(SObjectField field)`](#count-field) - [`count(SObjectField field, String alias)`](#count-with-alias) +[**GROUPING**](#grouping) + +- [`grouping(SObjectField field, String alias)`](#grouping) + [**SUBQUERY**](#sub-query) - [`with(SubQuery subQuery)`](#with-subquery) @@ -62,6 +66,7 @@ The following are methods for `SOQL`. - [`groupBy(SObjectField field)`](#group-by) - [`groupByRollup(SObjectField field)`](#groupbyrollup) +- [`groupByCube(SObjectField field)`](#groupbycube) [**ORDER BY**](#order-by) @@ -123,6 +128,10 @@ The following are methods for `SOQL`. - [`toList()`](#tolist) - [`toAggregated()`](#toaggregated) - [`toMap()`](#tomap) +- [`toMap(SObjectField keyField)`](#tomap-with-custom-key) +- [`toMap(SObjectField keyField, SObjectField valueField)`](#tomap-with-custom-key-and-value) +- [`toAggregatedMap(SObjectField keyField)`](#toaggregatedmap) +- [`toAggregatedMap(SObjectField keyField, SObjectField valueField)`](#toaggregatedmap-with-custom-value) - [`toQueryLocator()`](#toquerylocator) ## INIT @@ -436,6 +445,36 @@ SOQL.of(Account.SObjectType) .toAggregated(); ``` +## GROUPING + +### grouping + +**Signature** + +```apex +grouping(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT LeadSource, Rating, + GROUPING(LeadSource) grpLS, GROUPING(Rating) grpRating, + COUNT(Name) cnt +FROM Lead +GROUP BY ROLLUP(LeadSource, Rating) +``` +```apex +SOQL.of(Lead.SObjectType) + .with(Lead.LeadSource, Lead.Rating) + .grouping(Lead.LeadSource, 'grpLS') + .grouping(Lead.Rating, 'grpRating') + .count(Lead.Name, 'cnt') + .groupByRollup(Lead.LeadSource) + .groupByRollup(Lead.Rating) + .toAggregated(); +``` + ## USING SCOPE [USING SCOPE](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_using_scope.htm) @@ -726,13 +765,35 @@ FROM Lead GROUP BY ROLLUP(LeadSource) ``` ```apex -QS.of(Lead.SObjectType) +SOQL.of(Lead.SObjectType) .with(Lead.LeadSource) .count(Lead.Name, 'cnt') .groupByRollup(Lead.LeadSource) .toAggregated(); ``` +### groupByCube + +**Signature** + +```apex +SOQL groupByCube(SObjectField field) +``` + +**Example** + +```sql +SELECT Type +FROM Account +GROUP BY ROLLUP(Type) +``` +```apex +SOQL.of(Account.SObjectType) + .with(Account.Type) + .groupByCube(Account.Type) + .toAggregated(); +``` + ## ORDER BY [ORDER BY](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_orderby.htm) @@ -1420,7 +1481,64 @@ Map toMap() **Example** ```apex -SOQL.of(Account.SObjectType).toMap(); +Map idToAccount = (Map) SOQL.of(Account.SObjectType).toMap(); +``` + +### toMap with custom key + +**Signature** + +```apex +Map toMap(SObjectField keyField) +``` + +**Example** + +```apex +Map nameToAccount = (Map) SOQL.of(Account.SObjectType).toMap(Account.Name); +``` + +### toMap with custom key and value + +**Signature** + +```apex +Map toMap(SObjectField keyField, , SObjectField valueField) +``` + +**Example** + +```apex +Map nameToAccount = SOQL.of(Account.SObjectType).toMap(Account.Name, Account.Industry); +``` + + +### toAggregatedMap + +**Signature** + +```apex +Map> toAggregatedMap(SObjectField keyField) +``` + +**Example** + +```apex +Map industryToAccounts = SOQL.of(Account.SObjectType).toAggregatedMap(Account.Industry); +``` + +### toAggregatedMap with custom value + +**Signature** + +```apex +Map> toAggregatedMap(SObjectField keyField, SObjectField valueField) +``` + +**Example** + +```apex +Map> industryToAccounts = SOQL.of(Account.SObjectType).toAggregatedMap(Account.Industry, Account.Name); ``` ### toQueryLocator diff --git a/website/docs/docs/design.md b/website/docs/docs/design.md index ba47ccde..cdf991b5 100644 --- a/website/docs/docs/design.md +++ b/website/docs/docs/design.md @@ -81,6 +81,7 @@ public interface Queryable { Queryable groupBy(SObjectField field); Queryable groupByRollup(SObjectField field); + Queryable groupByCube(SObjectField field); Queryable orderBy(String field); // ASC, NULLS FIRST by default Queryable orderBy(String field, String direction); // dynamic order by, NULLS FIRST by default