diff --git a/package/main/default/classes/standard-soql/SOQL.cls b/package/main/default/classes/standard-soql/SOQL.cls index 469463b2..5d4f22e6 100644 --- a/package/main/default/classes/standard-soql/SOQL.cls +++ b/package/main/default/classes/standard-soql/SOQL.cls @@ -22,6 +22,10 @@ global virtual inherited sharing class SOQL implements Queryable { Queryable query(); } + global interface PostProcessor { + List process(List records); + } + global static Queryable of(SObjectType ofObject) { return new SOQL(ofObject); } @@ -469,6 +473,7 @@ global virtual inherited sharing class SOQL implements Queryable { private SoqlBuilder builder; private Executor executor; private Converter converter; + private Boolean skipPostProcessing = false; global SOQL(SObjectType ofObject) { this(ofObject.toString()); @@ -960,6 +965,31 @@ global virtual inherited sharing class SOQL implements Queryable { return this; } + global SOQL skipPostProcessors() { + this.skipPostProcessing = true; + return this; + } + + @TestVisible + protected virtual List getPostProcessors() { + return null; + } + + private List toListProcessed() { + List records = this.executor.toList(); + if (this.skipPostProcessing) { + return records; + } + List processors = this.getPostProcessors(); + if (processors == null || processors.isEmpty()) { + return records; + } + for (PostProcessor p : processors) { + records = p.process(records); + } + return records; + } + global Map binding() { return binder.getBindingMap(); } @@ -1033,11 +1063,18 @@ global virtual inherited sharing class SOQL implements Queryable { } global SObject toObject() { - return this.executor.toObject(); + List records = this.toListProcessed(); + if (records.isEmpty()) { + return null; + } + if (records.size() > 1) { + throw new QueryException('List has more than 1 row for assignment to SObject'); + } + return records[0]; } global List toList() { - return this.executor.toList(); + return this.toListProcessed(); } global List toAggregated() { @@ -1053,69 +1090,69 @@ global virtual inherited sharing class SOQL implements Queryable { } global Map toMap() { - return this.converter.transform(this.executor.toList()).toMap(); + return this.converter.transform(this.toListProcessed()).toMap(); } global Map toIdMapBy(SObjectField field) { this.with(field); this.whereAre(Filter.with(field).isNotNull()); - return this.converter.transform(this.executor.toList()).toIdMapBy(field); + return this.converter.transform(this.toListProcessed()).toIdMapBy(field); } global Map toIdMapBy(String relationshipName, SObjectField targetKeyField) { this.with(relationshipName + '.' + targetKeyField.toString()); this.whereAre(Filter.with(relationshipName, targetKeyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toIdMapBy(relationshipName, targetKeyField); + return this.converter.transform(this.toListProcessed()).toIdMapBy(relationshipName, targetKeyField); } global Map> toAggregatedIdMapBy(SObjectField keyField) { this.with(keyField); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedIdMapBy(keyField); + return this.converter.transform(this.toListProcessed()).toAggregatedIdMapBy(keyField); } global Map> toAggregatedIdMapBy(String relationshipName, SObjectField targetKeyField) { this.with(relationshipName + '.' + targetKeyField.toString()); this.whereAre(Filter.with(relationshipName, targetKeyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedIdMapBy(relationshipName, targetKeyField); + return this.converter.transform(this.toListProcessed()).toAggregatedIdMapBy(relationshipName, targetKeyField); } global Map toMap(SObjectField keyField) { this.with(keyField); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toMap(keyField); + return this.converter.transform(this.toListProcessed()).toMap(keyField); } global Map toMap(String relationshipName, SObjectField targetKeyField) { this.with(relationshipName + '.' + targetKeyField.toString()); this.whereAre(Filter.with(relationshipName, targetKeyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toMap(relationshipName, targetKeyField); + return this.converter.transform(this.toListProcessed()).toMap(relationshipName, targetKeyField); } global Map toMap(SObjectField keyField, SObjectField valueField) { this.builder.fields.clearAllFields(); // other fields not needed this.with(keyField, valueField); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toMap(keyField, valueField); + return this.converter.transform(this.toListProcessed()).toMap(keyField, valueField); } global Map> toAggregatedMap(SObjectField keyField) { this.with(keyField); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedMap(keyField); + return this.converter.transform(this.toListProcessed()).toAggregatedMap(keyField); } global Map> toAggregatedMap(String relationshipName, SObjectField targetKeyField) { this.with(relationshipName + '.' + targetKeyField.toString()); this.whereAre(Filter.with(relationshipName, targetKeyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedMap(relationshipName, targetKeyField); + return this.converter.transform(this.toListProcessed()).toAggregatedMap(relationshipName, targetKeyField); } global Map> toAggregatedMap(SObjectField keyField, SObjectField valueField) { this.builder.fields.clearAllFields(); // other fields not needed this.with(keyField, valueField); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedMap(keyField, valueField); + return this.converter.transform(this.toListProcessed()).toAggregatedMap(keyField, valueField); } global Map> toAggregatedMap(SObjectField keyField, String relationshipName, SObjectField targetKeyField) { @@ -1123,7 +1160,7 @@ global virtual inherited sharing class SOQL implements Queryable { this.with(keyField); this.with(relationshipName + '.' + targetKeyField.toString()); this.whereAre(Filter.with(keyField).isNotNull()); - return this.converter.transform(this.executor.toList()).toAggregatedMap(keyField, relationshipName, targetKeyField); + return this.converter.transform(this.toListProcessed()).toAggregatedMap(keyField, relationshipName, targetKeyField); } global Database.QueryLocator toQueryLocator() { diff --git a/package/main/default/classes/standard-soql/SOQL_Full_Test.cls b/package/main/default/classes/standard-soql/SOQL_Full_Test.cls index 86a8c360..1542f944 100644 --- a/package/main/default/classes/standard-soql/SOQL_Full_Test.cls +++ b/package/main/default/classes/standard-soql/SOQL_Full_Test.cls @@ -5266,6 +5266,122 @@ private class SOQL_Full_Test { SOQL.of(Account.SObjectType).count().preview().toInteger(); } + // PostProcessor — infrastructure tests + + private class TestableSOQL extends SOQL { + public TestableSOQL() { + super(Account.SObjectType); + with(Account.Name); + } + protected override List getPostProcessors() { + return new List{ new NamePrefixFilter('Keep') }; + } + } + + private class NamePrefixFilter implements SOQL.PostProcessor { + private final String prefix; + public NamePrefixFilter(String p) { this.prefix = p; } + public List process(List records) { + List kept = new List(); + for (SObject r : records) { + if (((Account) r).Name.startsWith(this.prefix)) { + kept.add(r); + } + } + return kept; + } + } + + @IsTest + static void postProcessorFiltersToList() { + // Setup + SOQL.mock(Account.SObjectType).thenReturn(new List{ + new Account(Name = 'Keep Me'), + new Account(Name = 'Drop Me') + }); + + // Test + List result = new TestableSOQL().toList(); + + // Verify + Assert.areEqual(1, result.size(), 'Post-processor should filter non-matching records.'); + Assert.areEqual('Keep Me', ((Account) result[0]).Name, 'Remaining record should be the kept one.'); + } + + @IsTest + static void postProcessorFiltersToObject() { + // Setup + SOQL.mock(Account.SObjectType).thenReturn(new List{ + new Account(Name = 'Keep Me') + }); + + // Test + Account result = (Account) new TestableSOQL().toObject(); + + // Verify + Assert.areEqual('Keep Me', result.Name, 'Post-processor should pass a matching record through.'); + } + + @IsTest + static void postProcessorFiltersToMap() { + // Setup + Id keepId = SOQL.IdGenerator.get(Account.SObjectType); + Id dropId = SOQL.IdGenerator.get(Account.SObjectType); + SOQL.mock(Account.SObjectType).thenReturn(new List{ + new Account(Id = keepId, Name = 'Keep Me'), + new Account(Id = dropId, Name = 'Drop Me') + }); + + // Test + Map result = new TestableSOQL().toMap(); + + // Verify + Assert.areEqual(1, result.size(), 'Post-processor should filter before map conversion.'); + Assert.isTrue(result.containsKey(keepId), 'Map should only contain the kept record.'); + } + + @IsTest + static void skipPostProcessorsReturnsFull() { + // Setup + SOQL.mock(Account.SObjectType).thenReturn(new List{ + new Account(Name = 'Keep Me'), + new Account(Name = 'Drop Me') + }); + + // Test + List result = new TestableSOQL().skipPostProcessors().toList(); + + // Verify + Assert.areEqual(2, result.size(), 'skipPostProcessors() should bypass all processors.'); + } + + @IsTest + static void noPostProcessorHasZeroCost() { + // Setup + SOQL.mock(Account.SObjectType).thenReturn(new List{ + new Account(Name = 'Account A'), + new Account(Name = 'Account B') + }); + + // Test — plain SOQL.of() with no override should be unaffected + List result = SOQL.of(Account.SObjectType).toList(); + + // Verify + Assert.areEqual(2, result.size(), 'Non-overriding path should return all records unchanged.'); + } + + @IsTest + static void postProcessorDoesNotAffectToInteger() { + // Setup + SOQL.mock(Account.SObjectType).thenReturn(5); + + // Test + Integer result = new TestableSOQL().toInteger(); + + // Verify + Assert.areEqual(5, result, 'toInteger() must not be affected by post-processors.'); + } + static List insertAccounts() { List accounts = new List{ new Account(Name = 'Test 1'),