Skip to content

Commit dd44f4d

Browse files
robert-borsptdevos
authored andcommitted
#219 @BeanProperty op record components
1 parent edf2b11 commit dd44f4d

6 files changed

Lines changed: 203 additions & 15 deletions

File tree

src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
import io.beanmapper.annotations.BeanRecordConstruct;
66
import io.beanmapper.annotations.BeanRecordConstructMode;
77
import io.beanmapper.config.Configuration;
8+
import io.beanmapper.core.BeanProperty;
9+
import io.beanmapper.core.BeanPropertyCreator;
10+
import io.beanmapper.core.BeanPropertyMatchupDirection;
811
import io.beanmapper.core.converter.BeanConverter;
912
import io.beanmapper.core.inspector.PropertyAccessor;
1013
import io.beanmapper.core.inspector.PropertyAccessors;
1114
import io.beanmapper.exceptions.BeanInstantiationException;
15+
import io.beanmapper.exceptions.BeanNoSuchPropertyException;
1216
import io.beanmapper.exceptions.RecordConstructorConflictException;
1317
import io.beanmapper.exceptions.RecordNoAvailableConstructorsExceptions;
1418
import io.beanmapper.utils.BeanMapperTraceLogger;
@@ -65,8 +69,9 @@ public <S, T> T map(final S source) {
6569

6670
Map<String, PropertyAccessor> sourcePropertyAccessors = getSourcePropertyAccessors(source);
6771
Constructor<T> constructor = (Constructor<T>) getSuitableConstructor(sourcePropertyAccessors, targetClass);
68-
String[] fieldNamesForConstructor = getNamesOfConstructorParameters(targetClass, constructor);
69-
List<Object> values = getValuesOfFields(source, sourcePropertyAccessors, Arrays.stream(fieldNamesForConstructor));
72+
String[] parameterNames = getParameterNames(constructor);
73+
String[] beanPropertyPaths = getNamesOfRecordComponents(targetClass);
74+
List<Object> values = getValuesOfFieldsWithNestedPathSupport(source, sourcePropertyAccessors, parameterNames, beanPropertyPaths);
7075

7176
return targetClass.cast(constructTargetObject(constructor, values));
7277
}
@@ -98,46 +103,58 @@ private <S> Map<String, PropertyAccessor> getSourcePropertyAccessors(final S sou
98103
* <p>The names of the RecordComponents are retrieved either from the {@link RecordComponent#getName()}-method, or
99104
* from an available {@link io.beanmapper.annotations.BeanProperty BeanProperty}-annotation.</p>
100105
*
106+
* <p>Note: Since @BeanProperty does not have @Target(ElementType.RECORD_COMPONENT), we need to read
107+
* the annotation from the accessor method where Java propagates it.</p>
108+
*
101109
* @param targetClass The class of the target record.
102110
* @param <T> The type of the target record.
103111
* @return The names of the RecordComponents as a String-array.
104112
*/
105113
private <T> String[] getNamesOfRecordComponents(final Class<T> targetClass) {
106114
return Arrays.stream(targetClass.getRecordComponents())
107115
.map(recordComponent -> {
108-
if (recordComponent.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.class)) {
109-
return recordComponent.getAnnotation(io.beanmapper.annotations.BeanProperty.class).value();
116+
// Read @BeanProperty from accessor method where Java propagates it
117+
// (RecordComponent.isAnnotationPresent only works for @Target(RECORD_COMPONENT))
118+
io.beanmapper.annotations.BeanProperty beanProperty =
119+
recordComponent.getAccessor().getAnnotation(io.beanmapper.annotations.BeanProperty.class);
120+
if (beanProperty != null && !beanProperty.value().isEmpty()) {
121+
return beanProperty.value();
110122
}
111123
return recordComponent.getName();
112124
})
113125
.toArray(String[]::new);
114126
}
115127

116128
/**
117-
* Gets the names of constructor parameters.
118-
*
119-
* <p>Prefers to use the RecordConstruct-annotation, if it is present. Otherwise, it will use the RecordComponents of the record, to determine the names and
120-
* order of the parameters.</p>
129+
* Gets the parameter names from the constructor.
121130
*
122-
* @param targetClass The target record.
123-
* @param constructor The target constructor.
131+
* @param constructor The constructor to get parameter names from.
124132
* @param <T> The type of the target class.
125133
* @return The String-array containing the names of the constructor-parameters.
126134
*/
127-
private <T> String[] getNamesOfConstructorParameters(final Class<T> targetClass, final Constructor<T> constructor) {
135+
private <T> String[] getParameterNames(final Constructor<T> constructor) {
128136
if (constructor.isAnnotationPresent(BeanRecordConstruct.class)) {
129137
return constructor.getAnnotation(BeanRecordConstruct.class).value();
130138
}
131139

132-
// We can only use this in cases where the compiler added the parameter-name to the classfile. If the name is
133-
// present, we use the parameter-names, otherwise we use the names as set in the record-components.
134140
var parameters = constructor.getParameters();
135-
if (constructor.getParameters()[0].isNamePresent()) {
141+
if (parameters.length > 0 && parameters[0].isNamePresent()) {
136142
return Arrays.stream(parameters)
137143
.map(Parameter::getName)
138144
.toArray(String[]::new);
139145
}
140-
return getNamesOfRecordComponents(targetClass);
146+
147+
// Fallback: use record component names
148+
Class<?> declaringClass = constructor.getDeclaringClass();
149+
if (declaringClass.isRecord()) {
150+
return Arrays.stream(declaringClass.getRecordComponents())
151+
.map(RecordComponent::getName)
152+
.toArray(String[]::new);
153+
}
154+
155+
return Arrays.stream(parameters)
156+
.map(Parameter::getName)
157+
.toArray(String[]::new);
141158
}
142159

143160
private <S> List<Object> getValuesOfFields(final S source, final Map<String, PropertyAccessor> accessors,
@@ -147,6 +164,60 @@ private <S> List<Object> getValuesOfFields(final S source, final Map<String, Pro
147164
.toList();
148165
}
149166

167+
private <S> List<Object> getValuesOfFieldsWithNestedPathSupport(S source,
168+
Map<String, PropertyAccessor> sourcePropertyAccessors,
169+
String[] parameterNames,
170+
String[] beanPropertyPaths) {
171+
List<Object> values = new ArrayList<>();
172+
for (int i = 0; i < parameterNames.length; i++) {
173+
String parameterName = parameterNames[i];
174+
String beanPropertyPath = beanPropertyPaths[i];
175+
values.add(resolveSourceValue(source, parameterName, beanPropertyPath, sourcePropertyAccessors));
176+
}
177+
return values;
178+
}
179+
180+
private <S> Object resolveSourceValue(S source, String parameterName, String beanPropertyPath,
181+
Map<String, PropertyAccessor> sourcePropertyAccessors) {
182+
// If @BeanProperty specifies a nested path (contains a dot), use path resolution
183+
if (beanPropertyPath.contains(".")) {
184+
return resolveNestedPath(source, beanPropertyPath);
185+
}
186+
187+
// Try parameter name first (supports @BeanAlias on source side)
188+
PropertyAccessor accessor = sourcePropertyAccessors.get(parameterName);
189+
if (accessor != null) {
190+
return getValueFromField(source, accessor);
191+
}
192+
193+
// If @BeanProperty specifies a different name, try that in sourcePropertyAccessors
194+
if (!beanPropertyPath.equals(parameterName)) {
195+
accessor = sourcePropertyAccessors.get(beanPropertyPath);
196+
if (accessor != null) {
197+
return getValueFromField(source, accessor);
198+
}
199+
// As last resort, try direct property access via BeanPropertyCreator
200+
return resolveNestedPath(source, beanPropertyPath);
201+
}
202+
203+
return null;
204+
}
205+
206+
private <S> Object resolveNestedPath(S source, String fieldName) {
207+
try {
208+
BeanProperty beanProperty = new BeanPropertyCreator(
209+
BeanPropertyMatchupDirection.SOURCE_TO_TARGET,
210+
source.getClass(),
211+
fieldName
212+
).determineNodesForPath();
213+
214+
return beanProperty.getObject(source);
215+
} catch (BeanNoSuchPropertyException e) {
216+
// Path doesn't exist in source - return null
217+
return null;
218+
}
219+
}
220+
150221
private Object[] getConstructorArgumentsMappedToCorrectTargetType(final Parameter[] parameters, final List<Object> values) {
151222
Object[] arguments = new Object[parameters.length];
152223
for (var i = 0; i < parameters.length; ++i) {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package io.beanmapper.annotations;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import io.beanmapper.BeanMapper;
6+
import io.beanmapper.annotations.model.bean_property.record.DeepNestedResultRecord;
7+
import io.beanmapper.annotations.model.bean_property.record.NestedSource;
8+
import io.beanmapper.annotations.model.bean_property.record.NestedResultRecord;
9+
import io.beanmapper.annotations.model.bean_property.record.SimpleResultRecord;
10+
import io.beanmapper.config.BeanMapperBuilder;
11+
12+
import org.junit.jupiter.api.BeforeEach;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Test;
15+
16+
class BeanPropertyRecordTest {
17+
18+
private BeanMapper beanMapper;
19+
20+
@BeforeEach
21+
void setUp() {
22+
beanMapper = new BeanMapperBuilder()
23+
.setApplyStrictMappingConvention(false)
24+
.addPackagePrefix(BeanMapper.class)
25+
.build();
26+
}
27+
28+
@Test
29+
@DisplayName("@BeanProperty on record component should map nested property")
30+
void beanPropertyOnRecordComponentShouldMapNestedProperty() {
31+
var parent = new NestedSource(1L, "Parent", null);
32+
var child = new NestedSource(2L, "Child", parent);
33+
34+
var result = beanMapper.map(child, NestedResultRecord.class);
35+
36+
assertEquals(2L, result.id());
37+
assertEquals("Child", result.name());
38+
assertEquals(1L, result.parentId(), "@BeanProperty('parent.id') should map parent.id");
39+
assertEquals("Parent", result.parentName(), "@BeanProperty('parent.name') should map parent.name");
40+
}
41+
42+
@Test
43+
@DisplayName("@BeanProperty with null parent should return null for nested properties")
44+
void beanPropertyWithNullParentShouldReturnNull() {
45+
var orphan = new NestedSource(3L, "Orphan", null);
46+
47+
var result = beanMapper.map(orphan, NestedResultRecord.class);
48+
49+
assertEquals(3L, result.id());
50+
assertEquals("Orphan", result.name());
51+
assertNull(result.parentId(), "Nested property should be null when parent is null");
52+
assertNull(result.parentName(), "Nested property should be null when parent is null");
53+
}
54+
55+
@Test
56+
@DisplayName("@BeanProperty should support deep nested paths (a.b.c)")
57+
void beanPropertyShouldSupportDeepNestedPaths() {
58+
var grandParent = new NestedSource(1L, "GrandParent", null);
59+
var parent = new NestedSource(2L, "Parent", grandParent);
60+
var child = new NestedSource(3L, "Child", parent);
61+
62+
var result = beanMapper.map(child, DeepNestedResultRecord.class);
63+
64+
assertEquals(3L, result.id());
65+
assertEquals("Child", result.name());
66+
assertEquals(1L, result.grandParentId(), "@BeanProperty('parent.parent.id') should map grandparent.id");
67+
}
68+
69+
@Test
70+
@DisplayName("Records without @BeanProperty should continue to work (backward compatibility)")
71+
void recordsWithoutBeanPropertyShouldWork() {
72+
var source = new NestedSource(42L, "Simple", null);
73+
74+
var result = beanMapper.map(source, SimpleResultRecord.class);
75+
76+
assertEquals(42L, result.id());
77+
assertEquals("Simple", result.name());
78+
}
79+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.beanmapper.annotations.model.bean_property.record;
2+
3+
import io.beanmapper.annotations.BeanProperty;
4+
5+
public record DeepNestedResultRecord(
6+
Long id,
7+
String name,
8+
@BeanProperty("parent.parent.id") Long grandParentId
9+
) {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.beanmapper.annotations.model.bean_property.record;
2+
3+
import io.beanmapper.annotations.BeanProperty;
4+
5+
public record NestedResultRecord(
6+
Long id,
7+
String name,
8+
@BeanProperty("parent.id") Long parentId,
9+
@BeanProperty("parent.name") String parentName
10+
) {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.beanmapper.annotations.model.bean_property.record;
2+
3+
public class NestedSource {
4+
public Long id;
5+
public String name;
6+
public NestedSource parent;
7+
8+
public NestedSource(Long id, String name, NestedSource parent) {
9+
this.id = id;
10+
this.name = name;
11+
this.parent = parent;
12+
}
13+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.beanmapper.annotations.model.bean_property.record;
2+
3+
public record SimpleResultRecord(
4+
Long id,
5+
String name
6+
) {}

0 commit comments

Comments
 (0)