Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 86 additions & 15 deletions src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,8 +69,9 @@ public <S, T> T map(final S source) {

Map<String, PropertyAccessor> sourcePropertyAccessors = getSourcePropertyAccessors(source);
Constructor<T> constructor = (Constructor<T>) getSuitableConstructor(sourcePropertyAccessors, targetClass);
String[] fieldNamesForConstructor = getNamesOfConstructorParameters(targetClass, constructor);
List<Object> values = getValuesOfFields(source, sourcePropertyAccessors, Arrays.stream(fieldNamesForConstructor));
String[] parameterNames = getParameterNames(constructor);
String[] beanPropertyPaths = getNamesOfRecordComponents(targetClass);
List<Object> values = getValuesOfFieldsWithNestedPathSupport(source, sourcePropertyAccessors, parameterNames, beanPropertyPaths);

return targetClass.cast(constructTargetObject(constructor, values));
}
Expand Down Expand Up @@ -98,46 +103,58 @@ private <S> Map<String, PropertyAccessor> getSourcePropertyAccessors(final S sou
* <p>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.</p>
*
* <p>Note: Since @BeanProperty does not have @Target(ElementType.RECORD_COMPONENT), we need to read
* the annotation from the accessor method where Java propagates it.</p>
*
* @param targetClass The class of the target record.
* @param <T> The type of the target record.
* @return The names of the RecordComponents as a String-array.
*/
private <T> String[] getNamesOfRecordComponents(final Class<T> 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();
})
.toArray(String[]::new);
}

/**
* Gets the names of constructor parameters.
*
* <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
* order of the parameters.</p>
* 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 <T> The type of the target class.
* @return The String-array containing the names of the constructor-parameters.
*/
private <T> String[] getNamesOfConstructorParameters(final Class<T> targetClass, final Constructor<T> constructor) {
private <T> String[] getParameterNames(final Constructor<T> 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 <S> List<Object> getValuesOfFields(final S source, final Map<String, PropertyAccessor> accessors,
Expand All @@ -147,6 +164,60 @@ private <S> List<Object> getValuesOfFields(final S source, final Map<String, Pro
.toList();
}

private <S> List<Object> getValuesOfFieldsWithNestedPathSupport(S source,
Map<String, PropertyAccessor> sourcePropertyAccessors,
String[] parameterNames,
String[] beanPropertyPaths) {
List<Object> 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 <S> Object resolveSourceValue(S source, String parameterName, String beanPropertyPath,
Map<String, PropertyAccessor> 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 <S> 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<Object> values) {
Object[] arguments = new Object[parameters.length];
for (var i = 0; i < parameters.length; ++i) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.beanmapper.annotations.model.bean_property.record;

public record SimpleResultRecord(
Long id,
String name
) {}