From e0a1f0b3ab42aae0bb3e2f3e1b5fdb9605e39289 Mon Sep 17 00:00:00 2001 From: Piotr Gajek Date: Fri, 3 Nov 2023 10:10:48 +0100 Subject: [PATCH] Btc 120 aggregate functions (#93) * AVG, COUNT_DUSTINCT, MIN, MAX, SUM * Aggregate Functions Unit Test * SOQL valuesOf fix + aggregate functions * refactoring * refactoring * aggregation methods * Remove byRecordType * refactoring * Aggregation Functions documentation * documentation + refactoring --- force-app/main/default/classes/SOQL.cls | 169 +++++++++---- force-app/main/default/classes/SOQL_Test.cls | 159 ++++++++++++- .../default/classes/example/SOQL_Account.cls | 9 +- .../default/classes/example/SOQL_Contact.cls | 9 +- .../classes/example/SOQL_Opportunity.cls | 3 + website/docs/api/soql.md | 224 +++++++++++++++++- 6 files changed, 517 insertions(+), 56 deletions(-) diff --git a/force-app/main/default/classes/SOQL.cls b/force-app/main/default/classes/SOQL.cls index 7fef6931..73ec02ac 100644 --- a/force-app/main/default/classes/SOQL.cls +++ b/force-app/main/default/classes/SOQL.cls @@ -61,11 +61,21 @@ public virtual inherited sharing class SOQL implements Queryable { Queryable with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5); Queryable with(String relationshipName, List fields); Queryable with(SubQuery subQuery); - // COUNT + // SELECT - AGGREGATE FUNCTIONS Queryable count(); Queryable count(SObjectField field); Queryable count(SObjectField field, String alias); - // GROUPING + Queryable avg(SObjectField field); + Queryable avg(SObjectField field, String alias); + Queryable countDistinct(SObjectField field); + Queryable countDistinct(SObjectField field, String alias); + Queryable min(SObjectField field); + Queryable min(SObjectField field, String alias); + Queryable max(SObjectField field); + Queryable max(SObjectField field, String alias); + Queryable sum(SObjectField field); + Queryable sum(SObjectField field, String alias); + // SELECT - GROUPING Queryable grouping(SObjectField field, String alias); // USING SCOPE Queryable delegatedScope(); @@ -346,8 +356,7 @@ public virtual inherited sharing class SOQL implements Queryable { } public SOQL count(SObjectField field) { - builder.fields.count(field); - return this; + return count(field, ''); } public SOQL count(SObjectField field, String alias) { @@ -355,6 +364,51 @@ public virtual inherited sharing class SOQL implements Queryable { return this; } + public SOQL avg(SObjectField field) { + return avg(field, ''); + } + + public SOQL avg(SObjectField field, String alias) { + builder.fields.avg(field, alias); + return this; + } + + public SOQL countDistinct(SObjectField field) { + return countDistinct(field, ''); + } + + public SOQL countDistinct(SObjectField field, String alias) { + builder.fields.countDistinct(field, alias); + return this; + } + + public SOQL min(SObjectField field) { + return min(field, ''); + } + + public SOQL min(SObjectField field, String alias) { + builder.fields.min(field, alias); + return this; + } + + public SOQL max(SObjectField field) { + return max(field, ''); + } + + public SOQL max(SObjectField field, String alias) { + builder.fields.max(field, alias); + return this; + } + + public SOQL sum(SObjectField field) { + return sum(field, ''); + } + + public SOQL sum(SObjectField field, String alias) { + builder.fields.sum(field, alias); + return this; + } + public SOQL grouping(SObjectField field, String alias) { builder.fields.grouping(field, alias); return this; @@ -417,19 +471,19 @@ public virtual inherited sharing class SOQL implements Queryable { public SOQL groupBy(SObjectField field) { builder.groupBy.with(field); - builder.fields.withAggregatedField(field); + builder.fields.withGroupedField(field); return this; } public SOQL groupByRollup(SObjectField field) { builder.groupBy.rollup(field); - builder.fields.withAggregatedField(field); + builder.fields.withGroupedField(field); return this; } public SOQL groupByCube(SObjectField field) { builder.groupBy.cube(field); - builder.fields.withAggregatedField(field); + builder.fields.withGroupedField(field); return this; } @@ -555,9 +609,7 @@ public virtual inherited sharing class SOQL implements Queryable { } public Integer toInteger() { - if (!builder.fields.hasCount()) { - count(); - } + builder.fields.addCountWhenNotPresented(); return executor.toInteger(); } @@ -731,47 +783,73 @@ public virtual inherited sharing class SOQL implements Queryable { private class QFields implements QueryClause { private Set fields = new Set(); - private Set aggregatedFields = new Set(); + private Set aggregateFunctions = new Set(); + private Set groupedFields = new Set(); public void count() { - count('COUNT()'); + clearAllFields(); // COUNT() must be the only element in the SELECT list. + withAggregateFunction('COUNT()', ''); } - public void count(SObjectField field) { - count('COUNT(' + field + ')'); + public void count(SObjectField field, String alias) { + withAggregateFunction('COUNT(' + field + ')', alias); } - public void count(SObjectField field, String alias) { - count('COUNT(' + field + ') ' + alias); - fields.add('COUNT(' + field + ') ' + alias); + private void avg(SObjectField field, String alias) { + withAggregateFunction('AVG(' + field + ')', alias); } - private void count(String count) { - withAggregatedField(count); - fields.add(count); + private void countDistinct(SObjectField field, String alias) { + withAggregateFunction('COUNT_DISTINCT(' + field + ')', alias); + } + + private void min(SObjectField field, String alias) { + withAggregateFunction('MIN(' + field + ')', alias); + } + + private void max(SObjectField field, String alias) { + withAggregateFunction('MAX(' + field + ')', alias); + } + + private void sum(SObjectField field, String alias) { + withAggregateFunction('SUM(' + field + ')', alias); } public void grouping(SObjectField field, String alias) { - withAggregatedField('GROUPING(' + field + ') ' + alias); - fields.add('GROUPING(' + field + ') ' + alias); + withAggregateFunction('GROUPING(' + field + ')', alias); } public void with(SObjectField field, String alias) { - withAggregatedField(field + ' ' + alias); - fields.add(field + ' ' + alias); + withAggregateFunction(field.getDescribe().getName(), alias); } - public void withAggregatedField(SObjectField field) { - withAggregatedField(field.getDescribe().getName()); + private void withAggregateFunction(String aggregateFunction, String alias) { + if (String.isNotBlank(alias)) { + aggregateFunction += ' ' + alias; + } + + aggregateFunctions.add(aggregateFunction); } - public void withAggregatedField(String field) { - aggregatedFields.add(field); + public void withGroupedField(SObjectField field) { + groupedFields.add(field.getDescribe().getName()); } - public void with(String stringFields) { + public void with(String commaSeparatedFields) { // To avoid field duplicates in query - fields.addAll(stringFields.deleteWhitespace().split(',')); + for (String splitedField : commaSeparatedFields.split(',')) { + String field = splitedField.trim(); + if (isAggregateFunction(field)) { + aggregateFunctions.add(field); + } else { + fields.add(field); + } + } + } + + private Boolean isAggregateFunction(String field) { + // AVG(), COUNT(), MIN(), MAX(), SUM() or Field aliasing + return field.contains('(') && field.contains(')') || field.contains(' '); } public void with(List fields) { @@ -796,29 +874,38 @@ public virtual inherited sharing class SOQL implements Queryable { public void clearAllFields() { fields.clear(); + aggregateFunctions.clear(); } - public Boolean hasCount() { - return !aggregatedFields.isEmpty(); + public void addCountWhenNotPresented() { + if (aggregateFunctions.isEmpty()) { + count(); + } } public override String toString() { - removeNotAggregatedFieldsFromAggregateSoql(); - - if (fields.isEmpty()) { + if (fields.isEmpty() && aggregateFunctions.isEmpty()) { return 'SELECT Id'; } + if (!groupedFields.isEmpty() || !aggregateFunctions.isEmpty()) { + // To avoid "Field must be grouped or aggregated" error + removeNotGroupedFields(); + + List selectFields = new List(); + + selectFields.addAll(fields); + selectFields.addAll(aggregateFunctions); + + return 'SELECT ' + String.join(selectFields, ', '); + } + return 'SELECT ' + String.join(fields, ', '); } - public void removeNotAggregatedFieldsFromAggregateSoql() { - if (aggregatedFields.isEmpty()) { - return; - } - // Clear not grouped or aggregated fields to avoid "Field must be grouped or aggregated" error + public void removeNotGroupedFields() { for (String field : fields) { - if (!aggregatedFields.contains(field)) { + if (!groupedFields.contains(field)) { fields.remove(field); } } diff --git a/force-app/main/default/classes/SOQL_Test.cls b/force-app/main/default/classes/SOQL_Test.cls index 018b250e..5c8450b4 100644 --- a/force-app/main/default/classes/SOQL_Test.cls +++ b/force-app/main/default/classes/SOQL_Test.cls @@ -41,6 +41,18 @@ private class SOQL_Test { Assert.areEqual('SELECT COUNT() FROM Account', soql); } + @IsTest + static void countWithDefaultFields() { + // Test + String soql = SOQL.of(Account.SObjectType) + .with(Account.Id, Account.Name) + .count() + .toString(); + + // Verify + Assert.areEqual('SELECT COUNT() FROM Account', soql); + } + @IsTest static void countField() { // Test @@ -53,6 +65,19 @@ private class SOQL_Test { Assert.areEqual('SELECT COUNT(Id), COUNT(CampaignId) FROM Opportunity', soql); } + @IsTest + static void countFieldWithDefaultFields() { + // Test + String soql = SOQL.of(Opportunity.SObjectType) + .with(Opportunity.LeadSource) + .count(Opportunity.Id) + .count(Opportunity.CampaignId) + .toString(); + + // Verify + Assert.areEqual('SELECT COUNT(Id), COUNT(CampaignId) FROM Opportunity', soql); + } + @IsTest static void countWithAlias() { // Test @@ -62,6 +87,122 @@ private class SOQL_Test { Assert.areEqual('SELECT COUNT(Name) names FROM Account', soql); } + @IsTest + static void avg() { + // Test + String soql = SOQL.of(Opportunity.SObjectType) + .with(Opportunity.CampaignId) + .avg(Opportunity.Amount) + .groupBy(Opportunity.CampaignId) + .toString(); + + // Verify + Assert.areEqual('SELECT CampaignId, AVG(Amount) FROM Opportunity GROUP BY CampaignId', soql); + } + + @IsTest + static void avgWithAlias() { + // Test + String soql = SOQL.of(Opportunity.SObjectType) + .with(Opportunity.CampaignId) + .avg(Opportunity.Amount, 'amount') + .groupBy(Opportunity.CampaignId) + .toString(); + + // Verify + Assert.areEqual('SELECT CampaignId, AVG(Amount) amount FROM Opportunity GROUP BY CampaignId', soql); + } + + @IsTest + static void countDistinct() { + // Test + String soql = SOQL.of(Lead.SObjectType).countDistinct(Lead.Company).toString(); + + // Verify + Assert.areEqual('SELECT COUNT_DISTINCT(Company) FROM Lead', soql); + } + + @IsTest + static void countDistinctWithAlias() { + // Test + String soql = SOQL.of(Lead.SObjectType).countDistinct(Lead.Company, 'company').toString(); + + // Verify + Assert.areEqual('SELECT COUNT_DISTINCT(Company) company FROM Lead', soql); + } + + @IsTest + static void min() { + // Test + String soql = SOQL.of(Contact.SObjectType) + .with(Contact.FirstName, Contact.LastName) + .min(Contact.CreatedDate) + .groupBy(Contact.FirstName) + .groupBy(Contact.LastName) + .toString(); + + // Verify + Assert.areEqual('SELECT FirstName, LastName, MIN(CreatedDate) FROM Contact GROUP BY FirstName, LastName', soql); + } + + @IsTest + static void minWithAlias() { + // Test + String soql = SOQL.of(Contact.SObjectType) + .with(Contact.FirstName, Contact.LastName) + .min(Contact.CreatedDate, 'createDate') + .groupBy(Contact.FirstName) + .groupBy(Contact.LastName) + .toString(); + + // Verify + Assert.areEqual('SELECT FirstName, LastName, MIN(CreatedDate) createDate FROM Contact GROUP BY FirstName, LastName', soql); + } + + @IsTest + static void max() { + // Test + String soql = SOQL.of(Campaign.SObjectType) + .with(Campaign.Name) + .max(Campaign.BudgetedCost) + .groupBy(Campaign.Name) + .toString(); + + // Verify + Assert.areEqual('SELECT Name, MAX(BudgetedCost) FROM Campaign GROUP BY Name', soql); + } + + @IsTest + static void maxWithAlias() { + // Test + String soql = SOQL.of(Campaign.SObjectType) + .with(Campaign.Name) + .max(Campaign.BudgetedCost, 'budgetedCost') + .groupBy(Campaign.Name) + .toString(); + + // Verify + Assert.areEqual('SELECT Name, MAX(BudgetedCost) budgetedCost FROM Campaign GROUP BY Name', soql); + } + + @IsTest + static void sum() { + // Test + String soql = SOQL.of(Opportunity.SObjectType).sum(Opportunity.Amount).toString(); + + // Verify + Assert.areEqual('SELECT SUM(Amount) FROM Opportunity', soql); + } + + @IsTest + static void sumWithAlias() { + // Test + String soql = SOQL.of(Opportunity.SObjectType).sum(Opportunity.Amount, 'amount').toString(); + + // Verify + Assert.areEqual('SELECT SUM(Amount) amount FROM Opportunity', soql); + } + @IsTest static void grouping() { // Test @@ -179,6 +320,18 @@ private class SOQL_Test { Assert.areEqual('SELECT Id, Name, BillingCity FROM Account', soql); } + @IsTest + static void withStringAggregationAndGroupingFields() { + // Test + String soql = SOQL.of(Opportunity.SObjectType) + .with('CampaignId campaign, AVG(Amount) amount') + .groupBy(Opportunity.CampaignId) + .toString(); + + // Verify + Assert.areEqual('SELECT CampaignId campaign, AVG(Amount) amount FROM Opportunity GROUP BY CampaignId', soql); + } + @IsTest static void withFieldAlias() { // Test @@ -1335,7 +1488,7 @@ private class SOQL_Test { .toString(); // Verify - Assert.areEqual('SELECT COUNT(Name) cnt, LeadSource FROM Lead GROUP BY ROLLUP(LeadSource)', soql); + Assert.areEqual('SELECT LeadSource, COUNT(Name) cnt FROM Lead GROUP BY ROLLUP(LeadSource)', soql); } @IsTest @@ -1803,7 +1956,7 @@ private class SOQL_Test { @IsTest static void toValuesOfWhenNoValues() { // Setup - insertAccounts(); + insertAccounts(); // Industry is empty // Test Set accountNames = SOQL.of(Account.SObjectType).toValuesOf(Account.Industry); @@ -1903,7 +2056,7 @@ private class SOQL_Test { } @IsTest - static void toIntegerWithoutCount() { + static void toIntegerWithoutSpecifiedCount() { // Setup List accounts = insertAccounts(); diff --git a/force-app/main/default/classes/example/SOQL_Account.cls b/force-app/main/default/classes/example/SOQL_Account.cls index 783e30e8..75323515 100644 --- a/force-app/main/default/classes/example/SOQL_Account.cls +++ b/force-app/main/default/classes/example/SOQL_Account.cls @@ -1,4 +1,6 @@ public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selector { + public final String MOCK_ID = 'SOQL_Account'; + public static SOQL_Account query() { return new SOQL_Account(); } @@ -6,9 +8,10 @@ public inherited sharing class SOQL_Account extends SOQL implements SOQL.Selecto private SOQL_Account() { super(Account.SObjectType); // default settings - with(Account.Id, Account.Name, Account.Type) - .systemMode() - .withoutSharing(); + with(Account.Id, Account.Name, Account.Type); + systemMode(); + withoutSharing(); + mockId(MOCK_ID); } public SOQL_Account byRecordType(String rt) { diff --git a/force-app/main/default/classes/example/SOQL_Contact.cls b/force-app/main/default/classes/example/SOQL_Contact.cls index 1586dc02..f4e753dd 100644 --- a/force-app/main/default/classes/example/SOQL_Contact.cls +++ b/force-app/main/default/classes/example/SOQL_Contact.cls @@ -1,4 +1,6 @@ public inherited sharing class SOQL_Contact extends SOQL implements SOQL.Selector { + public final String MOCK_ID = 'SOQL_Contact'; + public static SOQL_Contact query() { return new SOQL_Contact(); } @@ -6,9 +8,10 @@ public inherited sharing class SOQL_Contact extends SOQL implements SOQL.Selecto private SOQL_Contact() { super(Contact.SObjectType); // default settings - with(Contact.Id, Contact.Name, Contact.AccountId) - .systemMode() - .withoutSharing(); + with(Contact.Id, Contact.Name, Contact.AccountId); + systemMode(); + withoutSharing(); + mockId(MOCK_ID); } public SOQL_Contact byRecordType(String rt) { diff --git a/force-app/main/default/classes/example/SOQL_Opportunity.cls b/force-app/main/default/classes/example/SOQL_Opportunity.cls index 39d1395c..234440ef 100644 --- a/force-app/main/default/classes/example/SOQL_Opportunity.cls +++ b/force-app/main/default/classes/example/SOQL_Opportunity.cls @@ -1,4 +1,6 @@ public inherited sharing class SOQL_Opportunity extends SOQL implements SOQL.Selector { + public final String MOCK_ID = 'SOQL_Opportunity'; + public static SOQL_Opportunity query() { return new SOQL_Opportunity(); } @@ -7,6 +9,7 @@ public inherited sharing class SOQL_Opportunity extends SOQL implements SOQL.Sel super(Opportunity.SObjectType); // default settings with(Opportunity.Id, Opportunity.AccountId); + mockId(MOCK_ID); } public SOQL_Opportunity byAccountId(Id accountId) { diff --git a/website/docs/api/soql.md b/website/docs/api/soql.md index b0cc4a81..98b4450d 100644 --- a/website/docs/api/soql.md +++ b/website/docs/api/soql.md @@ -32,11 +32,21 @@ The following are methods for `SOQL`. - [`with(String relationshipName, SObjectField field1, SObjectField field2, SObjectField field3, SObjectField field4, SObjectField field5)`](#with-related-field1---field5) - [`with(String relationshipName, List fields)`](#with-related-fields) -[**COUNT**](#count) +[**AGGREGATION FUNCTIONS**](#aggregate-functions) - [`count()`](#count) - [`count(SObjectField field)`](#count-field) - [`count(SObjectField field, String alias)`](#count-with-alias) +- [`avg(SObjectField field)`](#avg) +- [`avg(SObjectField field, String alias)`](#avg-with-alias) +- [`countDistinct(SObjectField field)`](#count_distinct) +- [`countDistinct(SObjectField field, String alias)`](#count_distinct-with-alias) +- [`min(SObjectField field)`](#min) +- [`min(SObjectField field, String alias)`](#min-with-alias) +- [`max(SObjectField field)`](#max) +- [`max(SObjectField field, String alias)`](#max-with-alias) +- [`sum(SObjectField field)`](#sum) +- [`sum(SObjectField field, String alias)`](#sum-with-alias) [**GROUPING**](#grouping) @@ -369,7 +379,9 @@ SOQL.of(Account.SObjectType) ).toList(); ``` -## COUNT-QUERY +## [AGGREGATE FUNCTIONS](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_agg_functions.htm) + +**Note!** To avoid the `Field must be grouped or aggregated` error, any default fields that are neither in Aggregation Functions nor included in the [GROUP BY](#group-by) clause will be automatically removed. ### count @@ -430,10 +442,6 @@ FROM Opportunity count(SObjectField field, String alias) ``` -**Note!** To avoid the `Field must be grouped or aggregated` error, any default fields will be automatically removed. - -You can still specify additional fields, but they should be placed after the COUNT() function in the SELECT statement. - **Example** ```sql @@ -445,6 +453,210 @@ SOQL.of(Account.SObjectType) .toAggregated(); ``` +### avg + +**Signature** + +```apex +Queryable avg(SObjectField field) +``` + +**Example** + +```sql +SELECT CampaignId, AVG(Amount) FROM Opportunity GROUP BY CampaignId +``` +```apex +SOQL.of(Opportunity.SObjectType) + .with(Opportunity.CampaignId) + .avg(Opportunity.Amount) + .groupBy(Opportunity.CampaignId) + .toAggregate(); +``` + +### avg with alias + +**Signature** + +```apex +Queryable avg(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT CampaignId, AVG(Amount) amount FROM Opportunity GROUP BY CampaignId +``` +```apex +SOQL.of(Opportunity.SObjectType) + .with(Opportunity.CampaignId) + .avg(Opportunity.Amount, 'amount') + .groupBy(Opportunity.CampaignId) + .toAggregate(); +``` + +### count_distinct + +**Signature** + +```apex +Queryable countDistinct(SObjectField field +``` + +**Example** + +```sql +SELECT COUNT_DISTINCT(Company) FROM Lead +``` +```apex +SOQL.of(Lead.SObjectType).countDistinct(Lead.Company).toAggregate(); +``` + +### count_distinct with alias + +**Signature** + +```apex +Queryable countDistinct(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT COUNT_DISTINCT(Company) company FROM Lead +``` +```apex +SOQL.of(Lead.SObjectType).countDistinct(Lead.Company, 'company').toAggregate(); +``` + +### min + +**Signature** + +```apex +Queryable min(SObjectField field) +``` + +**Example** + +```sql +SELECT FirstName, LastName, MIN(CreatedDate) +FROM Contact +GROUP BY FirstName, LastName +``` +```apex +SOQL.of(Contact.SObjectType) + .with(Contact.FirstName, Contact.LastName) + .min(Contact.CreatedDate) + .groupBy(Contact.FirstName) + .groupBy(Contact.LastName) + .toAggregate(); +``` + +### min with alias + +**Signature** + +```apex +Queryable min(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT FirstName, LastName, MIN(CreatedDate) createDate +FROM Contact +GROUP BY FirstName, LastName +``` +```apex +SOQL.of(Contact.SObjectType) + .with(Contact.FirstName, Contact.LastName) + .min(Contact.CreatedDate, 'createDate') + .groupBy(Contact.FirstName) + .groupBy(Contact.LastName) + .toAggregate(); +``` + +### max + +**Signature** + +```apex +Queryable max(SObjectField field) +``` + +**Example** + +```sql +SELECT Name, MAX(BudgetedCost) +FROM Campaign +GROUP BY Name +``` +```apex + SOQL.of(Campaign.SObjectType) + .with(Campaign.Name) + .max(Campaign.BudgetedCost) + .groupBy(Campaign.Name) + .toAggregate(); +``` + +### max with alias + +**Signature** + +```apex +Queryable max(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT Name, MAX(BudgetedCost) budgetedCost +FROM Campaign +GROUP BY Name +``` +```apex + SOQL.of(Campaign.SObjectType) + .with(Campaign.Name) + .max(Campaign.BudgetedCost, 'budgetedCost') + .groupBy(Campaign.Name) + .toAggregate(); +``` + +### sum + +**Signature** + +```apex +Queryable sum(SObjectField field) +``` + +**Example** + +```sql +SELECT SUM(Amount) FROM Opportunity +``` +```apex +SOQL.of(Opportunity.SObjectType).sum(Opportunity.Amount).toAggregate(); +``` + +### sum with alias + +**Signature** + +```apex +Queryable sum(SObjectField field, String alias) +``` + +**Example** + +```sql +SELECT SUM(Amount) amount FROM Opportunity +``` +```apex +SOQL.of(Opportunity.SObjectType).sum(Opportunity.Amount, 'amount').toAggregate(); +``` + ## GROUPING ### grouping