diff --git a/hsweb-core/README.md b/hsweb-core/README.md index c5723914b..350276195 100644 --- a/hsweb-core/README.md +++ b/hsweb-core/README.md @@ -14,6 +14,15 @@ FastBeanCopier.copy(source,target,"id"); ``` + +#### 访问器支持 + +`FastBeanCopier` 支持标准 JavaBean 访问器、链式 setter(如 `getName()` / `Bean setName(String)`),以及同名的 fluent 方法对(如 `name()` / `Bean name(String)`,包括继承的方法)。扩展识别仅匹配公开实例方法,getter 返回类型必须与 setter 参数类型一致,避免重载选择依赖反射枚举顺序;已有标准 JavaBean 属性优先。属性仍需同时可读、可写,单独的 fluent 读取方法、静态 setter 及参数类型不匹配的方法对不会被识别为可复制属性。 + +实现位于 `hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java`,使用独立的 `PropertyUtilsBean` 和内省扩展点,不修改全局 BeanUtils 配置,复用现有代码生成、类型转换和缓存。 + +验证:`FastBeanCopierBeanTest` 的新增回归测试覆盖 Bean/Map 双向复制、不同访问器风格的 Bean 间复制、继承泛型转换、忽略属性、标准访问器优先级、方法配对边界、全局配置隔离、setter 重载声明顺序及静态 setter 排除。执行 `mvn -o -pl hsweb-core -am '-Dtest=org.hswebframework.web.bean.*Test' -Dsurefire.failIfNoSpecifiedTests=false -Dmaven.test.redirectTestOutputToFile=true test`,编译成功,34 项测试全部通过。 + 约定: 如果属性类实现了`Cloneable`接口,在复制的时候将调用`clone`方法.所以如果你实现了`Cloneable`接口,就必须重写`clone`方法并且为`public`修饰的. ### 数据字典 @@ -80,4 +89,3 @@ public class MyEntity{ } ``` - diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java index 4777917b0..9e5a479f1 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/FastBeanCopier.java @@ -20,9 +20,12 @@ import org.springframework.util.NumberUtils; import org.springframework.util.ReflectionUtils; +import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.lang.reflect.Array; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.lang.reflect.RecordComponent; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -43,7 +46,7 @@ public final class FastBeanCopier { private static final Map RECORD_CACHE = new ConcurrentHashMap<>(); - private static final PropertyUtilsBean propertyUtils = BeanUtilsBean.getInstance().getPropertyUtils(); + private static final PropertyUtilsBean propertyUtils = new PropertyUtilsBean(); private static final ConvertUtilsBean convertUtils = BeanUtilsBean.getInstance().getConvertUtils(); @@ -66,6 +69,37 @@ public static BeanFactory getBeanFactory() { } static { + // 访问器扩展仅作用于 copier,避免改变全局 BeanUtils 的属性规则。 + propertyUtils.addBeanIntrospector(context -> { + Class type = context.getTargetClass(); + for (Method method : type.getMethods()) { + if (Modifier.isStatic(method.getModifiers()) || method.isBridge()) { + continue; + } + if (method.getName().startsWith("set") && method.getParameterCount() == 1) { + String name = Introspector.decapitalize(method.getName().substring(3)); + PropertyDescriptor property = context.getPropertyDescriptor(name); + // 链式 setter 必须与 getter 类型一致,避免重载选择依赖反射方法顺序。 + if (property != null + && property.getReadMethod() != null + && property.getWriteMethod() == null + && property.getReadMethod().getReturnType() == method.getParameterTypes()[0]) { + context.addPropertyDescriptor(new PropertyDescriptor(name, property.getReadMethod(), method)); + } + continue; + } + if (method.getParameterCount() != 0 + || method.getReturnType() == void.class + || context.hasProperty(method.getName())) { + continue; + } + // 仅补充同名且类型匹配的 fluent 方法对,保留标准 JavaBean 属性的优先级。 + Method setter = ClassUtils.getMethodIfAvailable(type, method.getName(), method.getReturnType()); + if (setter != null && !Modifier.isStatic(setter.getModifiers())) { + context.addPropertyDescriptor(new PropertyDescriptor(method.getName(), method, setter)); + } + } + }); wrapperClassMapping.put(byte.class, Byte.class); wrapperClassMapping.put(short.class, Short.class); wrapperClassMapping.put(int.class, Integer.class); diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierBeanTest.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierBeanTest.java index 1df2aa84a..a530c4da8 100644 --- a/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierBeanTest.java +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierBeanTest.java @@ -2,9 +2,14 @@ import lombok.Getter; import lombok.Setter; +import lombok.experimental.Accessors; +import org.apache.commons.beanutils.BeanUtilsBean; +import org.apache.commons.beanutils.PropertyUtilsBean; import org.junit.Assert; import org.junit.Test; +import java.beans.PropertyDescriptor; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -40,6 +45,242 @@ public void testCopyGenericSuperclassListField() { Assert.assertEquals("image.jpg", output.getFileData().get(0).getName()); } + @Test + public void testCopyChainAccessors() { + Map values = Map.of("name", "chain", "age", 12, "enabled", true); + ChainBean bean = FastBeanCopier.copy(values, new ChainBean()); + + Assert.assertEquals("chain", bean.getName()); + Assert.assertEquals(12, bean.getAge()); + Assert.assertTrue(bean.isEnabled()); + Assert.assertEquals(values, FastBeanCopier.copy(bean, new LinkedHashMap<>())); + + StandardBean standard = FastBeanCopier.copy(bean, new StandardBean()); + Assert.assertEquals("chain", standard.getName()); + Assert.assertEquals(12, standard.getAge()); + ChainBean copied = FastBeanCopier.copy(standard, new ChainBean()); + Assert.assertEquals(values, FastBeanCopier.copy(copied, new LinkedHashMap<>())); + } + + @Test + public void testCopyFluentAccessors() { + Map values = Map.of("name", "fluent", "age", 18, "enabled", true); + FluentBean bean = FastBeanCopier.copy(values, new FluentBean()); + + Assert.assertEquals("fluent", bean.name()); + Assert.assertEquals(18, bean.age()); + Assert.assertTrue(bean.enabled()); + Assert.assertEquals(values, FastBeanCopier.copy(bean, new LinkedHashMap<>())); + + StandardBean standard = FastBeanCopier.copy(bean, new StandardBean()); + Assert.assertEquals("fluent", standard.getName()); + Assert.assertEquals(18, standard.getAge()); + FluentBean copied = FastBeanCopier.copy(standard, new FluentBean()); + Assert.assertEquals(values, FastBeanCopier.copy(copied, new LinkedHashMap<>())); + } + + @Test + public void testCopyBetweenAccessorStylesAndIgnoreProperty() { + ChainBean source = new ChainBean().setName("chain").setAge(20).setEnabled(true); + FluentBean fluent = FastBeanCopier.copy(source, new FluentBean()); + Assert.assertEquals("chain", fluent.name()); + Assert.assertEquals(20, fluent.age()); + Assert.assertTrue(fluent.enabled()); + + ChainBean target = FastBeanCopier.copy(fluent, new ChainBean().setName("retained"), "name"); + Assert.assertEquals("retained", target.getName()); + Assert.assertEquals(20, target.getAge()); + Assert.assertTrue(target.isEnabled()); + + FluentBean converted = FastBeanCopier.copy(Map.of("age", "21"), new FluentBean()); + Assert.assertEquals(21, converted.age()); + } + + @Test + public void testCopyInheritedFluentGenericList() { + FluentOutput output = FastBeanCopier.copy( + Map.of("fileData", List.of(Map.of("name", "fluent.jpg"))), + new FluentOutput() + ); + + Assert.assertEquals(1, output.fileData().size()); + Assert.assertTrue(output.fileData().get(0) instanceof FileItem); + Assert.assertEquals("fluent.jpg", output.fileData().get(0).getName()); + } + + @Test + public void testIgnoreUnpairedStaticAndMismatchedFluentMethods() { + Assert.assertTrue(FastBeanCopier.copy(new InvalidAccessors(), new LinkedHashMap<>()).isEmpty()); + } + + @Test + public void testAccessorExtensionsDoNotChangeGlobalBeanUtils() throws Exception { + PropertyUtilsBean global = BeanUtilsBean.getInstance().getPropertyUtils(); + PropertyDescriptor descriptor = global.getPropertyDescriptor(new ChainBean(), "name"); + Assert.assertNull(descriptor.getWriteMethod()); + + Assert.assertEquals("isolated", FastBeanCopier.copy(Map.of("name", "isolated"), new ChainBean()).getName()); + + Assert.assertNull(global.getPropertyDescriptor(new ChainBean(), "name").getWriteMethod()); + } + + @Test + public void testChainSetterOverloadsMatchGetterType() { + Map values = Map.of("name", "exact"); + + Assert.assertEquals("exact", FastBeanCopier.copy(values, new OverloadedChainA()).getName()); + Assert.assertEquals("exact", FastBeanCopier.copy(values, new OverloadedChainB()).getName()); + } + + @Test + public void testIgnoreStaticSetters() { + Map values = Map.of("name", "updated"); + + StaticChainSetter chain = FastBeanCopier.copy(values, new StaticChainSetter()); + StaticVoidSetter standard = FastBeanCopier.copy(values, new StaticVoidSetter()); + Assert.assertEquals("retained", chain.getName()); + Assert.assertEquals("retained", standard.getName()); + Assert.assertTrue(FastBeanCopier.copy(chain, new LinkedHashMap<>()).isEmpty()); + Assert.assertTrue(FastBeanCopier.copy(standard, new LinkedHashMap<>()).isEmpty()); + + Assert.assertEquals("updated", FastBeanCopier.copy(values, new ChainWithStaticOverload()).getName()); + } + + public static class OverloadedChainA { + private String name; + + public String getName() { + return name; + } + + public OverloadedChainA setName(Object name) { + throw new AssertionError("Setter must match getter type"); + } + + public OverloadedChainA setName(String name) { + this.name = name; + return this; + } + } + + public static class OverloadedChainB { + private String name; + + public String getName() { + return name; + } + + public OverloadedChainB setName(String name) { + this.name = name; + return this; + } + + public OverloadedChainB setName(Object name) { + throw new AssertionError("Setter must match getter type"); + } + } + + public static class StaticChainSetter { + public String getName() { + return "retained"; + } + + public static StaticChainSetter setName(String name) { + throw new AssertionError("Static setter must not be called"); + } + } + + public static class StaticVoidSetter { + public String getName() { + return "retained"; + } + + public static void setName(String name) { + throw new AssertionError("Static setter must not be called"); + } + } + + public static class ChainWithStaticOverload extends ChainBean { + public static ChainBean setName(Object name) { + throw new AssertionError("Static overload must not be called"); + } + } + + @Getter + @Setter + public static class StandardBean { + private String name; + private int age; + private boolean enabled; + + public String name() { + throw new AssertionError("Standard getter must take precedence"); + } + + public StandardBean name(String name) { + throw new AssertionError("Standard setter must take precedence"); + } + } + + @Getter + @Setter + @Accessors(chain = true) + public static class ChainBean { + private String name; + private int age; + private boolean enabled; + } + + @Getter + @Setter + @Accessors(fluent = true) + public static class FluentBean { + private String name; + private int age; + private boolean enabled; + + public FluentBean name(String name) { + this.name = name; + return this; + } + + public FluentBean name(Integer name) { + throw new AssertionError("Setter must match getter type"); + } + } + + @Getter + @Setter + @Accessors(fluent = true) + public static class FluentGenericBase { + private List fileData; + } + + public static class FluentOutput extends FluentGenericBase { + } + + public static class InvalidAccessors { + public String readOnly() { + throw new AssertionError("Unpaired method must not be copied"); + } + + public static String staticValue() { + throw new AssertionError("Static method must not be copied"); + } + + public static InvalidAccessors staticValue(String value) { + throw new AssertionError("Static method must not be copied"); + } + + public String mismatched() { + throw new AssertionError("Mismatched method must not be copied"); + } + + public InvalidAccessors mismatched(Integer value) { + throw new AssertionError("Mismatched method must not be copied"); + } + } + @Getter @Setter public static class DirectOutput {