Skip to content

Commit d066c09

Browse files
committed
Allow @DeconstructorAccessor on fields
For uses cases such as Lombok, allow `@DeconstructorAccessor` on fields and search for a matching getter to use as the accessor. Additionally, refine ordering rules for generated records. The default is now encounter order. Closes #264
1 parent 6d2feed commit d066c09

File tree

10 files changed

+245
-53
lines changed

10 files changed

+245
-53
lines changed

deconstructors.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ A deconstructor is either:
2424
- is annotated with `@RecordBuilder.Deconstructor`
2525
2. a class or interface that is:
2626
- annotated with `@RecordBuilder.Deconstructor`
27-
- has one or more accessor methods annotated with `@RecordBuilder.DeconstructorAccessor`
27+
- has one or more accessor methods or fields annotated with `@RecordBuilder.DeconstructorAccessor`. If it is
28+
a field that is annotated, there must be a matching public, non-static method that is named either the same as the field
29+
or is prefixed with `get` or `is`.
2830

2931
When RecordBuilder encounters a deconstructor it generates a `record` that matches the deconstructor's parameters
3032
and, optionally, creates a record builder for it.

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
<javax-el-version>3.0.1-b09</javax-el-version>
6767
<central-publishing-maven-plugin-version>0.7.0</central-publishing-maven-plugin-version>
6868
<jspecify-version>1.0.0</jspecify-version>
69+
<lombok-version>1.18.42</lombok-version>
6970
</properties>
7071

7172
<name>Record Builder</name>
@@ -177,6 +178,12 @@
177178
<artifactId>jspecify</artifactId>
178179
<version>${jspecify-version}</version>
179180
</dependency>
181+
182+
<dependency>
183+
<groupId>org.projectlombok</groupId>
184+
<artifactId>lombok</artifactId>
185+
<version>${lombok-version}</version>
186+
</dependency>
180187
</dependencies>
181188
</dependencyManagement>
182189

record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ enum ConcreteSettersForOptionalMode {
441441
}
442442

443443
@Retention(RetentionPolicy.SOURCE)
444-
@Target(ElementType.METHOD)
444+
@Target({ ElementType.METHOD, ElementType.FIELD })
445445
@Inherited
446446
@interface DeconstructorAccessor {
447447
/**
@@ -457,7 +457,8 @@ enum ConcreteSettersForOptionalMode {
457457

458458
/**
459459
* The order of the component in the generated record. Components are ordered by this value (lowest to highest).
460-
* Components with the same order value are then ordered alphabetically by component name.
460+
* Components with the same order value are then ordered alphabetically by component name. Note: if
461+
* {@code order()} is the default value, the order in which it occurs in the class is used.
461462
*/
462463
int order() default Integer.MAX_VALUE;
463464
}

record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalDeconstructorProcessor.java

Lines changed: 112 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242

4343
import static io.soabase.recordbuilder.processor.ElementUtils.generateName;
4444
import static io.soabase.recordbuilder.processor.ElementUtils.hasAnnotationTarget;
45+
import static io.soabase.recordbuilder.processor.InternalRecordBuilderProcessor.capitalize;
4546
import static io.soabase.recordbuilder.processor.ParameterSpecUtil.createParameterSpec;
4647
import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.generatedRecordBuilderAnnotation;
4748
import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.recordBuilderGeneratedAnnotation;
@@ -145,60 +146,124 @@ private void addVisibility(Set<Modifier> modifiers) {
145146
// is package-private
146147
}
147148

