From f4436d09bfc510b5a81659e6f29f2f2378c089cb Mon Sep 17 00:00:00 2001
From: Scott Leberknight <174812+sleberknight@users.noreply.github.com>
Date: Mon, 29 Jul 2024 12:37:04 -0400
Subject: [PATCH] Add new KiwiConstraintViolations utilities (#1171)
* Add asMap, asSingleValuedMap, asMultiValuedMap, and asMultimap methods
to convert a Set of ConstraintViolation into a JDK or Guava Multimap.
Overloads provide the ability to customize the translation from a
property Path to the map key.
* Add pathStringOf, a pure convenience method to eliminate boilerplate.
* Clean up a few minor grammatical errors in javadoc.
Closes #1169
Closes #1170
---
.../validation/KiwiConstraintViolations.java | 213 +++++++++-
.../KiwiConstraintViolationsTest.java | 365 +++++++++++++++++-
2 files changed, 567 insertions(+), 11 deletions(-)
diff --git a/src/main/java/org/kiwiproject/validation/KiwiConstraintViolations.java b/src/main/java/org/kiwiproject/validation/KiwiConstraintViolations.java
index 79a83198..118d217c 100644
--- a/src/main/java/org/kiwiproject/validation/KiwiConstraintViolations.java
+++ b/src/main/java/org/kiwiproject/validation/KiwiConstraintViolations.java
@@ -1,12 +1,19 @@
package org.kiwiproject.validation;
import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toUnmodifiableMap;
+import static java.util.stream.Collectors.toUnmodifiableSet;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull;
import static org.kiwiproject.collect.KiwiSets.isNotNullOrEmpty;
import static org.kiwiproject.collect.KiwiSets.isNullOrEmpty;
+import static org.kiwiproject.stream.KiwiMultimapCollectors.toLinkedHashMultimap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Path;
import lombok.experimental.UtilityClass;
@@ -14,6 +21,7 @@
import org.apache.commons.text.WordUtils;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -37,6 +45,207 @@
@UtilityClass
public class KiwiConstraintViolations {
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ *
+ * The map's values are the single {@link ConstraintViolation} associated with each property.
+ *
+ * WARNING:
+ * An {@link IllegalStateException} is thrown if there is more than one violation associated
+ * with any key. Therefore, this method should only be used if you are sure there can only
+ * be at most one violation per property. Otherwise, use either {@link #asMultiValuedMap(Set)}
+ * or {@link #asSingleValuedMap(Set)}.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are the violations
+ * @throws IllegalStateException if there is more than one violation associated with any key
+ * @see #asSingleValuedMap(Set)
+ * @see #asMultiValuedMap(Set)
+ */
+ public static Map> asMap(Set> violations) {
+ return asMap(violations, Path::toString);
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ * The property path is determined by the {@code pathTransformer}.
+ *
+ * The map's values are the single {@link ConstraintViolation} associated with each property.
+ *
+ * WARNING:
+ * An {@link IllegalStateException} is thrown if there is more than one violation associated
+ * with any key. Therefore, this method should only be used if you are sure there can only
+ * be at most one violation per property. Otherwise, use either {@link #asMultiValuedMap(Set)}
+ * or {@link #asSingleValuedMap(Set)}.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param pathTransformer function to convert a Path into a String
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are the violations
+ * @throws IllegalStateException if there is more than one violation associated with any key
+ * @see #asSingleValuedMap(Set)
+ * @see #asMultiValuedMap(Set)
+ */
+ public static Map> asMap(Set> violations,
+ Function pathTransformer) {
+ return violations.stream().collect(toUnmodifiableMap(
+ violation -> pathTransformer.apply(violation.getPropertyPath()),
+ violation -> violation));
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ *
+ * The map's values are the last {@link ConstraintViolation} associated with each property.
+ * The definition of "last" depends on the iteration order of the provided set of violations, which
+ * may be non-deterministic if the set does not have a well-defined traversal order.
+ *
+ * WARNING:
+ * If there is more than one violation associated with any key, the last violation, as
+ * determined by the set traversal order, becomes they key. If you need to retain all violations
+ * associated with each key, use {@link #asMultiValuedMap(Set)}.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are the violations
+ * @see #asMultiValuedMap(Set)
+ */
+ public static Map> asSingleValuedMap(Set> violations) {
+ return asSingleValuedMap(violations, Path::toString);
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ * The property path is determined by the {@code pathTransformer}.
+ *
+ * The map's values are the last {@link ConstraintViolation} associated with each property.
+ * The definition of "last" depends on the iteration order of the provided set of violations, which
+ * may be non-deterministic if the set does not have a well-defined traversal order.
+ *
+ * WARNING:
+ * If there is more than one violation associated with any key, the last violation, as
+ * determined by the set traversal order, becomes they key. If you need to retain all violations
+ * associated with each key, use {@link #asMultiValuedMap(Set)}.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param pathTransformer function to convert a Path into a String
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are the violations
+ * @see #asMultiValuedMap(Set)
+ */
+ public static Map> asSingleValuedMap(Set> violations,
+ Function pathTransformer) {
+ return violations.stream().collect(toUnmodifiableMap(
+ violation -> pathTransformer.apply(violation.getPropertyPath()),
+ violation -> violation,
+ (violation1, violation2) -> violation2));
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ *
+ * The map's values are the set of {@link ConstraintViolation} associated with each property.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are a Set containing
+ * violations for the corresponding property
+ */
+ public static Map>> asMultiValuedMap(Set> violations) {
+ return asMultiValuedMap(violations, Path::toString);
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable map keyed by the property path.
+ * The property path is determined by the {@code pathTransformer}.
+ *
+ * The map's values are unmodifiable sets of {@link ConstraintViolation} associated with each property.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param pathTransformer function to convert a Path into a String
+ * @param the type of the root bean that was validated
+ * @return a map whose keys are the property path of the violations, and values are a Set containing
+ * violations for the corresponding property
+ */
+ public static Map>> asMultiValuedMap(Set> violations,
+ Function pathTransformer) {
+ return violations.stream().collect(
+ collectingAndThen(
+ groupingBy(violation -> pathTransformer.apply(violation.getPropertyPath()), toUnmodifiableSet()),
+ Collections::unmodifiableMap));
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable {@link Multimap} keyed by the property path.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param the type of the root bean that was validated
+ * @return a {@link Multimap} whose keys are the property path of the violations, and values contain
+ * the violations for the corresponding property
+ * @implNote The returned value is a {@link com.google.common.collect.LinkedHashMultimap}; the iteration
+ * order of the values for each key is always the order in which the values were added, and there
+ * cannot be duplicate values for a key.
+ */
+ public static Multimap> asMultimap(Set> violations) {
+ return asMultimap(violations, Path::toString);
+ }
+
+ /**
+ * Convert the set of {@link ConstraintViolation} to an unmodifiable {@link Multimap} keyed by the property path.
+ *
+ * @param violations set of non-null but possibly empty violations
+ * @param pathTransformer function to convert a Path into a String
+ * @param the type of the root bean that was validated
+ * @return a {@link Multimap} whose keys are the property path of the violations, and values contain
+ * the violations for the corresponding property
+ * @implNote The returned value is a {@link com.google.common.collect.LinkedHashMultimap}; the iteration
+ * order of the values for each key is always the order in which the values were added, and there
+ * cannot be duplicate values for a key.
+ */
+ public static Multimap> asMultimap(Set> violations,
+ Function pathTransformer) {
+ return violations.stream()
+ .map(violation -> Maps.immutableEntry(pathTransformer.apply(violation.getPropertyPath()), violation))
+ .collect(collectingAndThen(toLinkedHashMultimap(), ImmutableMultimap::copyOf));
+ }
+
+ /**
+ * Convenience method to get the property path of the {@link ConstraintViolation} as a String.
+ *
+ * Please refer to the Implementation Note for details on the structure of the returned values
+ * and warnings about that structure.
+ *
+ * @param violation the constraint violation
+ * @param the type of the root bean that was validated
+ * @return the property path of the violation, as a String
+ * @implNote This uses {@link ConstraintViolation#getPropertyPath()} to obtain a {@link Path}
+ * and then calls {@link Path#toString()} to get the final value. Therefore, the issues on
+ * {@link Path#toString()} with regard to the structure of the return value apply here as well.
+ * However, in many years of usage, the implementation (in Hibernate Validator anyway) has
+ * always returned the same expected result, and is generally what you expect.
+ *
+ * The main exception is iterable types, such as Set, that don't have a consistent traversal
+ * order. For example, if you have a property named "nicknames" declared as
+ * {@code Set<@NotBlank String> nicknames}, the property path for violation errors
+ * look like {@code "nicknames[]."}.
+ *
+ * Maps look similar to Sets. For example, in the Hibernate Validator reference
+ * documentation, one example shows the property path of a constraint violation
+ * on a Map as {@code "fuelConsumption[HIGHWAY].