Skip to content

Commit 5bc784d

Browse files
authored
Constructor parser upgrades (#16)
* Constructor-based parsing follow-ups - Honor @JsonIgnore consistently across analyzer, validator, and generator (collapses four drifting field-skip predicates into a single ConstructorAnalyzer.getParseableFields). - Match generic constructor parameter types: List<String> now resolves to reflection's raw List, fixing immutable POJOs with generic-typed constructor parameters silently failing constructor matching. - Use field names for constructor-bound locals so sibling fields don't collide on level-based names (threaded through Collection, Map, PrimitiveArray, CustomObject, Enum, Simple parsers; legacy setter-based output stays byte-identical). - Allow user Map subclasses (class FooMap extends HashMap<K,V>) to dispatch to a custom parser by simple name, while keeping JDK Map types excluded so raw Map fields don't route to a non-existent MapParser. - Bump version to 1.1.1-SNAPSHOT. * Test new constructor-based parsing behaviors via t1-t3 pipeline Adds two new immutable POJO fixtures wired into TestRootObjectType so the t1 (expected roundtrip), t2 (generated-vs-expected), and t3 (generated roundtrip) scripts all exercise the new behaviors: - TestConstructorWithGenericsType — constructor params List<String>, Map<String,Integer>, Set<String>, int[], String[]. Covers stripGenerics matching plus the variableName overrides on CollectionFieldParser, MapFieldParser, and PrimitiveArrayFieldParser. - TestConstructorWithIgnoredFieldType — final @JsonIgnore field that is NOT a constructor parameter (derived from another). Covers getParseableFields skipping @JsonIgnore on the constructor-based path so the single-arg constructor matches the single parseable field. Plus a focused unit test CustomObjectFieldParserTest for the isJdkMap guard (raw java.util Maps don't route to a non-existent parser; user Map subclasses still pass through). * Support Java records in constructor-based parsing - Detect clazz.isRecord() and use getRecordComponents() instead of source-file parsing - Skip validateGetterSetter shape check for records (bare x() accessors) - Add TestRecordType fixture wired into TestRootObjectType for t1-t3 coverage * Extract local-variable name resolution into ParserCommonUtils.localVarName Replaces the variableName != null ? ... : getVariableNameForLevel(...) ternary duplicated 17 times across the collection/map/primitive-array parsers.
1 parent f0d7a30 commit 5bc784d

22 files changed

Lines changed: 662 additions & 148 deletions

gwt-beans-codegen-core/src/main/java/nl/aerius/codegen/analyzer/ConstructorAnalyzer.java

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.lang.reflect.Constructor;
44
import java.lang.reflect.Field;
55
import java.lang.reflect.Modifier;
6+
import java.lang.reflect.RecordComponent;
67
import java.nio.file.Path;
78
import java.util.ArrayList;
89
import java.util.Arrays;
@@ -12,11 +13,13 @@
1213
import java.util.Set;
1314
import java.util.stream.Collectors;
1415

16+
import com.fasterxml.jackson.annotation.JsonIgnore;
1517
import com.github.javaparser.StaticJavaParser;
1618
import com.github.javaparser.ast.CompilationUnit;
1719
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
1820
import com.github.javaparser.ast.body.ConstructorDeclaration;
1921

22+
import nl.aerius.codegen.generator.parser.ParserCommonUtils;
2023
import nl.aerius.codegen.util.Logger;
2124

