diff --git a/.github/workflows/pull_request_5x.yml b/.github/workflows/pull_request_5x.yml index c2233f249..5e204668a 100644 --- a/.github/workflows/pull_request_5x.yml +++ b/.github/workflows/pull_request_5x.yml @@ -8,9 +8,10 @@ jobs: build: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v1 with: @@ -20,5 +21,5 @@ jobs: with: path: ~/.m2 key: ${{ runner.os }}-${{ hashFiles('**/pom.xml') }} - - name: Build with Maven - run: ./mvnw test -q + - name: Build changed modules with Maven + run: ./mvnw test -q -pl "$(./changes.sh)" diff --git a/changes.sh b/changes.sh index 86569f777..44b22bddd 100755 --- a/changes.sh +++ b/changes.sh @@ -1,18 +1,32 @@ #!/usr/bin/env bash -# 收集变更模块 -modules=$(git diff --name-only HEAD~1 HEAD | \ -while read file; do +set -euo pipefail + +# 收集变更模块。 +# PR 场景按目标分支对比,避免 CI 修复类提交只修改 .github 时退化为全仓库测试; +# push 场景保持原有 HEAD~1..HEAD 行为,兼容发布工作流。 +if [ -n "${GITHUB_BASE_REF:-}" ]; then + git fetch origin "${GITHUB_BASE_REF}" --depth=1 >/dev/null 2>&1 || true + if git rev-parse --verify "origin/${GITHUB_BASE_REF}" >/dev/null 2>&1; then + diff_args=("origin/${GITHUB_BASE_REF}...HEAD") + else + diff_args=("HEAD~1" "HEAD") + fi +else + diff_args=("HEAD~1" "HEAD") +fi + +modules=$(git diff --name-only "${diff_args[@]}" | while read -r file; do dir=$(dirname "$file") while [ "$dir" != "." ] && [ "$dir" != "/" ]; do if [ -f "$dir/pom.xml" ]; then echo "$dir"; break; fi dir=$(dirname "$dir") done -done | sort -u | tr '\n' ',' | sed 's/,$//') +done | sort -u | paste -sd, -) # 如果为空,则使用默认值 '.' if [ -z "$modules" ]; then echo "." else echo "$modules" -fi \ No newline at end of file +fi 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 2b969f60a..4777917b0 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 @@ -23,6 +23,7 @@ import java.beans.PropertyDescriptor; import java.lang.reflect.Array; import java.lang.reflect.Field; +import java.lang.reflect.RecordComponent; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -40,6 +41,8 @@ public final class FastBeanCopier { private static final Map CACHE = new ConcurrentHashMap<>(); + private static final Map RECORD_CACHE = new ConcurrentHashMap<>(); + private static final PropertyUtilsBean propertyUtils = BeanUtilsBean.getInstance().getPropertyUtils(); private static final ConvertUtilsBean convertUtils = BeanUtilsBean.getInstance().getConvertUtils(); @@ -112,6 +115,9 @@ public static T copy(S source, Supplier target, String... ignore) { @SneakyThrows public static T copy(S source, Class target, String... ignore) { + if (target.isRecord()) { + return copyToRecord(source, target, DEFAULT_CONVERT, ignore); + } return copy(source, target.newInstance(), DEFAULT_CONVERT, ignore); } @@ -125,6 +131,10 @@ public static T copy(S source, T target, Set ignore) { @SuppressWarnings("all") public static T copy(S source, T target, Converter converter, Set ignore) { + if (target != null && getUserClass(target).isRecord()) { + // record 不可变,无法写入已存在实例;这里按组件名重新构造并返回新实例。 + return (T) copyToRecord(source, (Class) getUserClass(target), converter, ignore); + } if (source instanceof Map && target instanceof Map) { if (CollectionUtils.isEmpty(ignore)) { ((Map) target).putAll(((Map) source)); @@ -144,6 +154,45 @@ public static T copy(S source, T target, Converter converter, Set return target; } + static T copyToRecord(S source, Class target, Converter converter, String... ignore) { + Set ignored = (ignore == null || ignore.length == 0) + ? Collections.emptySet() + : new HashSet<>(Arrays.asList(ignore)); + return copyToRecord(source, target, converter, ignored); + } + + @SuppressWarnings("unchecked") + static T copyToRecord(S source, Class target, Converter converter, Set ignore) { + return (T) getRecordCopier(getUserClass(source), target) + .copy(source, ignore == null ? Collections.emptySet() : ignore, converter); + } + + private static RecordCopier getRecordCopier(Class source, Class target) { + CacheKey key = createCacheKey(source, target); + return RECORD_CACHE.computeIfAbsent(key, k -> createRecordCopier(k.sourceType, k.targetType)); + } + + private static RecordCopier createRecordCopier(Class source, Class target) { + String method = "public Object copy(Object s, java.util.Set ignore, " + + "org.hswebframework.web.bean.Converter converter){\n" + + "try{\n\t" + + createRecordCopierCode(source, target) + + "}catch(Throwable e){\n" + + "\tthrow new UnsupportedOperationException(e.getMessage(), e);" + + "\n}\n" + + "\n}"; + try { + @SuppressWarnings("all") + Proxy proxy = Proxy + .create(RecordCopier.class, new Class[]{source, target}) + .addMethod(method); + return proxy.newInstance(); + } catch (Exception e) { + log.error("创建record copy代理对象失败:\n{}", method, e); + throw new UnsupportedOperationException(e.getMessage(), e); + } + } + static Class getUserClass(Object object) { if (object instanceof Map) { return Map.class; @@ -220,6 +269,10 @@ public static Copier createCopier(Class source, Class target) { private static Map createProperty(Class type) { + if (type.isRecord()) { + return createRecordProperty(type); + } + List fieldNames = Arrays .stream(type.getDeclaredFields()) .map(Field::getName) @@ -236,6 +289,15 @@ private static Map createProperty(Class type) { } + private static Map createRecordProperty(Class type) { + return Arrays.stream(type.getRecordComponents()) + .map(RecordClassProperty::new) + .collect(Collectors.toMap(ClassProperty::getName, + Function.identity(), + (k, k2) -> k, + LinkedHashMap::new)); + } + private static Map createMapProperty(Map template) { return template .values() @@ -244,6 +306,69 @@ private static Map createMapProperty(Map k, LinkedHashMap::new)); } + private static String createRecordCopierCode(Class source, Class target) { + Map sourceProperties = Map.class.isAssignableFrom(source) + ? createMapProperty(createRecordProperty(target)) + : createProperty(source); + String sourceTypeName = getTypeName(source); + String targetTypeName = getTypeName(target); + boolean sourceIsMap = Map.class.isAssignableFrom(source); + StringBuilder code = new StringBuilder(); + code.append(sourceTypeName).append(" $$__source=(").append(sourceTypeName).append(")s;\n\t"); + + RecordComponent[] components = target.getRecordComponents(); + List constructorArgs = new ArrayList<>(components.length); + for (RecordComponent component : components) { + String name = component.getName(); + Class type = component.getType(); + String typeName = getTypeName(type); + String varName = "$$__" + name; + code.append(typeName).append(" ").append(varName).append("=").append(defaultValueCode(type)).append(";\n\t"); + code.append("if(!ignore.contains(\"").append(name).append("\")){\n\t\t"); + + ClassProperty sourceProperty = sourceProperties.get(name); + if (sourceProperty == null) { + code.append("// source property not found, keep default value.\n\t"); + code.append("}\n\t"); + constructorArgs.add(varName); + continue; + } + + String valueExpression = sourceIsMap + ? "$$__source.get(\"" + name + "\")" + : "$$__source." + sourceProperty.getReadMethod(); + if (sourceProperty.isPrimitive()) { + valueExpression = wrapperClassMapping + .get(sourceProperty.getType()) + .getName() + ".valueOf(" + valueExpression + ")"; + } + String generic = resolveRecordGenericCode(component); + code.append("Object $$__value=(Object)(").append(valueExpression).append(");\n\t\t"); + code.append("if($$__value!=null){\n\t\t\t"); + if (requiresRecordGenericConversion(component, type)) { + code.append(varName).append("=").append(convertValueCode(type, generic)).append(";\n\t\t"); + } else { + code.append("if(").append(directAssignableCode(type, "$$__value")).append("){\n\t\t\t"); + code.append(varName).append("=").append(castValueCode(type, "$$__value")).append(";\n\t\t\t"); + code.append("}else{\n\t\t\t"); + code.append(varName).append("=").append(convertValueCode(type, generic)).append(";\n\t\t\t"); + code.append("}\n\t\t"); + } + code.append("}\n\t"); + code.append("}\n\t"); + constructorArgs.add(varName); + } + code.append("return new ").append(targetTypeName).append("(") + .append(String.join(",", constructorArgs)) + .append(");\n"); + return code.toString(); + } + + private static boolean requiresRecordGenericConversion(RecordComponent component, Class type) { + return ResolvableType.forType(component.getGenericType()).hasGenerics() + && (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)); + } + private static String createCopierCode(Class source, Class target) { Map sourceProperties = null; @@ -521,6 +646,21 @@ public BeanClassProperty(PropertyDescriptor descriptor) { } } + static class RecordClassProperty extends ClassProperty { + public RecordClassProperty(RecordComponent component) { + type = component.getType(); + readMethodName = component.getAccessor().getName(); + writeMethodName = null; + + getter = createGetterFunction(); + setter = createSetterFunction(paramGetter -> { + throw new UnsupportedOperationException("Record property is read-only: " + component.getName()); + }); + name = component.getName(); + beanType = component.getDeclaringRecord(); + } + } + static class MapClassProperty extends ClassProperty { public MapClassProperty(String name) { type = Object.class; @@ -545,6 +685,78 @@ public String getReadMethodName() { } + private static String resolveRecordGenericCode(RecordComponent component) { + Class[] genericTypes = Arrays.stream(ResolvableType.forType(component.getGenericType()).getGenerics()) + .map(ResolvableType::getRawClass) + .filter(Objects::nonNull) + .toArray(Class[]::new); + if (genericTypes.length == 0) { + return "org.hswebframework.web.bean.FastBeanCopier.EMPTY_CLASS_ARRAY"; + } + return "new Class[]{" + Arrays.stream(genericTypes) + .map(type -> getTypeName(type) + ".class") + .collect(Collectors.joining(",")) + "}"; + } + + private static String getTypeName(Class type) { + if (type.isArray()) { + return getTypeName(type.getComponentType()) + "[]"; + } + return type.getName(); + } + + private static String defaultValueCode(Class type) { + if (!type.isPrimitive()) { + return "null"; + } + if (type == boolean.class) { + return "false"; + } + if (type == char.class) { + return "(char)0"; + } + if (type == byte.class) { + return "(byte)0"; + } + if (type == short.class) { + return "(short)0"; + } + if (type == long.class) { + return "0L"; + } + if (type == float.class) { + return "0F"; + } + if (type == double.class) { + return "0D"; + } + return "0"; + } + + private static String castValueCode(Class type, String value) { + if (!type.isPrimitive()) { + return "(" + getTypeName(type) + ")" + value; + } + Class wrapper = wrapperClassMapping.get(type); + return "((" + wrapper.getName() + ")" + value + ")." + type.getName() + "Value()"; + } + + private static String convertValueCode(Class type, String generic) { + String converted = "converter.convert($$__value," + getTypeName(type) + ".class," + generic + ")"; + if (!type.isPrimitive()) { + return "(" + getTypeName(type) + ")" + converted; + } + Class wrapper = wrapperClassMapping.get(type); + return "((" + wrapper.getName() + ")" + converted + ")." + type.getName() + "Value()"; + } + + private static String directAssignableCode(Class type, String value) { + Class directType = type.isPrimitive() + ? wrapperClassMapping.get(type) + : type; + return value + " instanceof " + getTypeName(directType); + } + public static final class DefaultConverter implements Converter { private BeanFactory beanFactory = BEAN_FACTORY; @@ -697,6 +909,9 @@ public T convert(Object source, Class targetClass, Class[] genericType) { source = ((Date) source).getTime(); } } + if (targetClass.isRecord()) { + return copyToRecord(source, targetClass, this, Collections.emptySet()); + } try { org.apache.commons.beanutils.Converter converter = convertUtils.lookup(targetClass); if (null != converter) { diff --git a/hsweb-core/src/main/java/org/hswebframework/web/bean/RecordCopier.java b/hsweb-core/src/main/java/org/hswebframework/web/bean/RecordCopier.java new file mode 100644 index 000000000..42e72a6e3 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/bean/RecordCopier.java @@ -0,0 +1,25 @@ +package org.hswebframework.web.bean; + +import java.util.Set; + +/** + * Record constructor copier. + *