148-
private List<RecordClassType> buildRecordComponents(TypeElement typeElement) {
149-
List<RecordClassType> components = typeElement.getEnclosedElements().stream()
149+
private static boolean isGetter(Name methodName, Name fieldName) {
150+
return methodName.equals(fieldName) || methodName.toString().equals("get" + capitalize(fieldName.toString()))
151+
|| methodName.toString().equals("is" + capitalize(fieldName.toString()));
152+
}
153+
154+
private List<ExecutableElement> methodsInClass(TypeElement typeElement) {
155+
return typeElement.getEnclosedElements().stream()
150156
.flatMap(e -> (e.getKind() == ElementKind.METHOD) ? Stream.of((ExecutableElement) e) : Stream.empty())
151-
.flatMap(executableElement -> {
152-
DeconstructorAccessor deconstructorAccessor = executableElement
153-
.getAnnotation(DeconstructorAccessor.class);
154-
if (deconstructorAccessor == null) {
155-
return Stream.empty();
156-
}
157+
.toList();
158+
}
157159

158-
if (!executableElement.getModifiers().contains(Modifier.PUBLIC)) {
159-
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
160-
"@DeconstructorAccessor methods must be public.", executableElement);
161-
return Stream.empty();
162-
}
160+
private List<RecordClassType> buildRecordComponents(TypeElement typeElement) {
161+
List<ExecutableElement> methods = methodsInClass(typeElement);
162+
163+
boolean isARecord = typeElement.getKind() == ElementKind.RECORD;
164+
165+
var accessorOrdinal = new Object() {
166+
int value;
167+
};
168+
List<RecordClassType> components = typeElement.getEnclosedElements().stream().flatMap(element -> {
169+
DeconstructorAccessor deconstructorAccessor = element.getAnnotation(DeconstructorAccessor.class);
170+
if (deconstructorAccessor == null) {
171+
return Stream.empty();
172+
}
173+
174+
ExecutableElement executableElement;
175+
176+
if (element.getKind() == ElementKind.METHOD) {
177+
executableElement = (ExecutableElement) element;
178+
} else if (element.getKind() == ElementKind.FIELD) {
179+
if (isARecord) {
180+
return Stream.empty();
181+
}
182+
183+
List<ExecutableElement> candidateGetters = methods.stream()
184+
.filter(method -> method.getModifiers().contains(Modifier.PUBLIC)
185+
&& !method.getModifiers().contains(Modifier.STATIC))
186+
.filter(method -> processingEnv.getTypeUtils().isSameType(method.getReturnType(),
187+
element.asType()))
188+
.filter(method -> isGetter(method.getSimpleName(), element.getSimpleName())).toList();
189+
190+
if (candidateGetters.isEmpty()) {
191+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
192+
"No public getter method found for field annotated with @DeconstructorAccessor: %s"
193+
.formatted(element.getSimpleName()),
194+
element);
195+
return Stream.empty();
196+
} else if (candidateGetters.size() > 1) {
197+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
198+
"Multiple public getter methods found for field annotated with @DeconstructorAccessor: %s. %s"
199+
.formatted(element.getSimpleName(), candidateGetters.stream()
200+
.map(ExecutableElement::getSimpleName).collect(Collectors.joining(", "))),
201+
element);
202+
return Stream.empty();
203+
}
204+
205+
executableElement = candidateGetters.get(0);
206+
} else {
207+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
208+
"@DeconstructorAccessor can only be applied to methods or fields.", element);
209+
return Stream.empty();
210+
}
163211

164-
if (executableElement.getModifiers().contains(Modifier.STATIC)) {
212+
if (!executableElement.getModifiers().contains(Modifier.PUBLIC)) {
213+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
214+
"@DeconstructorAccessor methods must be public.", executableElement);
215+
return Stream.empty();
216+
}
217+
218+
if (executableElement.getModifiers().contains(Modifier.STATIC)) {
219+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
220+
"@DeconstructorAccessor only valid for non-static methods.", executableElement);
221+
return Stream.empty();
222+
}
223+
224+
if (!executableElement.getParameters().isEmpty()) {
225+
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
226+
"@DeconstructorAccessor methods cannot have parameters.", executableElement);
227+
return Stream.empty();
228+
}
229+
230+
TypeName typeName = TypeName.get(executableElement.getReturnType());
231+
TypeName rawTypeName = TypeName
232+
.get(processingEnv.getTypeUtils().erasure(executableElement.getReturnType()));
233+
234+
String name;
235+
if (deconstructorAccessor.name().isEmpty()) {
236+
name = executableElement.getSimpleName().toString();
237+
if (!deconstructorAccessor.prefixPattern().isEmpty()) {
238+
try {
239+
name = extractAndLowercase(Pattern.compile(deconstructorAccessor.prefixPattern()), name);
240+
} catch (Exception e) {
165241
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
166-
"@DeconstructorAccessor only valid for non-static methods.", executableElement);
167-
return Stream.empty();
242+
"Invalid prefix pattern: " + deconstructorAccessor.prefixPattern(), element);
168243
}
244+
}
245+
} else {
246+
name = deconstructorAccessor.name();
247+
}
169248

