Skip to content

Commit d96c42f

Browse files
authored
Add @SubTypes (#280)
* implement subtypes * blackbox test * doc
1 parent 699abf1 commit d96c42f

File tree

25 files changed

+438
-9
lines changed

25 files changed

+438
-9
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package example.avaje.subtypes;
2+
3+
import java.util.List;
4+
import java.util.UUID;
5+
6+
import io.avaje.validation.constraints.NotEmpty;
7+
8+
public final record ByIdSelector(@NotEmpty List<UUID> ids) implements EntitySelector {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package example.avaje.subtypes;
2+
3+
import io.avaje.validation.constraints.NotBlank;
4+
5+
public final record ByQuerySelector(@NotBlank String query) implements EntitySelector {}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package example.avaje.subtypes;
2+
3+
import io.avaje.validation.ValidSubTypes;
4+
5+
@ValidSubTypes({ByQuerySelector.class, ByIdSelector.class})
6+
public interface EntitySelector {}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package example.avaje.subtypes;
2+
3+
import jakarta.validation.Valid;
4+
5+
@Valid
6+
public record SubtypeEntity(@Valid EntitySelector selector) {}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package example.avaje.subtypes.sealed;
2+
3+
import java.util.List;
4+
import java.util.UUID;
5+
6+
import io.avaje.validation.constraints.NotEmpty;
7+
8+
public final class ByIdSelectorSealed implements SealedEntitySelector {
9+
10+
@NotEmpty private final List<UUID> ids;
11+
12+
public ByIdSelectorSealed(@NotEmpty List<UUID> ids) {
13+
this.ids = ids;
14+
}
15+
16+
public List<UUID> getIds() {
17+
return ids;
18+
}
19+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package example.avaje.subtypes.sealed;
2+
3+
import io.avaje.validation.constraints.NotBlank;
4+
5+
public final record ByQuerySelectorSealed(@NotBlank String query) implements SealedEntitySelector {}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package example.avaje.subtypes.sealed;
2+
3+
import jakarta.validation.Valid;
4+
5+
@Valid
6+
public record SealedEntity(@Valid SealedEntitySelector selector) {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package example.avaje.subtypes.sealed;
2+
3+
import java.util.List;
4+
import java.util.UUID;
5+
6+
import example.avaje.subtypes.sealed.SealedEntitySelector.NestedSealed;
7+
import io.avaje.validation.ValidSubTypes;
8+
import io.avaje.validation.constraints.NotEmpty;
9+
10+
@ValidSubTypes
11+
public sealed interface SealedEntitySelector
12+
permits ByQuerySelectorSealed, ByIdSelectorSealed, NestedSealed {
13+
14+
public final record NestedSealed(@NotEmpty List<UUID> ids) implements SealedEntitySelector {}
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package example.avaje.subtypes;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.List;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
import io.avaje.validation.Validator;
10+
11+
class EntitySelectorTest {
12+
13+
Validator validator = Validator.builder().build();
14+
15+
@Test
16+
void validByIdSelector() {
17+
var entity = new SubtypeEntity(new ByIdSelector(List.of()));
18+
19+
assertThat(validator.check(entity).iterator().next().message()).isEqualTo("must not be empty");
20+
}
21+
22+
@Test
23+
void validByIdQuery() {
24+
var entity = new SubtypeEntity(new ByQuerySelector(""));
25+
assertThat(validator.check(entity).iterator().next().message()).isEqualTo("must not be blank");
26+
}
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package example.avaje.subtypes.sealed;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.util.List;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
import io.avaje.validation.Validator;
10+
11+
class SealedEntitySelectorTest {
12+
13+
Validator validator = Validator.builder().build();
14+
15+
@Test
16+
void validByIdSelector() {
17+
var entity = new SealedEntity(new ByIdSelectorSealed(List.of()));
18+
19+
assertThat(validator.check(entity).iterator().next().message()).isEqualTo("must not be empty");
20+
}
21+
22+
@Test
23+
void validByIdQuery() {
24+
var entity = new SealedEntity(new ByQuerySelectorSealed(""));
25+
assertThat(validator.check(entity).iterator().next().message()).isEqualTo("must not be blank");
26+
}
27+
}

blackbox-test/src/test/java/example/avaje/typeuse/DefaultValidatorProviderTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88

99
import io.avaje.http.api.ValidationException;
1010
import io.avaje.http.api.Validator;
11+
import io.avaje.inject.spi.AvajeModule;
1112
import io.avaje.inject.spi.Builder;
12-
import io.avaje.inject.spi.Module;
1313
import io.avaje.inject.test.InjectTest;
1414
import jakarta.inject.Inject;
1515

1616
@InjectTest
1717
class DefaultValidatorProviderTest {
18-
Module mod =
19-
new Module() {
18+
AvajeModule mod =
19+
new AvajeModule() {
2020

2121
@Override
2222
public Class<?>[] classes() {

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<nexus.staging.autoReleaseAfterClose>true</nexus.staging.autoReleaseAfterClose>
2929
<maven.compiler.release>17</maven.compiler.release>
3030
<inject.version>10.3</inject.version>
31-
<spi.version>2.5</spi.version>
31+
<spi.version>2.9</spi.version>
3232
<project.build.outputTimestamp>2025-02-10T08:50:36Z</project.build.outputTimestamp>
3333
</properties>
3434

validator-generator/pom.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<description>annotation processor generating validation adapters</description>
1515
<properties>
1616
<avaje.prisms.version>1.38</avaje.prisms.version>
17+
<io.jstach.version>1.3.6</io.jstach.version>
1718
</properties>
1819

1920
<dependencies>
@@ -66,6 +67,22 @@
6667
<optional>true</optional>
6768
<scope>provided</scope>
6869
</dependency>
70+
71+
<dependency>
72+
<groupId>io.jstach</groupId>
73+
<artifactId>jstachio-annotation</artifactId>
74+
<version>${io.jstach.version}</version>
75+
<optional>true</optional>
76+
<scope>provided</scope>
77+
</dependency>
78+
<dependency>
79+
<groupId>io.jstach</groupId>
80+
<artifactId>jstachio-apt</artifactId>
81+
<version>${io.jstach.version}</version>
82+
<optional>true</optional>
83+
<scope>provided</scope>
84+
</dependency>
85+
6986
<dependency>
7087
<groupId>io.avaje</groupId>
7188
<artifactId>avaje-inject</artifactId>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package io.avaje.validation.generator;
2+
3+
import static io.avaje.validation.generator.APContext.createSourceFile;
4+
import static io.avaje.validation.generator.APContext.logError;
5+
6+
import java.io.IOException;
7+
import java.io.Writer;
8+
import java.util.List;
9+
import java.util.Set;
10+
import java.util.TreeSet;
11+
12+
import javax.lang.model.element.Modifier;
13+
import javax.lang.model.element.TypeElement;
14+
import javax.lang.model.type.TypeMirror;
15+
import javax.tools.JavaFileObject;
16+
17+
import io.jstach.jstache.JStache;
18+
19+
public class SubTypeWriter {
20+
21+
TypeElement element;
22+
List<String> subtypeStrings;
23+
private final Set<String> importTypes = new TreeSet<>();
24+
private final String shortName;
25+
private final String adapterPackage;
26+
private final String adapterFullName;
27+
private final String shortType;
28+
29+
public SubTypeWriter(TypeElement element, List<TypeMirror> subtypes) {
30+
this.element = element;
31+
this.subtypeStrings =
32+
subtypes.stream().map(TypeMirror::toString).map(ProcessorUtils::shortType).toList();
33+
final AdapterName adapterName = new AdapterName(element);
34+
this.shortName = adapterName.shortName();
35+
this.adapterPackage = adapterName.adapterPackage();
36+
this.adapterFullName = adapterName.fullName();
37+
this.shortType = element.getQualifiedName().toString().transform(ProcessorUtils::shortType);
38+
39+
importTypes.add("io.avaje.validation.adapter.ValidationAdapter");
40+
importTypes.add("io.avaje.validation.adapter.ValidationContext");
41+
importTypes.add("io.avaje.validation.adapter.ValidationRequest");
42+
importTypes.add("io.avaje.validation.spi.Generated");
43+
subtypes.stream().map(TypeMirror::toString).forEach(importTypes::add);
44+
}
45+
46+
private Writer createFileWriter() throws IOException {
47+
final JavaFileObject jfo = createSourceFile(adapterFullName);
48+
return jfo.openWriter();
49+
}
50+
51+
void write() {
52+
Append writer;
53+
try {
54+
writer = new Append(createFileWriter());
55+
56+
var template =
57+
new SubTemplate(
58+
adapterPackage,
59+
importTypes,
60+
shortName,
61+
shortType,
62+
subtypeStrings,
63+
APContext.jdkVersion() > 17,
64+
element.getModifiers().contains(Modifier.SEALED))
65+
.render();
66+
67+
writer.append(template).close();
68+
} catch (IOException e) {
69+
70+
logError("Error writing ValidationAdapter for %s %s", element, e);
71+
}
72+
}
73+
74+
String fullName() {
75+
return adapterFullName;
76+
}
77+
78+
@JStache(
79+
template =
80+
"""
81+
package {{packageName}};
82+
83+
{{#imports}}
84+
import {{.}};
85+
{{/imports}}
86+
87+
@Generated("avaje-validation-generator")
88+
public class {{shortName}}ValidationAdapter implements ValidationAdapter<{{shortType}}> {
89+
90+
{{#subtypes}}
91+
private final ValidationAdapter<{{.}}> subAdapter{{@index}};
92+
{{/subtypes}}
93+
94+
public {{shortName}}ValidationAdapter(ValidationContext ctx) {
95+
{{#subtypes}}
96+
this.subAdapter{{@index}} = ctx.adapter({{.}}.class);
97+
{{/subtypes}}
98+
}
99+
100+
@Override
101+
public boolean validate({{shortType}} value, ValidationRequest request, String field) {
102+
{{#switchValid}}
103+
return switch(value) {
104+
case null -> true;
105+
{{#subtypes}}
106+
case {{.}} val -> subAdapter{{@index}}.validate(val, request, field);
107+
{{/subtypes}}
108+
{{^sealed}}
109+
default -> true;
110+
{{/sealed}}
111+
};
112+
{{/switchValid}}
113+
{{^switchValid}}
114+
{{#subtypes}}
115+
if (value instanceof {{.}} val) {
116+
return subAdapter{{@index}}.validate(val, request, field);
117+
}
118+
{{/subtypes}}
119+
return true;
120+
{{/switchValid}}
121+
}
122+
}
123+
""")
124+
public record SubTemplate(
125+
String packageName,
126+
Set<String> imports,
127+
String shortName,
128+
String shortType,
129+
List<String> subtypes,
130+
boolean switchValid,
131+
boolean sealed) {
132+
String render() {
133+
return SubTemplateRenderer.of().execute(this);
134+
}
135+
}
136+
}

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
import static java.util.stream.Collectors.joining;
44

55
import java.io.IOException;
6-
import java.net.URI;
76
import java.nio.file.Files;
8-
import java.nio.file.OpenOption;
9-
import java.nio.file.Path;
107
import java.nio.file.StandardOpenOption;
118
import java.util.ArrayList;
129
import java.util.HashSet;
@@ -28,7 +25,6 @@
2825
import io.avaje.prism.GenerateAPContext;
2926
import io.avaje.prism.GenerateModuleInfoReader;
3027
import io.avaje.prism.GenerateUtils;
31-
3228
import static io.avaje.validation.generator.APContext.*;
3329

3430
@GenerateUtils
@@ -46,6 +42,7 @@
4642
JavaxConstraintPrism.PRISM_TYPE,
4743
CrossParamConstraintPrism.PRISM_TYPE,
4844
ValidMethodPrism.PRISM_TYPE,
45+
ValidSubTypesPrism.PRISM_TYPE,
4946
"io.avaje.spi.ServiceProvider"
5047
})
5148
public final class ValidationProcessor extends AbstractProcessor {
@@ -121,6 +118,7 @@ public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment
121118
getElements(round, MixInPrism.PRISM_TYPE).ifPresent(this::writeAdaptersForMixInTypes);
122119
getElements(round, ImportValidPojoPrism.PRISM_TYPE).ifPresent(this::writeAdaptersForImported);
123120
getElements(round, "io.avaje.spi.ServiceProvider").ifPresent(this::registerSPI);
121+
getElements(round, ValidSubTypesPrism.PRISM_TYPE).ifPresent(this::writeSubTypeAdaptersForImported);
124122

125123
initialiseComponent();
126124
cascadeTypes();
@@ -213,6 +211,29 @@ private boolean ignoreType(String type) {
213211
|| sourceTypes.contains(type);
214212
}
215213

214+
/** Elements that have a {@code @ValidSubTypes} annotation. */
215+
private void writeSubTypeAdaptersForImported(Set<? extends Element> subtypeElements) {
216+
for (final var element : ElementFilter.typesIn(subtypeElements)) {
217+
var prism = ValidSubTypesPrism.getInstanceOn(element);
218+
var subtypes = new ArrayList<>(prism.value());
219+
subtypes.addAll(element.getPermittedSubclasses());
220+
221+
var seen = new HashSet<>();
222+
subtypes.removeIf(s -> !seen.add(s.toString()));
223+
var writer = new SubTypeWriter(element, subtypes);
224+
writer.write();
225+
metaData.add(writer.fullName());
226+
// cascade types
227+
for (final TypeMirror importType : subtypes) {
228+
// if imported by mixin annotation skip
229+
if (mixInImports.contains(importType.toString())) {
230+
continue;
231+
}
232+
writeAdapterForType(asTypeElement(importType));
233+
}
234+
}
235+
}
236+
216237
/** Elements that have a {@code @Valid.Import} annotation. */
217238
private void writeAdaptersForImported(Set<? extends Element> importedElements) {
218239
for (final var importedElement : ElementFilter.typesIn(importedElements)) {

0 commit comments

Comments
 (0)