diff --git a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java index cbec4ef8..e9d4ac43 100644 --- a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java +++ b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java @@ -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; @@ -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; /** @@ -65,11 +63,8 @@ public T map(final S source) { } } - Map sourceFields = getSourceFields(source); - Map 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 constructor = (Constructor) getSuitableConstructor(sourceFields, targetClass); + Map sourcePropertyAccessors = getSourcePropertyAccessors(source); + Constructor constructor = (Constructor) getSuitableConstructor(sourcePropertyAccessors, targetClass); String[] fieldNamesForConstructor = getNamesOfConstructorParameters(targetClass, constructor); List values = getValuesOfFields(source, sourcePropertyAccessors, Arrays.stream(fieldNamesForConstructor)); @@ -77,40 +72,24 @@ public T map(final S source) { } /** - * Gets a Map<String, Field>, 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 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 The type of the source object. + * @return A Map containing readable PropertyAccessors, keyed by property name or BeanAlias value. */ - private Map getFieldsOfClass(final Class 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 - * @return The fields of the source-object, mapped to the - */ - private Map getSourceFields(final S source) { - Map sourceFields = new HashMap<>(); - Class sourceClass = (Class) source.getClass(); - while (!sourceClass.equals(Object.class)) { - sourceFields.putAll(getFieldsOfClass(sourceClass)); - sourceClass = sourceClass.getSuperclass(); + private Map getSourcePropertyAccessors(final S source) { + Map 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; } /** @@ -203,7 +182,7 @@ private Object getValueFromField(final S source, PropertyAccessor accessor) return accessor.getValue(source); } - private Constructor getSuitableConstructor(final Map sourceFields, final Class targetClass) { + private Constructor getSuitableConstructor(final Map sourceAccessors, final Class targetClass) { List> constructors = new ArrayList<>(Records.getConstructorsAnnotatedWithRecordConstruct(targetClass)); List> mandatoryConstructor = constructors.stream() .filter(constructor -> constructor.getAnnotation(BeanRecordConstruct.class).constructMode() == BeanRecordConstructMode.FORCE) @@ -219,7 +198,7 @@ private Constructor getSuitableConstructor(final Map 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)) { @@ -233,13 +212,13 @@ private Constructor getSuitableConstructor(final Map sourc } private Optional> getConstructorWithMostMatchingParameters(final List> constructors, - final Map sourceFields) { + final Map sourceAccessors) { for (var constructor : constructors) { BeanRecordConstruct recordConstruct = constructor.getAnnotation(BeanRecordConstruct.class); - List relevantFields = Arrays.stream(recordConstruct.value()) - .map(sourceFields::get) + List 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(); diff --git a/src/test/java/io/beanmapper/strategy/MapToRecordStrategyTest.java b/src/test/java/io/beanmapper/strategy/MapToRecordStrategyTest.java index 1f1a0757..a8a859c9 100644 --- a/src/test/java/io/beanmapper/strategy/MapToRecordStrategyTest.java +++ b/src/test/java/io/beanmapper/strategy/MapToRecordStrategyTest.java @@ -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; @@ -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'"); + } } \ No newline at end of file diff --git a/src/test/java/io/beanmapper/strategy/record/model/computed_getter/SourceWithComputedUrl.java b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/SourceWithComputedUrl.java new file mode 100644 index 00000000..f3bb579f --- /dev/null +++ b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/SourceWithComputedUrl.java @@ -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; + } +} diff --git a/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetClassWithUrl.java b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetClassWithUrl.java new file mode 100644 index 00000000..12535b02 --- /dev/null +++ b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetClassWithUrl.java @@ -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; +} diff --git a/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetRecordWithUrl.java b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetRecordWithUrl.java new file mode 100644 index 00000000..ba3d7711 --- /dev/null +++ b/src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetRecordWithUrl.java @@ -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 +) {}