diff --git a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java index e9d4ac43..d9faa13b 100644 --- a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java +++ b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java @@ -5,10 +5,14 @@ import io.beanmapper.annotations.BeanRecordConstruct; import io.beanmapper.annotations.BeanRecordConstructMode; import io.beanmapper.config.Configuration; +import io.beanmapper.core.BeanProperty; +import io.beanmapper.core.BeanPropertyCreator; +import io.beanmapper.core.BeanPropertyMatchupDirection; import io.beanmapper.core.converter.BeanConverter; import io.beanmapper.core.inspector.PropertyAccessor; import io.beanmapper.core.inspector.PropertyAccessors; import io.beanmapper.exceptions.BeanInstantiationException; +import io.beanmapper.exceptions.BeanNoSuchPropertyException; import io.beanmapper.exceptions.RecordConstructorConflictException; import io.beanmapper.exceptions.RecordNoAvailableConstructorsExceptions; import io.beanmapper.utils.BeanMapperTraceLogger; @@ -65,8 +69,9 @@ public T map(final S source) { Map sourcePropertyAccessors = getSourcePropertyAccessors(source); Constructor constructor = (Constructor) getSuitableConstructor(sourcePropertyAccessors, targetClass); - String[] fieldNamesForConstructor = getNamesOfConstructorParameters(targetClass, constructor); - List values = getValuesOfFields(source, sourcePropertyAccessors, Arrays.stream(fieldNamesForConstructor)); + String[] parameterNames = getParameterNames(constructor); + String[] beanPropertyPaths = getNamesOfRecordComponents(targetClass); + List values = getValuesOfFieldsWithNestedPathSupport(source, sourcePropertyAccessors, parameterNames, beanPropertyPaths); return targetClass.cast(constructTargetObject(constructor, values)); } @@ -98,6 +103,9 @@ private Map getSourcePropertyAccessors(final S sou *

The names of the RecordComponents are retrieved either from the {@link RecordComponent#getName()}-method, or * from an available {@link io.beanmapper.annotations.BeanProperty BeanProperty}-annotation.

* + *

Note: Since @BeanProperty does not have @Target(ElementType.RECORD_COMPONENT), we need to read + * the annotation from the accessor method where Java propagates it.

+ * * @param targetClass The class of the target record. * @param The type of the target record. * @return The names of the RecordComponents as a String-array. @@ -105,8 +113,12 @@ private Map getSourcePropertyAccessors(final S sou private String[] getNamesOfRecordComponents(final Class targetClass) { return Arrays.stream(targetClass.getRecordComponents()) .map(recordComponent -> { - if (recordComponent.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.class)) { - return recordComponent.getAnnotation(io.beanmapper.annotations.BeanProperty.class).value(); + // Read @BeanProperty from accessor method where Java propagates it + // (RecordComponent.isAnnotationPresent only works for @Target(RECORD_COMPONENT)) + io.beanmapper.annotations.BeanProperty beanProperty = + recordComponent.getAccessor().getAnnotation(io.beanmapper.annotations.BeanProperty.class); + if (beanProperty != null && !beanProperty.value().isEmpty()) { + return beanProperty.value(); } return recordComponent.getName(); }) @@ -114,30 +126,35 @@ private String[] getNamesOfRecordComponents(final Class targetClass) { } /** - * Gets the names of constructor parameters. - * - *

Prefers to use the RecordConstruct-annotation, if it is present. Otherwise, it will use the RecordComponents of the record, to determine the names and - * order of the parameters.

+ * Gets the parameter names from the constructor. * - * @param targetClass The target record. - * @param constructor The target constructor. + * @param constructor The constructor to get parameter names from. * @param The type of the target class. * @return The String-array containing the names of the constructor-parameters. */ - private String[] getNamesOfConstructorParameters(final Class targetClass, final Constructor constructor) { + private String[] getParameterNames(final Constructor constructor) { if (constructor.isAnnotationPresent(BeanRecordConstruct.class)) { return constructor.getAnnotation(BeanRecordConstruct.class).value(); } - // We can only use this in cases where the compiler added the parameter-name to the classfile. If the name is - // present, we use the parameter-names, otherwise we use the names as set in the record-components. var parameters = constructor.getParameters(); - if (constructor.getParameters()[0].isNamePresent()) { + if (parameters.length > 0 && parameters[0].isNamePresent()) { return Arrays.stream(parameters) .map(Parameter::getName) .toArray(String[]::new); } - return getNamesOfRecordComponents(targetClass); + + // Fallback: use record component names + Class declaringClass = constructor.getDeclaringClass(); + if (declaringClass.isRecord()) { + return Arrays.stream(declaringClass.getRecordComponents()) + .map(RecordComponent::getName) + .toArray(String[]::new); + } + + return Arrays.stream(parameters) + .map(Parameter::getName) + .toArray(String[]::new); } private List getValuesOfFields(final S source, final Map accessors, @@ -147,6 +164,60 @@ private List getValuesOfFields(final S source, final Map List getValuesOfFieldsWithNestedPathSupport(S source, + Map sourcePropertyAccessors, + String[] parameterNames, + String[] beanPropertyPaths) { + List values = new ArrayList<>(); + for (int i = 0; i < parameterNames.length; i++) { + String parameterName = parameterNames[i]; + String beanPropertyPath = beanPropertyPaths[i]; + values.add(resolveSourceValue(source, parameterName, beanPropertyPath, sourcePropertyAccessors)); + } + return values; + } + + private Object resolveSourceValue(S source, String parameterName, String beanPropertyPath, + Map sourcePropertyAccessors) { + // If @BeanProperty specifies a nested path (contains a dot), use path resolution + if (beanPropertyPath.contains(".")) { + return resolveNestedPath(source, beanPropertyPath); + } + + // Try parameter name first (supports @BeanAlias on source side) + PropertyAccessor accessor = sourcePropertyAccessors.get(parameterName); + if (accessor != null) { + return getValueFromField(source, accessor); + } + + // If @BeanProperty specifies a different name, try that in sourcePropertyAccessors + if (!beanPropertyPath.equals(parameterName)) { + accessor = sourcePropertyAccessors.get(beanPropertyPath); + if (accessor != null) { + return getValueFromField(source, accessor); + } + // As last resort, try direct property access via BeanPropertyCreator + return resolveNestedPath(source, beanPropertyPath); + } + + return null; + } + + private Object resolveNestedPath(S source, String fieldName) { + try { + BeanProperty beanProperty = new BeanPropertyCreator( + BeanPropertyMatchupDirection.SOURCE_TO_TARGET, + source.getClass(), + fieldName + ).determineNodesForPath(); + + return beanProperty.getObject(source); + } catch (BeanNoSuchPropertyException e) { + // Path doesn't exist in source - return null + return null; + } + } + private Object[] getConstructorArgumentsMappedToCorrectTargetType(final Parameter[] parameters, final List values) { Object[] arguments = new Object[parameters.length]; for (var i = 0; i < parameters.length; ++i) { diff --git a/src/test/java/io/beanmapper/annotations/BeanPropertyRecordTest.java b/src/test/java/io/beanmapper/annotations/BeanPropertyRecordTest.java new file mode 100644 index 00000000..5aafde95 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/BeanPropertyRecordTest.java @@ -0,0 +1,79 @@ +package io.beanmapper.annotations; + +import static org.junit.jupiter.api.Assertions.*; + +import io.beanmapper.BeanMapper; +import io.beanmapper.annotations.model.bean_property.record.DeepNestedResultRecord; +import io.beanmapper.annotations.model.bean_property.record.NestedSource; +import io.beanmapper.annotations.model.bean_property.record.NestedResultRecord; +import io.beanmapper.annotations.model.bean_property.record.SimpleResultRecord; +import io.beanmapper.config.BeanMapperBuilder; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class BeanPropertyRecordTest { + + private BeanMapper beanMapper; + + @BeforeEach + void setUp() { + beanMapper = new BeanMapperBuilder() + .setApplyStrictMappingConvention(false) + .addPackagePrefix(BeanMapper.class) + .build(); + } + + @Test + @DisplayName("@BeanProperty on record component should map nested property") + void beanPropertyOnRecordComponentShouldMapNestedProperty() { + var parent = new NestedSource(1L, "Parent", null); + var child = new NestedSource(2L, "Child", parent); + + var result = beanMapper.map(child, NestedResultRecord.class); + + assertEquals(2L, result.id()); + assertEquals("Child", result.name()); + assertEquals(1L, result.parentId(), "@BeanProperty('parent.id') should map parent.id"); + assertEquals("Parent", result.parentName(), "@BeanProperty('parent.name') should map parent.name"); + } + + @Test + @DisplayName("@BeanProperty with null parent should return null for nested properties") + void beanPropertyWithNullParentShouldReturnNull() { + var orphan = new NestedSource(3L, "Orphan", null); + + var result = beanMapper.map(orphan, NestedResultRecord.class); + + assertEquals(3L, result.id()); + assertEquals("Orphan", result.name()); + assertNull(result.parentId(), "Nested property should be null when parent is null"); + assertNull(result.parentName(), "Nested property should be null when parent is null"); + } + + @Test + @DisplayName("@BeanProperty should support deep nested paths (a.b.c)") + void beanPropertyShouldSupportDeepNestedPaths() { + var grandParent = new NestedSource(1L, "GrandParent", null); + var parent = new NestedSource(2L, "Parent", grandParent); + var child = new NestedSource(3L, "Child", parent); + + var result = beanMapper.map(child, DeepNestedResultRecord.class); + + assertEquals(3L, result.id()); + assertEquals("Child", result.name()); + assertEquals(1L, result.grandParentId(), "@BeanProperty('parent.parent.id') should map grandparent.id"); + } + + @Test + @DisplayName("Records without @BeanProperty should continue to work (backward compatibility)") + void recordsWithoutBeanPropertyShouldWork() { + var source = new NestedSource(42L, "Simple", null); + + var result = beanMapper.map(source, SimpleResultRecord.class); + + assertEquals(42L, result.id()); + assertEquals("Simple", result.name()); + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/record/DeepNestedResultRecord.java b/src/test/java/io/beanmapper/annotations/model/bean_property/record/DeepNestedResultRecord.java new file mode 100644 index 00000000..364238bd --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/record/DeepNestedResultRecord.java @@ -0,0 +1,9 @@ +package io.beanmapper.annotations.model.bean_property.record; + +import io.beanmapper.annotations.BeanProperty; + +public record DeepNestedResultRecord( + Long id, + String name, + @BeanProperty("parent.parent.id") Long grandParentId +) {} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedResultRecord.java b/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedResultRecord.java new file mode 100644 index 00000000..a03585c4 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedResultRecord.java @@ -0,0 +1,10 @@ +package io.beanmapper.annotations.model.bean_property.record; + +import io.beanmapper.annotations.BeanProperty; + +public record NestedResultRecord( + Long id, + String name, + @BeanProperty("parent.id") Long parentId, + @BeanProperty("parent.name") String parentName +) {} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedSource.java b/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedSource.java new file mode 100644 index 00000000..b42888e7 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/record/NestedSource.java @@ -0,0 +1,13 @@ +package io.beanmapper.annotations.model.bean_property.record; + +public class NestedSource { + public Long id; + public String name; + public NestedSource parent; + + public NestedSource(Long id, String name, NestedSource parent) { + this.id = id; + this.name = name; + this.parent = parent; + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/record/SimpleResultRecord.java b/src/test/java/io/beanmapper/annotations/model/bean_property/record/SimpleResultRecord.java new file mode 100644 index 00000000..86e65fa6 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/record/SimpleResultRecord.java @@ -0,0 +1,6 @@ +package io.beanmapper.annotations.model.bean_property.record; + +public record SimpleResultRecord( + Long id, + String name +) {}