Skip to content

Commit c68afbb

Browse files
committed
Extract utility factories to ResultCollectors and extract index logic to a wrapper class
1 parent e378adb commit c68afbb

File tree

6 files changed

+805
-691
lines changed

6 files changed

+805
-691
lines changed

README.md

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,15 @@ repositories {
135135
| `getErrors()` | Get errors (empty if Ok) |
136136
| `withPrefix(String)` | Namespace errors for nested objects |
137137

138-
### ResultCollector
138+
### ResultCollectors
139139

140-
| Method | Description |
141-
|-----------------------------------------------------------|----------------------------------------------------------------------|
142-
| `toResultList(String)` / `toResultList(String, int)` | Returns `Result<List<T>>` (`Ok` if all valid, `Err` with all errors) |
143-
| `toList(String)` / `toList(String, int)` | Returns `List<T>` or throws with all accumulated errors |
144-
| `toPartitioned(String)` / `toPartitioned(String, int)` | Returns valid items + errors (partial success) |
140+
| Method | Description |
141+
|---------------------------------------------|----------------------------------------------------------------------|
142+
| `toResultList()` / `toResultList(int)` | Returns `Result<List<T>>` (`Ok` if all valid, `Err` with all errors) |
143+
| `toList()` / `toList(int)` | Returns `List<T>` or throws with all accumulated errors |
144+
| `toPartitioned()` / `toPartitioned(int)` | Returns valid items + errors (partial success) |
145+
| `indexed(Collector<...>)` | Wraps a collector to add `[0]`, `[1]`, etc. prefixes to errors |
146+
| `indexed(Collector<...>, String)` | Wraps a collector to add `prefix[0]`, `prefix[1]`, etc. to errors |
145147

146148
> **Note:** The optional `int` parameter provides an `initialCapacity` hint to avoid ArrayList resizing when the
147149
> collection size is known upfront, improving performance for large streams.
@@ -370,9 +372,9 @@ for (int i = 0; i < items.size(); i++) {
370372
Result<List<Item>> result = validation.asResult(items);
371373
```
372374

373-
#### Stream-Based Approach with ResultCollector
375+
#### Stream-Based Approach with ResultCollectors
374376

375-
The `ResultCollector` class provides three specialized collectors for validating streams. **All three collectors
377+
The `ResultCollectors` class provides three specialized collectors for validating streams. **All three collectors
376378
accumulate all validation errors** before returning/throwing - they do not fail fast, so you can get comprehensive
377379
feedback on all items in the collection.
378380

@@ -381,21 +383,20 @@ feedback on all items in the collection.
381383
Returns `Result<List<T>>` with all validation errors accumulated:
382384

383385
```java
384-
import io.github.raniagus.javalidation.ResultCollector;
386+
import io.github.raniagus.javalidation.ResultCollectors;
385387

386388
// Validate all items, collect ALL errors
387389
Result<List<User>> result = items.stream()
388-
.map(this::validateUser)
389-
.collect(ResultCollector.toResultList());
390+
.map(this::validateUser)
391+
.collect(ResultCollectors.toResultList());
390392

391393
// Handle result
392394
switch (result) {
393395
case Result.Ok(List<User> users) ->
394396
processUsers(users);
395397
case Result.Err(ValidationErrors errors) -> {
396-
// errors contain all validation failures with indexes:
397-
// "[0].email": ["Invalid format"]
398-
// "[2].age": ["Must be 18 or older"]
398+
// Errors contain all validation failures (without indexes by default)
399+
// Use indexed() wrapper to add automatic indexing
399400
logErrors(errors);
400401
}
401402
}
@@ -408,13 +409,14 @@ Returns `List<T>` directly, throwing `JavalidationException` with all accumulate
408409
```java
409410
try {
410411
List<User> users = items.stream()
411-
.map(this::validateUser)
412-
.collect(ResultCollector.toList());
412+
.map(this::validateUser)
413+
.collect(ResultCollectors.toList());
413414

414415
// All items valid
415416
processUsers(users);
416417
} catch (JavalidationException e) {
417-
// Contains ALL indexed errors: "[0].email", "[2].age", etc.
418+
// Contains ALL errors (without indexes by default)
419+
// Use indexed() wrapper to add automatic indexing
418420
logErrors(e.getErrors());
419421
}
420422
```
@@ -425,8 +427,8 @@ Returns `PartitionedResult<List<T>>` with both valid items and all errors:
425427

426428
```java
427429
var partitioned = items.stream()
428-
.map(this::validateUser)
429-
.collect(ResultCollector.toPartitioned());
430+
.map(this::validateUser)
431+
.collect(ResultCollectors.toPartitioned());
430432

431433
// Process valid items even if some failed
432434
List<User> validUsers = partitioned.value();
@@ -441,70 +443,92 @@ if (errors.isNotEmpty()) {
441443
processUsers(validUsers);
442444
```
443445

444-
**Error Indexing:**
446+
**Automatic Error Indexing with indexed():**
445447

446-
All three collectors automatically index errors by their position in the stream:
448+
By default, the collectors do not add index prefixes to errors. Use the `indexed()` wrapper to automatically prefix
449+
errors with `[0]`, `[1]`, etc. based on the item's position in the stream:
447450

448451
```java
449-
// Input stream with 3 items (indices 0, 1, 2)
450-
// Items at index 0 and 2 have validation errors
451-
452-
Result<List<Item>> result = stream.collect(ResultCollector.toResultList());
452+
// Without indexing (default)
453+
Result<List<Item>> result = stream.collect(ResultCollectors.toResultList());
454+
// Errors: "field": ["Error message"]
453455

456+
// With automatic indexing
457+
Result<List<Item>> result = stream.collect(
458+
ResultCollectors.indexed(ResultCollectors.toResultList())
459+
);
454460
// Errors are prefixed with "[index]":
455-
// "[0].field1": ["Error message"]
456-
// "[0].field2": ["Another error"]
461+
// "[0].field": ["Error message"]
457462
// "[2].price": ["Must be positive"]
458463
```
459464

460465
**Custom Prefix for Nested Collections:**
461466

462-
Each collector has an overloaded variant accepting a `prefix` parameter to namespace errors for nested structures:
467+
The `indexed()` wrapper accepts an optional `prefix` parameter to namespace errors for nested structures:
463468

464469
```java
465-
// Validate items in an order
470+
// Validate items in an order with custom prefix
466471
Result<List<Item>> items = order.getItems().stream()
467-
.map(this::validateItem)
468-
.collect(ResultCollector.toResultList("order.items"));
472+
.map(this::validateItem)
473+
.collect(ResultCollectors.indexed(
474+
ResultCollectors.toResultList(),
475+
"order.items"
476+
));
469477

470478
// Errors appear as: "order.items[0].price", "order.items[1].name", etc.
471479

472480
// Process valid items with prefix
473481
var partitioned = order.getLineItems().stream()
474-
.map(this::validateLineItem)
475-
.collect(ResultCollector.toPartitioned("lineItems"));
482+
.map(this::validateLineItem)
483+
.collect(ResultCollectors.indexed(
484+
ResultCollectors.toPartitioned(),
485+
"lineItems"
486+
));
476487

477488
// Errors: "lineItems[0].quantity", "lineItems[2].discount", etc.
478489

479490
// Throw on error with custom prefix
480491
try {
481492
List<Address> addresses = user.getAddresses().stream()
482-
.map(this::validateAddress)
483-
.collect(ResultCollector.toList("addresses"));
493+
.map(this::validateAddress)
494+
.collect(ResultCollectors.indexed(
495+
ResultCollectors.toList(),
496+
"addresses"
497+
));
484498
} catch (JavalidationException e) {
485499
// Errors: "addresses[0].street", "addresses[1].zipCode", etc.
486500
}
487501
```
488502

489503
**Performance Optimization with Size Hints:**
490504

491-
Each collector also accepts an optional `initialCapacity` parameter to avoid ArrayList resizing when the collection size is known upfront:
505+
All three collectors accept an optional `initialCapacity` parameter to avoid ArrayList resizing when the collection
506+
size is known upfront:
492507

493508
```java
494509
// When you know the collection size, provide a capacity hint
495510
List<User> users = items.stream()
496-
.map(this::validateUser)
497-
.collect(ResultCollector.toList("users", items.size()));
511+
.map(this::validateUser)
512+
.collect(ResultCollectors.indexed(
513+
ResultCollectors.toList(items.size()),
514+
"users"
515+
));
498516

499517
// For large streams, this avoids multiple ArrayList resizing operations
500518
Result<List<Product>> products = productStream
501-
.map(this::validateProduct)
502-
.collect(ResultCollector.toResultList("products", expectedSize));
519+
.map(this::validateProduct)
520+
.collect(ResultCollectors.indexed(
521+
ResultCollectors.toResultList(expectedSize),
522+
"products"
523+
));
503524

504525
// Works with all three collectors
505526
var partitioned = orders.stream()
506-
.map(this::validateOrder)
507-
.collect(ResultCollector.toPartitioned("orders", orders.size()));
527+
.map(this::validateOrder)
528+
.collect(ResultCollectors.indexed(
529+
ResultCollectors.toPartitioned(orders.size()),
530+
"orders"
531+
));
508532
```
509533

510534
**Choosing the Right Collector:**
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.github.raniagus.javalidation;
2+
3+
import java.util.Set;
4+
import java.util.function.BiConsumer;
5+
import java.util.function.BinaryOperator;
6+
import java.util.function.Function;
7+
import java.util.function.Supplier;
8+
import java.util.stream.Collector;
9+
import org.jspecify.annotations.Nullable;
10+
11+
public class IndexCollectorWrapper<T extends @Nullable Object, R, C extends ResultCollector<T, R>> implements Collector<Result<T>, C, R> {
12+
private final Collector<Result<T>, C, R> collector;
13+
private final String prefix;
14+
private int index = 0;
15+
16+
public IndexCollectorWrapper(Collector<Result<T>, C, R> collector) {
17+
this(collector, "");
18+
}
19+
20+
public IndexCollectorWrapper(Collector<Result<T>, C, R> collector, String prefix) {
21+
this.collector = collector;
22+
this.prefix = prefix;
23+
}
24+
25+
@Override
26+
public Supplier<C> supplier() {
27+
return () -> collector.supplier().get();
28+
}
29+
30+
@Override
31+
public BiConsumer<C, Result<T>> accumulator() {
32+
return (c, result) -> c.add(result.withPrefix(prefix, "[", index++, "]"));
33+
}
34+
35+
@Override
36+
public BinaryOperator<C> combiner() {
37+
return (c1, c2) -> {
38+
throw new UnsupportedOperationException();
39+
};
40+
}
41+
42+
@Override
43+
public Function<C, R> finisher() {
44+
return collector.finisher();
45+
}
46+
47+
@Override
48+
public Set<Characteristics> characteristics() {
49+
return Set.of();
50+
}
51+
}

0 commit comments

Comments
 (0)