170-
TypeName typeName = TypeName.get(executableElement.getReturnType());
171-
TypeName rawTypeName = TypeName
172-
.get(processingEnv.getTypeUtils().erasure(executableElement.getReturnType()));
173-
174-
String name;
175-
if (deconstructorAccessor.name().isEmpty()) {
176-
name = executableElement.getSimpleName().toString();
177-
if (!deconstructorAccessor.prefixPattern().isEmpty()) {
178-
try {
179-
name = extractAndLowercase(Pattern.compile(deconstructorAccessor.prefixPattern()),
180-
name);
181-
} catch (Exception e) {
182-
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
183-
"Invalid prefix pattern: " + deconstructorAccessor.prefixPattern(), element);
184-
}
185-
}
186-
} else {
187-
name = deconstructorAccessor.name();
188-
}
249+
List<? extends AnnotationMirror> annotationMirrors = element.getAnnotationMirrors().stream()
250+
.filter(annotation -> !annotation.getAnnotationType().asElement().getSimpleName().toString()
251+
.equals(DeconstructorAccessor.class.getSimpleName()))
252+
.toList();
253+
var type = new RecordClassType(typeName, rawTypeName, name, executableElement.getSimpleName().toString(),
254+
annotationMirrors, List.of());
255+
256+
int order = deconstructorAccessor.order();
257+
if (order == Integer.MAX_VALUE) {
258+
order = accessorOrdinal.value++;
259+
}
189260

190-
List<? extends AnnotationMirror> annotationMirrors = executableElement.getAnnotationMirrors()
191-
.stream().filter(annotation -> !annotation.getAnnotationType().asElement().getSimpleName()
192-
.toString().equals(DeconstructorAccessor.class.getSimpleName()))
193-
.toList();
194-
var type = new RecordClassType(typeName, rawTypeName, name,
195-
executableElement.getSimpleName().toString(), annotationMirrors, List.of());
196-
var orderedType = Map.entry(deconstructorAccessor.order(), type);
197-
return Stream.of(orderedType);
198-
}).sorted((o1, o2) -> {
199-
int diff = o1.getKey().compareTo(o2.getKey());
200-
return (diff == 0) ? o1.getValue().name().compareTo(o2.getValue().name()) : diff;
201-
}).map(Map.Entry::getValue).toList();
261+
var orderedType = Map.entry(order, type);
262+
return Stream.of(orderedType);
263+
}).sorted((o1, o2) -> {
264+
int diff = o1.getKey().compareTo(o2.getKey());
265+
return (diff == 0) ? o1.getValue().name().compareTo(o2.getValue().name()) : diff;
266+
}).map(Map.Entry::getValue).toList();
202267

203268
if (components.isEmpty()) {
204269
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,

record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordBuilderProcessor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -955,7 +955,7 @@ private void addAccessorAnnotations(RecordClassType component, MethodSpec.Builde
955955
}
956956
}
957957

