diff --git a/src/main/java/it/aboutbits/springboot/testing/validation/core/BaseRuleBuilder.java b/src/main/java/it/aboutbits/springboot/testing/validation/core/BaseRuleBuilder.java index a20d073..2e2f831 100644 --- a/src/main/java/it/aboutbits/springboot/testing/validation/core/BaseRuleBuilder.java +++ b/src/main/java/it/aboutbits/springboot/testing/validation/core/BaseRuleBuilder.java @@ -14,6 +14,7 @@ import it.aboutbits.springboot.testing.validation.rule.PastRule; import it.aboutbits.springboot.testing.validation.rule.PositiveOrZeroRule; import it.aboutbits.springboot.testing.validation.rule.PositiveRule; +import it.aboutbits.springboot.testing.validation.rule.SizeRule; import it.aboutbits.springboot.testing.validation.rule.ValidBeanRule; import lombok.AccessLevel; import lombok.Getter; @@ -42,6 +43,7 @@ public abstract class BaseRuleBuilder> implements PositiveOrZeroRule, PositiveRule, NotValidatedRule, + SizeRule, ValidBeanRule { @Getter(AccessLevel.PACKAGE) private final List rules = new ArrayList<>(); diff --git a/src/main/java/it/aboutbits/springboot/testing/validation/rule/SizeRule.java b/src/main/java/it/aboutbits/springboot/testing/validation/rule/SizeRule.java new file mode 100644 index 0000000..6f7130b --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/testing/validation/rule/SizeRule.java @@ -0,0 +1,47 @@ +package it.aboutbits.springboot.testing.validation.rule; + +import it.aboutbits.springboot.testing.validation.core.BaseRuleBuilder; +import it.aboutbits.springboot.testing.validation.core.Rule; +import it.aboutbits.springboot.testing.validation.core.ValidationRulesData; +import it.aboutbits.springboot.testing.validation.source.SizeGreaterThanValueSource; +import it.aboutbits.springboot.testing.validation.source.SizeLessThanValueSource; +import lombok.AccessLevel; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +@SuppressWarnings("unchecked") +public interface SizeRule> extends ValidationRulesData { + default Builder size(@NonNull String property) { + return new Builder<>((V) this, property); + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + final class Builder> { + private final V parent; + private final String property; + + public V min(long minSize) { + parent.addRule( + new Rule(property, SizeLessThanValueSource.class, minSize) + ); + return parent; + } + + public V max(long maxSize) { + parent.addRule( + new Rule(property, SizeGreaterThanValueSource.class, maxSize) + ); + return parent; + } + + public V minMax(long minSize, long maxSize) { + parent.addRule( + new Rule(property, SizeLessThanValueSource.class, minSize) + ); + parent.addRule( + new Rule(property, SizeGreaterThanValueSource.class, maxSize) + ); + return parent; + } + } +} diff --git a/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeGreaterThanValueSource.java b/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeGreaterThanValueSource.java new file mode 100644 index 0000000..591dd00 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeGreaterThanValueSource.java @@ -0,0 +1,143 @@ +package it.aboutbits.springboot.testing.validation.source; + +import it.aboutbits.springboot.testing.validation.core.ValueSource; +import lombok.NonNull; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.LongFunction; +import java.util.stream.Stream; + +public class SizeGreaterThanValueSource implements ValueSource { + private static final Map, LongFunction>> TYPE_SOURCES = new HashMap<>(); + private static final Random RANDOM = new Random(); + + static { + TYPE_SOURCES.put(String.class, SizeGreaterThanValueSource::getStringStream); + TYPE_SOURCES.put(Collection.class, SizeGreaterThanValueSource::getArrayListStream); + TYPE_SOURCES.put(List.class, SizeGreaterThanValueSource::getArrayListStream); + TYPE_SOURCES.put(ArrayList.class, SizeGreaterThanValueSource::getArrayListStream); + TYPE_SOURCES.put(LinkedList.class, SizeGreaterThanValueSource::getLinkedListStream); + TYPE_SOURCES.put(Set.class, SizeGreaterThanValueSource::getHashSetStream); + TYPE_SOURCES.put(HashSet.class, SizeGreaterThanValueSource::getHashSetStream); + TYPE_SOURCES.put(TreeSet.class, SizeGreaterThanValueSource::getTreeSetStream); + } + + @SuppressWarnings("unused") + public static void registerType(Class type, LongFunction> source) { + TYPE_SOURCES.put(type, source); + } + + @Override + @SuppressWarnings("unchecked") + public Stream values(Class propertyClass, Object... args) { + var value = (Long.valueOf((long) args[0])); + + if (propertyClass.isArray()) { + return (Stream) arrayFunction(propertyClass, value); + } + + var sourceFunction = TYPE_SOURCES.get(propertyClass); + if (sourceFunction != null) { + return (Stream) sourceFunction.apply(value); + } + + throw new IllegalArgumentException("Property class not supported!"); + } + + private static Stream arrayFunction(Class arrayClass, long value) { + + return getTestSizes(value) + .stream() + .map(size -> generateArray( + Math.toIntExact(size), + arrayClass + )); + + } + + @NonNull + private static Stream getStringStream(long value) { + return getTestSizes(value) + .stream() + .map(size -> generateRandomString(Math.toIntExact(size))); + } + + @NonNull + private static Stream> getHashSetStream(long value) { + return getTestSizes(value) + .stream() + .map(size -> generateCollection( + Math.toIntExact(size), + new HashSet<>() + )); + } + + @NonNull + private static Stream> getTreeSetStream(long value) { + return getTestSizes(value) + .stream() + .map(size -> generateCollection( + Math.toIntExact(size), + new TreeSet<>() + )); + } + + @NonNull + private static Stream> getArrayListStream(long value) { + return getTestSizes(value) + .stream() + .map(size -> generateCollection( + Math.toIntExact(size), + new ArrayList<>() + )); + } + + @NonNull + private static Stream> getLinkedListStream(long value) { + return getTestSizes(value) + .stream() + .map(size -> generateCollection( + Math.toIntExact(size), + new LinkedList<>() + )); + } + + private static List getTestSizes(long value) { + var sizes = new ArrayList(); + + sizes.add(value + 1); + sizes.add(value + 2); + sizes.add(value + 6); + + return sizes; + } + + private static String generateRandomString(int length) { + // Include printable ASCII characters (32-126) which includes space and common characters + return RANDOM.ints(length, 32, 127) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + + private static Collection generateCollection(int size, Collection collection) { + for (int i = 0; i < size; i++) { + collection.add("dummy_" + i); // Add dummy elements, content doesn't matter + } + return collection; + } + + private static Object generateArray(int size, Class arrayClass) { + var componentType = arrayClass.getComponentType(); + return Array.newInstance(componentType, size); + } +} diff --git a/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeLessThanValueSource.java b/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeLessThanValueSource.java new file mode 100644 index 0000000..d934663 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/testing/validation/source/SizeLessThanValueSource.java @@ -0,0 +1,151 @@ +package it.aboutbits.springboot.testing.validation.source; + +import it.aboutbits.springboot.testing.validation.core.ValueSource; +import lombok.NonNull; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.LongFunction; +import java.util.stream.Stream; + +public class SizeLessThanValueSource implements ValueSource { + private static final Map, LongFunction>> TYPE_SOURCES = new HashMap<>(); + private static final Random RANDOM = new Random(); + + static { + TYPE_SOURCES.put(String.class, SizeLessThanValueSource::getStringStream); + TYPE_SOURCES.put(Collection.class, SizeLessThanValueSource::getArrayListStream); + TYPE_SOURCES.put(List.class, SizeLessThanValueSource::getArrayListStream); + TYPE_SOURCES.put(ArrayList.class, SizeLessThanValueSource::getArrayListStream); + TYPE_SOURCES.put(LinkedList.class, SizeLessThanValueSource::getLinkedListStream); + TYPE_SOURCES.put(Set.class, SizeLessThanValueSource::getHashSetStream); + TYPE_SOURCES.put(HashSet.class, SizeLessThanValueSource::getHashSetStream); + TYPE_SOURCES.put(TreeSet.class, SizeLessThanValueSource::getTreeSetStream); + } + + @SuppressWarnings("unused") + public static void registerType(Class type, LongFunction> source) { + TYPE_SOURCES.put(type, source); + } + + @Override + @SuppressWarnings("unchecked") + public Stream values(Class propertyClass, Object... args) { + var value = (Long.valueOf((long) args[0])); + + if (value == 0) { + return Stream.empty(); + } + + if (value < 0) { + throw new IllegalArgumentException("Value must be positive or zero. A size cannot be less than empty."); + } + + if (propertyClass.isArray()) { + return (Stream) arrayFunction(propertyClass, value); + } + + var sourceFunction = TYPE_SOURCES.get(propertyClass); + if (sourceFunction != null) { + return (Stream) sourceFunction.apply(value); + } + + throw new IllegalArgumentException("Property class not supported!"); + } + + private static Stream arrayFunction(Class arrayClass, long value) { + return Stream.concat( + Stream.of(generateArray(0, arrayClass)), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(size -> generateArray( + Math.toIntExact(size), + arrayClass + )) + ); + } + + @NonNull + private static Stream getStringStream(long value) { + return Stream.concat( + Stream.of(""), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(length -> generateRandomString(Math.toIntExact(length))) + ); + } + + @NonNull + private static Stream> getArrayListStream(long value) { + return Stream.concat( + Stream.of(new ArrayList<>()), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(size -> generateCollection( + Math.toIntExact(size), + new ArrayList<>() + )) + ); + } + + @NonNull + private static Stream> getLinkedListStream(long value) { + return Stream.concat( + Stream.of(new LinkedList<>()), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(size -> generateCollection( + Math.toIntExact(size), + new LinkedList<>() + )) + ); + } + + @NonNull + private static Stream> getHashSetStream(long value) { + return Stream.concat( + Stream.of(new HashSet<>()), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(size -> generateCollection( + Math.toIntExact(size), + new HashSet<>() + )) + ); + } + + @NonNull + private static Stream> getTreeSetStream(long value) { + return Stream.concat( + Stream.of(new TreeSet<>()), + Stream.iterate(1L, i -> i < value, i -> i + 1) + .map(size -> generateCollection( + Math.toIntExact(size), + new TreeSet<>() + )) + ); + } + + private static String generateRandomString(int length) { + // Include printable ASCII characters (32-126) which includes space and common characters + return RANDOM.ints(length, 32, 127) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + + private static Collection generateCollection(int size, Collection collection) { + for (int i = 0; i < size; i++) { + collection.add("dummy_" + i); // Add dummy elements, content doesn't matter + } + return collection; + } + + private static Object generateArray(int size, Class arrayClass) { + var componentType = arrayClass.getComponentType(); + return Array.newInstance(componentType, size); + } +} diff --git a/src/test/java/it/aboutbits/springboot/testing/validation/ValidationAssertTest.java b/src/test/java/it/aboutbits/springboot/testing/validation/ValidationAssertTest.java index a210553..763083b 100644 --- a/src/test/java/it/aboutbits/springboot/testing/validation/ValidationAssertTest.java +++ b/src/test/java/it/aboutbits/springboot/testing/validation/ValidationAssertTest.java @@ -14,6 +14,7 @@ import jakarta.validation.constraints.Past; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; import lombok.With; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -32,6 +33,10 @@ import java.time.YearMonth; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.UUID; import java.util.function.Consumer; import static it.aboutbits.springboot.testing.validation.ValidationAssertTest.TestValidationAssert.assertThatValidation; @@ -149,6 +154,21 @@ public record SomeValidParameter( // Valid @Valid Object validObject, + // Size - Min + @Size(min = 3) String minSizeString, + @Size(min = 3) Set minSizeCollection, + @Size(min = 3) UUID[] minSizeArray, + + // Size - Max + @Size(max = 10) String maxSizeString, + @Size(max = 10) Collection maxSizeCollection, + @Size(max = 10) String[] maxSizeArray, + + // Size - Min/Max + @Size(min = 2, max = 8) String minMaxSizeString, + @Size(min = 2, max = 8) Collection minMaxSizeCollection, + @Size(min = 2, max = 8) String[] minMaxSizeArray, + // Nullable @Nullable Object nullable, @@ -273,6 +293,21 @@ void testWithBeanValidation() { // Valid .validBean("validObject") + // Size - Min + .size("minSizeString").min(3) + .size("minSizeCollection").min(3) + .size("minSizeArray").min(3) + + // Size - Max + .size("maxSizeString").max(10) + .size("maxSizeCollection").max(10) + .size("maxSizeArray").max(10) + + // Size - Min/Max + .size("minMaxSizeString").minMax(2, 8) + .size("minMaxSizeCollection").minMax(2, 8) + .size("minMaxSizeArray").minMax(2, 8) + // Nullable .nullable("nullable") @@ -398,6 +433,21 @@ void invalidParameter_shouldFail() { // Valid .validBean("validObject") + // Size - Min + .size("minSizeString").min(3) + .size("minSizeCollection").min(3) + .size("minSizeArray").min(3) + + // Size - Max + .size("maxSizeString").max(10) + .size("maxSizeCollection").max(10) + .size("maxSizeArray").max(10) + + // Size - Min/Max + .size("minMaxSizeString").minMax(2, 8) + .size("minMaxSizeCollection").minMax(2, 8) + .size("minMaxSizeArray").minMax(2, 8) + // Nullable .nullable("nullable") @@ -517,9 +567,25 @@ void propertyMissingRule_shouldFail() { .past("pastYear") .past("pastYearMonth") .past("pastZonedDateTime") + // Valid .validBean("validObject") + // Size - Min + .size("minSizeString").min(3) + .size("minSizeCollection").min(3) + .size("minSizeArray").min(3) + + // Size - Max + .size("maxSizeString").max(10) + .size("maxSizeCollection").max(10) + .size("maxSizeArray").max(10) + + // Size - Min/Max + .size("minMaxSizeString").minMax(2, 8) + .size("minMaxSizeCollection").minMax(2, 8) + .size("minMaxSizeArray").minMax(2, 8) + // Nullable .nullable("nullable") @@ -772,6 +838,31 @@ private static SomeValidParameter getSomeValidParameter() { // Valid null, + // Size - Min + "validstring", // 11 characters, > 3 min + Set.of( + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID() + ), // 4 elements, > 3 min + new UUID[]{ + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID() + }, // 4 elements, > 3 min + + // Size - Max + "small", // 5 characters, < 10 max + List.of("a", "b", "c"), // 3 elements, < 10 max + new String[]{"a", "b", "c"}, // 3 elements, < 10 max + + // Size - Min/Max + "medium", // 6 characters, between 2-8 + List.of("a", "b", "c", "d", "e"), // 5 elements, between 2-8 + new String[]{"a", "b", "c", "d", "e"}, // 5 elements, between 2-8 + // Nullable null,