Skip to content

Commit e77f57a

Browse files
committed
add copy constructors to custom type for jpa implicit dto projection (#53)
* add copy constructors to custom type for jpa implicit dto projection * ignore AI file * fix swagger nullability for collections * fix class scanning of sub-types
1 parent 124ca22 commit e77f57a

11 files changed

Lines changed: 380 additions & 73 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@ build/
4646

4747
### Local Development ###
4848
application-local.yml
49+
/.output.txt

src/main/java/it/aboutbits/springboot/toolbox/reflection/util/ClassScannerUtil.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import io.github.classgraph.ClassGraph;
44
import io.github.classgraph.ClassInfo;
55
import io.github.classgraph.ScanResult;
6-
import lombok.NonNull;
6+
import org.jspecify.annotations.NullMarked;
77

88
import java.lang.annotation.Annotation;
99
import java.util.Set;
1010
import java.util.stream.Collectors;
1111

12+
@NullMarked
1213
public final class ClassScannerUtil {
1314
private ClassScannerUtil() {
1415
}
@@ -34,14 +35,17 @@ public String[] getScannedPackages() {
3435
}
3536

3637
@SuppressWarnings("unchecked")
37-
public <T> Set<Class<? extends T>> getSubTypesOf(@NonNull Class<T> clazz) {
38-
return scanResult.getClassesImplementing(clazz).loadClasses()
38+
public <T> Set<Class<? extends T>> getSubTypesOf(Class<T> clazz) {
39+
var classInfoList = clazz.isInterface()
40+
? scanResult.getClassesImplementing(clazz)
41+
: scanResult.getSubclasses(clazz);
42+
return classInfoList.loadClasses()
3943
.stream()
4044
.map(item -> (Class<? extends T>) item)
4145
.collect(Collectors.toSet());
4246
}
4347

44-
public Set<Class<?>> getClassesAnnotatedWith(@NonNull Class<? extends Annotation> clazz) {
48+
public Set<Class<?>> getClassesAnnotatedWith(Class<? extends Annotation> clazz) {
4549
var result = scanResult.getClassesWithAnnotation(clazz);
4650
return result.stream().map(
4751
ClassInfo::loadClass

src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMeta.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ public class SwaggerMeta {
1919
@JsonProperty("isMap")
2020
private Boolean isMap = null;
2121
private String mapKeyTypeFqn = null;
22+
23+
@Nullable
24+
@JsonProperty("isNullable")
25+
private Boolean isNullable = null;
2226
}

src/main/java/it/aboutbits/springboot/toolbox/swagger/SwaggerMetaUtil.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22

33
import com.fasterxml.jackson.core.JsonProcessingException;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5-
import lombok.NonNull;
65
import lombok.SneakyThrows;
76
import lombok.extern.slf4j.Slf4j;
8-
import org.springframework.lang.Nullable;
7+
import org.jspecify.annotations.NullMarked;
8+
import org.jspecify.annotations.Nullable;
99

1010
@Slf4j
11+
@NullMarked
1112
public final class SwaggerMetaUtil {
1213
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
1314

1415
private SwaggerMetaUtil() {
1516
}
1617

1718
@SneakyThrows(JsonProcessingException.class)
18-
public static String setMapKeyTypeFqn(@Nullable String currentMeta, @NonNull String value) {
19+
public static String setMapKeyTypeFqn(@Nullable String currentMeta, String value) {
1920
var meta = getSwaggerMeta(currentMeta);
2021
meta.setMapKeyTypeFqn(value);
2122

@@ -31,7 +32,7 @@ public static String setIsMap(@Nullable String currentMeta, boolean value) {
3132
}
3233

3334
@SneakyThrows(JsonProcessingException.class)
34-
public static String setOriginalTypeFqn(@Nullable String currentMeta, @NonNull String value) {
35+
public static String setOriginalTypeFqn(@Nullable String currentMeta, String value) {
3536
var meta = getSwaggerMeta(currentMeta);
3637
meta.setOriginalTypeFqn(value);
3738

@@ -62,7 +63,15 @@ public static String setIsNestedStructure(@Nullable String currentMeta, boolean
6263
return OBJECT_MAPPER.writeValueAsString(meta);
6364
}
6465

65-
private static SwaggerMeta getSwaggerMeta(String currentMeta) {
66+
@SneakyThrows(JsonProcessingException.class)
67+
public static String setIsNullable(@Nullable String currentMeta, boolean value) {
68+
var meta = getSwaggerMeta(currentMeta);
69+
meta.setIsNullable(!value ? null : true);
70+
71+
return OBJECT_MAPPER.writeValueAsString(meta);
72+
}
73+
74+
private static SwaggerMeta getSwaggerMeta(@Nullable String currentMeta) {
6675
var meta = new SwaggerMeta();
6776
if (currentMeta != null) {
6877
try {

src/main/java/it/aboutbits/springboot/toolbox/swagger/customization/default_not_null/NullableCustomizer.java

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
import io.swagger.v3.oas.models.OpenAPI;
44
import io.swagger.v3.oas.models.media.Schema;
5+
import it.aboutbits.springboot.toolbox.swagger.SwaggerMetaUtil;
56
import org.jspecify.annotations.NullMarked;
67
import org.springdoc.core.customizers.OpenApiCustomizer;
78

89
import java.lang.annotation.Annotation;
10+
import java.lang.reflect.AnnotatedArrayType;
11+
import java.lang.reflect.AnnotatedParameterizedType;
912
import java.lang.reflect.AnnotatedType;
13+
import java.lang.reflect.ParameterizedType;
1014
import java.util.ArrayList;
15+
import java.util.Collection;
1116
import java.util.Map;
1217

1318
@NullMarked
@@ -57,9 +62,98 @@ private static void processProperties(
5762
} else {
5863
requiredProperties.remove(propertyName);
5964
}
65+
66+
// Check for nullable type parameters in collections/arrays
67+
var annotatedType = getAnnotatedType(cls, propertyName);
68+
if (annotatedType != null) {
69+
var nullableDepths = new ArrayList<Integer>();
70+
findNullableDepths(annotatedType, 0, nullableDepths);
71+
// Add "nullable" description at each depth where nullable elements are found
72+
for (var depth : nullableDepths) {
73+
addNullableDescriptionAtDepth(property, depth);
74+
}
75+
}
6076
});
6177
}
6278

79+
@org.jspecify.annotations.Nullable
80+
private static AnnotatedType getAnnotatedType(Class<?> cls, String propertyName) {
81+
var currentClass = cls;
82+
while (currentClass != null) {
83+
try {
84+
var field = currentClass.getDeclaredField(propertyName);
85+
return field.getAnnotatedType();
86+
} catch (NoSuchFieldException _) {
87+
}
88+
89+
for (var method : currentClass.getDeclaredMethods()) {
90+
if (method.getName().equals(propertyName)
91+
|| method.getName().equals("get" + capitalize(propertyName))
92+
|| method.getName().equals("is" + capitalize(propertyName))) {
93+
return method.getAnnotatedReturnType();
94+
}
95+
}
96+
97+
currentClass = currentClass.getSuperclass();
98+
}
99+
return null;
100+
}
101+
102+
private static void findNullableDepths(AnnotatedType annotatedType, int depth, ArrayList<Integer> nullableDepths) {
103+
if (annotatedType instanceof AnnotatedParameterizedType parameterizedType) {
104+
var rawType = parameterizedType.getType();
105+
if (rawType instanceof ParameterizedType pt) {
106+
var rawClass = pt.getRawType();
107+
if (rawClass instanceof Class<?> clazz && isCollectionType(clazz)) {
108+
var typeArgs = parameterizedType.getAnnotatedActualTypeArguments();
109+
for (var typeArg : typeArgs) {
110+
if (hasNullableAnnotation(typeArg)) {
111+
nullableDepths.add(depth);
112+
}
113+
// Recursively check nested type parameters
114+
findNullableDepths(typeArg, depth + 1, nullableDepths);
115+
}
116+
}
117+
}
118+
} else if (annotatedType instanceof AnnotatedArrayType arrayType) {
119+
var componentType = arrayType.getAnnotatedGenericComponentType();
120+
if (hasNullableAnnotation(componentType)) {
121+
nullableDepths.add(depth);
122+
}
123+
// Recursively check nested array types
124+
findNullableDepths(componentType, depth + 1, nullableDepths);
125+
}
126+
}
127+
128+
@SuppressWarnings("rawtypes")
129+
private static void addNullableDescriptionAtDepth(Schema<?> schema, int depth) {
130+
Schema currentSchema = schema;
131+
for (int i = 0; i <= depth; i++) {
132+
var items = currentSchema.getItems();
133+
if (items == null) {
134+
return; // Schema structure doesn't match expected depth
135+
}
136+
currentSchema = items;
137+
}
138+
currentSchema.setDescription(SwaggerMetaUtil.setIsNullable(
139+
currentSchema.getDescription(),
140+
true
141+
));
142+
}
143+
144+
private static boolean isCollectionType(Class<?> clazz) {
145+
return Collection.class.isAssignableFrom(clazz) || clazz.isArray();
146+
}
147+
148+
private static boolean hasNullableAnnotation(AnnotatedType annotatedType) {
149+
for (var annotation : annotatedType.getAnnotations()) {
150+
if (annotation.annotationType().getSimpleName().equals("Nullable")) {
151+
return true;
152+
}
153+
}
154+
return false;
155+
}
156+
63157
@org.jspecify.annotations.Nullable
64158
private static Class<?> loadClass(String fqn) {
65159
try {

src/main/java/it/aboutbits/springboot/toolbox/type/EmailAddress.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package it.aboutbits.springboot.toolbox.type;
22

33
import it.aboutbits.springboot.toolbox.validation.util.EmailAddressValidator;
4-
import lombok.NonNull;
4+
import org.jspecify.annotations.NullMarked;
5+
import org.jspecify.annotations.Nullable;
56

67
/**
78
* A record representing an email address, validated and stored in lowercase.
@@ -15,23 +16,28 @@
1516
* @param value the email address string
1617
* @throws IllegalArgumentException if the provided email address is not in a valid format
1718
*/
19+
@NullMarked
1820
public record EmailAddress(String value) implements CustomType<String>, Comparable<EmailAddress> {
19-
public EmailAddress(String value) {
21+
public EmailAddress(@Nullable String value) {
2022
if (value == null || EmailAddressValidator.isNotValid(value)) {
2123
throw new IllegalArgumentException("Value is not a valid email address: " + value);
2224
}
2325

2426
this.value = value.toLowerCase();
2527
}
2628

27-
@NonNull
29+
@SuppressWarnings("unused")
30+
EmailAddress(EmailAddress other) {
31+
this(other.value);
32+
}
33+
2834
@Override
2935
public String toString() {
3036
return value;
3137
}
3238

3339
@Override
34-
public int compareTo(@NonNull EmailAddress o) {
40+
public int compareTo(EmailAddress o) {
3541
return value().compareTo(o.value());
3642
}
3743
}

src/main/java/it/aboutbits/springboot/toolbox/type/Iban.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package it.aboutbits.springboot.toolbox.type;
22

33
import it.aboutbits.springboot.toolbox.validation.util.IbanValidator;
4-
import lombok.NonNull;
4+
import org.jspecify.annotations.NullMarked;
5+
import org.jspecify.annotations.Nullable;
56

67
import java.util.Optional;
78

@@ -11,16 +12,21 @@
1112
*
1213
* @param value the IBAN value, which must be a valid and non-null string
1314
*/
15+
@NullMarked
1416
public record Iban(String value) implements CustomType<String>, Comparable<Iban> {
15-
public Iban(String value) {
17+
public Iban(@Nullable String value) {
1618
if (value == null || IbanValidator.isNotValid(value.toUpperCase())) {
1719
throw new IllegalArgumentException("Value is not a valid IBAN: " + value);
1820
}
1921

2022
this.value = value.toUpperCase();
2123
}
2224

23-
@NonNull
25+
@SuppressWarnings("unused")
26+
Iban(Iban other) {
27+
this(other.value);
28+
}
29+
2430
@Override
2531
public String toString() {
2632
return value;
@@ -31,7 +37,7 @@ public String toString() {
3137
*
3238
* @return An {@code Optional} containing the ABI value if the IBAN is Italian, or an empty {@code Optional} otherwise.
3339
*/
34-
@NonNull
40+
3541
public Optional<String> getAbiIfItalian() {
3642
var iban = value();
3743

@@ -42,7 +48,7 @@ public Optional<String> getAbiIfItalian() {
4248
}
4349

4450
@Override
45-
public int compareTo(@NonNull Iban o) {
51+
public int compareTo(Iban o) {
4652
return value().compareTo(o.value());
4753
}
4854
}

0 commit comments

Comments
 (0)