+ * Record is immutable and cannot reuse {@link Copier}'s in-place target mutation contract, + * so generated implementations return a newly constructed record instance. + * + * @author zhouhao + * @since 5.0.2 + */ +public interface RecordCopier { + + /** + * Copy source values into a new record instance by canonical constructor order. + * + * @param source source bean, record or map; generated implementations cast it to the cached source type + * @param ignore record component names to keep at Java default values + * @param converter value converter used when source and target component types are not directly assignable + * @return new record instance; never mutates an existing target record + */ + Object copy(Object source, Set ignore, Converter converter); +} diff --git a/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java index c69bce1ed..f5a2d0867 100644 --- a/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java +++ b/hsweb-core/src/test/java/org/hswebframework/web/bean/FastBeanCopierTest.java @@ -207,6 +207,80 @@ public static class CharSequenceTarget { private CharSequence value; } + @Test + public void testRecordCopy() { + Map values = new LinkedHashMap<>(); + values.put("name", "record-target"); + values.put("age", "18"); + values.put("color2", "RED"); + values.put("nested", Collections.singletonMap("name", "nested-map")); + values.put("nestedList", Collections.singletonList(Collections.singletonMap("name", "nested-list"))); + + TargetRecord target = FastBeanCopier.copy(values, TargetRecord.class); + + Assert.assertEquals("record-target", target.name()); + Assert.assertEquals(18, target.age()); + Assert.assertEquals(Color.RED, target.color2()); + Assert.assertEquals("nested-map", target.nested().name()); + Assert.assertEquals("nested-list", target.nestedList().get(0).name()); + + SourceRecord source = new SourceRecord("record-source", 20, Color.BLUE, new NestedRecord("nested-source")); + Target beanTarget = FastBeanCopier.copy(source, new Target()); + Assert.assertEquals("record-source", beanTarget.getName()); + Assert.assertEquals(20, beanTarget.getAge()); + Assert.assertEquals(Color.BLUE, beanTarget.getColor2()); + + Map copiedMap = FastBeanCopier.copy(source, new HashMap<>()); + Assert.assertEquals("record-source", copiedMap.get("name")); + Assert.assertEquals(20, copiedMap.get("age")); + + TargetRecord ignored = FastBeanCopier.copy(values, TargetRecord.class, "age"); + Assert.assertEquals(0, ignored.age()); + + TargetRecord ignoredNested = FastBeanCopier.copy(values, TargetRecord.class, "nested"); + Assert.assertNull(ignoredNested.nested()); + + TargetRecord rebuilt = FastBeanCopier.copy(values, new TargetRecord("old", 1, Color.BLUE, null, Collections.emptyList())); + Assert.assertEquals("record-target", rebuilt.name()); + + Source beanSource = new Source(); + beanSource.setName("bean-source"); + beanSource.setAge(19); + beanSource.setColor2("蓝色"); + BeanRecord beanRecord = FastBeanCopier.copy(beanSource, BeanRecord.class); + Assert.assertEquals("bean-source", beanRecord.name()); + Assert.assertEquals(19, beanRecord.age()); + Assert.assertEquals(Color.BLUE, beanRecord.color2()); + + TargetRecord recordTarget = FastBeanCopier.copy(source, TargetRecord.class); + Assert.assertEquals("record-source", recordTarget.name()); + Assert.assertEquals(20, recordTarget.age()); + Assert.assertEquals(Color.BLUE, recordTarget.color2()); + Assert.assertEquals("nested-source", recordTarget.nested().name()); + Assert.assertNull(recordTarget.nestedList()); + + Map partialValues = new LinkedHashMap<>(); + partialValues.put("name", "partial-record"); + partialValues.put("nested", new NestedRecord("direct-nested")); + TargetRecord partial = FastBeanCopier.copy(partialValues, TargetRecord.class); + Assert.assertEquals("partial-record", partial.name()); + Assert.assertEquals(0, partial.age()); + Assert.assertNull(partial.color2()); + Assert.assertEquals("direct-nested", partial.nested().name()); + } + + public record SourceRecord(String name, int age, Color color2, NestedRecord nested) { + } + + public record TargetRecord(String name, int age, Color color2, NestedRecord nested, List nestedList) { + } + + public record BeanRecord(String name, int age, Color color2) { + } + + public record NestedRecord(String name) { + } + @Test public void testCopyMap() {