From 1871bcf9f45f51f51e6f56796cb8b87faa14f19f Mon Sep 17 00:00:00 2001 From: junhyeong9812 Date: Sat, 13 Jun 2026 10:12:55 +0900 Subject: [PATCH] Fix property name resolution for record accessors Property.resolveName() located the get/is/set accessor prefix with String.indexOf, which matches the prefix anywhere in the method name. A record component accessor whose name embeds such a prefix (for example budget()) had the wrong portion stripped and resolved to an empty or wrong property name, which in turn caused the component's backing field annotations to be dropped. Detect record component accessors first and otherwise match the get/is/set prefix only at the start of the method name via startsWith. Regular JavaBeans accessors are unaffected. Signed-off-by: junhyeong9812 --- .../core/convert/Property.java | 46 +++++--- .../core/convert/PropertyTests.java | 110 ++++++++++++++++++ 2 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 spring-core/src/test/java/org/springframework/core/convert/PropertyTests.java diff --git a/spring-core/src/main/java/org/springframework/core/convert/Property.java b/spring-core/src/main/java/org/springframework/core/convert/Property.java index 09bf46f46bb9..3485c3cd6486 100644 --- a/spring-core/src/main/java/org/springframework/core/convert/Property.java +++ b/spring-core/src/main/java/org/springframework/core/convert/Property.java @@ -20,6 +20,7 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -133,35 +134,48 @@ Annotation[] getAnnotations() { private String resolveName() { if (this.readMethod != null) { - int index = this.readMethod.getName().indexOf("get"); - if (index != -1) { - index += 3; + String methodName = this.readMethod.getName(); + int index; + if (isRecordAccessor(this.readMethod)) { + // Record-style plain accessor method, for example, name() + index = 0; + } + else if (methodName.startsWith("get")) { + index = 3; + } + else if (methodName.startsWith("is")) { + index = 2; } else { - index = this.readMethod.getName().indexOf("is"); - if (index != -1) { - index += 2; - } - else { - // Record-style plain accessor method, for example, name() - index = 0; - } + index = 0; } - return StringUtils.uncapitalize(this.readMethod.getName().substring(index)); + return StringUtils.uncapitalize(methodName.substring(index)); } else if (this.writeMethod != null) { - int index = this.writeMethod.getName().indexOf("set"); - if (index == -1) { + String methodName = this.writeMethod.getName(); + if (!methodName.startsWith("set")) { throw new IllegalArgumentException("Not a setter method"); } - index += 3; - return StringUtils.uncapitalize(this.writeMethod.getName().substring(index)); + return StringUtils.uncapitalize(methodName.substring(3)); } else { throw new IllegalStateException("Property is neither readable nor writable"); } } + private static boolean isRecordAccessor(Method method) { + Class declaringClass = method.getDeclaringClass(); + if (!declaringClass.isRecord()) { + return false; + } + for (RecordComponent component : declaringClass.getRecordComponents()) { + if (component.getAccessor().equals(method)) { + return true; + } + } + return false; + } + private MethodParameter resolveMethodParameter() { MethodParameter read = resolveReadMethodParameter(); MethodParameter write = resolveWriteMethodParameter(); diff --git a/spring-core/src/test/java/org/springframework/core/convert/PropertyTests.java b/spring-core/src/test/java/org/springframework/core/convert/PropertyTests.java new file mode 100644 index 000000000000..ffae33f9e3f7 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/convert/PropertyTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.core.convert; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Property} name resolution. + * + * @author junhyeong9812 + */ +class PropertyTests { + + @Test + void resolveNameForStandardGetter() throws Exception { + assertThat(readProperty(TestBean.class, "getName").getName()).isEqualTo("name"); + } + + @Test + void resolveNameForBooleanGetter() throws Exception { + assertThat(readProperty(TestBean.class, "isEnabled").getName()).isEqualTo("enabled"); + } + + @Test + void resolveNameForSetter() throws Exception { + Method setter = TestBean.class.getMethod("setName", String.class); + assertThat(new Property(TestBean.class, null, setter).getName()).isEqualTo("name"); + } + + @Test // record component accessor whose name embeds the "get" prefix + void resolveNameForRecordAccessorEmbeddingGetPrefix() throws Exception { + assertThat(readProperty(SampleRecord.class, "budget").getName()).isEqualTo("budget"); + } + + @Test // record component accessor whose name starts with the "is" prefix + void resolveNameForRecordAccessorStartingWithIsPrefix() throws Exception { + assertThat(readProperty(SampleRecord.class, "issue").getName()).isEqualTo("issue"); + } + + @Test // plain record component accessor with no prefix collision (regression guard) + void resolveNameForPlainRecordAccessor() throws Exception { + assertThat(readProperty(SampleRecord.class, "name").getName()).isEqualTo("name"); + } + + @Test // a JavaBeans-style getter declared on a record must still be stripped + void resolveNameForGetterDeclaredOnRecord() throws Exception { + assertThat(readProperty(SampleRecord.class, "getWidget").getName()).isEqualTo("widget"); + } + + @Test // component literally named "get": proves record detection must precede startsWith + void resolveNameForRecordAccessorNamedGet() throws Exception { + assertThat(readProperty(EdgeRecord.class, "get").getName()).isEqualTo("get"); + } + + @Test // component literally named "is": proves record detection must precede startsWith + void resolveNameForRecordAccessorNamedIs() throws Exception { + assertThat(readProperty(EdgeRecord.class, "is").getName()).isEqualTo("is"); + } + + + private static Property readProperty(Class objectType, String readMethodName) throws Exception { + Method readMethod = objectType.getMethod(readMethodName); + return new Property(objectType, readMethod, null); + } + + + @SuppressWarnings("unused") + static class TestBean { + + public String getName() { + return null; + } + + public boolean isEnabled() { + return false; + } + + public void setName(String name) { + } + } + + record SampleRecord(String name, String budget, String issue) { + + public String getWidget() { + return null; + } + } + + record EdgeRecord(String get, String is) { + } + +}