From b29d4587bf4a82d0fdbf15e5163b7a3333a33c76 Mon Sep 17 00:00:00 2001 From: Yusuf Alamu Musa Date: Mon, 30 Dec 2024 16:44:17 +0100 Subject: [PATCH 1/2] HV-2073 Add new OneOfValidator for CharSequence validation Description: Contributed a new OneOfValidator for the Hibernate Validator project. This validator checks if a given CharSequence matches one of the allowed values specified in the OneOf annotation. Features: - Validates if a CharSequence is one of the allowed values. - Supports case-sensitive and case-insensitive validation based on the ignoreCase flag. - Supports both Enum constants and manually provided allowed values. The validator is implemented with the following: - OneOfValidator class implementing ConstraintValidator. - Adds values from allowedValues() and enumClass() in the OneOf annotation for validation. - Provides methods to handle case-sensitive and case-insensitive validation. Tests have been added, and all tests pass. --- .../validator/constraints/OneOf.java | 39 ++++++ .../bv/OneOfValidator.java | 121 +++++++++++++++++ .../bv/OneOfValidatorTest.java | 128 ++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 engine/src/main/java/org/hibernate/validator/constraints/OneOf.java create mode 100644 engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java create mode 100644 engine/src/test/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidatorTest.java diff --git a/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java b/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java new file mode 100644 index 0000000000..fb5aef0168 --- /dev/null +++ b/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.validator.constraints; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import org.hibernate.validator.internal.constraintvalidators.bv.OneOfValidator; + +@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) +@Retention(RUNTIME) +@Documented +@Constraint(validatedBy = OneOfValidator.class) +public @interface OneOf { + + String[] allowedValues() default { }; + + Class> enumClass() default DefaultEnum.class; + + boolean ignoreCase() default false; + + String message() default "must be one of {allowedValues} or is an invalid enum"; + + Class[] groups() default { }; + + Class[] payload() default { }; + + enum DefaultEnum { + } +} diff --git a/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java b/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java new file mode 100644 index 0000000000..469816c5a4 --- /dev/null +++ b/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.validator.internal.constraintvalidators.bv; + + +import static java.util.Objects.nonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import org.hibernate.validator.constraints.OneOf; + +/** + * Validator that checks if a given {@link CharSequence} matches one of the allowed values specified + * in the {@link OneOf} annotation. + * + *

This class implements the {@link ConstraintValidator} interface to perform the validation logic + * based on the configuration in the {@link OneOf} annotation.

+ * + * @author Yusuf Àlàmù Musa + * @version 1.0 + */ +public class OneOfValidator implements ConstraintValidator { + + private final List acceptedValues = new ArrayList<>(); + private boolean ignoreCase; + + /** + * Initializes the validator with the values specified in the {@link OneOf} annotation. + * + *

This method sets the case sensitivity flag and adds the allowed values (either enum constants or specified values) + * to the list of accepted values for validation.

+ * + * @param constraintAnnotation the {@link OneOf} annotation containing the configuration for validation. + */ + @Override + public void initialize(final OneOf constraintAnnotation) { + ignoreCase = constraintAnnotation.ignoreCase(); + + // If an enum class is specified, initialize accepted values from the enum constants + if ( constraintAnnotation.enumClass() != null ) { + final Enum[] enumConstants = constraintAnnotation.enumClass().getEnumConstants(); + initializeAcceptedValues( enumConstants ); + } + + // If specific allowed values are provided, initialize accepted values from them + if ( constraintAnnotation.allowedValues() != null ) { + initializeAcceptedValues( constraintAnnotation.allowedValues() ); + } + } + + /** + * Validates the given value based on the accepted values. + * + *

If the value is not null, it checks whether the value matches any of the accepted values. + * If the value is null, it is considered valid.

+ * + * @param value the value to validate. + * @param context the validation context. + * @return {@code true} if the value is valid, {@code false} otherwise. + */ + @Override + public boolean isValid(final CharSequence value, final ConstraintValidatorContext context) { + if ( nonNull( value ) ) { + return checkIfValueTheSame( value.toString() ); + } + return true; + } + + /** + * Checks if the provided value matches any of the accepted values. + * + *

If {@code ignoreCase} is false, the comparison is case-sensitive. + * If {@code ignoreCase} is true, the value is compared in lowercase.

+ * + * @param value the value to check. + * @return {@code true} if the value matches an accepted value, {@code false} otherwise. + */ + protected boolean checkIfValueTheSame(final String value) { + if ( !ignoreCase ) { + return acceptedValues.contains( value ); + } + + for ( final String acceptedValue : acceptedValues ) { + if ( acceptedValue.toLowerCase( Locale.ROOT ).equals( value.toLowerCase( Locale.ROOT ) ) ) { + return true; + } + } + + return false; + } + + /** + * Initializes and adds the names of the provided enum constants to the accepted values list. + * + * @param enumConstants the enum constants to be added, ignored if null. + */ + protected void initializeAcceptedValues(final Enum... enumConstants) { + if ( nonNull( enumConstants ) ) { + acceptedValues.addAll( Stream.of( enumConstants ).map( Enum::name ).toList() ); + } + } + + /** + * Initializes and adds the provided values to the accepted values list after trimming them. + * + * @param values the values to be added, ignored if null. + */ + protected void initializeAcceptedValues(final String... values) { + if ( nonNull( values ) ) { + acceptedValues.addAll( Stream.of( values ).map( String::trim ).toList() ); + } + } +} diff --git a/engine/src/test/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidatorTest.java b/engine/src/test/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidatorTest.java new file mode 100644 index 0000000000..b867b73b20 --- /dev/null +++ b/engine/src/test/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidatorTest.java @@ -0,0 +1,128 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.validator.internal.constraintvalidators.bv; + +import static org.easymock.EasyMock.mock; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; + +import org.hibernate.validator.constraints.OneOf; +import org.hibernate.validator.testutil.TestForIssue; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OneOfValidatorTest { + + private ConstraintValidatorContext context; + private OneOfValidator validator; + + @BeforeMethod + public void setUp() { + validator = new OneOfValidator(); + context = mock( ConstraintValidatorContext.class ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidNullValueShouldReturnTrue() { + assertTrue( validator.isValid( null, context ), "Null value should be considered valid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidCaseSensitiveMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, new String[] { "Value1", "Value2" }, null ); + + validator.initialize( annotation ); + + assertTrue( validator.isValid( "Value1", context ), "Exact case-sensitive match should return true." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidCaseSensitiveMismatchShouldReturnFalse() { + OneOf annotation = createOneOf( false, new String[] { "Value1", "Value2" }, null ); + + validator.initialize( annotation ); + + assertFalse( validator.isValid( "value1", context ), "Case-sensitive mismatch should return false." ); + } + + @Test + public void testIsValidIgnoreCaseMatchShouldReturnTrue() { + OneOf annotation = createOneOf( true, new String[] { "Value1", "Value2" }, null ); + + validator.initialize( annotation ); + + assertTrue( validator.isValid( "value1", context ), "Ignore-case match should return true." ); + } + + @Test + public void testIsValidIgnoreCaseMismatchShouldReturnFalse() { + OneOf annotation = createOneOf( true, new String[] { "Value1", "Value2" }, null ); + + validator.initialize( annotation ); + + assertFalse( validator.isValid( "invalid", context ), "Ignore-case mismatch should return false." ); + } + + @Test + public void testInitializeEnumClassShouldAcceptEnumValues() { + OneOf annotation = createOneOf( false, new String[] { }, TestEnum.class ); + + validator.initialize( annotation ); + + assertTrue( validator.isValid( "ONE", context ), "Enum constant 'ONE' should be valid." ); + assertFalse( validator.isValid( "TWO", context ), "'TWO' should not be valid as it's not in the enum." ); + } + + private OneOf createOneOf(boolean ignoreCase, String[] allowedValues, Class> enumClass) { + return new OneOf() { + @Override + public String[] allowedValues() { + return allowedValues; + } + + @Override + public Class> enumClass() { + return enumClass; + } + + @Override + public boolean ignoreCase() { + return ignoreCase; + } + + @Override + public String message() { + return ""; + } + + @Override + public Class[] groups() { + return new Class[0]; + } + + @Override + public Class[] payload() { + return new Class[0]; + } + + @Override + public Class annotationType() { + return OneOf.class; + } + }; + } + + private enum TestEnum { + ONE, THREE + } + +} From 9f3089490b1646a51d40d1a775acf89c782f9085 Mon Sep 17 00:00:00 2001 From: Yusuf Alamu Musa Date: Fri, 17 Jan 2025 17:13:30 +0100 Subject: [PATCH 2/2] HV-2073 1. Added OneOf annotation and constraint details in BuiltinConstraint, ConstraintHelper, TypeNames. 2. Added OneOfDef and its unit test. 3. Added documentation in ch02.asciidoc. 4. Updated OneOf constraints to accept different array data types like int, long, float and double. 5. Updated OneOfValidator to map to Object instead of CharSequence so that it can be used on fields with Integer, Long, Float, Double and String types. 6. Added detailed unit tests for OneOf in OneOfValidatorTest. 7. Added validation messages for OneOf message code or key in ValidationMessages_[LOCALE].properties files. --- .../ap/internal/util/ConstraintHelper.java | 1 + .../validator/ap/internal/util/TypeNames.java | 1 + documentation/src/main/asciidoc/ch02.asciidoc | 4 + .../validator/cfg/defs/OneOfDef.java | 45 ++ .../validator/constraints/OneOf.java | 56 +- .../{bv => hv}/OneOfValidator.java | 82 ++- .../metadata/core/BuiltinConstraint.java | 3 +- .../metadata/core/ConstraintHelper.java | 6 + .../validator/ValidationMessages.properties | 1 + .../ValidationMessages_ar.properties | 1 + .../ValidationMessages_az.properties | 1 + .../ValidationMessages_cs.properties | 1 + .../ValidationMessages_da.properties | 1 + .../ValidationMessages_de.properties | 1 + .../ValidationMessages_es.properties | 1 + .../ValidationMessages_fa.properties | 1 + .../ValidationMessages_fr.properties | 1 + .../ValidationMessages_hu.properties | 1 + .../ValidationMessages_it.properties | 1 + .../ValidationMessages_ja.properties | 1 + .../ValidationMessages_ko.properties | 1 + .../ValidationMessages_mn_MN.properties | 2 + .../ValidationMessages_nl.properties | 3 + .../ValidationMessages_pl.properties | 1 + .../ValidationMessages_pt.properties | 1 + .../ValidationMessages_pt_BR.properties | 4 +- .../ValidationMessages_pt_PT.properties | 4 +- .../ValidationMessages_ro.properties | 1 + .../ValidationMessages_ru.properties | 1 + .../ValidationMessages_sk.properties | 1 + .../ValidationMessages_tr.properties | 1 + .../ValidationMessages_uk.properties | 1 + .../ValidationMessages_zh.properties | 1 + .../ValidationMessages_zh_CN.properties | 1 + .../ValidationMessages_zh_TW.properties | 1 + .../bv/OneOfValidatorTest.java | 128 ----- .../hv/OneOfValidatorTest.java | 491 ++++++++++++++++++ 37 files changed, 709 insertions(+), 144 deletions(-) create mode 100644 engine/src/main/java/org/hibernate/validator/cfg/defs/OneOfDef.java rename engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/{bv => hv}/OneOfValidator.java (51%) delete mode 100644 engine/src/test/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidatorTest.java create mode 100644 engine/src/test/java/org/hibernate/validator/test/internal/constraintvalidators/hv/OneOfValidatorTest.java diff --git a/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/ConstraintHelper.java b/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/ConstraintHelper.java index c7b477ae94..081e86e55b 100644 --- a/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/ConstraintHelper.java +++ b/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/ConstraintHelper.java @@ -307,6 +307,7 @@ public ConstraintHelper(Types typeUtils, AnnotationApiHelper annotationApiHelper registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.NOT_BLANK, CharSequence.class ); registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.NOT_EMPTY, TYPES_SUPPORTED_BY_SIZE_AND_NOT_EMPTY_ANNOTATIONS ); registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.NORMALIZED, CharSequence.class ); + registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.ONE_OF, Object.class ); registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.SCRIPT_ASSERT, Object.class ); registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.UNIQUE_ELEMENTS, Collection.class ); registerAllowedTypesForBuiltInConstraint( HibernateValidatorTypes.URL, CharSequence.class ); diff --git a/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/TypeNames.java b/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/TypeNames.java index 30d7c2dd4c..2007597bab 100644 --- a/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/TypeNames.java +++ b/annotation-processor/src/main/java/org/hibernate/validator/ap/internal/util/TypeNames.java @@ -85,6 +85,7 @@ public static class HibernateValidatorTypes { public static final String UUID = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".UUID"; public static final String NOT_BLANK = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".NotBlank"; public static final String NOT_EMPTY = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".NotEmpty"; + public static final String ONE_OF = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".OneOf"; public static final String SCRIPT_ASSERT = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".ScriptAssert"; public static final String UNIQUE_ELEMENTS = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".UniqueElements"; public static final String URL = ORG_HIBERNATE_VALIDATOR_CONSTRAINTS + ".URL"; diff --git a/documentation/src/main/asciidoc/ch02.asciidoc b/documentation/src/main/asciidoc/ch02.asciidoc index 24f0987056..0ac6e8c8c5 100644 --- a/documentation/src/main/asciidoc/ch02.asciidoc +++ b/documentation/src/main/asciidoc/ch02.asciidoc @@ -711,6 +711,10 @@ With one exception also these constraints apply to the field/property level, onl Supported data types::: `CharSequence` Hibernate metadata impact::: None +`@OneOf`:: Checks that the annotated character sequence or object is one of the allowed values. The allowed values are defined using `allowedValues`, `allowedIntegers`, `allowedLongs`, `allowedFloats`, `allowedDoubles` or by specifying an `enumClass`. The validation occurs after converting the annotated object to a `String`. + Supported data types::: `CharSequence`, `Integer`, `Long`, `Float`, `Double`, `Enum` + Hibernate metadata impact::: None + `@Range(min=, max=)`:: Checks whether the annotated value lies between (inclusive) the specified minimum and maximum Supported data types::: `BigDecimal`, `BigInteger`, `CharSequence`, `byte`, `short`, `int`, `long` and the respective wrappers of the primitive types Hibernate metadata impact::: None diff --git a/engine/src/main/java/org/hibernate/validator/cfg/defs/OneOfDef.java b/engine/src/main/java/org/hibernate/validator/cfg/defs/OneOfDef.java new file mode 100644 index 0000000000..e247e4eacb --- /dev/null +++ b/engine/src/main/java/org/hibernate/validator/cfg/defs/OneOfDef.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.validator.cfg.defs; + +import org.hibernate.validator.cfg.ConstraintDef; +import org.hibernate.validator.constraints.OneOf; + +public class OneOfDef extends ConstraintDef { + + public OneOfDef() { + super( OneOf.class ); + } + + public OneOfDef enumClass(Class> enumClass) { + addParameter( "enumClass", enumClass ); + return this; + } + + public OneOfDef allowedIntegers(int[] allowedIntegers) { + addParameter( "allowedIntegers", allowedIntegers ); + return this; + } + + public OneOfDef allowedLongs(long[] allowedLongs) { + addParameter( "allowedLongs", allowedLongs ); + return this; + } + + public OneOfDef allowedFloats(float[] allowedFloats) { + addParameter( "allowedFloats", allowedFloats ); + return this; + } + + public OneOfDef allowedDoubles(double[] allowedDoubles) { + addParameter( "allowedDoubles", allowedDoubles ); + return this; + } + + public OneOfDef allowedValues(String[] allowedValues) { + addParameter( "allowedValues", allowedValues ); + return this; + } +} diff --git a/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java b/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java index fb5aef0168..da9d8000dd 100644 --- a/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java +++ b/engine/src/main/java/org/hibernate/validator/constraints/OneOf.java @@ -14,25 +14,61 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import org.hibernate.validator.internal.constraintvalidators.bv.OneOfValidator; - +/** + * Annotation to specify that a field or parameter must be one of a defined set of values. + * This can be enforced using string, integer, float, or double values, or by restricting the values to those + * within an enum type. + * + *

For string values, the annotation supports case-insensitive matching.

+ * + *

Usage example:

+ *
{@code
+ * @OneOf(allowedValues = {"ACTIVE", "INACTIVE"}, ignoreCase = true)
+ * private String status;
+ * }
+ * + *

The message attribute provides a customizable error message when validation fails. The groups and payload + * attributes allow the validation to be applied to specific validation groups or custom payload types.

+ * + *

You can use the following fields in the annotation:

+ *
    + *
  • allowedIntegers: A set of allowed integer values.
  • + *
  • allowedLongs: A set of allowed long values.
  • + *
  • allowedFloats: A set of allowed float values.
  • + *
  • allowedDoubles: A set of allowed double values.
  • + *
  • allowedValues: A set of allowed string values.
  • + *
  • enumClass: The class of the enum type, if applicable.
  • + *
  • ignoreCase: If true, string matching is case-insensitive.
  • + *
+ * + * @author Yusuf Álàmù + * @since 9.0.0 + */ +@Documented +@Constraint(validatedBy = { }) @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME) -@Documented -@Constraint(validatedBy = OneOfValidator.class) public @interface OneOf { - String[] allowedValues() default { }; + String message() default "{org.hibernate.validator.constraints.OneOf.message}"; - Class> enumClass() default DefaultEnum.class; + Class[] groups() default { }; - boolean ignoreCase() default false; + Class[] payload() default { }; - String message() default "must be one of {allowedValues} or is an invalid enum"; + int[] allowedIntegers() default { }; - Class[] groups() default { }; + long[] allowedLongs() default { }; - Class[] payload() default { }; + float[] allowedFloats() default { }; + + double[] allowedDoubles() default { }; + + String[] allowedValues() default { }; + + Class> enumClass() default DefaultEnum.class; + + boolean ignoreCase() default false; enum DefaultEnum { } diff --git a/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java b/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/hv/OneOfValidator.java similarity index 51% rename from engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java rename to engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/hv/OneOfValidator.java index 469816c5a4..00ff9096da 100644 --- a/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/bv/OneOfValidator.java +++ b/engine/src/main/java/org/hibernate/validator/internal/constraintvalidators/hv/OneOfValidator.java @@ -2,12 +2,13 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright Red Hat Inc. and Hibernate Authors */ -package org.hibernate.validator.internal.constraintvalidators.bv; +package org.hibernate.validator.internal.constraintvalidators.hv; import static java.util.Objects.nonNull; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.stream.Stream; @@ -27,7 +28,7 @@ * @author Yusuf Àlàmù Musa * @version 1.0 */ -public class OneOfValidator implements ConstraintValidator { +public class OneOfValidator implements ConstraintValidator { private final List acceptedValues = new ArrayList<>(); private boolean ignoreCase; @@ -54,6 +55,30 @@ public void initialize(final OneOf constraintAnnotation) { if ( constraintAnnotation.allowedValues() != null ) { initializeAcceptedValues( constraintAnnotation.allowedValues() ); } + + // If specific allowed values are provided, initialize accepted values from them + if ( constraintAnnotation.allowedIntegers() != null ) { + final String[] acceptedValues = convertIntToStringArray( constraintAnnotation.allowedIntegers() ); + initializeAcceptedValues( acceptedValues ); + } + + // If specific allowed values are provided, initialize accepted values from them + if ( constraintAnnotation.allowedLongs() != null ) { + final String[] acceptedValues = convertLongToStringArray( constraintAnnotation.allowedLongs() ); + initializeAcceptedValues( acceptedValues ); + } + + // If specific allowed values are provided, initialize accepted values from them + if ( constraintAnnotation.allowedFloats() != null ) { + final String[] acceptedValues = convertFloatToStringArray( constraintAnnotation.allowedFloats() ); + initializeAcceptedValues( acceptedValues ); + } + + // If specific allowed values are provided, initialize accepted values from them + if ( constraintAnnotation.allowedDoubles() != null ) { + final String[] acceptedValues = convertDoubleToStringArray( constraintAnnotation.allowedDoubles() ); + initializeAcceptedValues( acceptedValues ); + } } /** @@ -67,7 +92,7 @@ public void initialize(final OneOf constraintAnnotation) { * @return {@code true} if the value is valid, {@code false} otherwise. */ @Override - public boolean isValid(final CharSequence value, final ConstraintValidatorContext context) { + public boolean isValid(final Object value, final ConstraintValidatorContext context) { if ( nonNull( value ) ) { return checkIfValueTheSame( value.toString() ); } @@ -118,4 +143,55 @@ protected void initializeAcceptedValues(final String... values) { acceptedValues.addAll( Stream.of( values ).map( String::trim ).toList() ); } } + + /** + * Converts an array of integers to an array of their corresponding string representations. + * + * @param allowedIntegers The array of integers to be converted. + * @return A new array of strings, where each element is the string representation of the corresponding integer from the input array. + */ + private static String[] convertIntToStringArray(final int[] allowedIntegers) { + return Arrays.stream( allowedIntegers ) + .mapToObj( String::valueOf ) // Convert each int to String + .toArray( String[]::new ); + } + + /** + * Converts an array of longs to an array of their corresponding string representations. + * + * @param allowedLongs The array of longs to be converted. + * @return A new array of strings, where each element is the string representation of the corresponding long from the input array. + */ + private static String[] convertLongToStringArray(final long[] allowedLongs) { + return Arrays.stream( allowedLongs ) + .mapToObj( String::valueOf ) // Convert each long to String + .toArray( String[]::new ); + } + + /** + * Converts an array of doubles to an array of their corresponding string representations. + * + * @param allowedDoubles The array of doubles to be converted. + * @return A new array of strings, where each element is the string representation of the corresponding double from the input array. + */ + private static String[] convertDoubleToStringArray(final double[] allowedDoubles) { + return Arrays.stream( allowedDoubles ) + .mapToObj( String::valueOf ) // Convert each double to String + .toArray( String[]::new ); + } + + /** + * Converts an array of floats to an array of their corresponding string representations. + * + * @param allowedFloats The array of floats to be converted. + * @return A new array of strings, where each element is the string representation of the corresponding float from the input array. + */ + private static String[] convertFloatToStringArray(final float[] allowedFloats) { + final String[] acceptedValues = new String[allowedFloats.length]; + for ( int i = 0; i < allowedFloats.length; i++ ) { + acceptedValues[i] = String.valueOf( allowedFloats[i] ); // Convert each float to String + } + return acceptedValues; + } + } diff --git a/engine/src/main/java/org/hibernate/validator/internal/metadata/core/BuiltinConstraint.java b/engine/src/main/java/org/hibernate/validator/internal/metadata/core/BuiltinConstraint.java index e2b4b17128..d3c681c7c5 100644 --- a/engine/src/main/java/org/hibernate/validator/internal/metadata/core/BuiltinConstraint.java +++ b/engine/src/main/java/org/hibernate/validator/internal/metadata/core/BuiltinConstraint.java @@ -83,7 +83,8 @@ enum BuiltinConstraint { ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_TIME_DURATION_MAX( "org.hibernate.validator.constraints.time.DurationMax" ), ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_TIME_DURATION_MIN( "org.hibernate.validator.constraints.time.DurationMin" ), ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_UUID( "org.hibernate.validator.constraints.UUID" ), - ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_BITCOIN_ADDRESS( "org.hibernate.validator.constraints.BitcoinAddress" ); + ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_BITCOIN_ADDRESS( "org.hibernate.validator.constraints.BitcoinAddress" ), + ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_ONE_OF( "org.hibernate.validator.constraints.OneOf" ); private static final Map> CONSTRAINT_MAPPING; diff --git a/engine/src/main/java/org/hibernate/validator/internal/metadata/core/ConstraintHelper.java b/engine/src/main/java/org/hibernate/validator/internal/metadata/core/ConstraintHelper.java index 67c59f17f9..014fa3a813 100644 --- a/engine/src/main/java/org/hibernate/validator/internal/metadata/core/ConstraintHelper.java +++ b/engine/src/main/java/org/hibernate/validator/internal/metadata/core/ConstraintHelper.java @@ -41,6 +41,7 @@ import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_MOD10_CHECK; import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_MOD11_CHECK; import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_NORMALIZED; +import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_ONE_OF; import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_PARAMETER_SCRIPT_ASSERT; import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_PL_NIP; import static org.hibernate.validator.internal.metadata.core.BuiltinConstraint.ORG_HIBERNATE_VALIDATOR_CONSTRAINTS_PL_PESEL; @@ -112,6 +113,7 @@ import org.hibernate.validator.constraints.Mod10Check; import org.hibernate.validator.constraints.Mod11Check; import org.hibernate.validator.constraints.Normalized; +import org.hibernate.validator.constraints.OneOf; import org.hibernate.validator.constraints.ParameterScriptAssert; import org.hibernate.validator.constraints.Range; import org.hibernate.validator.constraints.ScriptAssert; @@ -332,6 +334,7 @@ import org.hibernate.validator.internal.constraintvalidators.hv.Mod10CheckValidator; import org.hibernate.validator.internal.constraintvalidators.hv.Mod11CheckValidator; import org.hibernate.validator.internal.constraintvalidators.hv.NormalizedValidator; +import org.hibernate.validator.internal.constraintvalidators.hv.OneOfValidator; import org.hibernate.validator.internal.constraintvalidators.hv.ParameterScriptAssertValidator; import org.hibernate.validator.internal.constraintvalidators.hv.ScriptAssertValidator; import org.hibernate.validator.internal.constraintvalidators.hv.URLValidator; @@ -814,6 +817,9 @@ protected Map, List> enumClass) { - return new OneOf() { - @Override - public String[] allowedValues() { - return allowedValues; - } - - @Override - public Class> enumClass() { - return enumClass; - } - - @Override - public boolean ignoreCase() { - return ignoreCase; - } - - @Override - public String message() { - return ""; - } - - @Override - public Class[] groups() { - return new Class[0]; - } - - @Override - public Class[] payload() { - return new Class[0]; - } - - @Override - public Class annotationType() { - return OneOf.class; - } - }; - } - - private enum TestEnum { - ONE, THREE - } - -} diff --git a/engine/src/test/java/org/hibernate/validator/test/internal/constraintvalidators/hv/OneOfValidatorTest.java b/engine/src/test/java/org/hibernate/validator/test/internal/constraintvalidators/hv/OneOfValidatorTest.java new file mode 100644 index 0000000000..f01b191550 --- /dev/null +++ b/engine/src/test/java/org/hibernate/validator/test/internal/constraintvalidators/hv/OneOfValidatorTest.java @@ -0,0 +1,491 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.validator.test.internal.constraintvalidators.hv; + + +import static org.easymock.EasyMock.mock; +import static org.hibernate.validator.testutil.ConstraintViolationAssert.assertNoViolations; +import static org.hibernate.validator.testutil.ConstraintViolationAssert.assertThat; +import static org.hibernate.validator.testutil.ConstraintViolationAssert.violationOf; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import java.util.Set; + +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Payload; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import org.hibernate.validator.HibernateValidator; +import org.hibernate.validator.HibernateValidatorConfiguration; +import org.hibernate.validator.cfg.ConstraintMapping; +import org.hibernate.validator.cfg.defs.OneOfDef; +import org.hibernate.validator.constraints.OneOf; +import org.hibernate.validator.internal.constraintvalidators.hv.OneOfValidator; +import org.hibernate.validator.testutil.TestForIssue; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class OneOfValidatorTest { + + private ConstraintValidatorContext context; + private OneOfValidator validator; + + @BeforeMethod + public void setUp() { + validator = new OneOfValidator(); + context = mock( ConstraintValidatorContext.class ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidNullValueShouldReturnTrue() { + assertTrue( validator.isValid( null, context ), "Null value should be considered valid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidCaseSensitiveMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, new String[] { "Value1", "Value2" }, null, null, null, null, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "Value1", context ), "Exact case-sensitive match should return true." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidCaseSensitiveMismatchShouldReturnFalse() { + OneOf annotation = createOneOf( false, new String[] { "Value1", "Value2" }, null, null, null, null, null ); + validator.initialize( annotation ); + assertFalse( validator.isValid( "value1", context ), "Case-sensitive mismatch should return false." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidIgnoreCaseMatchShouldReturnTrue() { + OneOf annotation = createOneOf( true, new String[] { "Value1", "Value2" }, null, null, null, null, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "value1", context ), "Ignore-case match should return true." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testIsValidIgnoreCaseMismatchShouldReturnFalse() { + OneOf annotation = createOneOf( true, new String[] { "Value1", "Value2" }, null, null, null, null, null ); + validator.initialize( annotation ); + assertFalse( validator.isValid( "invalid", context ), "Ignore-case mismatch should return false." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testInitializeEnumClassShouldAcceptEnumValues() { + OneOf annotation = createOneOf( false, new String[] { }, null, null, null, null, TestEnum.class ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "ONE", context ), "Enum constant 'ONE' should be valid." ); + assertFalse( validator.isValid( "FOUR", context ), "'FOUR' should not be valid as it's not in the enum." ); + } + + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testAllowedIntegersMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, null, new int[] { 1, 2, 3 }, null, null, null, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "1", context ), "Integer value '1' should be valid." ); + assertTrue( validator.isValid( 1, context ), "Integer value 1 should be valid." ); + assertFalse( validator.isValid( 4, context ), "Integer value '4' should be invalid." ); + assertFalse( validator.isValid( "4", context ), "Integer value '4' should be invalid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testAllowedLongsMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, null, null, new long[] { 100L, 200L, 300L }, null, null, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "100", context ), "Long value '100' should be valid." ); + assertTrue( validator.isValid( 100L, context ), "Long value 100L should be valid." ); + assertFalse( validator.isValid( 400L, context ), "Long value '400' should be invalid." ); + assertFalse( validator.isValid( "400", context ), "Long value '400' should be invalid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testAllowedFloatsMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, null, null, null, new float[] { 1.1f, 2.2f, 3.3f }, null, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "1.1", context ), "Float value '1.1' should be valid." ); + assertTrue( validator.isValid( 1.1f, context ), "Float value 1.1f should be valid." ); + assertFalse( validator.isValid( 4.4f, context ), "Float value '4.4' should be invalid." ); + assertFalse( validator.isValid( "4.4", context ), "Float value '4.4' should be invalid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testAllowedDoublesMatchShouldReturnTrue() { + OneOf annotation = createOneOf( false, null, null, null, null, new double[] { 1.11, 2.22, 3.33 }, null ); + validator.initialize( annotation ); + assertTrue( validator.isValid( "1.11", context ), "Double value '1.11' should be valid." ); + assertTrue( validator.isValid( 1.11, context ), "Double value 1.11 should be valid." ); + assertFalse( validator.isValid( 4.44, context ), "Double value '4.44' should be invalid." ); + assertFalse( validator.isValid( "4.44", context ), "Double value '4.44' should be invalid." ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testValidDto() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + + OneOfDto dto = new OneOfDto(); + dto.setOneOfString( "value1" ); + dto.setOneOfInteger( 100 ); + dto.setOneOfLong( 1000L ); + dto.setOneOfDouble( 1.5 ); + dto.setOneOfFloat( 0.1f ); + dto.setOneOfEnum( TestEnum.ONE ); + dto.setOneOfIgnoreCaseString( "enabled" ); + + Set> violations = validator.validate( dto ); + assertNoViolations( violations, "The DTO should be valid" ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testInvalidDto() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + + OneOfDto dto = new OneOfDto(); + dto.setOneOfString( "invalid" ); + dto.setOneOfInteger( 400 ); + dto.setOneOfLong( 4000L ); + dto.setOneOfDouble( 4.5 ); + dto.setOneOfFloat( 0.4f ); + dto.setOneOfEnum( TestEnum.valueOf( "TWO" ) ); + dto.setOneOfIgnoreCaseString( "invalid" ); + + Set> violations = validator.validate( dto ); + assertFalse( violations.isEmpty(), "The DTO should be invalid" ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testProgrammaticDefinitionWithString() throws Exception { + HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class ); + ConstraintMapping mapping = config.createConstraintMapping(); + mapping.type( MyClassString.class ) + .field( "myValue" ) + .constraint( new OneOfDef().allowedValues( new String[] { "value1", "value2" } ) ); + + config.addMapping( mapping ); + Validator validator = config.buildValidatorFactory().getValidator(); + + Set> constraintViolations = validator.validate( new MyClassString( "value1" ) ); + assertNoViolations( constraintViolations ); + + constraintViolations = validator.validate( new MyClassString( "invalid" ) ); + assertThat( constraintViolations ).containsOnlyViolations( + violationOf( OneOf.class ) + ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testProgrammaticDefinitionWithInt() throws Exception { + HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class ); + ConstraintMapping mapping = config.createConstraintMapping(); + mapping.type( MyClassInt.class ) + .field( "myValue" ) + .constraint( new OneOfDef().allowedIntegers( new int[] { 1, 2, 3 } ) ); + + config.addMapping( mapping ); + Validator validator = config.buildValidatorFactory().getValidator(); + + Set> constraintViolations = validator.validate( new MyClassInt( 1 ) ); + assertNoViolations( constraintViolations ); + + constraintViolations = validator.validate( new MyClassInt( 4 ) ); + assertThat( constraintViolations ).containsOnlyViolations( + violationOf( OneOf.class ) + ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testProgrammaticDefinitionWithLong() throws Exception { + HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class ); + ConstraintMapping mapping = config.createConstraintMapping(); + mapping.type( MyClassLong.class ) + .field( "myValue" ) + .constraint( new OneOfDef().allowedLongs( new long[] { 100L, 200L, 300L } ) ); + + config.addMapping( mapping ); + Validator validator = config.buildValidatorFactory().getValidator(); + + Set> constraintViolations = validator.validate( new MyClassLong( 100L ) ); + assertNoViolations( constraintViolations ); + + constraintViolations = validator.validate( new MyClassLong( 400L ) ); + assertThat( constraintViolations ).containsOnlyViolations( + violationOf( OneOf.class ) + ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testProgrammaticDefinitionWithFloat() throws Exception { + HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class ); + ConstraintMapping mapping = config.createConstraintMapping(); + mapping.type( MyClassFloat.class ) + .field( "myValue" ) + .constraint( new OneOfDef().allowedFloats( new float[] { 1.1f, 2.2f, 3.3f } ) ); + + + config.addMapping( mapping ); + Validator validator = config.buildValidatorFactory().getValidator(); + + Set> constraintViolations = validator.validate( new MyClassFloat( 1.1f ) ); + assertNoViolations( constraintViolations ); + + + constraintViolations = validator.validate( new MyClassFloat( 4.4f ) ); + assertThat( constraintViolations ).containsOnlyViolations( + violationOf( OneOf.class ) + ); + } + + @Test + @TestForIssue(jiraKey = "HV-2073") + public void testProgrammaticDefinitionWithDouble() throws Exception { + HibernateValidatorConfiguration config = getConfiguration( HibernateValidator.class ); + ConstraintMapping mapping = config.createConstraintMapping(); + mapping.type( MyClassDouble.class ) + .field( "myValue" ) + .constraint( new OneOfDef().allowedDoubles( new double[] { 1.11, 2.22, 3.33 } ) ); + + config.addMapping( mapping ); + Validator validator = config.buildValidatorFactory().getValidator(); + + Set> constraintViolations = validator.validate( new MyClassDouble( 1.11 ) ); + assertNoViolations( constraintViolations ); + + + constraintViolations = validator.validate( new MyClassDouble( 4.44 ) ); + assertThat( constraintViolations ).containsOnlyViolations( + violationOf( OneOf.class ) + ); + } + + private HibernateValidatorConfiguration getConfiguration(Class validatorClass) { + return (HibernateValidatorConfiguration) jakarta.validation.Validation.byProvider( validatorClass ).configure(); + } + + private static class MyClassString { + + @SuppressWarnings("unused") + private String myValue; + + public MyClassString(String myValue) { + this.myValue = myValue; + } + } + + private static class MyClassInt { + + @SuppressWarnings("unused") + private int myValue; + + public MyClassInt(int myValue) { + this.myValue = myValue; + } + } + + private static class MyClassLong { + + @SuppressWarnings("unused") + private long myValue; + + public MyClassLong(long myValue) { + this.myValue = myValue; + } + } + + private static class MyClassFloat { + + @SuppressWarnings("unused") + private float myValue; + + public MyClassFloat(float myValue) { + this.myValue = myValue; + } + } + + private static class MyClassDouble { + + @SuppressWarnings("unused") + private double myValue; + + public MyClassDouble(double myValue) { + this.myValue = myValue; + } + } + + private static class MyClassEnum { + + @SuppressWarnings("unused") + private TestEnum myValue; + + public MyClassEnum(TestEnum myValue) { + this.myValue = myValue; + } + } + + private enum TestEnum { + ONE, TWO, THREE + } + + private OneOf createOneOf(boolean ignoreCase, String[] allowedValues, int[] allowedInts, long[] allowedLongs, float[] allowedFloats, double[] allowDoubles, Class> enumClass) { + return new OneOf() { + @Override + public String[] allowedValues() { + return allowedValues; + } + + @Override + public int[] allowedIntegers() { + return allowedInts; + } + + @Override + public long[] allowedLongs() { + return allowedLongs; + } + + @Override + public float[] allowedFloats() { + return allowedFloats; + } + + @Override + public double[] allowedDoubles() { + return allowDoubles; + } + + @Override + public Class> enumClass() { + return enumClass; + } + + @Override + public boolean ignoreCase() { + return ignoreCase; + } + + @Override + public String message() { + return ""; + } + + @Override + public Class[] groups() { + return new Class[0]; + } + + @Override + public Class[] payload() { + return new Class[0]; + } + + @Override + public Class annotationType() { + return OneOf.class; + } + }; + } + + public static class OneOfDto { + + @OneOf(allowedValues = { "value1", "value2", "value3" }) + private String oneOfString; + + @OneOf(allowedIntegers = { 100, 200, 300 }) + private Integer oneOfInteger; + + @OneOf(allowedLongs = { 1000L, 2000L, 3000L }) + private Long oneOfLong; + + @OneOf(allowedDoubles = { 1.5, 2.5, 3.5 }) + private Double oneOfDouble; + + @OneOf(allowedFloats = { 0.1f, 0.2f, 0.3f }) + private Float oneOfFloat; + + @OneOf(enumClass = TestEnum.class) + private TestEnum oneOfEnum; + + @OneOf(allowedValues = { "enabled", "disabled" }, ignoreCase = true) + private String oneOfIgnoreCaseString; + + public String getOneOfString() { + return oneOfString; + } + + public void setOneOfString(String oneOfString) { + this.oneOfString = oneOfString; + } + + public Integer getOneOfInteger() { + return oneOfInteger; + } + + public void setOneOfInteger(Integer oneOfInteger) { + this.oneOfInteger = oneOfInteger; + } + + public Long getOneOfLong() { + return oneOfLong; + } + + public void setOneOfLong(Long oneOfLong) { + this.oneOfLong = oneOfLong; + } + + public Double getOneOfDouble() { + return oneOfDouble; + } + + public void setOneOfDouble(Double oneOfDouble) { + this.oneOfDouble = oneOfDouble; + } + + + public Float getOneOfFloat() { + return oneOfFloat; + } + + public void setOneOfFloat(Float oneOfFloat) { + this.oneOfFloat = oneOfFloat; + } + + public TestEnum getOneOfEnum() { + return oneOfEnum; + } + + public void setOneOfEnum(TestEnum oneOfEnum) { + this.oneOfEnum = oneOfEnum; + } + + public String getOneOfIgnoreCaseString() { + return oneOfIgnoreCaseString; + } + + public void setOneOfIgnoreCaseString(String oneOfIgnoreCaseString) { + this.oneOfIgnoreCaseString = oneOfIgnoreCaseString; + } + } +}