From fc0291628d792bb858627defacf015441397f5bb Mon Sep 17 00:00:00 2001 From: Robert Bor Date: Sun, 18 Jan 2026 22:17:39 +0100 Subject: [PATCH 1/3] Map to record issue At this time there is a problem with mapping getters to a record. The problem does not occur with target classes. --- .../strategy/MapToRecordStrategyTest.java | 48 +++++++++++++++++++ .../SourceWithComputedUrl.java | 24 ++++++++++ .../computed_getter/TargetClassWithUrl.java | 12 +++++ .../computed_getter/TargetRecordWithUrl.java | 10 ++++ 4 files changed, 94 insertions(+) create mode 100644 src/test/java/io/beanmapper/strategy/record/model/computed_getter/SourceWithComputedUrl.java create mode 100644 src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetClassWithUrl.java create mode 100644 src/test/java/io/beanmapper/strategy/record/model/computed_getter/TargetRecordWithUrl.java 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 +) {} From 3eb35099c377bffe5414c351d8ba625887062b3c Mon Sep 17 00:00:00 2001 From: Robert Bor Date: Sun, 18 Jan 2026 22:24:43 +0100 Subject: [PATCH 2/3] Map to record issue Solution for mapping to records. --- .../strategy/MapToRecordStrategy.java | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java index cbec4ef8..c97145ba 100644 --- a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java +++ b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java @@ -14,6 +14,10 @@ import io.beanmapper.utils.BeanMapperTraceLogger; import io.beanmapper.utils.Records; +import java.beans.BeanInfo; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -24,8 +28,10 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -65,10 +71,17 @@ 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)); + Map sourceFields = getSourceFields(source); + Map sourcePropertyAccessors = new HashMap<>(); + for (Map.Entry entry : sourceFields.entrySet()) { + String aliasOrName = entry.getKey(); + Object value = entry.getValue(); + String propertyName = (value instanceof Field field) ? field.getName() : aliasOrName; + PropertyAccessor accessor = PropertyAccessors.findProperty(source.getClass(), propertyName); + if (accessor != null) { + sourcePropertyAccessors.put(aliasOrName, accessor); + } + } Constructor constructor = (Constructor) getSuitableConstructor(sourceFields, targetClass); String[] fieldNamesForConstructor = getNamesOfConstructorParameters(targetClass, constructor); List values = getValuesOfFields(source, sourcePropertyAccessors, Arrays.stream(fieldNamesForConstructor)); @@ -95,21 +108,45 @@ private Map getFieldsOfClass(final Class sourceClass) { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } + private Set getReadablePropertyNames(final Class sourceClass) { + Set result = new HashSet<>(); + try { + BeanInfo beanInfo = Introspector.getBeanInfo(sourceClass, Object.class); + for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) { + if (pd.getReadMethod() != null) { + result.add(pd.getName()); + } + } + } catch (IntrospectionException e) {} + return result; + } + /** * Gets the fields in the source-object, mapped to their name, or, if present, the value of the - * BeanAlias-annotation. + * BeanAlias-annotation. Also includes getter-only properties (computed getters). * * @param source The source-object, of which the fields will be mapped. * @param - * @return The fields of the source-object, mapped to the + * @return The fields of the source-object, mapped to the property name. Values are either Field objects + * for fields, or String (property name) for getter-only properties. */ - private Map getSourceFields(final S source) { - Map sourceFields = new HashMap<>(); + private Map getSourceFields(final S source) { + Map sourceFields = new HashMap<>(); Class sourceClass = (Class) source.getClass(); + + // Collect fields while (!sourceClass.equals(Object.class)) { sourceFields.putAll(getFieldsOfClass(sourceClass)); sourceClass = sourceClass.getSuperclass(); } + + // Add getter-only properties (computed getters) + for (String propertyName : getReadablePropertyNames(source.getClass())) { + if (!sourceFields.containsKey(propertyName)) { + sourceFields.put(propertyName, propertyName); // Marker for getter-only + } + } + return sourceFields; } @@ -203,7 +240,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 sourceFields, final Class targetClass) { List> constructors = new ArrayList<>(Records.getConstructorsAnnotatedWithRecordConstruct(targetClass)); List> mandatoryConstructor = constructors.stream() .filter(constructor -> constructor.getAnnotation(BeanRecordConstruct.class).constructMode() == BeanRecordConstructMode.FORCE) @@ -233,10 +270,10 @@ private Constructor getSuitableConstructor(final Map sourc } private Optional> getConstructorWithMostMatchingParameters(final List> constructors, - final Map sourceFields) { + final Map sourceFields) { for (var constructor : constructors) { BeanRecordConstruct recordConstruct = constructor.getAnnotation(BeanRecordConstruct.class); - List relevantFields = Arrays.stream(recordConstruct.value()) + List relevantFields = Arrays.stream(recordConstruct.value()) .map(sourceFields::get) .toList(); if (!relevantFields.contains(null) || recordConstruct.allowNull()) From f3aa57f861b4fc92b77d3dc57bbe91041c0183ab Mon Sep 17 00:00:00 2001 From: Robert Bor Date: Wed, 21 Jan 2026 09:55:44 +0100 Subject: [PATCH 3/3] Na Deep Dive met Bas en Claude verbeteringen in de MapToRecordStrategy aangebracht. --- .../strategy/MapToRecordStrategy.java | 100 ++++-------------- 1 file changed, 21 insertions(+), 79 deletions(-) diff --git a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java index c97145ba..e9d4ac43 100644 --- a/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java +++ b/src/main/java/io/beanmapper/strategy/MapToRecordStrategy.java @@ -14,12 +14,7 @@ import io.beanmapper.utils.BeanMapperTraceLogger; import io.beanmapper.utils.Records; -import java.beans.BeanInfo; -import java.beans.IntrospectionException; -import java.beans.Introspector; -import java.beans.PropertyDescriptor; 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; @@ -28,12 +23,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -71,18 +63,8 @@ public T map(final S source) { } } - Map sourceFields = getSourceFields(source); - Map sourcePropertyAccessors = new HashMap<>(); - for (Map.Entry entry : sourceFields.entrySet()) { - String aliasOrName = entry.getKey(); - Object value = entry.getValue(); - String propertyName = (value instanceof Field field) ? field.getName() : aliasOrName; - PropertyAccessor accessor = PropertyAccessors.findProperty(source.getClass(), propertyName); - if (accessor != null) { - sourcePropertyAccessors.put(aliasOrName, accessor); - } - } - 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)); @@ -90,64 +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)); - } - - private Set getReadablePropertyNames(final Class sourceClass) { - Set result = new HashSet<>(); - try { - BeanInfo beanInfo = Introspector.getBeanInfo(sourceClass, Object.class); - for (PropertyDescriptor pd : beanInfo.getPropertyDescriptors()) { - if (pd.getReadMethod() != null) { - result.add(pd.getName()); + 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); } - } catch (IntrospectionException e) {} - return result; - } - - /** - * Gets the fields in the source-object, mapped to their name, or, if present, the value of the - * BeanAlias-annotation. Also includes getter-only properties (computed getters). - * - * @param source The source-object, of which the fields will be mapped. - * @param - * @return The fields of the source-object, mapped to the property name. Values are either Field objects - * for fields, or String (property name) for getter-only properties. - */ - private Map getSourceFields(final S source) { - Map sourceFields = new HashMap<>(); - Class sourceClass = (Class) source.getClass(); - - // Collect fields - while (!sourceClass.equals(Object.class)) { - sourceFields.putAll(getFieldsOfClass(sourceClass)); - sourceClass = sourceClass.getSuperclass(); } - - // Add getter-only properties (computed getters) - for (String propertyName : getReadablePropertyNames(source.getClass())) { - if (!sourceFields.containsKey(propertyName)) { - sourceFields.put(propertyName, propertyName); // Marker for getter-only - } - } - - return sourceFields; + return result; } /** @@ -240,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) @@ -256,7 +198,7 @@ private Constructor getSuitableConstructor(final Map sour // 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)) { @@ -270,13 +212,13 @@ private Constructor getSuitableConstructor(final Map sour } 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();