29 | *
30 | * @author Shamkhal Maharramov
31 | * @see CrossFieldConstraintValidator
32 | * @see CrossFieldConstraintViolation
33 | * @since 1.0.0
34 | */
35 | public abstract class BaseCrossFieldValidator implements CrossFieldConstraintValidator
36 | {
37 | /**
38 | * Default constructor for the `AbstractCrossFieldConstraintValidator`.
39 | */
40 | public BaseCrossFieldValidator()
41 | {
42 | // This constructor is intentionally left blank.
43 | }
44 |
45 | /**
46 | * Retrieves the value of a given property field from an object.
47 | *
48 | * @param object The object instance from which to fetch the property value.
49 | * @param fieldName The name of the field whose value needs to be fetched.
50 | * @return The value of the field if it exists, or null otherwise.
51 | */
52 | protected Object getProperty(Object object, String fieldName)
53 | {
54 | try
55 | {
56 | PropertyDescriptor[] propertyDescriptors = Introspector.getBeanInfo(object.getClass()).getPropertyDescriptors();
57 | for (PropertyDescriptor propertyDescriptor : propertyDescriptors)
58 | {
59 | if (propertyDescriptor.getName().equals(fieldName))
60 | {
61 | return propertyDescriptor.getReadMethod().invoke(object);
62 | }
63 | }
64 | }
65 | catch (IntrospectionException | ReflectiveOperationException e)
66 | {
67 | return null;
68 | }
69 | return null;
70 | }
71 |
72 | /**
73 | * Processes each annotated field of a given object, applying the specified {@code fieldProcessor} function.
74 | *
75 | *
This method iterates through all fields in the object that are annotated with the given annotation type.
76 | * For each annotated field, the {@code fieldProcessor} function is invoked with the field and its annotation as arguments.
77 | *
78 | * @param The type of annotation to look for on the fields.
79 | * @param obj The object instance containing the fields to be processed.
80 | * @param fieldMapping A map containing field information for each class type, used to look up fields in the object.
81 | * @param annotationClass The class type of the annotation to look for on the fields.
82 | * @param fieldProcessor A {@code BiConsumer} that takes a field and its annotation as arguments and processes them.
83 | * This consumer function is responsible for the actual work to be done on each field.
84 | */
85 | protected void processFields(Object obj,
86 | Map, List> fieldMapping,
87 | Class annotationClass,
88 | BiConsumer fieldProcessor)
89 | {
90 | List fields = fieldMapping.getOrDefault(obj.getClass(), Collections.emptyList());
91 | for (Field field : fields)
92 | {
93 | T annotation = field.getAnnotation(annotationClass);
94 | if (annotation != null)
95 | {
96 | fieldProcessor.accept(field, annotation);
97 | }
98 | }
99 | }
100 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cross-Field Validation Library
2 |
3 | ## Unleash the Power of Cross-Field Validation in Java
4 |
5 | This library unlocks powerful cross-field validation for your Java applications, enabling you to define complex
6 | constraints that span multiple fields within an object.
7 |
8 | ### Why You Need This
9 |
10 | Jakarta Bean Validation is great for single-field validation, but it falls short when you need to enforce rules that
11 | involve relationships between different fields. This library bridges that gap, providing a flexible and intuitive way to
12 | define and apply cross-field validation.
13 |
14 | This solution overcomes the limitations of `ConstraintValidatorContext`, which doesn't allow interference with the
15 | object context when writing field-level validators. There are long-standing open issues on this topic:
16 |
17 | - [BVAL-237](https://hibernate.atlassian.net/browse/BVAL-237)
18 | - [BVAL-240](https://hibernate.atlassian.net/browse/BVAL-240)
19 |
20 | **Advantages of this library:**
21 |
22 | 1. **More flexible validations:** Define complex validation logic involving multiple fields.
23 | 2. **Improved readability:** Create custom annotations that resemble built-in constraints like `@NotNull`
24 | and `@NotEmpty`.
25 | 3. **Simplified validation:** Use a single `@EnableCrossFieldConstraints` annotation to enable all custom validators for
26 | a class.
27 |
28 | ### Getting Started
29 |
30 | **1. Add the Dependency**
31 |
32 | ```xml
33 |
34 |
35 | io.github.maharramoff
36 | cross-field-validation
37 | 1.3.0
38 |
39 | ```
40 |
41 | **2. Annotate your class with `@EnableCrossFieldConstraints`**
42 |
43 | ```java
44 | @EnableCrossFieldConstraints
45 | public class SignupRequestDTO
46 | {
47 | private String username;
48 | private String password;
49 |
50 | @MatchWith("password")
51 | private String confirmPassword;
52 | }
53 | ```
54 |
55 | **3. Implement a custom validator**
56 |
57 | ```java
58 | @Target(ElementType.FIELD)
59 | @Retention(RetentionPolicy.RUNTIME)
60 | @CrossFieldConstraint(validatedBy = MatchWithValidator.class)
61 | public @interface MatchWith
62 | {
63 | String field();
64 |
65 | String message() default "Fields do not match.";
66 | }
67 |
68 | public class MatchWithValidator extends BaseCrossFieldValidator
69 | {
70 | @Override
71 | public boolean isValid(Object obj, Map, List> fieldMapping, List violations)
72 | {
73 | processFields(obj, fieldMapping, MatchWith.class, (field, annotation) ->
74 | {
75 | Object fieldValue = getProperty(obj, field.getName());
76 | Object otherFieldValue = getProperty(obj, annotation.field());
77 | if (fieldValue == null && otherFieldValue == null)
78 | {
79 | return; // Both null is considered valid
80 | }
81 | if (fieldValue == null || !fieldValue.equals(otherFieldValue))
82 | {
83 | violations.add(new CrossFieldConstraintViolation(field.getName(), annotation.message()));
84 | }
85 | });
86 | return violations.isEmpty();
87 | }
88 | }
89 | ```
90 |
91 | ### How It Works
92 |
93 | This library utilizes a ConstraintValidator to manage cross-field validation. Custom validators implement the
94 | CrossFieldConstraintValidator interface, providing the logic for your specific constraints.
95 |
96 | **1. Annotation Processing:** When the validation framework encounters the `@EnableCrossFieldConstraints` annotation on
97 | a class, it triggers the `CrossFieldValidationProcessor`.
98 |
99 | **2. Validator Execution:** The `CrossFieldValidationProcessor` iterates through
100 | registered `CrossFieldConstraintValidator` implementations.
101 |
102 | **3. Field Analysis:** Each validator analyzes the fields of the object, looking for its corresponding annotation (
103 | e.g., `@MatchWith`).
104 |
105 | **4. Validation Logic:** If the annotation is present, the validator executes its custom validation logic, comparing
106 | field values as needed.
107 |
108 | **5. Violation Reporting:** If a constraint is violated, the validator adds a `CrossFieldConstraintViolation` to the
109 | list, which
110 | is then handled by the validation framework.
111 |
112 | ### Contributing
113 |
114 | Contributions are welcome! Please fork the repository and submit a pull request with your changes. Ensure your code
115 | follows the existing code style and includes appropriate unit tests.
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 | io.github.maharramoff
8 | cross-field-validation
9 | 1.3.0
10 | jar
11 |
12 | ${project.groupId}:${project.artifactId}
13 | A Java library that extends Jakarta Bean Validation capabilities to enable cross-field validation, so
14 | developers can define constraints that span multiple fields within an object.
15 |
16 | https://github.com/Maharramoff/cross-field-validation
17 |
18 |
19 | The Apache Software License, Version 2.0
20 | https://www.apache.org/licenses/LICENSE-2.0.txt
21 | repo
22 |
23 |
24 |
25 |
26 | author
27 | Shamkhal Maharramov
28 | sh.maharramov@gmail.com
29 |
30 |
31 |
32 | https://github.com/Maharramoff/cross-field-validation
33 | scm:git:https://github.com/Maharramoff/cross-field-validation.git
34 | scm:git:git@github.com:maharramoff/cross-field-validation.git
35 |
36 |
37 |
38 |
39 | ossrh
40 | https://s01.oss.sonatype.org/content/repositories/snapshots
41 |
42 |
43 | ossrh
44 | https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/
45 |
46 |
47 |
48 |
49 |
50 | org.projectlombok
51 | lombok
52 | 1.18.34
53 |
54 |
55 | org.junit.jupiter
56 | junit-jupiter-api
57 | 5.9.3
58 | test
59 |
60 |
61 | org.mockito
62 | mockito-core
63 | 4.5.1
64 | test
65 |
66 |
67 | org.hibernate.validator
68 | hibernate-validator
69 | 7.0.5.Final
70 | test
71 |
72 |
73 | org.glassfish
74 | jakarta.el
75 | 4.0.2
76 | test
77 |
78 |
79 | jakarta.validation
80 | jakarta.validation-api
81 | 3.1.0
82 |
83 |
84 | org.slf4j
85 | slf4j-api
86 | 2.0.16
87 |
88 |
89 | org.slf4j
90 | slf4j-simple
91 | 2.0.13
92 | runtime
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | org.apache.maven.plugins
101 | maven-source-plugin
102 | 3.3.1
103 |
104 |
105 | attach-sources
106 |
107 | jar-no-fork
108 |
109 |
110 |
111 |
112 |
113 | org.apache.maven.plugins
114 | maven-javadoc-plugin
115 | 3.10.1
116 |
117 |
118 | attach-javadocs
119 |
120 | jar
121 |
122 |
123 |
124 |
125 |
126 | org.apache.maven.plugins
127 | maven-gpg-plugin
128 | 3.2.7
129 |
130 |
131 | sign-artifacts
132 | verify
133 |
134 | sign
135 |
136 |
137 |
138 |
139 |
140 | net.nicoulaj.maven.plugins
141 | checksum-maven-plugin
142 | 1.11
143 |
144 |
145 | create-checksums
146 | verify
147 |
148 | files
149 |
150 |
151 |
152 |
153 | ${project.build.directory}
154 |
155 | ${project.build.finalName}.jar
156 | ${project.build.finalName}-javadoc.jar
157 | ${project.build.finalName}-sources.jar
158 | ${project.build.finalName}.pom
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 | org.sonatype.central
168 | central-publishing-maven-plugin
169 | 0.6.0
170 | true
171 |
172 | central
173 | true
174 | required
175 | ${project.artifactId}-${project.version}
176 |
177 |
178 |
179 | org.jacoco
180 | jacoco-maven-plugin
181 | 0.8.12
182 |
183 |
184 |
185 | prepare-agent
186 |
187 |
188 |
189 | report
190 | prepare-package
191 |
192 | report
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 | 8
204 | 8
205 | UTF-8
206 |
207 |
208 |
--------------------------------------------------------------------------------
/src/main/java/io/github/maharramoff/crossfieldvalidation/CrossFieldValidationProcessor.java:
--------------------------------------------------------------------------------
1 | package io.github.maharramoff.crossfieldvalidation;
2 |
3 | import jakarta.validation.ConstraintValidator;
4 | import jakarta.validation.ConstraintValidatorContext;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 |
8 | import java.lang.annotation.Annotation;
9 | import java.lang.reflect.Field;
10 | import java.util.ArrayList;
11 | import java.util.Arrays;
12 | import java.util.List;
13 | import java.util.Map;
14 | import java.util.concurrent.ConcurrentHashMap;
15 | import java.util.function.Function;
16 | import java.util.stream.Collectors;
17 |
18 | /**
19 | * A {@code ConstraintValidator} implementation that enables cross-field validation for objects.
20 | * This implementation automatically discovers and caches validators for fields annotated with
21 | * cross-field constraint annotations.
22 | *
23 | *
Key Components:
24 | *
25 | *
{@code fieldMapping}: A mapping from each class to its list of fields for efficient field access.
26 | *
{@code validatorCache}: A cache of validator instances for each constraint annotation type.
Create a validator class that extends {@link CrossFieldConstraintValidator}
43 | *
Create a constraint annotation marked with {@link CrossFieldConstraint}
44 | *
Apply the constraint annotation to fields requiring validation
45 | *
46 | *
47 | * @author Shamkhal Maharramov
48 | * @see CrossFieldConstraintValidator
49 | * @see CrossFieldConstraint
50 | * @see CrossFieldConstraintViolation
51 | * @since 1.0.0
52 | */
53 | public class CrossFieldValidationProcessor implements ConstraintValidator
54 | {
55 | private static final Logger logger = LoggerFactory.getLogger(CrossFieldValidationProcessor.class);
56 | private final Map, List> fieldMapping = new ConcurrentHashMap<>();
57 | private final Map, Map> fieldAnnotationsCache = new ConcurrentHashMap<>();
58 | private final Map, CrossFieldConstraintValidator> validatorCache = new ConcurrentHashMap<>();
59 |
60 | /**
61 | * Default constructor for the `CrossFieldValidationProcessor`.
62 | */
63 | public CrossFieldValidationProcessor()
64 | {
65 | // This constructor is intentionally left blank.
66 | }
67 |
68 | /**
69 | * Validates the given object by discovering and applying all relevant cross-field validators.
70 | * The method scans all fields for annotations marked with {@link CrossFieldConstraint},
71 | * creates or retrieves the corresponding validator instances, and applies them.
72 | *
73 | * @param obj The object to validate
74 | * @param context The constraint validator context
75 | * @return {@code true} if all validations pass, {@code false} otherwise
76 | */
77 | @Override
78 | public boolean isValid(final Object obj, final ConstraintValidatorContext context)
79 | {
80 | logger.debug("Starting validation for object: {}", obj.getClass().getName());
81 |
82 | List violations = new ArrayList<>();
83 | boolean isValid = processFields(obj, violations);
84 |
85 | if (!isValid)
86 | {
87 | addViolationsToContext(context, violations);
88 | }
89 |
90 | return isValid;
91 | }
92 |
93 | /**
94 | * Processes all fields of the given object to perform cross-field validation.
95 | *
96 | * @param obj The object to validate
97 | * @param violations A list to store any constraint violations encountered during validation
98 | * @return {@code true} if all validations pass, {@code false} otherwise
99 | */
100 | private boolean processFields(Object obj, List violations)
101 | {
102 | Class> clazz = obj.getClass();
103 | List fields = fieldMapping.computeIfAbsent(clazz, this::getAllDeclaredFields);
104 | Map fieldAnnotations = fieldAnnotationsCache.computeIfAbsent(clazz, this::getFieldAnnotations);
105 |
106 | boolean isValid = true;
107 |
108 | logger.debug("Processing {} fields for class: {}", fields.size(), clazz.getName());
109 |
110 | for (Field field : fields)
111 | {
112 | Annotation[] annotations = fieldAnnotations.get(field);
113 | for (Annotation annotation : annotations)
114 | {
115 | logger.debug("Processing annotation: {}", annotation.annotationType().getName());
116 |
117 | CrossFieldConstraintValidator validator = getValidatorForAnnotation(annotation.annotationType());
118 |
119 | logger.debug("Validator obtained: {}", (validator != null ? validator.getClass().getName() : "null"));
120 |
121 | if (validator != null)
122 | {
123 | isValid = applyValidator(obj, violations, isValid, validator);
124 | }
125 | }
126 | }
127 | return isValid;
128 | }
129 |
130 | /**
131 | * Retrieves all declared fields of the specified class.
132 | *
133 | * @param clazz The class whose declared fields are to be retrieved.
134 | * @return A list containing all declared fields of the class.
135 | */
136 | private List getAllDeclaredFields(Class> clazz)
137 | {
138 | return Arrays.asList(clazz.getDeclaredFields());
139 | }
140 |
141 | /**
142 | * Retrieves annotations for all fields of the specified class and maps them accordingly.
143 | *
144 | * @param clazz The class whose field annotations are to be retrieved.
145 | * @return A map where each field is associated with its array of annotations.
146 | */
147 | private Map getFieldAnnotations(Class> clazz)
148 | {
149 | List fields = fieldMapping.get(clazz);
150 | return fields.stream()
151 | .collect(Collectors.toMap(Function.identity(), Field::getAnnotations));
152 | }
153 |
154 | /**
155 | * Retrieves a cached validator instance for the given annotation type,
156 | * creating a new instance if none exists.
157 | *
158 | * @param annotationType The type of the constraint annotation
159 | * @return The validator instance, or null if the annotation is not a cross-field constraint
160 | * @since 1.1.0
161 | */
162 | CrossFieldConstraintValidator getValidatorForAnnotation(Class extends Annotation> annotationType)
163 | {
164 | logger.debug("Getting validator for annotation type: {}", annotationType.getName());
165 |
166 | return validatorCache.computeIfAbsent(annotationType, this::registerValidator);
167 | }
168 |
169 | /**
170 | * Creates a new validator instance for the given annotation type.
171 | *
172 | * @param annotationType The type of the constraint annotation
173 | * @return A new validator instance, or null if the annotation is not marked with {@link CrossFieldConstraint}
174 | * @throws IllegalStateException if validator instantiation fails
175 | * @since 1.1.0
176 | */
177 | private CrossFieldConstraintValidator registerValidator(Class extends Annotation> annotationType)
178 | {
179 | logger.debug("Attempting to register validator for: {}", annotationType.getName());
180 |
181 | CrossFieldConstraint crossFieldConstraint = annotationType.getAnnotation(CrossFieldConstraint.class);
182 |
183 | logger.debug("CrossFieldConstraint annotation present: {}", (crossFieldConstraint != null));
184 |
185 | if (crossFieldConstraint == null)
186 | {
187 | logger.debug("No @CrossFieldConstraint found on {}", annotationType.getName());
188 |
189 | return null;
190 | }
191 |
192 | try
193 | {
194 | Class extends CrossFieldConstraintValidator> validatorClass = crossFieldConstraint.validatedBy();
195 |
196 | logger.debug("Validator class to instantiate: {}", validatorClass.getName());
197 |
198 | CrossFieldConstraintValidator validator = validatorClass.getDeclaredConstructor().newInstance();
199 |
200 | logger.debug("Successfully created validator instance: {}", validator.getClass().getName());
201 |
202 | return validator;
203 | }
204 | catch (Exception e)
205 | {
206 | logger.debug("Failed to instantiate validator: {}", e.getMessage());
207 |
208 | throw new IllegalStateException("Failed to instantiate validator for " + annotationType, e);
209 | }
210 | }
211 |
212 | /**
213 | * Applies the given validator to the object and updates the list of violations.
214 | *
215 | * @param obj The object to validate.
216 | * @param violations The list to store any constraint violations encountered during validation.
217 | * @param isValid The current validity status before applying the validator.
218 | * @param validator The validator to apply to the object.
219 | * @return {@code true} if the object passes validation after applying the validator; {@code false} otherwise.
220 | */
221 | private boolean applyValidator(Object obj, List violations, boolean isValid, CrossFieldConstraintValidator validator)
222 | {
223 | try
224 | {
225 | isValid &= validator.isValid(obj, fieldMapping, violations);
226 | logger.debug("Validation result: {}", isValid);
227 | }
228 | catch (Exception e)
229 | {
230 | logger.debug("Validation error: {}", e.getMessage());
231 | isValid = false;
232 | }
233 | return isValid;
234 | }
235 |
236 | /**
237 | * Adds constraint violations to the provided ConstraintValidatorContext.
238 | * This method disables the default constraint violation and adds custom violations
239 | * based on the provided list of CrossFieldConstraintViolation objects.
240 | *
241 | * @param context The ConstraintValidatorContext to which violations will be added
242 | * @param violations A list of CrossFieldConstraintViolation objects representing the violations to be added
243 | */
244 | private void addViolationsToContext(ConstraintValidatorContext context, List violations)
245 | {
246 | context.disableDefaultConstraintViolation();
247 | for (CrossFieldConstraintViolation violation : violations)
248 | {
249 | context.buildConstraintViolationWithTemplate(violation.getMessage()).addPropertyNode(violation.getFieldName()).addConstraintViolation();
250 | }
251 | }
252 | }
253 |
254 |
255 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [2023-2024] [Shamkhal Maharramov]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/src/test/java/io/github/maharramoff/crossfieldvalidation/CrossFieldValidationProcessorTest.java:
--------------------------------------------------------------------------------
1 | package io.github.maharramoff.crossfieldvalidation;
2 |
3 | import jakarta.validation.*;
4 | import jakarta.validation.constraints.Email;
5 | import jakarta.validation.constraints.NotNull;
6 | import jakarta.validation.constraints.Size;
7 | import lombok.Getter;
8 | import org.junit.jupiter.api.*;
9 |
10 | import java.lang.annotation.ElementType;
11 | import java.lang.annotation.Retention;
12 | import java.lang.annotation.RetentionPolicy;
13 | import java.lang.annotation.Target;
14 | import java.lang.reflect.Field;
15 | import java.util.List;
16 | import java.util.Map;
17 | import java.util.Set;
18 |
19 | import static org.junit.jupiter.api.Assertions.*;
20 | import static org.mockito.ArgumentMatchers.anyString;
21 | import static org.mockito.Mockito.*;
22 |
23 | @SuppressWarnings({"FieldCanBeLocal", "unused", "unchecked"})
24 | class CrossFieldValidationProcessorTest
25 | {
26 |
27 | private CrossFieldValidationProcessor validationProcessor;
28 | private ConstraintValidatorContext mockContext;
29 | private Validator standardValidator;
30 | private ValidatorFactory validatorFactory;
31 |
32 | @BeforeEach
33 | void setUp()
34 | {
35 | validationProcessor = new CrossFieldValidationProcessor();
36 | mockContext = createMockContext();
37 | validatorFactory = Validation.buildDefaultValidatorFactory();
38 | standardValidator = validatorFactory.getValidator();
39 | }
40 |
41 | private ConstraintValidatorContext createMockContext()
42 | {
43 | ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
44 | ConstraintValidatorContext.ConstraintViolationBuilder violationBuilder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
45 | ConstraintValidatorContext.ConstraintViolationBuilder.NodeBuilderCustomizableContext nodeBuilder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.NodeBuilderCustomizableContext.class);
46 |
47 | when(context.buildConstraintViolationWithTemplate(anyString())).thenReturn(violationBuilder);
48 | when(violationBuilder.addPropertyNode(anyString())).thenReturn(nodeBuilder);
49 | when(nodeBuilder.addConstraintViolation()).thenReturn(context);
50 |
51 | return context;
52 | }
53 |
54 | @Nested
55 | @DisplayName("Validation Success Cases")
56 | class ValidationSuccessTests
57 | {
58 |
59 | @Test
60 | void shouldReturnTrueWhenConstraintsSatisfied()
61 | {
62 | TestObject testObject = new TestObject("password123", "password123");
63 | boolean result = validationProcessor.isValid(testObject, mockContext);
64 | assertTrue(result);
65 | verifyNoInteractions(mockContext);
66 | }
67 |
68 | @Test
69 | void shouldHandleEmptyObjectGracefully()
70 | {
71 | EmptyTestObject emptyTestObject = new EmptyTestObject();
72 | boolean result = validationProcessor.isValid(emptyTestObject, mockContext);
73 | assertTrue(result);
74 | verifyNoInteractions(mockContext);
75 | }
76 |
77 | @Test
78 | void shouldIgnoreNonAnnotatedFields()
79 | {
80 | TestObjectWithoutAnnotations testObject = new TestObjectWithoutAnnotations("value1", "value2");
81 | boolean result = validationProcessor.isValid(testObject, mockContext);
82 | assertTrue(result);
83 | verifyNoInteractions(mockContext);
84 | }
85 | }
86 |
87 | @Nested
88 | @DisplayName("Validator Caching and Instantiation")
89 | class ValidatorCachingTests
90 | {
91 |
92 | @Test
93 | void shouldCacheValidatorInstances()
94 | {
95 | TestObject testObject = new TestObject("password123", "password123");
96 | validationProcessor.isValid(testObject, mockContext);
97 | validationProcessor.isValid(testObject, mockContext); // Second call to trigger caching
98 |
99 | CrossFieldConstraintValidator cachedValidator = validationProcessor.getValidatorForAnnotation(MatchWith.class);
100 | assertNotNull(cachedValidator);
101 | }
102 |
103 | @Test
104 | void shouldReturnNullForNonCrossFieldConstraint()
105 | {
106 | CrossFieldConstraintValidator validator = validationProcessor.getValidatorForAnnotation(NonCrossFieldConstraint.class);
107 | assertNull(validator);
108 | }
109 |
110 | @Test
111 | void shouldThrowExceptionWhenValidatorInstantiationFails()
112 | {
113 | IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
114 | validationProcessor.getValidatorForAnnotation(InvalidValidatorAnnotation.class));
115 |
116 | assertTrue(exception.getMessage().contains("Failed to instantiate validator for"));
117 | }
118 | }
119 |
120 | @Nested
121 | @DisplayName("Edge Cases and Special Conditions")
122 | class EdgeCaseTests
123 | {
124 |
125 | @Test
126 | @DisplayName("Should fail validation when one field is null and the other is not")
127 | void shouldFailWhenOneFieldIsNull()
128 | {
129 | TestObject testObject = new TestObject(null, "value");
130 | boolean result = validationProcessor.isValid(testObject, mockContext);
131 | assertFalse(result);
132 | verify(mockContext).disableDefaultConstraintViolation();
133 | verify(mockContext).buildConstraintViolationWithTemplate("Fields do not match.");
134 | }
135 |
136 | @Test
137 | @DisplayName("Should handle objects with inherited fields")
138 | void shouldHandleInheritedFields()
139 | {
140 | SubTestObject testObject = new SubTestObject("password123", "password123", "extraField");
141 | boolean result = validationProcessor.isValid(testObject, mockContext);
142 | assertTrue(result);
143 | verifyNoInteractions(mockContext);
144 | }
145 |
146 | @Test
147 | @DisplayName("Should validate objects with multiple cross-field constraints")
148 | void shouldValidateMultipleCrossFieldConstraints()
149 | {
150 | MultiConstraintTestObject2 testObject = new MultiConstraintTestObject2("value", "value", 5, 5);
151 | boolean result = validationProcessor.isValid(testObject, mockContext);
152 | assertTrue(result);
153 | verifyNoInteractions(mockContext);
154 | }
155 | }
156 |
157 | @Nested
158 | @DisplayName("Multiple CrossFieldConstraints Tests")
159 | class MultipleConstraintsTests
160 | {
161 |
162 | @Test
163 | void shouldCollectAllViolationsWhenMultipleConstraintsViolated()
164 | {
165 | MultiConstraintTestObject testObject = new MultiConstraintTestObject(
166 | 10, 100, 5, "secret123", "different");
167 | boolean result = validationProcessor.isValid(testObject, mockContext);
168 | assertFalse(result);
169 | verify(mockContext).disableDefaultConstraintViolation();
170 | verify(mockContext, times(2)).buildConstraintViolationWithTemplate(anyString());
171 | }
172 | }
173 |
174 | @Nested
175 | @DisplayName("Combined Constraints Tests")
176 | class CombinedConstraintsTests
177 | {
178 |
179 | private ValidatorFactory validatorFactory;
180 | private Validator standardValidator;
181 |
182 | @BeforeEach
183 | void setUpValidators()
184 | {
185 | validatorFactory = Validation.buildDefaultValidatorFactory();
186 | standardValidator = validatorFactory.getValidator();
187 | }
188 |
189 | @AfterEach
190 | void tearDownValidators()
191 | {
192 | validatorFactory.close();
193 | }
194 |
195 | @Test
196 | void shouldFailWhenStandardConstraintsViolated()
197 | {
198 | CombinedConstraintsTestObject testObject = new CombinedConstraintsTestObject(
199 | "usr", "pass", "pass", "invalid-email");
200 | Set> violations = standardValidator.validate(testObject);
201 | assertFalse(violations.isEmpty());
202 | }
203 |
204 | @Test
205 | void shouldCollectViolationsFromBothStandardAndCrossFieldConstraints()
206 | {
207 | CombinedConstraintsTestObject testObject = new CombinedConstraintsTestObject(
208 | "usr", "pass", "different", "invalid-email");
209 | Set> violations = standardValidator.validate(testObject);
210 | assertFalse(violations.isEmpty());
211 |
212 | boolean crossFieldResult = validationProcessor.isValid(testObject, mockContext);
213 | assertFalse(crossFieldResult);
214 | verify(mockContext).disableDefaultConstraintViolation();
215 | verify(mockContext).buildConstraintViolationWithTemplate("Fields do not match.");
216 |
217 | assertFalse(violations.isEmpty());
218 | }
219 | }
220 |
221 |
222 | // Test helper methods and classes
223 |
224 | @Retention(RetentionPolicy.RUNTIME) @interface NonCrossFieldConstraint
225 | {
226 | }
227 |
228 | @Retention(RetentionPolicy.RUNTIME)
229 | @CrossFieldConstraint(validatedBy = NonInstantiableValidator.class) @interface InvalidValidatorAnnotation
230 | {
231 | }
232 |
233 | static class NonInstantiableValidator implements CrossFieldConstraintValidator
234 | {
235 | public NonInstantiableValidator(String arg)
236 | {
237 | }
238 |
239 | @Override
240 | public boolean isValid(Object obj, Map, List> fields, List violations)
241 | {
242 | return false;
243 | }
244 | }
245 |
246 | static class EmptyTestObject
247 | {
248 | }
249 |
250 | static class TestObjectWithoutAnnotations
251 | {
252 | private final String value;
253 | private final String other;
254 |
255 | TestObjectWithoutAnnotations(String value, String other)
256 | {
257 | this.value = value;
258 | this.other = other;
259 | }
260 | }
261 |
262 | @Target(ElementType.FIELD)
263 | @Retention(RetentionPolicy.RUNTIME)
264 | @CrossFieldConstraint(validatedBy = MatchWithValidator.class) @interface MatchWith
265 | {
266 | String field();
267 |
268 | String message() default "Fields do not match.";
269 | }
270 |
271 | static class MatchWithValidator extends BaseCrossFieldValidator
272 | {
273 | @Override
274 | public boolean isValid(Object obj, Map, List> fieldMapping, List violations)
275 | {
276 | processFields(obj, fieldMapping, MatchWith.class, (field, annotation) ->
277 | {
278 | Object fieldValue = getProperty(obj, field.getName());
279 | Object otherFieldValue = getProperty(obj, annotation.field());
280 |
281 | if (fieldValue == null || !fieldValue.equals(otherFieldValue))
282 | {
283 | violations.add(new CrossFieldConstraintViolation(field.getName(), annotation.message()));
284 | }
285 | });
286 | return violations.isEmpty();
287 | }
288 | }
289 |
290 | @Getter
291 | @EnableCrossFieldConstraints
292 | static class TestObject
293 | {
294 | private final String value;
295 |
296 | @MatchWith(field = "value")
297 | private final String other;
298 |
299 | TestObject(String value, String other)
300 | {
301 | this.value = value;
302 | this.other = other;
303 | }
304 | }
305 |
306 | @Getter
307 | @EnableCrossFieldConstraints
308 | static class MultiConstraintTestObject
309 | {
310 | private final Integer minValue;
311 | private final Integer maxValue;
312 |
313 | @GreaterThan(field = "minValue")
314 | private final Integer currentValue;
315 |
316 | @MatchWith(field = "password")
317 | private final String confirmPassword;
318 |
319 | private final String password;
320 |
321 | MultiConstraintTestObject(Integer minValue, Integer maxValue, Integer currentValue, String password, String confirmPassword)
322 | {
323 | this.minValue = minValue;
324 | this.maxValue = maxValue;
325 | this.currentValue = currentValue;
326 | this.password = password;
327 | this.confirmPassword = confirmPassword;
328 | }
329 | }
330 |
331 | @Target(ElementType.FIELD)
332 | @Retention(RetentionPolicy.RUNTIME)
333 | @CrossFieldConstraint(validatedBy = GreaterThanValidator.class) @interface GreaterThan
334 | {
335 | String field();
336 |
337 | String message() default "Field must be greater than {field}.";
338 | }
339 |
340 | static class GreaterThanValidator extends BaseCrossFieldValidator
341 | {
342 | @Override
343 | public boolean isValid(Object obj, Map, List> fieldMapping, List violations)
344 | {
345 | processFields(obj, fieldMapping, GreaterThan.class, (field, annotation) ->
346 | {
347 | Comparable