├── .gitignore ├── LICENSE.md ├── README.md └── src ├── classes ├── Collection.cls ├── Collection.cls-meta.xml ├── CollectionTest.cls └── CollectionTest.cls-meta.xml └── package.xml /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .idea/ 3 | IlluminatedCloud/ 4 | *.iml 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michał Woźniak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Collection Library 2 | Library allowing collection manipulations in APEX which helps in reducing conditional `for` loops.\ 3 | The main goal is to provide all the functionality in a standalone class without much preformance overhead. 4 | 5 | Latest version: **0.0.4** 6 | 7 | ## Features 8 | - fluent interface within standalone class 9 | - operations chaining 10 | - lazy evaluation 11 | - missing fields detection (not populated, not existing) 12 | 13 | ## Example Usage 14 | ### Filtering 15 | Filter accounts whose `Name` field equals `Foo` 16 | ```$xslt 17 | List filteredAccounts = Collection.of(accountList) 18 | .filter() 19 | .byField(Account.Name).eq('Foo') 20 | .get(); 21 | ``` 22 | 23 | Filter accounts whose `Name` field equals `Foo` and field `AnnualRevenue` is greater or equal than `150000` 24 | ```$xslt 25 | List filteredAccounts = Collection.of(accountList) 26 | .filter() 27 | .byField(Account.Name).eq('Foo') 28 | .andAlso() 29 | .byField(Account.AnnualRevenue).gte(150000) 30 | .get(); 31 | ``` 32 | 33 | Get first account which `Name` field equals `Bar` or `Foo` 34 | ```$xslt 35 | List filteredAccounts = Collection.of(accountList) 36 | .filter() 37 | .byField(Account.Name).eq('Bar') 38 | .orElse() 39 | .byField(Account.Name).eq('Foo') 40 | .getFirst(); 41 | ``` 42 | 43 | Get top 10 accounts which `AnnualRevenue` field is less or equal than `100000` 44 | ```$xslt 45 | List filteredAccounts = Collection.of(accountList) 46 | .filter() 47 | .byField(Account.Name).lte(100000) 48 | .get(10); 49 | ``` 50 | 51 | Ignore non populated `Website` field (effectively treating them as `null`) 52 | ```$xslt 53 | List filteredAccounts = Collection.of(accountList) 54 | .filter() 55 | .ignoreNonPopulatedFields() 56 | .byField(Account.Website).isNull() 57 | .get(); 58 | ``` 59 | 60 | ### Grouping 61 | Group accounts by `Active__c` `Boolean` field 62 | ```$xslt 63 | Map> groupedAccounts = Collection.of(accountList) 64 | .group() 65 | .byField(Account.Active__c) 66 | .get(); 67 | ``` 68 | 69 | ### Reducing 70 | Sum accounts `AnnualRevenue` field values 71 | ```$xslt 72 | Decimal sum = Collection.of(accountList) 73 | .reduce() 74 | .byField(Account.AnnualRevenue) 75 | .sum(); 76 | ``` 77 | 78 | Average accounts `AnnualRevenue` field values 79 | ```$xslt 80 | Decimal sum = Collection.of(accountList) 81 | .reduce() 82 | .byField(Account.AnnualRevenue) 83 | .average(); 84 | ``` 85 | 86 | ### Operations chaining 87 | Filter accounts whose `Name` field equals to `Foo` then sum matching records `AnnualRevenue` field 88 | ```$xslt 89 | Decimal sum = Collection.of(accountList) 90 | .filter() 91 | .byField(Account.Name).eq('Foo') 92 | .then() 93 | .reduce() 94 | .byField(Account.AnnualRevenue) 95 | .sum(); 96 | ``` 97 | Filter accounts whose `AnnualRevenue` field is greater or equal than `100000` then group matching records by `BillingCity` field 98 | ```$xslt 99 | Map> found = Collection.of(accountList) 100 | .filter() 101 | .byField(Account.AnnualRevenue).gte(100000) 102 | .then() 103 | .group() 104 | .byField(Account.BillingCity) 105 | .get(); 106 | ``` 107 | 108 | ## Considerations 109 | ### Set and Map does not implement Iterable interface 110 | Currently Set and Map types are not supported for manipulations, only List type 111 | 112 | ## Changelog 113 | 114 | ### v0.0.4 115 | - **Introduced breaking changes** 116 | - Renamed `map` operation to `reduce` 117 | - Operation chaining proceeds by `then()` call 118 | - Changed `isIn` and `isNotIn` filtering operations to accept only `List` values 119 | - Improved class test coverage to `91.48%` 120 | 121 | ### v0.0.3 122 | - Added operations chaining: filter-then-map, filter-then-group 123 | 124 | ### v0.0.2 125 | - Added mapping by field operations: sum, average 126 | - Code base refactor 127 | 128 | ### v0.0.1 129 | - Initial version 130 | - Added grouping by field operation 131 | - Added filtering by fields operation -------------------------------------------------------------------------------- /src/classes/Collection.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Library allowing SObject collection manipulations in APEX which helps in reducing conditional for loops. 3 | * 4 | * @author Michał Woźniak 5 | */ 6 | public inherited sharing class Collection { 7 | 8 | private final Iterable collection; 9 | 10 | private Collection() { } 11 | 12 | private Collection(Iterable collection) { 13 | this.collection = collection; 14 | } 15 | 16 | /** 17 | * Wraps {@code Iterable} into new {@code Collection} 18 | * 19 | * @param collection 20 | * 21 | * @return wrapped {@code Iterable} 22 | */ 23 | public static Collection of(Iterable collection) { 24 | return new Collection(collection); 25 | } 26 | 27 | /** 28 | * Returns original underlying collection 29 | * 30 | * @return {@code Iterable} collection 31 | */ 32 | public Iterable getCollection() { 33 | return collection; 34 | } 35 | 36 | /** 37 | * Allows execution of grouping operations 38 | */ 39 | public CollectionGroup group() { 40 | return new CollectionGroupImpl(collection); 41 | } 42 | 43 | public interface CollectionGroup { 44 | /** 45 | * Groups collection by given field 46 | * 47 | * @param field to group collection 48 | */ 49 | CollectionGroupResult byField(SObjectField field); 50 | } 51 | 52 | public interface CollectionGroupResult { 53 | /** 54 | * Returns Map containing results of previous grouping calls 55 | * 56 | * map key - contains unique values of given field 57 | * map value - contains list of {@code SObject}'s 58 | * 59 | * @return grouped map for given field 60 | */ 61 | Map> get(); 62 | } 63 | 64 | /** 65 | * Allows execution of mapping operations 66 | */ 67 | public CollectionReduce reduce() { 68 | return new CollectionReduceImpl(collection); 69 | } 70 | 71 | public interface CollectionReduce { 72 | 73 | /** 74 | * Reduces collection by given field 75 | * 76 | * @param field to map collection 77 | */ 78 | CollectionReduceResult byField(SObjectField field); 79 | 80 | } 81 | 82 | public interface CollectionReduceResult { 83 | /** 84 | * Returns sum of given {@code SObject} field 85 | * 86 | * @return sum value 87 | */ 88 | Decimal sum(); 89 | 90 | /** 91 | * Returns average of given {@code SObject} field 92 | * 93 | * @return average value 94 | */ 95 | Decimal average(); 96 | } 97 | 98 | /** 99 | * Allows execution of filtering operations 100 | */ 101 | public CollectionFilter filter() { 102 | return new CollectionFilterImpl(collection); 103 | } 104 | 105 | public interface CollectionFilter { 106 | /** 107 | * Disables checks for non populated fields in all further filtering calls 108 | */ 109 | CollectionFilter ignoreNonPopulatedFields(); 110 | 111 | /** 112 | * Filters collection by given field 113 | * 114 | * @param field to filter collection 115 | */ 116 | CollectionFilterPredicate byField(SObjectField field); 117 | } 118 | 119 | public interface CollectionFilterPredicate { 120 | /** 121 | * Appends equals predicate with given value 122 | */ 123 | CollectionFilterResult eq(Object value); 124 | 125 | /** 126 | * Appends not equals predicate with given value 127 | */ 128 | CollectionFilterResult ne(Object value); 129 | 130 | /** 131 | * Appends greater than predicate with given value 132 | */ 133 | CollectionFilterResult gt(Object value); 134 | 135 | /** 136 | * Appends greater or equal than predicate with given value 137 | */ 138 | CollectionFilterResult gte(Object value); 139 | 140 | /** 141 | * Appends less than predicate with given value 142 | */ 143 | CollectionFilterResult lt(Object value); 144 | 145 | /** 146 | * Appends less or equal than predicate with given value 147 | */ 148 | CollectionFilterResult lte(Object value); 149 | 150 | /** 151 | * Appends is in predicate with given value 152 | * 153 | * Equivalent of calling .eq(value) for each {@code List} value 154 | */ 155 | CollectionFilterResult isIn(List values); 156 | 157 | /** 158 | * Appends is not in predicate with given value 159 | * 160 | * Equivalent of calling .ne(value) for each {@code List} value 161 | */ 162 | CollectionFilterResult isNotIn(List values); 163 | 164 | /** 165 | * Appends is null predicate with given value 166 | * 167 | * Equivalent of calling .eq(null) 168 | */ 169 | CollectionFilterResult isNull(); 170 | 171 | /** 172 | * Appends is not null predicate with given value 173 | * 174 | * Equivalent of calling .ne(null) 175 | */ 176 | CollectionFilterResult isNotNull(); 177 | } 178 | 179 | public interface CollectionFilterResult { 180 | 181 | /** 182 | * Chains next operation after all previous filtering calls 183 | */ 184 | CollectionFilterChain then(); 185 | 186 | /** 187 | * Chains next filtering predicate with AND condition 188 | */ 189 | CollectionFilter andAlso(); 190 | 191 | /** 192 | * Chains next filtering predicate with OR condition 193 | */ 194 | CollectionFilter orElse(); 195 | 196 | /** 197 | * Returns list containing results of all previous filtering calls 198 | * 199 | * @return filtered {@code List} 200 | */ 201 | List get(); 202 | 203 | /** 204 | * Returns list limited in size to {@param resultLimit}, containing results of all previous filtering calls 205 | * 206 | * @param resultLimit 207 | * 208 | * @return filtered {@code List} 209 | */ 210 | List get(Long resultLimit); 211 | 212 | /** 213 | * Returns first found result of all previous filtering calls 214 | * 215 | * @return filtered {@code SObject} 216 | */ 217 | SObject getFirst(); 218 | } 219 | 220 | public interface CollectionFilterChain { 221 | 222 | /** 223 | * Chains mapping operation after filtering operation 224 | */ 225 | CollectionReduce reduce(); 226 | 227 | /** 228 | * Chains grouping operation after filtering operation 229 | */ 230 | CollectionGroup group(); 231 | } 232 | 233 | @TestVisible 234 | private class CollectionFilterImpl implements 235 | CollectionFilterResult, CollectionFilter, CollectionFilterPredicate, CollectionFilterChain { 236 | 237 | private final Iterable collection; 238 | private final PredicateCollection predicateCollection; 239 | private SObjectField field; 240 | 241 | public CollectionFilterImpl(Iterable collection) { 242 | this.collection = collection; 243 | this.predicateCollection = new PredicateCollection(collection); 244 | } 245 | 246 | public CollectionFilterChain then() { 247 | return this; 248 | } 249 | 250 | public CollectionReduce reduce() { 251 | return new CollectionReduceImpl(collection, predicateCollection); 252 | } 253 | 254 | public CollectionGroup group() { 255 | return new CollectionGroupImpl(collection, predicateCollection); 256 | } 257 | 258 | public CollectionFilter ignoreNonPopulatedFields() { 259 | this.predicateCollection.ignoreNonPopulatedFields(); 260 | return this; 261 | } 262 | 263 | public CollectionFilterPredicate byField(SObjectField field) { 264 | this.field = field; 265 | return this; 266 | } 267 | 268 | public CollectionFilter orElse() { 269 | this.predicateCollection.orElse(); 270 | return this; 271 | } 272 | 273 | public CollectionFilter andAlso() { 274 | this.predicateCollection.andAlso(); 275 | return this; 276 | } 277 | 278 | public CollectionFilterResult eq(Object value) { 279 | this.predicateCollection.eq(field, value); 280 | return this; 281 | } 282 | 283 | public CollectionFilterResult ne(Object value) { 284 | this.predicateCollection.ne(field, value); 285 | return this; 286 | } 287 | 288 | public CollectionFilterResult gt(Object value) { 289 | this.predicateCollection.gt(field, value); 290 | return this; 291 | } 292 | 293 | public CollectionFilterResult gte(Object value) { 294 | this.predicateCollection.gte(field, value); 295 | return this; 296 | } 297 | 298 | public CollectionFilterResult lt(Object value) { 299 | this.predicateCollection.lt(field, value); 300 | return this; 301 | } 302 | 303 | public CollectionFilterResult lte(Object value) { 304 | this.predicateCollection.lte(field, value); 305 | return this; 306 | } 307 | 308 | public CollectionFilterResult isNull() { 309 | this.predicateCollection.eq(field, null); 310 | return this; 311 | } 312 | 313 | public CollectionFilterResult isNotNull() { 314 | this.predicateCollection.ne(field, null); 315 | return this; 316 | } 317 | 318 | public CollectionFilterResult isIn(List values) { 319 | this.predicateCollection.isIn(field, values); 320 | return this; 321 | } 322 | 323 | public CollectionFilterResult isNotIn(List values) { 324 | this.predicateCollection.isNotIn(field, values); 325 | return this; 326 | } 327 | 328 | public SObject getFirst() { 329 | List objects = this.predicateCollection.process(1); 330 | return objects.isEmpty() ? null : objects[0]; 331 | } 332 | 333 | public List get() { 334 | return this.predicateCollection.process(-1); 335 | } 336 | 337 | public List get(Long resultLimit) { 338 | return this.predicateCollection.process(resultLimit); 339 | } 340 | } 341 | 342 | @TestVisible 343 | private class CollectionGroupImpl implements CollectionGroup, CollectionGroupResult { 344 | 345 | private final Iterable collection; 346 | private final PredicateCollection predicateCollection; 347 | private SObjectField field; 348 | 349 | public CollectionGroupImpl(Iterable collection) { 350 | this.collection = collection; 351 | this.predicateCollection = new PredicateCollection(collection); 352 | } 353 | 354 | public CollectionGroupImpl(Iterable collection, PredicateCollection predicateCollection) { 355 | this.collection = collection; 356 | this.predicateCollection = predicateCollection; 357 | } 358 | 359 | public CollectionGroupResult byField(SObjectField field) { 360 | this.field = field; 361 | return this; 362 | } 363 | 364 | public Map> get() { 365 | final Map> values = new Map>(); 366 | final List predicateCollection = this.predicateCollection.process(-1); 367 | for (SObject collectionObject : predicateCollection) { 368 | Object key = collectionObject.get(field); 369 | List objectList = values.get(key); 370 | if (objectList == null) { 371 | objectList = new List(); 372 | values.put(key, objectList); 373 | } 374 | objectList.add(collectionObject); 375 | } 376 | return values; 377 | } 378 | } 379 | 380 | @TestVisible 381 | private class CollectionReduceImpl implements CollectionReduce, CollectionReduceResult { 382 | 383 | private final Iterable collection; 384 | private final PredicateCollection predicateCollection; 385 | private SObjectField field; 386 | 387 | public CollectionReduceImpl(Iterable collection) { 388 | this.collection = collection; 389 | this.predicateCollection = new PredicateCollection(collection); 390 | } 391 | 392 | public CollectionReduceImpl(Iterable collection, PredicateCollection predicateCollection) { 393 | this.collection = collection; 394 | this.predicateCollection = predicateCollection; 395 | } 396 | 397 | public CollectionReduceResult byField(SObjectField field) { 398 | this.field = field; 399 | return this; 400 | } 401 | 402 | public Decimal sum() { 403 | final List predicateCollection = this.predicateCollection.process(-1); 404 | Decimal sum = 0.0; 405 | for (SObject collectionObject : predicateCollection) { 406 | sum += getDecimal(collectionObject.get(field)); 407 | } 408 | return sum; 409 | } 410 | 411 | public Decimal average() { 412 | final List predicateCollection = this.predicateCollection.process(-1); 413 | Decimal average = 0.0; 414 | Integer count = 0; 415 | for (SObject collectionObject : predicateCollection) { 416 | average += getDecimal(collectionObject.get(field)); 417 | count++; 418 | } 419 | return (count > 0) ? (average / count) : 0; 420 | } 421 | } 422 | 423 | /** 424 | * Common implementation for collection filtered by predicates 425 | */ 426 | @TestVisible 427 | private class PredicateCollection { 428 | 429 | private final Iterable collection; 430 | private final Set predicateNodes; 431 | private BooleanRelation relation; 432 | private Boolean ignoreNonPopulatedFields = false; 433 | 434 | public PredicateCollection(Iterable collection) { 435 | this.collection = collection; 436 | this.predicateNodes = new Set(); 437 | } 438 | 439 | public PredicateCollection ignoreNonPopulatedFields() { 440 | this.ignoreNonPopulatedFields = true; 441 | return this; 442 | } 443 | 444 | public PredicateCollection eq(SObjectField field, Object value) { 445 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.EQUAL, relation)); 446 | } 447 | 448 | public PredicateCollection ne(SObjectField field, Object value) { 449 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.NOT_EQUAL, relation)); 450 | } 451 | 452 | public PredicateCollection gt(SObjectField field, Object value) { 453 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.GREATER_THAN, relation)); 454 | } 455 | 456 | public PredicateCollection gte(SObjectField field, Object value) { 457 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.GREATER_THAN_OR_EQUAL, relation)); 458 | } 459 | 460 | public PredicateCollection lt(SObjectField field, Object value) { 461 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.LESS_THAN, relation)); 462 | } 463 | 464 | public PredicateCollection lte(SObjectField field, Object value) { 465 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.LESS_THAN_OR_EQUAL, relation)); 466 | } 467 | 468 | public PredicateCollection isIn(SObjectField field, Object value) { 469 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.IS_IN, relation)); 470 | } 471 | 472 | public PredicateCollection isNotIn(SObjectField field, Object value) { 473 | return this.addPredicate(new PredicateNode(field, value, BooleanOperation.IS_NOT_IN, relation)); 474 | } 475 | 476 | public PredicateCollection orElse() { 477 | this.relation = BooleanRelation.OR_ELSE; 478 | return this; 479 | } 480 | 481 | public PredicateCollection andAlso() { 482 | this.relation = BooleanRelation.AND_ALSO; 483 | return this; 484 | } 485 | 486 | private PredicateCollection addPredicate(PredicateNode node) { 487 | this.predicateNodes.add(node); 488 | return this; 489 | } 490 | 491 | public List process(Long resultLimit) { 492 | final Map> fieldNodes = this.groupNodesByField(); 493 | final List values = new List(); 494 | final Iterator iterator = this.collection.iterator(); 495 | 496 | while (iterator.hasNext() && resultLimit != 0) { 497 | SObject collectionObject = (SObject) iterator.next(); 498 | Boolean isMatching = true; 499 | 500 | Map populatedObjectFields; 501 | if (!ignoreNonPopulatedFields) { 502 | populatedObjectFields = collectionObject.getPopulatedFieldsAsMap(); 503 | } 504 | 505 | for (SObjectField field : fieldNodes.keySet()) { 506 | if (!ignoreNonPopulatedFields) { 507 | String fieldName = field.getDescribe().getName(); 508 | if (!populatedObjectFields.containsKey(fieldName)) { 509 | throw new CollectionException( 510 | 'Field ' + fieldName + ' seems to be not populated or does not exists. ' + 511 | 'Check if passed SObject fields are valid and properly populated e.g. in SOQL statement. ' + 512 | 'Use ignoreNonPopulatedFields() to disable field population checks.'); 513 | } 514 | } 515 | for (PredicateNode node : fieldNodes.get(field)) { 516 | Object objectFieldValue; 517 | try { 518 | objectFieldValue = collectionObject.get(node.field); 519 | } catch (SObjectException e) { 520 | throw new CollectionException( 521 | 'SObject does not contain ' + field.getDescribe().getName() + ' field.'); 522 | } 523 | isMatching = processNode(objectFieldValue, node, isMatching); 524 | } 525 | } 526 | if (isMatching) { 527 | values.add(collectionObject); 528 | resultLimit--; 529 | } 530 | } 531 | return values; 532 | } 533 | 534 | private Map> groupNodesByField() { 535 | final Map> values = new Map>(); 536 | for (PredicateNode predicateNode : predicateNodes) { 537 | List nodes = values.get(predicateNode.field); 538 | if (nodes == null) { 539 | nodes = new List(); 540 | values.put(predicateNode.field, nodes); 541 | } 542 | nodes.add(predicateNode); 543 | } 544 | return values; 545 | } 546 | 547 | private Boolean processNode(Object objectFieldValue, PredicateNode node, Boolean isMatchingYet) { 548 | Boolean isMatching; 549 | if (node.operation == BooleanOperation.IS_IN || 550 | node.operation == BooleanOperation.IS_NOT_IN) { 551 | isMatching = containsOperation(objectFieldValue, node); 552 | } else { 553 | isMatching = compareOperation(objectFieldValue, node); 554 | } 555 | switch on node.relation { 556 | when AND_ALSO { return isMatchingYet && isMatching; } 557 | when OR_ELSE { return isMatchingYet || isMatching; } 558 | when else { return isMatchingYet && isMatching; } 559 | } 560 | } 561 | 562 | private Boolean compareOperation(Object value, PredicateNode node) { 563 | final Integer result = compare(value, node.value); 564 | switch on node.operation { 565 | when EQUAL { return result == 0; } 566 | when NOT_EQUAL { return result != 0; } 567 | when LESS_THAN { return result < 0; } 568 | when LESS_THAN_OR_EQUAL { return result <= 0; } 569 | when GREATER_THAN { return result > 0; } 570 | when GREATER_THAN_OR_EQUAL { return result >= 0; } 571 | when else { return false; } 572 | } 573 | } 574 | 575 | private Boolean containsOperation(Object collection, PredicateNode node) { 576 | final Boolean result = contains(node.value, collection); 577 | switch on node.operation { 578 | when IS_IN { return result == true; } 579 | when IS_NOT_IN { return result == false; } 580 | when else { return false; } 581 | } 582 | } 583 | } 584 | 585 | private class PredicateNode { 586 | public SObjectField field { get; set; } 587 | public Object value { get; set; } 588 | public BooleanOperation operation { get; set; } 589 | public BooleanRelation relation { get; set; } 590 | 591 | public PredicateNode(SObjectField field, Object value, BooleanOperation operation, BooleanRelation relation) { 592 | this.field = field; 593 | this.value = value; 594 | this.operation = operation; 595 | this.relation = relation; 596 | } 597 | } 598 | 599 | /* 600 | * Common methods 601 | */ 602 | 603 | private static Decimal getDecimal(Object value) { 604 | if (value instanceof Long) { 605 | return Decimal.valueOf((Long)value); 606 | } 607 | else if (value instanceof Decimal) { 608 | return (Decimal)value; 609 | } 610 | else if (value instanceof String) { 611 | try { 612 | return Decimal.valueOf((String)value); 613 | } catch (Exception e) { 614 | throw new CollectionException('Invalid string format for Decimal: ' + value); 615 | } 616 | } 617 | else { 618 | throw new CollectionException('Unsupported type supplied for decimal. ' + 619 | 'Check if passed SObject field is either Integer, Long, Double, Decimal or proper String.'); 620 | } 621 | } 622 | 623 | private static Integer compare(Object first, Object second) { 624 | if (first instanceof Id && second instanceof Id) { 625 | return compareIds((Id)first, (Id)second); 626 | } 627 | else if (first instanceof String && second instanceof String) { 628 | return compareStrings((String)first, (String)second); 629 | } 630 | else if (first instanceof Long && second instanceof Long) { 631 | return compareLongs((Long)first, (Long)second); 632 | } 633 | else if (first instanceof Decimal && second instanceof Decimal) { 634 | return compareDecimals((Decimal)first, (Decimal)second); 635 | } 636 | else if (first instanceof Boolean && second instanceof Boolean) { 637 | return compareBooleans((Boolean)first, (Boolean)second); 638 | } 639 | else if (first instanceof Date && second instanceof Date) { 640 | return compareDates((Date)first, (Date)second); 641 | } 642 | else if (first instanceof Datetime && second instanceof Datetime) { 643 | return compareDateTimes((Datetime)first, (Datetime)second); 644 | } 645 | else if (first instanceof Time && second instanceof Time) { 646 | return compareTimes((Time) first, (Time) second); 647 | } 648 | else if (first == null || second == null) { 649 | return compareNulls(first, second); 650 | } 651 | throw new CollectionException('Unsupported types supplied for compare. ' + 652 | 'Check if passed SObject fields and values are the same type.'); 653 | } 654 | 655 | private static Boolean contains(Object values, Object value) { 656 | if (values == null) { 657 | return false; 658 | } 659 | else if (values instanceof List) { 660 | List validValues = (List) values; 661 | return listContains(validValues, value); 662 | } 663 | throw new CollectionException('Unsupported types supplied for contain. ' + 664 | 'Check if values passed to isIn and isNotIn predicate are Set type.'); 665 | } 666 | 667 | private static Boolean listContains(List objects, Object o) { 668 | for (Object obj : objects) { 669 | if (obj.equals(o)) { 670 | return true; 671 | } 672 | } 673 | return false; 674 | } 675 | 676 | private static Integer compareNulls(Object a, Object b) { return (a == null && b == null) ? 0 : (a == null ? 1 : -1); } 677 | private static Integer compareBooleans(Boolean a, Boolean b) { return (a == b) ? 0 : (a ? 1 : -1); } 678 | private static Integer compareDates(Date a, Date b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 679 | private static Integer compareTimes(Time a, Time b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 680 | private static Integer compareDateTimes(Datetime a, Datetime b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 681 | private static Integer compareDecimals(Decimal a, Decimal b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 682 | private static Integer compareIds(Id a, Id b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 683 | private static Integer compareLongs(Long a, Long b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 684 | private static Integer compareStrings(String a, String b) { return (a == b) ? 0 : (a > b ? 1 : -1); } 685 | 686 | private enum BooleanRelation { AND_ALSO, OR_ELSE } 687 | 688 | private enum BooleanOperation { 689 | EQUAL, NOT_EQUAL, 690 | IS_IN, IS_NOT_IN, 691 | LESS_THAN, GREATER_THAN, 692 | LESS_THAN_OR_EQUAL, GREATER_THAN_OR_EQUAL 693 | } 694 | 695 | public class CollectionException extends Exception { } 696 | } -------------------------------------------------------------------------------- /src/classes/Collection.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/CollectionTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class CollectionTest { 3 | 4 | @IsTest 5 | static void should_filter_eq_then_map_sum() { 6 | Decimal sum = Collection.of(getList()) 7 | .filter() 8 | .byField(Account.Name).eq('Foo') 9 | .then() 10 | .reduce() 11 | .byField(Account.AnnualRevenue) 12 | .sum(); 13 | System.assertEquals(310.0, sum); 14 | } 15 | 16 | @IsTest 17 | static void should_filter_lt_then_map_average() { 18 | Decimal average = Collection.of(getList()) 19 | .filter() 20 | .byField(Account.AnnualRevenue).lt(100) 21 | .then() 22 | .reduce() 23 | .byField(Account.AnnualRevenue) 24 | .average(); 25 | System.assertEquals(60.0, average); 26 | } 27 | 28 | 29 | @IsTest 30 | static void should_group_by_field() { 31 | Map> found = Collection.of(getList()) 32 | .group() 33 | .byField(Account.Name) 34 | .get(); 35 | System.assertEquals(2, found.keySet().size()); 36 | for (Object accountName : found.keySet()) { 37 | System.assert(accountName == 'Foo' || accountName == 'Bar'); 38 | List accounts = found.get(accountName); 39 | System.assertEquals(3, accounts.size()); 40 | for (Account account : accounts) { 41 | System.assertEquals(account.Name, accountName); 42 | } 43 | } 44 | } 45 | 46 | @IsTest 47 | static void should_filter_then_group_by_field() { 48 | Map> found = Collection.of(getList()) 49 | .filter() 50 | .ignoreNonPopulatedFields() 51 | .byField(Account.BillingStreet).isNotNull() 52 | .then() 53 | .group() 54 | .byField(Account.BillingStreet) 55 | .get(); 56 | System.assertEquals(2, found.keySet().size()); 57 | for (Object accountBillingStreet : found.keySet()) { 58 | System.assert(accountBillingStreet == 'Narutowicza' || accountBillingStreet == '3 Maja'); 59 | List accounts = found.get(accountBillingStreet); 60 | System.assertEquals(1, accounts.size()); 61 | for (Account account : accounts) { 62 | System.assertEquals(account.BillingStreet, accountBillingStreet); 63 | } 64 | } 65 | } 66 | 67 | @IsTest 68 | static void should_filter_single_field_eq() { 69 | List found = Collection.of(getList()) 70 | .filter() 71 | .byField(Account.Name).eq('Foo') 72 | .get(); 73 | System.assertEquals(3, found.size()); 74 | for (Account account : found) { 75 | System.assertEquals('Foo', account.Name); 76 | } 77 | } 78 | 79 | @IsTest 80 | static void should_filter_single_field_is_null() { 81 | List found = Collection.of(getList()) 82 | .filter() 83 | .byField(Account.Id).isNull() 84 | .get(); 85 | System.assertEquals(3, found.size()); 86 | for (Account account : found) { 87 | System.assertEquals(null, account.Id); 88 | } 89 | } 90 | 91 | @IsTest 92 | static void should_filter_single_field_is_not_null() { 93 | List found = Collection.of(getList()) 94 | .filter() 95 | .byField(Account.Id).isNotNull() 96 | .get(); 97 | System.assertEquals(3, found.size()); 98 | for (Account account : found) { 99 | System.assert(account.Id != null); 100 | } 101 | } 102 | 103 | @IsTest 104 | static void should_filter_single_field_in() { 105 | List values = new List { 60, 100 }; 106 | List found = Collection.of(getList()) 107 | .filter() 108 | .byField(Account.AnnualRevenue).isIn(values) 109 | .get(); 110 | System.assertEquals(4, found.size()); 111 | for (Account account : found) { 112 | System.assert(values.contains(account.AnnualRevenue)); 113 | } 114 | } 115 | 116 | @IsTest 117 | static void should_filter_single_field_not_in() { 118 | List values = new List { 'Foo' }; 119 | List found = Collection.of(getList()) 120 | .filter() 121 | .byField(Account.Name).isNotIn(values) 122 | .get(); 123 | System.assertEquals(3, found.size()); 124 | for (Account account : found) { 125 | System.assert(values.contains(account.Name) == false); 126 | } 127 | } 128 | 129 | @IsTest 130 | static void should_filter_multiple_fields_or() { 131 | List found = Collection.of(getList()) 132 | .filter() 133 | .byField(Account.AnnualRevenue).eq(60) 134 | .orElse() 135 | .byField(Account.AnnualRevenue).eq(150) 136 | .get(); 137 | System.assertEquals(4, found.size()); 138 | for (Account account : found) { 139 | System.assert(account.AnnualRevenue == 60 || account.AnnualRevenue == 150); 140 | } 141 | } 142 | 143 | @IsTest 144 | static void should_filter_multiple_fields_and() { 145 | List found = Collection.of(getList()) 146 | .filter() 147 | .byField(Account.Name).eq('Bar') 148 | .andAlso() 149 | .byField(Account.AnnualRevenue).gte(100) 150 | .get(); 151 | System.assertEquals(2, found.size()); 152 | for (Account account : found) { 153 | System.assert(account.Name == 'Bar' && account.AnnualRevenue >= 100); 154 | } 155 | } 156 | 157 | @IsTest 158 | static void should_filter_first() { 159 | Account found = (Account)Collection.of(getList()) 160 | .filter() 161 | .byField(Account.Name).eq('Bar') 162 | .andAlso() 163 | .byField(Account.AnnualRevenue).eq(60) 164 | .getFirst(); 165 | System.assertNotEquals(null, found); 166 | System.assert(found.Name == 'Bar' && found.AnnualRevenue == 60 && found.Website == 'http://website.com'); 167 | } 168 | 169 | @IsTest 170 | static void should_filter_limit() { 171 | List found = Collection.of(getList()) 172 | .filter() 173 | .byField(Account.AnnualRevenue).gt(60) 174 | .get(3); 175 | System.assertEquals(3, found.size()); 176 | for (Account account : found) { 177 | System.assert(account.AnnualRevenue > 60); 178 | } 179 | } 180 | 181 | @IsTest 182 | static void should_map_sum_field() { 183 | Decimal sum = Collection.of(getList()) 184 | .reduce() 185 | .byField(Account.AnnualRevenue) 186 | .sum(); 187 | 188 | System.assertEquals(620.0, sum); 189 | } 190 | 191 | @IsTest 192 | static void should_map_average_field() { 193 | Decimal average = Collection.of(getList()) 194 | .reduce() 195 | .byField(Account.AnnualRevenue) 196 | .average(); 197 | 198 | System.assertEquals(103.333333333333333333333333333333, average); 199 | } 200 | 201 | @IsTest 202 | static void should_return_underlying_collection() { 203 | List accountList = getList(); 204 | List underlyingAccountList = (List)Collection.of(accountList).getCollection(); 205 | System.assertEquals(accountList.size(), underlyingAccountList.size()); 206 | accountList.add(new Account(Name = 'test')); 207 | System.assertEquals(accountList.size(), underlyingAccountList.size()); 208 | } 209 | 210 | /* 211 | * Exception Cases 212 | */ 213 | 214 | 215 | @IsTest 216 | static void should_throw_field_not_supported_for_sum() { 217 | try { 218 | Collection.of(getList()) 219 | .reduce() 220 | .byField(Account.Name) 221 | .sum(); 222 | } catch (Collection.CollectionException e) { 223 | System.assert(e.getMessage().contains('Invalid string format for Decimal')); 224 | } 225 | } 226 | 227 | @IsTest 228 | static void should_throw_field_not_populated_or_not_exist_collection_exception() { 229 | try { 230 | Collection.of(getList()).filter() 231 | .byField(Account.Fax).isNotNull() 232 | .get(); 233 | } catch (Collection.CollectionException e) { 234 | System.assert(e.getMessage().contains('seems to be not populated or does not exists')); 235 | } 236 | } 237 | 238 | @IsTest 239 | static void should_throw_field_not_populated_collection_exception() { 240 | try { 241 | Collection.of(getList()).filter() 242 | .byField(Case.CaseNumber).isNotNull() 243 | .get(); 244 | } catch (Collection.CollectionException e) { 245 | System.assert(e.getMessage().contains('seems to be not populated or does not exists')); 246 | } 247 | } 248 | 249 | @IsTest 250 | static void should_throw_field_does_not_belong_sobject_exception() { 251 | try { 252 | Collection.of(getList()).filter() 253 | .ignoreNonPopulatedFields() 254 | .byField(Case.CaseNumber).isNotNull() 255 | .get(); 256 | } catch (Collection.CollectionException e) { 257 | System.assert(e.getMessage().contains('SObject does not contain')); 258 | } 259 | } 260 | 261 | @IsTest 262 | static void should_throw_unsupported_types_supplied_for_compare_exception() { 263 | try { 264 | Collection.of(getList()).filter() 265 | .byField(Account.Id).eq(1) 266 | .get(); 267 | } catch (Collection.CollectionException e) { 268 | System.assert(e.getMessage().contains('Unsupported types supplied for compare')); 269 | } 270 | } 271 | 272 | /* 273 | * Performance Tests 274 | */ 275 | 276 | @IsTest 277 | static void performance_test_ignoring_non_populated_field_enabled() { 278 | Collection.of(getLargeList()).filter() 279 | .ignoreNonPopulatedFields() 280 | .byField(Account.Name).eq('A') 281 | .andAlso() 282 | .byField(Account.AnnualRevenue).eq(0) 283 | .andAlso() 284 | .byField(Account.Website).eq('C') 285 | .andAlso() 286 | .byField(Account.BillingStreet).eq('D') 287 | .andAlso() 288 | .byField(Account.BillingCity).eq('E') 289 | .get(); 290 | } 291 | 292 | @IsTest 293 | static void performance_test_ignoring_non_populated_field_disabled() { 294 | Collection.of(getLargeList()).filter() 295 | .byField(Account.Name).eq('A') 296 | .andAlso() 297 | .byField(Account.AnnualRevenue).eq(0) 298 | .andAlso() 299 | .byField(Account.Website).eq('C') 300 | .andAlso() 301 | .byField(Account.BillingStreet).eq('D') 302 | .andAlso() 303 | .byField(Account.BillingCity).eq('E') 304 | .get(); 305 | } 306 | 307 | /* 308 | * PredicateCollection Tests 309 | */ 310 | 311 | @IsTest 312 | static void test_equal_predicate() { 313 | // given 314 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 315 | 316 | // when 317 | collection.eq(Account.Name, 'Foo'); 318 | 319 | // then 320 | List accounts = collection.process(-1); 321 | System.assertEquals(3, accounts.size()); 322 | for (Account account : accounts) { 323 | System.assertEquals(account.Name, 'Foo'); 324 | } 325 | } 326 | 327 | @IsTest 328 | static void test_not_equal_predicate() { 329 | // given 330 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 331 | 332 | // when 333 | collection.ne(Account.Name, 'Foo'); 334 | 335 | // then 336 | List accounts = collection.process(-1); 337 | System.assertEquals(3, accounts.size()); 338 | for (Account account : accounts) { 339 | System.assertEquals(account.Name, 'Bar'); 340 | } 341 | } 342 | 343 | @IsTest 344 | static void test_greater_than_predicate() { 345 | // given 346 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 347 | 348 | // when 349 | collection.gt(Account.AnnualRevenue, 100); 350 | 351 | // then 352 | List accounts = collection.process(-1); 353 | System.assertEquals(2, accounts.size()); 354 | for (Account account : accounts) { 355 | System.assert(account.AnnualRevenue > 100); 356 | } 357 | } 358 | 359 | @IsTest 360 | static void test_greater_or_equal_than_predicate() { 361 | // given 362 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 363 | 364 | // when 365 | collection.gte(Account.AnnualRevenue, 100); 366 | 367 | // then 368 | List accounts = collection.process(-1); 369 | System.assertEquals(4, accounts.size()); 370 | for (Account account : accounts) { 371 | System.assert(account.AnnualRevenue >= 100); 372 | } 373 | } 374 | 375 | @IsTest 376 | static void test_less_than_predicate() { 377 | // given 378 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 379 | 380 | // when 381 | collection.lt(Account.AnnualRevenue, 100); 382 | 383 | // then 384 | List accounts = collection.process(-1); 385 | System.assertEquals(2, accounts.size()); 386 | for (Account account : accounts) { 387 | System.assert(account.AnnualRevenue < 100); 388 | } 389 | } 390 | 391 | @IsTest 392 | static void test_less_or_equal_than_predicate() { 393 | // given 394 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 395 | 396 | // when 397 | collection.lte(Account.AnnualRevenue, 100); 398 | 399 | // then 400 | List accounts = collection.process(-1); 401 | System.assertEquals(4, accounts.size()); 402 | for (Account account : accounts) { 403 | System.assert(account.AnnualRevenue <= 100); 404 | } 405 | } 406 | 407 | @IsTest 408 | static void test_is_in_list_predicate() { 409 | // given 410 | List revenues = new List { 60.0, 100.0 }; 411 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 412 | 413 | // when 414 | collection.isIn(Account.AnnualRevenue, revenues); 415 | 416 | // then 417 | List accounts = collection.process(-1); 418 | System.assertEquals(4, accounts.size()); 419 | for (Account account : accounts) { 420 | System.assert(account.AnnualRevenue == 60.0 || account.AnnualRevenue == 100.0); 421 | } 422 | } 423 | 424 | @IsTest 425 | static void test_is_not_in_list_predicate() { 426 | // given 427 | List revenues = new List { 60.0, 100.0 }; 428 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 429 | 430 | // when 431 | collection.isNotIn(Account.AnnualRevenue, revenues); 432 | 433 | // then 434 | List accounts = collection.process(-1); 435 | System.assertEquals(2, accounts.size()); 436 | for (Account account : accounts) { 437 | System.assert(account.AnnualRevenue != 60.0 && account.AnnualRevenue != 100.0); 438 | } 439 | } 440 | 441 | @IsTest 442 | static void test_and_also_predicate() { 443 | // given 444 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 445 | 446 | // when 447 | collection.eq(Account.Id, '0011t00000L0aiN'); 448 | collection.andAlso(); 449 | collection.eq(Account.Name, 'Bar'); 450 | 451 | // then 452 | List accounts = collection.process(-1); 453 | System.assertEquals(1, accounts.size()); 454 | for (Account account : accounts) { 455 | System.assertEquals(account.Id, '0011t00000L0aiN'); 456 | System.assertEquals(account.Name, 'Bar'); 457 | } 458 | } 459 | 460 | @IsTest 461 | static void test_or_else_predicate() { 462 | // given 463 | Collection.PredicateCollection collection = new Collection.PredicateCollection(getList()); 464 | 465 | // when 466 | collection.eq(Account.AnnualRevenue, 60); 467 | collection.orElse(); 468 | collection.eq(Account.AnnualRevenue, 100); 469 | 470 | // then 471 | List accounts = collection.process(-1); 472 | System.assertEquals(4, accounts.size()); 473 | for (Account account : accounts) { 474 | System.assert(account.AnnualRevenue == 60 || account.AnnualRevenue == 100); 475 | } 476 | } 477 | 478 | /* 479 | * Test Data 480 | */ 481 | 482 | private static List getList() { 483 | return new List{ 484 | new Account(Id = null, Name = 'Foo', AnnualRevenue = 100, Website = 'http://test.eu'), 485 | new Account(Id = '0011t00000L0aiN', Name = 'Bar', AnnualRevenue = 100, BillingCity = 'Lublin'), 486 | new Account(Id = null, Name = 'Foo', AnnualRevenue = 60, BillingStreet = '3 Maja'), 487 | new Account(Id = '0011t00000L0amh', Name = 'Bar', AnnualRevenue = 60, Website = 'http://website.com'), 488 | new Account(Id = null, Name = 'Foo', AnnualRevenue = 150, BillingCity = 'Lublin'), 489 | new Account(Id = '0011t00000L0aiP', Name = 'Bar', AnnualRevenue = 150, BillingStreet = 'Narutowicza') 490 | }; 491 | } 492 | 493 | private static List getLargeList() { 494 | List accounts = new List(); 495 | for (Integer i = 0; i < 10000; i++) { 496 | accounts.add(new Account( 497 | Name = 'A ' + i, 498 | AnnualRevenue = i, 499 | Website = 'B ' + i, 500 | BillingStreet = 'C ' + 1, 501 | BillingCity = 'D ' + i 502 | )); 503 | } 504 | return accounts; 505 | } 506 | } -------------------------------------------------------------------------------- /src/classes/CollectionTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Collection 5 | CollectionTest 6 | ApexClass 7 | 8 | 45.0 9 | 10 | --------------------------------------------------------------------------------