Skip to content

Commit c5d0414

Browse files
authored
Handle Repeatable Constraints (#267)
1 parent 1a41fdc commit c5d0414

File tree

7 files changed

+159
-106
lines changed

7 files changed

+159
-106
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package example.avaje.repeat;
2+
3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
6+
import jakarta.validation.constraints.Size;
7+
8+
@Valid
9+
public class SignupRequest {
10+
11+
@NotBlank(message = "{signup.password.notblank1}")
12+
@Size(min = 5, max = 32, message = "{signup.password.size}")
13+
@Pattern(regexp = "^[a-zA-Z0-9!@#$^&*]*$", message = "{signup.password.invalid}")
14+
@Pattern(regexp = ".*[a-z].*", message = "{signup.password.lowercase}")
15+
@Pattern(regexp = ".*[A-Z].*", message = "{signup.password.uppercase}")
16+
@Pattern(regexp = ".*[0-9].*", message = "{signup.password.digit}")
17+
@Pattern(regexp = ".*[!@#$^&*].*", message = "{signup.password.special}")
18+
private String password;
19+
20+
public SignupRequest(String password) {
21+
this.password = password;
22+
}
23+
24+
public String getPassword() {
25+
return password;
26+
}
27+
28+
public void setPassword(String password) {
29+
this.password = password;
30+
}
31+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
example.avaje.MyKey.message=Invalid MyKey
22
example.avaje.MySerial.message=Invalid my serial
33
org.foo.MyCustomALong.message=Invalid special number
4+
5+
signup.password.notblank1=Signup password must not be blank
6+
signup.password.size=Signup password size error
7+
signup.password.invalid=Signup password invalid
8+
signup.password.lowercase=Signup must have a lower case
9+
signup.password.uppercase=Signup must have at least 1 upper case
10+
signup.password.digit=Signup digit
11+
signup.password.special=Signup special character
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package example.avaje.repeat;
2+
3+
import io.avaje.validation.ConstraintViolation;
4+
import io.avaje.validation.ConstraintViolationException;
5+
import io.avaje.validation.Validator;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Locale;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.assertj.core.api.Assertions.fail;
14+
15+
class SignupRequestTest {
16+
17+
final Validator validator = Validator.builder()
18+
.addResourceBundles("example.avaje.CustomMessages")
19+
.build();
20+
21+
@Test
22+
void lowercaseNoSpecial() {
23+
SignupRequest req = new SignupRequest("foo");
24+
25+
var violations = all(req, Locale.ENGLISH);
26+
assertThat(violations).hasSize(3);
27+
assertThat(violations.get(0).message()).isEqualTo("Signup password size error");
28+
assertThat(violations.get(1).message()).isEqualTo("Signup must have at least 1 upper case");
29+
assertThat(violations.get(2).message()).isEqualTo("Signup special character");
30+
}
31+
32+
@Test
33+
void missingDigit() {
34+
SignupRequest req = new SignupRequest("fooBar!");
35+
36+
var violations = all(req, Locale.ENGLISH);
37+
assertThat(violations).hasSize(1);
38+
assertThat(violations.get(0).message()).isEqualTo("Signup digit");
39+
}
40+
41+
List<ConstraintViolation> all(Object any, Locale locale) {
42+
try {
43+
validator.validate(any, locale);
44+
fail("not expected");
45+
return List.of();
46+
} catch (ConstraintViolationException e) {
47+
return new ArrayList<>(e.violations());
48+
}
49+
}
50+
}

validator-generator/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<name>validator generator</name>
1414
<description>annotation processor generating validation adapters</description>
1515
<properties>
16-
<avaje.prisms.version>1.31</avaje.prisms.version>
16+
<avaje.prisms.version>1.38</avaje.prisms.version>
1717
</properties>
1818

1919
<dependencies>

validator-generator/src/main/java/io/avaje/validation/generator/AdapterHelper.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package io.avaje.validation.generator;
22

3-
import java.util.Map;
4-
53
import static io.avaje.validation.generator.APContext.isAssignable;
64

5+
import java.util.List;
6+
import java.util.Map.Entry;
7+
78
final class AdapterHelper {
89

910
private final Append writer;
@@ -113,9 +114,9 @@ void write() {
113114
}
114115
}
115116

116-
private void writeFirst(Map<UType, String> annotations) {
117+
private void writeFirst(List<Entry<UType, String>> annotations) {
117118
boolean first = true;
118-
for (final var a : annotations.entrySet()) {
119+
for (final var a : annotations) {
119120
if (first) {
120121
writer.append("%sctx.<%s>adapter(%s.class, %s)", indent, type, a.getKey().shortWithoutAnnotations(), a.getValue());
121122
first = false;
@@ -128,7 +129,7 @@ private void writeFirst(Map<UType, String> annotations) {
128129
}
129130
}
130131

131-
private boolean isMapType(Map<UType, String> typeUse1, Map<UType, String> typeUse2) {
132+
private boolean isMapType(List<Entry<UType, String>> typeUse1, List<Entry<UType, String>> typeUse2) {
132133
return (!typeUse1.isEmpty() || !typeUse2.isEmpty())
133134
&& "java.util.Map".equals(genericType.mainType());
134135
}
@@ -137,12 +138,12 @@ private boolean isTopTypeIterable() {
137138
return mainType != null && isAssignable(mainType.mainType(), "java.lang.Iterable");
138139
}
139140

140-
private void writeTypeUse(UType uType, Map<UType, String> typeUse12) {
141-
writeTypeUse(uType, typeUse12, true);
141+
private void writeTypeUse(UType uType, List<Entry<UType, String>> typeUse1) {
142+
writeTypeUse(uType, typeUse1, true);
142143
}
143144

144-
private void writeTypeUse(UType uType, Map<UType, String> typeUseMap, boolean keys) {
145-
for (final var a : typeUseMap.entrySet()) {
145+
private void writeTypeUse(UType uType, List<Entry<UType, String>> typeUse1, boolean keys) {
146+
for (final var a : typeUse1) {
146147

147148
if (Constants.VALID_ANNOTATIONS.contains(a.getKey().mainType())) {
148149
continue;
@@ -153,7 +154,7 @@ private void writeTypeUse(UType uType, Map<UType, String> typeUseMap, boolean ke
153154
}
154155

155156
if (!Util.isBasicType(uType.fullWithoutAnnotations())
156-
&& typeUseMap.keySet().stream()
157+
&& typeUse1.stream().map(Entry::getKey)
157158
.map(UType::mainType)
158159
.anyMatch(Constants.VALID_ANNOTATIONS::contains)) {
159160
var typeUse = keys ? genericType.param0() : genericType.param1();

validator-generator/src/main/java/io/avaje/validation/generator/ComponentReader.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ private void readMetaData(TypeElement moduleType) {
3535

3636
if (metaData != null) {
3737
metaData.value().stream().map(TypeMirror::toString).forEach(componentMetaData::add);
38-
3938
} else if (metaDataFactory != null) {
4039
metaDataFactory.value().stream().map(TypeMirror::toString).forEach(componentMetaData::add);
4140

validator-generator/src/main/java/io/avaje/validation/generator/ElementAnnotationContainer.java

Lines changed: 58 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -2,148 +2,112 @@
22

33
import static io.avaje.validation.generator.APContext.typeElement;
44
import static io.avaje.validation.generator.PrimitiveUtil.isPrimitiveValidationAnnotations;
5-
import static java.util.stream.Collectors.toMap;
5+
import static java.util.stream.Collectors.toList;
66

7-
import java.util.HashMap;
7+
import java.util.ArrayList;
88
import java.util.List;
99
import java.util.Map;
10+
import java.util.Map.Entry;
1011
import java.util.Objects;
1112
import java.util.Optional;
1213
import java.util.Set;
14+
import java.util.stream.Stream;
1315

1416
import javax.lang.model.element.AnnotationMirror;
1517
import javax.lang.model.element.Element;
1618
import javax.lang.model.element.ExecutableElement;
17-
import javax.lang.model.element.VariableElement;
1819

1920
record ElementAnnotationContainer(
2021
UType genericType,
2122
boolean hasValid,
22-
Map<UType, String> annotations,
23-
Map<UType, String> typeUse1,
24-
Map<UType, String> typeUse2,
25-
Map<UType, String> crossParam) {
23+
List<Entry<UType, String>> annotations,
24+
List<Entry<UType, String>> typeUse1,
25+
List<Entry<UType, String>> typeUse2,
26+
List<Entry<UType, String>> crossParam) {
2627

2728
static ElementAnnotationContainer create(Element element) {
28-
final var hasValid = ValidPrism.isPresent(element);
29-
Map<UType, String> typeUse1;
30-
Map<UType, String> typeUse2;
31-
final Map<UType, String> crossParam = new HashMap<>();
3229
UType uType;
3330
if (element instanceof final ExecutableElement executableElement) {
3431
uType = UType.parse(executableElement.getReturnType());
3532
} else {
3633
uType = UType.parse(element.asType());
3734
}
3835

39-
typeUse1 =
40-
Optional.ofNullable(uType.param0()).map(UType::annotations).stream()
41-
.flatMap(List::stream)
42-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
43-
.collect(
44-
toMap(
45-
a -> UType.parse(a.getAnnotationType()),
46-
a -> AnnotationUtil.annotationAttributeMap(a, element)));
47-
48-
typeUse2 =
49-
Optional.ofNullable(uType.param1()).map(UType::annotations).stream()
50-
.flatMap(List::stream)
51-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
52-
.collect(
53-
toMap(
54-
a -> UType.parse(a.getAnnotationType()),
55-
a -> AnnotationUtil.annotationAttributeMap(a, element)));
56-
57-
final var annotations =
58-
element.getAnnotationMirrors().stream()
59-
.filter(m -> !ValidPrism.isInstance(m))
60-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
61-
.map(a -> {
62-
if (CrossParamConstraintPrism.isPresent(a.getAnnotationType().asElement())) {
63-
crossParam.put(
64-
UType.parse(a.getAnnotationType()),
65-
AnnotationUtil.annotationAttributeMap(a, element));
66-
return null;
67-
}
68-
return a;
69-
})
70-
.filter(Objects::nonNull)
71-
.collect(
72-
toMap(
73-
a -> UType.parse(a.getAnnotationType()),
74-
a -> AnnotationUtil.annotationAttributeMap(a, element)));
36+
final var hasValid =
37+
ValidPrism.isPresent(element)
38+
|| uType.annotations().stream().anyMatch(ValidPrism::isInstance);
39+
40+
List<Entry<UType, String>> typeUse1 = typeUseFor(uType.param0(), element);
41+
List<Entry<UType, String>> typeUse2 = typeUseFor(uType.param1(), element);
42+
43+
final List<Entry<UType, String>> crossParam = new ArrayList<>();
44+
final var annotations = annotations(element, uType, crossParam);
7545

7646
if (Util.isNonNullable(element)) {
7747
var nonNull = UType.parse(APContext.typeElement(NonNullPrism.PRISM_TYPE).asType());
78-
annotations.put(nonNull, "Map.of(\"message\",\"{avaje.NotNull.message}\")");
48+
annotations.add(Map.entry(nonNull, "Map.of(\"message\",\"{avaje.NotNull.message}\")"));
7949
}
8050

8151
return new ElementAnnotationContainer(uType, hasValid, annotations, typeUse1, typeUse2, crossParam);
8252
}
8353

54+
private static List<Entry<UType, String>> annotations(Element element, UType uType, List<Entry<UType, String>> crossParam) {
55+
return Stream.concat(element.getAnnotationMirrors().stream(), uType.annotations().stream())
56+
.filter(m -> !ValidPrism.isInstance(m))
57+
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
58+
.map(a -> {
59+
if (CrossParamConstraintPrism.isPresent(a.getAnnotationType().asElement())) {
60+
crossParam.add(
61+
Map.entry(
62+
UType.parse(a.getAnnotationType()),
63+
AnnotationUtil.annotationAttributeMap(a, element)));
64+
return null;
65+
}
66+
return a;
67+
})
68+
.filter(Objects::nonNull)
69+
.map(a ->
70+
Map.entry(
71+
UType.parse(a.getAnnotationType()),
72+
AnnotationUtil.annotationAttributeMap(a, element)))
73+
.distinct()
74+
.collect(toList());
75+
}
76+
77+
private static List<Entry<UType, String>> typeUseFor(UType uType, Element element) {
78+
return Optional.ofNullable(uType).map(UType::annotations).stream()
79+
.flatMap(List::stream)
80+
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
81+
.map(a ->
82+
Map.entry(
83+
UType.parse(a.getAnnotationType()),
84+
AnnotationUtil.annotationAttributeMap(a, element)))
85+
.toList();
86+
}
87+
8488
static boolean hasMetaConstraintAnnotation(AnnotationMirror m) {
8589
return hasMetaConstraintAnnotation(m.getAnnotationType().asElement())
86-
|| ValidPrism.isInstance(m);
90+
|| ValidPrism.isInstance(m);
8791
}
8892

8993
static boolean hasMetaConstraintAnnotation(Element element) {
9094
return ConstraintPrism.isPresent(element);
9195
}
9296

93-
// it seems we cannot directly retrieve mirrors from var elements, so var Elements needs special handling
94-
95-
static ElementAnnotationContainer create(VariableElement varElement) {
96-
var uType = UType.parse(varElement.asType());
97-
final var annotations =
98-
uType.annotations().stream()
99-
.filter(m -> !ValidPrism.isInstance(m))
100-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
101-
.collect(
102-
toMap(
103-
a -> UType.parse(a.getAnnotationType()),
104-
a -> AnnotationUtil.annotationAttributeMap(a, varElement)));
105-
106-
var typeUse1 =
107-
Optional.ofNullable(uType.param0()).map(UType::annotations).stream()
108-
.flatMap(List::stream)
109-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
110-
.collect(
111-
toMap(
112-
a -> UType.parse(a.getAnnotationType()),
113-
a -> AnnotationUtil.annotationAttributeMap(a, varElement)));
114-
115-
var typeUse2 =
116-
Optional.ofNullable(uType.param1()).map(UType::annotations).stream()
117-
.flatMap(List::stream)
118-
.filter(ElementAnnotationContainer::hasMetaConstraintAnnotation)
119-
.collect(
120-
toMap(
121-
a -> UType.parse(a.getAnnotationType()),
122-
a -> AnnotationUtil.annotationAttributeMap(a, varElement)));
123-
124-
final boolean hasValid = uType.annotations().stream().anyMatch(ValidPrism::isInstance);
125-
126-
if (Util.isNonNullable(varElement)) {
127-
var nonNull = UType.parse(APContext.typeElement(NonNullPrism.PRISM_TYPE).asType());
128-
annotations.put(nonNull, "Map.of(\"message\",\"{avaje.NotNull.message}\")");
129-
}
130-
131-
return new ElementAnnotationContainer(uType, hasValid, annotations, typeUse1, typeUse2, Map.of());
132-
}
133-
13497
public void addImports(Set<String> importTypes) {
13598
importTypes.addAll(genericType.importTypes());
136-
annotations.keySet().forEach(t -> importTypes.addAll(t.importTypes()));
137-
typeUse1.keySet().forEach(t -> importTypes.addAll(t.importTypes()));
138-
typeUse2.keySet().forEach(t -> importTypes.addAll(t.importTypes()));
99+
annotations.forEach(t -> importTypes.addAll(t.getKey().importTypes()));
100+
typeUse1.forEach(t -> importTypes.addAll(t.getKey().importTypes()));
101+
typeUse2.forEach(t -> importTypes.addAll(t.getKey().importTypes()));
139102
}
140103

141104
boolean isEmpty() {
142105
return annotations.isEmpty() && typeUse1.isEmpty() && typeUse2.isEmpty();
143106
}
144107

145108
boolean supportsPrimitiveValidation() {
146-
for (final var validationAnnotation : annotations.keySet()) {
109+
for (final var entry : annotations) {
110+
var validationAnnotation = entry.getKey();
147111
ConstraintPrism.getOptionalOn(typeElement(validationAnnotation.full()))
148112
.ifPresent(p -> {
149113
if (p.unboxPrimitives()) {

0 commit comments

Comments
 (0)