958-
private String capitalize(String s) {
958+
public static String capitalize(String s) {
959959
return (s.length() < 2) ? s.toUpperCase(Locale.ROOT) : (Character.toUpperCase(s.charAt(0)) + s.substring(1));
960960
}
961961

record-builder-test/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@
6161
<artifactId>record-builder-validator</artifactId>
6262
</dependency>
6363

64+
<dependency>
65+
<groupId>org.projectlombok</groupId>
66+
<artifactId>lombok</artifactId>
67+
</dependency>
68+
6469
<dependency>
6570
<groupId>org.hibernate.validator</groupId>
6671
<artifactId>hibernate-validator</artifactId>
@@ -93,6 +98,11 @@
9398
<artifactId>maven-compiler-plugin</artifactId>
9499
<configuration>
95100
<annotationProcessorPaths>
101+
<path>
102+
<groupId>org.projectlombok</groupId>
103+
<artifactId>lombok</artifactId>
104+
<version>${lombok-version}</version>
105+
</path>
96106
<path>
97107
<groupId>io.soabase.record-builder</groupId>
98108
<artifactId>record-builder-processor</artifactId>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2019 The original author or authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.soabase.recordbuilder.test.deconstructors;
17+
18+
import io.soabase.recordbuilder.core.RecordBuilder.Deconstructor;
19+
import io.soabase.recordbuilder.core.RecordBuilder.DeconstructorAccessor;
20+
import lombok.Getter;
21+
import lombok.experimental.Accessors;
22+
23+
@Deconstructor
24+
@Getter
25+
@Accessors(fluent = true)
26+
public class LombokTest {
27+
@DeconstructorAccessor
28+
private final int qty;
29+
30+
@DeconstructorAccessor
31+
private final String name;
32+
33+
public LombokTest(int qty, String name) {
34+
this.qty = qty;
35+
this.name = name;
36+
}
37+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2019 The original author or authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.soabase.recordbuilder.test.deconstructors;
17+
18+
import io.soabase.recordbuilder.core.RecordBuilder.Deconstructor;
19+
import io.soabase.recordbuilder.core.RecordBuilder.DeconstructorAccessor;
20+
import lombok.Getter;
21+
22+
@Deconstructor
23+
@Getter
24+
public class LombokTest2 {
25+
@DeconstructorAccessor
26+
private final int qty;
27+
28+
@DeconstructorAccessor
29+
private final String name;
30+
31+
public LombokTest2(int qty, String name) {
32+
this.qty = qty;
33+
this.name = name;
34+
}
35+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright 2019 The original author or authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.soabase.recordbuilder.test.deconstructors;
17+
18+
import io.soabase.recordbuilder.core.RecordBuilder.Deconstructor;
19+
import io.soabase.recordbuilder.core.RecordBuilder.DeconstructorAccessor;
20+
21+
@Deconstructor
22+
public record RecordTest(@DeconstructorAccessor int a, @DeconstructorAccessor String b) {
23+
}

record-builder-test/src/test/java/io/soabase/recordbuilder/test/deconstructors/TestAccessors.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,24 @@ public class TestAccessors {
2424
@Test
2525
public void testAccessors() {
2626
AccessorTest accessorTest = AccessorTest.create("hey", 42);
27-
assertThat(AccessorTestDao(42, "hey")).isEqualTo(AccessorTestDao.from(accessorTest));
27+
assertThat(AccessorTestDao("hey", 42)).isEqualTo(AccessorTestDao.from(accessorTest));
2828
}
2929

3030
@Test
3131
public void testAccessorsNoBuilder() {
3232
AccessorTestNoBuilder accessorTest = new AccessorTestNoBuilder("hey", 42);
33-
assertThat(new AccessorTestNoBuilderDao(42, "hey")).isEqualTo(AccessorTestNoBuilderDao.from(accessorTest));
33+
assertThat(new AccessorTestNoBuilderDao("hey", 42)).isEqualTo(AccessorTestNoBuilderDao.from(accessorTest));
34+
}
35+
36+
@Test
37+
public void testLombok() {
38+
LombokTest lombokTest = new LombokTest(42, "hey");
39+
assertThat(new LombokTestDao(42, "hey")).isEqualTo(LombokTestDao.from(lombokTest));
40+
}
41+
42+
@Test
43+
public void testLombok2() {
44+
LombokTest2 lombokTest = new LombokTest2(42, "hey");
45+
assertThat(new LombokTest2Dao(42, "hey")).isEqualTo(LombokTest2Dao.from(lombokTest));
3446
}
3547
}

0 commit comments

Comments
 (0)