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
67 changes: 23 additions & 44 deletions src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import io.beanmapper.utils.Records;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
Expand All @@ -27,7 +26,6 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
Expand Down Expand Up @@ -65,52 +63,33 @@ public <S, T> T map(final S source) {
}
}

Map<String, Field> sourceFields = getSourceFields(source);
Map<String, PropertyAccessor> sourcePropertyAccessors = sourceFields.entrySet().stream()
.map(entry -> Map.entry(entry.getKey(), PropertyAccessors.findProperty(source.getClass(), entry.getValue().getName())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
Constructor<T> constructor = (Constructor<T>) getSuitableConstructor(sourceFields, targetClass);
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));

return targetClass.cast(constructTargetObject(constructor, values));
}

/**
* Gets a Map&lt;String, Field&gt;, containing the fields of the class, mapped by the name of the field, or the value of the BeanAlias-annotation if it is
* present.
* Gets all readable property accessors from the source object, mapped by their name or BeanAlias value.
*
* @param sourceClass The class of the source-object.
* @param <S> The type of the sourceClass.
* @return A Map containing the fields of the source-class, mapped by the name of the field, or the value of an available BeanAlias.
* @param source The source-object to get property accessors from.
* @param <S> The type of the source object.
* @return A Map containing readable PropertyAccessors, keyed by property name or BeanAlias value.
*/
private <S> Map<String, Field> getFieldsOfClass(final Class<S> sourceClass) {
return Arrays.stream(sourceClass.getDeclaredFields())
.map(field -> {
if (field.isAnnotationPresent(BeanAlias.class)) {
return Map.entry(field.getAnnotation(BeanAlias.class).value(), field);
}
return Map.entry(field.getName(), field);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}

/**
* Gets the fields in the source-object, mapped to their name, or, if present, the value of the
* BeanAlias-annotation.
*
* @param source The source-object, of which the fields will be mapped.
* @param <S>
* @return The fields of the source-object, mapped to the
*/
private <S> Map<String, Field> getSourceFields(final S source) {
Map<String, Field> sourceFields = new HashMap<>();
Class<? super S> sourceClass = (Class<? super S>) source.getClass();
while (!sourceClass.equals(Object.class)) {
sourceFields.putAll(getFieldsOfClass(sourceClass));
sourceClass = sourceClass.getSuperclass();
private <S> Map<String, PropertyAccessor> getSourcePropertyAccessors(final S source) {
Map<String, PropertyAccessor> result = new HashMap<>();
for (PropertyAccessor accessor : PropertyAccessors.getAll(source.getClass())) {
if (accessor.isReadable()) {
String name = accessor.getName();
if (accessor.isAnnotationPresent(BeanAlias.class)) {
name = accessor.findAnnotation(BeanAlias.class).value();
}
result.put(name, accessor);
}
}
return sourceFields;
return result;
}

/**
Expand Down Expand Up @@ -203,7 +182,7 @@ private <S> Object getValueFromField(final S source, PropertyAccessor accessor)
return accessor.getValue(source);
}

private <T> Constructor<?> getSuitableConstructor(final Map<String, Field> sourceFields, final Class<T> targetClass) {
private <T> Constructor<?> getSuitableConstructor(final Map<String, PropertyAccessor> sourceAccessors, final Class<T> targetClass) {
List<Constructor<T>> constructors = new ArrayList<>(Records.getConstructorsAnnotatedWithRecordConstruct(targetClass));
List<Constructor<T>> mandatoryConstructor = constructors.stream()
.filter(constructor -> constructor.getAnnotation(BeanRecordConstruct.class).constructMode() == BeanRecordConstructMode.FORCE)
Expand All @@ -219,7 +198,7 @@ private <T> Constructor<?> getSuitableConstructor(final Map<String, Field> sourc
// RecordConstructMode.ON_DEMAND-option.
// Sorts the list in reverse, to make constructor with the most arguments the first to be considered.
constructors.sort((arg0, arg1) -> Integer.compare(arg1.getParameterCount(), arg0.getParameterCount()));
return getConstructorWithMostMatchingParameters(constructors, sourceFields).orElse(Records.getCanonicalConstructorOfRecord((Class) targetClass));
return getConstructorWithMostMatchingParameters(constructors, sourceAccessors).orElse(Records.getCanonicalConstructorOfRecord((Class) targetClass));
}
var canonicalConstructor = Records.getCanonicalConstructorOfRecord((Class) targetClass);
if (canonicalConstructor.isAnnotationPresent(BeanRecordConstruct.class)) {
Expand All @@ -233,13 +212,13 @@ private <T> Constructor<?> getSuitableConstructor(final Map<String, Field> sourc
}

private <T> Optional<Constructor<T>> getConstructorWithMostMatchingParameters(final List<Constructor<T>> constructors,
final Map<String, Field> sourceFields) {
final Map<String, PropertyAccessor> sourceAccessors) {
for (var constructor : constructors) {
BeanRecordConstruct recordConstruct = constructor.getAnnotation(BeanRecordConstruct.class);
List<Field> relevantFields = Arrays.stream(recordConstruct.value())
.map(sourceFields::get)
List<PropertyAccessor> relevantAccessors = Arrays.stream(recordConstruct.value())
.map(sourceAccessors::get)
.toList();
if (!relevantFields.contains(null) || recordConstruct.allowNull())
if (!relevantAccessors.contains(null) || recordConstruct.allowNull())
return Optional.of(constructor);
}
return Optional.empty();
Expand Down
48 changes: 48 additions & 0 deletions src/test/java/io/beanmapper/strategy/MapToRecordStrategyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import io.beanmapper.shared.AssertionUtils;
import io.beanmapper.shared.ReflectionUtils;
import io.beanmapper.strategy.record.model.AlternativeResultRecordWithIdAndName;
import io.beanmapper.strategy.record.model.computed_getter.SourceWithComputedUrl;
import io.beanmapper.strategy.record.model.computed_getter.TargetClassWithUrl;
import io.beanmapper.strategy.record.model.computed_getter.TargetRecordWithUrl;
import io.beanmapper.strategy.record.model.FormRecordWithId_Name_Place_BankAccount;
import io.beanmapper.strategy.record.model.FormWithIdAndName;
import io.beanmapper.strategy.record.model.FormWithId_Name_Place_BankAccount;
Expand Down Expand Up @@ -440,4 +443,49 @@ void testInaccessibleFieldsShouldNotBeMappedToRecord() {
assertNull(resultRecord.s());
assertNull(resultRecord.d());
}

/**
* This test demonstrates that BeanMapper CAN map computed getters to a CLASS.
* A computed getter is a getter method without a backing field.
*/
@Test
void testComputedGetterShouldBeMappedToClass() {
var source = new SourceWithComputedUrl(42L, "Test");

// Verify the source getter works
assertEquals("/api/items/42", source.getUrl());

// Map to CLASS - this SHOULD work
var result = this.beanMapper.map(source, TargetClassWithUrl.class);

assertEquals(42L, result.id);
assertEquals("Test", result.name);
// Computed getter IS mapped to class
assertEquals("/api/items/42", result.url);
}

/**
* This test demonstrates that BeanMapper CANNOT map computed getters to a RECORD.
* A computed getter is a getter method without a backing field.
*
* Expected behavior: url should be "/api/items/42"
* Actual behavior: url is null
*
* This is a known limitation when mapping to records.
*/
@Test
void testComputedGetterShouldBeMappedToRecord() {
var source = new SourceWithComputedUrl(42L, "Test");

// Verify the source getter works
assertEquals("/api/items/42", source.getUrl());

// Map to RECORD - this FAILS (url becomes null)
var result = this.beanMapper.map(source, TargetRecordWithUrl.class);

assertEquals(42L, result.id());
assertEquals("Test", result.name());
// BUG: Computed getter is NOT mapped to record - this assertion will FAIL
assertEquals("/api/items/42", result.url(), "Computed getter getUrl() should be mapped to record field 'url'");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.beanmapper.strategy.record.model.computed_getter;

/**
* Source class with a computed getter (getter without backing field).
* This simulates a JPA entity with a computed URL property.
*/
public class SourceWithComputedUrl {

public Long id;
public String name;

public SourceWithComputedUrl(Long id, String name) {
this.id = id;
this.name = name;
}

/**
* Computed getter - no backing field exists for 'url'.
* This is a common pattern in JPA entities for computed properties.
*/
public String getUrl() {
return id != null ? "/api/items/" + id : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.beanmapper.strategy.record.model.computed_getter;

/**
* Target class (not record) that expects 'url' to be mapped from source's computed getter.
* Used to demonstrate that BeanMapper CAN map computed getters to classes.
*/
public class TargetClassWithUrl {

public Long id;
public String name;
public String url;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.beanmapper.strategy.record.model.computed_getter;

/**
* Target record that expects 'url' to be mapped from source's computed getter.
*/
public record TargetRecordWithUrl(
Long id,
String name,
String url
) {}