2225
/**
@@ -91,6 +94,12 @@ private Optional<ConstructorInfo> findMatchingConstructorInfo(final Class<?> cla
9194
return Optional.empty();
9295
}
9396

97+
// Records: use the compiler-generated canonical constructor and component names
98+
// directly via reflection. No source-file lookup needed.
99+
if (clazz.isRecord()) {
100+
return buildRecordConstructorInfo(clazz, parseableFields);
101+
}
102+
94103
final Set<String> fieldNames = parseableFields.stream()
95104
.map(Field::getName)
96105
.collect(Collectors.toSet());
@@ -159,6 +168,25 @@ private Optional<ConstructorInfo> findMatchingConstructorInfo(final Class<?> cla
159168
return Optional.empty();
160169
}
161170

171+
private Optional<ConstructorInfo> buildRecordConstructorInfo(final Class<?> clazz, final List<Field> parseableFields) {
172+
final RecordComponent[] components = clazz.getRecordComponents();
173+
final List<String> paramNames = Arrays.stream(components)
174+
.map(RecordComponent::getName)
175+
.collect(Collectors.toList());
176+
final Class<?>[] paramTypes = Arrays.stream(components)
177+
.map(RecordComponent::getType)
178+
.toArray(Class<?>[]::new);
179+
try {
180+
final Constructor<?> canonical = clazz.getDeclaredConstructor(paramTypes);
181+
logger.info("Found canonical record constructor for " + clazz.getName() + " with parameters: " + paramNames);
182+
return Optional.of(new ConstructorInfo(canonical, paramNames, parseableFields));
183+
} catch (final NoSuchMethodException e) {
184+
// Should be impossible: every record has a compiler-generated canonical constructor.
185+
logger.warn("Record " + clazz.getName() + " has no canonical constructor: " + e.getMessage());
186+
return Optional.empty();
187+
}
188+
}
189+
162190
/**
163191
* Validates that constructor parameter types match the corresponding field types.
164192
*
@@ -231,20 +259,27 @@ private Class<?> getPrimitiveWrapper(final Class<?> type) {
231259
}
232260

233261
/**
234-
* Gets all fields that should be parsed (non-static, non-transient, non-synthetic).
262+
* Canonical predicate for fields that participate in JSON parsing: non-static,
263+
* non-transient, non-synthetic, and not {@code @JsonIgnore}. Shared by analyzer,
264+
* validator, and generator.
235265
*/
236266
public static List<Field> getParseableFields(final Class<?> clazz) {
237267
final List<Field> fields = new ArrayList<>();
238268
for (final Field field : clazz.getDeclaredFields()) {
239-
if (!Modifier.isStatic(field.getModifiers())
240-
&& !Modifier.isTransient(field.getModifiers())
241-
&& !field.isSynthetic()) {
269+
if (isParseable(field)) {
242270
fields.add(field);
243271
}
244272
}
245273
return fields;
246274
}
247275

276+
public static boolean isParseable(final Field field) {
277+
return !Modifier.isStatic(field.getModifiers())
278+
&& !Modifier.isTransient(field.getModifiers())
279+
&& !field.isSynthetic()
280+
&& !field.isAnnotationPresent(JsonIgnore.class);
281+
}
282+
248283
/**
249284
* Checks if a class has setter methods for all parseable fields.
250285
* If true, setter-based parsing is preferred over constructor-based.
@@ -301,11 +336,13 @@ private Constructor<?> findMatchingReflectionConstructor(final Class<?> clazz,
301336
}
302337

303338
/**
304-
* Checks if a source type name matches a reflection type.
339+
* Matches a source type name (with generics stripped) against a reflection type, so
340+
* {@code List<String>} resolves to raw {@code List} / {@code java.util.List}.
305341
*/
306342
private boolean typeNamesMatch(final String sourceTypeName, final Class<?> reflectionType) {
307-
return sourceTypeName.equals(reflectionType.getName())
308-
|| sourceTypeName.equals(reflectionType.getSimpleName());
343+
final String rawSourceName = ParserCommonUtils.stripGenerics(sourceTypeName);
344+
return rawSourceName.equals(reflectionType.getName())
345+
|| rawSourceName.equals(reflectionType.getSimpleName());
309346
}
310347

311348
/**

gwt-beans-codegen-core/src/main/java/nl/aerius/codegen/analyzer/TypeAnalyzer.java

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package nl.aerius.codegen.analyzer;
22

33
import java.lang.reflect.Field;
4-
import java.lang.reflect.Modifier;
54
import java.lang.reflect.ParameterizedType;
65
import java.lang.reflect.Type;
76
import java.math.BigDecimal;
@@ -160,13 +159,11 @@ private void analyzeTypeAndSubtypes(final Class<?> type) {
160159

161160
// Always analyze fields, even for types with custom parsers
162161
// This ensures we discover all types that might need parsers
163-
for (final Field field : type.getDeclaredFields()) {
164-
if (!Modifier.isStatic(field.getModifiers()) && !Modifier.isTransient(field.getModifiers())) {
165-
try {
166-
analyzeField(field);
167-
} catch (final TypeNotPresentException e) {
168-
skippedTypes.add(e.typeName());
169-
}
162+
for (final Field field : ConstructorAnalyzer.getParseableFields(type)) {
163+
try {
164+
analyzeField(field);
165+
} catch (final TypeNotPresentException e) {
166+
skippedTypes.add(e.typeName());
170167
}
171168
}
172169
}

gwt-beans-codegen-core/src/main/java/nl/aerius/codegen/generator/ParserWriterUtils.java

Lines changed: 31 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -133,24 +133,24 @@ public static void generateParserForFields(final TypeSpec.Builder typeSpec, fina
133133

134134
if (constructorInfo.isPresent()) {
135135
// Constructor-based: single parse method that constructs the object
136-
typeSpec.addMethod(createConstructorBasedParseMethod(targetClass, parserPackage, classFinder, constructorInfo.get()));
136+
typeSpec.addMethod(createConstructorBasedParseMethod(targetClass, parserPackage, constructorInfo.get()));
137137
} else {
138138
// Setter-based: existing approach
139-
addSetterBasedParseMethods(typeSpec, targetClass, parserPackage, classFinder);
139+
addSetterBasedParseMethods(typeSpec, targetClass, parserPackage);
140140
}
141141
}
142142

143143
/**
144144
* Adds setter-based parse methods to the type specification.
145145
*/
146146
private static void addSetterBasedParseMethods(final TypeSpec.Builder typeSpec, final Class<?> targetClass,
147-
final String parserPackage, final ClassFinder classFinder) {
147+
final String parserPackage) {
148148
if (hasJsonTypeInfoWithNameDiscriminator(targetClass)) {
149149
typeSpec.addMethod(createPolymorphicObjectParseMethod(targetClass, parserPackage));
150150
} else {
151151
typeSpec.addMethod(createStandardObjectParseMethod(targetClass, parserPackage));
152152
}
153-
typeSpec.addMethod(createConfigParseMethod(targetClass, parserPackage, classFinder));
153+
typeSpec.addMethod(createConfigParseMethod(targetClass, parserPackage));
154154
}
155155

156156
/**
@@ -248,16 +248,11 @@ public static ClassName determineParserClassName(final Type type, final String p
248248
}
249249
}
250250
// Fallback for complex types - might need refinement
251-
String typeName = type.getTypeName();
252-
// Basic attempt to get a simple name
253-
if (typeName.contains("<")) {
254-
typeName = typeName.substring(0, typeName.indexOf('<'));
255-
}
251+
String typeName = ParserCommonUtils.stripGenerics(type.getTypeName());
256252
if (typeName.contains(".")) {
257253
typeName = typeName.substring(typeName.lastIndexOf('.') + 1);
258254
}
259255
return determineParserClassName(typeName, parserPackage);
260-
// throw new IllegalArgumentException("Cannot determine parser class name for type: " + type.getTypeName());
261256
}
262257

263258
/**
@@ -376,7 +371,7 @@ private static MethodSpec createPolymorphicObjectParseMethod(final Class<?> targ
376371
* Parses all fields into local variables and then calls the constructor.
377372
*/
378373
private static MethodSpec createConstructorBasedParseMethod(final Class<?> targetClass, final String parserPackage,
379-
final ClassFinder classFinder, final ConstructorInfo constructorInfo) {
374+
final ConstructorInfo constructorInfo) {
380375
final ClassName targetClassName = ClassName.get(targetClass);
381376
final MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder("parse")
382377
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
@@ -439,7 +434,7 @@ private static String generateFieldParsingCode(final MethodSpec.Builder methodBu
439434
return resultVar;
440435
}
441436

442-
private static MethodSpec createConfigParseMethod(final Class<?> targetClass, final String parserPackage, final ClassFinder classFinder) {
437+
private static MethodSpec createConfigParseMethod(final Class<?> targetClass, final String parserPackage) {
443438
final ClassName targetClassName = ClassName.get(targetClass);
444439
final MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder("parse")
445440
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
@@ -458,56 +453,30 @@ private static MethodSpec createConfigParseMethod(final Class<?> targetClass, fi
458453
.addStatement("$T.parse($L, config)", determineParserClassName(superclass, parserPackage), ParserCommonUtils.BASE_OBJECT_PARAM_NAME);
459454
}
460455

461-
// Process all fields
462-
for (final Field field : targetClass.getDeclaredFields()) {
463-
if (!java.lang.reflect.Modifier.isStatic(field.getModifiers())
464-
&& !java.lang.reflect.Modifier.isTransient(field.getModifiers())
465-
&& !field.isSynthetic()) {
466-
467-
// ==> INSERT @JsonIgnore CHECK HERE <==
468-
try {
469-
final Class<? extends java.lang.annotation.Annotation> jsonIgnoreAnnotation = (Class<? extends java.lang.annotation.Annotation>) classFinder
470-
.forName("com.fasterxml.jackson.annotation.JsonIgnore");
471-
if (field.isAnnotationPresent(jsonIgnoreAnnotation)) {
472-
methodBuilder.addCode("\n"); // Add newline for spacing
473-
methodBuilder.addComment("Skipping ignored field: $L", field.getName());
474-
continue; // Skip processing this ignored field
475-
}
476-
} catch (final ClassNotFoundException e) {
477-
// Log or handle the case where JsonIgnore annotation class is not available on the classpath during generation
478-
methodBuilder.addCode("\n");
479-
methodBuilder.addComment("WARNING: Cannot check for @JsonIgnore, com.fasterxml.jackson.annotation.JsonIgnore not found.");
480-
// Proceed without checking - might generate code for ignored fields if annotation is used but class not found
481-
}
482-
// ==> END @JsonIgnore CHECK <==
483-
484-
methodBuilder.addCode("\n");
485-
methodBuilder.addComment("Parse $L", field.getName());
486-
// Determine if null check is required (true for non-primitives)
487-
final boolean requireNonNull = !ParserCommonUtils.isPrimitiveType(field.getGenericType());
488-
methodBuilder.addCode(ParserCommonUtils.createFieldExistsCheck(
489-
ParserCommonUtils.BASE_OBJECT_PARAM_NAME, // Use constant here
490-
field.getName(),
491-
requireNonNull, // Pass determined value
492-
innerCode -> {
493-
// Pass CodeBlock representing the field name string literal
494-
final CodeBlock fieldAccess = ParserCommonUtils.createFieldAccessCode(
495-
field.getGenericType(),
496-
ParserCommonUtils.BASE_OBJECT_PARAM_NAME, // Use constant here
497-
CodeBlock.of("$S", field.getName()));
498-
499-
final String resultVar = dispatchGenerateParsingCodeInto(
500-
innerCode,
501-
field.getGenericType(),
502-
ParserCommonUtils.BASE_OBJECT_PARAM_NAME, // Use constant here
503-
parserPackage,
504-
fieldAccess, // Pass the code to access the field's data
505-
1, // Start top-level fields at level 1 - Remove last arg
506-
field.getGenericType() // Pass fieldType
507-
);
508-
innerCode.addStatement("config.set$L($L)", ParserCommonUtils.capitalize(field.getName()), resultVar);
509-
}));
510-
}
456+
for (final Field field : ConstructorAnalyzer.getParseableFields(targetClass)) {
457+
methodBuilder.addCode("\n");
458+
methodBuilder.addComment("Parse $L", field.getName());
459+
final boolean requireNonNull = !ParserCommonUtils.isPrimitiveType(field.getGenericType());
460+
methodBuilder.addCode(ParserCommonUtils.createFieldExistsCheck(
461+
ParserCommonUtils.BASE_OBJECT_PARAM_NAME,
462+
field.getName(),
463+
requireNonNull,
464+
innerCode -> {
465+
final CodeBlock fieldAccess = ParserCommonUtils.createFieldAccessCode(
466+
field.getGenericType(),
467+
ParserCommonUtils.BASE_OBJECT_PARAM_NAME,
468+
CodeBlock.of("$S", field.getName()));
469+
470+
final String resultVar = dispatchGenerateParsingCodeInto(
471+
innerCode,
472+
field.getGenericType(),
473+
ParserCommonUtils.BASE_OBJECT_PARAM_NAME,
474+
parserPackage,
475+
fieldAccess,
476+
1,
477+
field.getGenericType());
478+
innerCode.addStatement("config.set$L($L)", ParserCommonUtils.capitalize(field.getName()), resultVar);
479+
}));
511480
}
512481

513482
return methodBuilder.build();

0 commit comments

Comments
 (0)