Skip to content

Fix property name resolution for record-style accessors - #36911

Open
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/property-resolvename-startswith
Open

Fix property name resolution for record-style accessors#36911
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/property-resolvename-startswith

Conversation

@junhyeong9812

@junhyeong9812 junhyeong9812 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Overview

org.springframework.core.convert.Property#resolveName() derives a property
name from a read Method. It located the get/is accessor prefix with
String#indexOf, which matches the prefix anywhere in the method name.

Problem

For record-style plain accessors (supported since gh-26029) whose property name
contains such a token, the wrong portion is stripped. Because is is a very
common bigram, this is not limited to contrived names — many ordinary component
names are affected:

accessor current result expected
budget() / widget() / gadget() "" (empty) budget / widget / gadget
issue() / island() sue / land issue / island
history() / distance() tory / tance history / distance
decision() / visible() ion / ible decision / visible

An empty/incorrect name then makes Property#getField() fail to locate the
backing field, so annotations declared on that field are silently dropped.

This affects not only Java records but any "data class" with record-style
accessors, such as Kotlin data classes and hand-written Java data classes.

Fix

Match the get/is prefix only at the start of the method name via
startsWith, and do not strip it when the method is a plain accessor for a
data class — that is, a non-static no-arg method referring to an instance
field of the same name (mirroring the core signal of
CachedIntrospectionResults#isPlainAccessor). This supports Java records,
Kotlin data classes, and custom Java data classes alike, without relying on
java.lang.Record or RecordComponent.

Regular JavaBeans accessors without a backing field of the same name are
unaffected.

Note on impact

A getter that is backed by an instance field of the exact same name — for
example private boolean isUrgent; with public boolean isUrgent(), the shape
Kotlin produces for val isUrgent and that a record component named
isUrgent produces — now resolves to the field name (isUrgent) rather than
the stripped form (urgent). This keeps the resolved name aligned with the
backing field (and the record component name), at the cost of diverging from
java.beans.Introspector for that specific shape; the two cases are
structurally identical, so they cannot be distinguished reflectively. This is
pinned by resolveNameForBooleanGetterBackedByFieldOfSameName and called out
here for explicit review.

Tests

PropertyTests covers standard getters, boolean getters and setters; a getter
embedding get mid-name (isTarget(), the original defect shape); record
accessors that embed (budget) or start with (issue) a prefix; plain record
accessors; a JavaBeans getter declared on a record; record components literally
named get, is, and getWidget; the same scenarios on a hand-written
non-record data class; a field-backed isUrgent() getter; and static
method/field edge cases.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jun 13, 2026
@sbrannen sbrannen added the in: core Issues in core modules (aop, beans, core, context, expression) label Jun 13, 2026
@sbrannen sbrannen self-assigned this Jun 13, 2026
@junhyeong9812

Copy link
Copy Markdown
Contributor Author

One note in case it helps: this PR bundles two independent changes, and only the first is needed for the defect.

  1. Record component accessors resolve to a wrong or empty property name (budget() -> "", issue() -> sue), so Property#getField() misses the backing component field and annotations declared on that component are dropped.
  2. The setter branch switches from indexOf("set") to startsWith("set"). That one is a behavior change - write methods that are not setters were accepted before and now throw IllegalArgumentException.

The two are separate blocks in resolveName(), so dropping (2) leaves the record fix intact. If (2) is the part that makes this worth deliberating, I can push a version that touches only the record path and leaves the setter branch as it is.

sbrannen added a commit to sbrannen/spring-framework that referenced this pull request Aug 13, 2026
ReflectivePropertyAccessor's canRead(), read(), and canWrite() methods
previously constructed org.springframework.core.convert.Property
instances without an explicit name, forcing Property#resolveName() to
re-derive the property name from the accessor method via prefix
matching. That heuristic incorrectly resolves record-style and other
prefix-less accessor methods whose names embed or start with "get"/"is"
(for example, budget(), issue(), or island()), and it also normalizes
acronym-style JavaBean properties inconsistently (for example, getURL()
resolves to "uRL" rather than "URL").

By the time these three methods construct a Property, they have already
located the accessor method by searching for exactly the requested
property name, so the resolved name is already known and verified. This
commit passes that name through explicitly via the 4-arg Property
constructor, bypassing Property#resolveName() entirely at these call
sites.

This commit also introduces tests in PropertyAccessTests to cover the
following scenarios:

- A genuine record accessor whose component name embeds or starts with
  a "get"/"is" prefix
- The same scenario on a hand-written, non-record "data class"
- The read() call site exercised directly, since it is otherwise
  unreachable once canRead() has warmed the cache
- A boolean isXxx() getter, as a plain regression check
- An acronym-style property with a decoy field to prove that the
  correct field (and its annotations) is now resolved for both reads
  and writes

See spring-projectsgh-36911
Closes spring-projectsgh-37123
@sbrannen sbrannen added this to the 7.1.0-M2 milestone Aug 14, 2026

@sbrannen sbrannen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and thanks for the PR! 👍

I've requested changes to support "data classes" in general, not just Java records.

Also, please note that I raised #37123 to address issues within SpEL's ReflectivePropertyAccessor, which I've already implemented in sbrannen@d8df4bb.

So, thanks for bringing this issue to my attention.

Cheers,

Sam

}
}

private static boolean isRecordAccessor(Method method) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring's "record-style accessor" support was never scoped to java.lang.Record. Rather, we support Java records, Kotlin data classes, and custom Java "data classes" (whose accessors look like those of a Java record).

Thus, isRecordAccessor() should be renamed to something like isPlainAccessor(), and it needs to be revised to support those additional "data class" use cases without the use of isRecord() or RecordComponent – for example, mirroring the logic in ReflectivePropertyAccessor.findGetterForProperty(...) or
CachedIntrospectionResults.isPlainAccessor().

In addition, please introduce tests which cover those scenarios – or rather at least the Java "data class" use case, since that should cover the Kotlin data class use case as well. For inspiration, see sbrannen@d8df4bb.

@sbrannen sbrannen added status: waiting-for-feedback We need additional information before we can continue type: bug A general bug and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Aug 14, 2026
@sbrannen sbrannen changed the title Fix property name resolution for record accessors Fix property name resolution for record-style accessors Aug 14, 2026
@sbrannen

Copy link
Copy Markdown
Member

The two are separate blocks in resolveName(), so dropping (2) leaves the record fix intact. If (2) is the part that makes this worth deliberating, I can push a version that touches only the record path and leaves the setter branch as it is.

Actually, yes, please do extract the setter changes into a separate, dedicated PR with an appropriate title.

Property.resolveName() located the get/is accessor prefix with
String.indexOf, which matches the prefix anywhere in the method name.
A plain 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 backing field's
annotations to be silently dropped.

Match the get/is prefix only at the start of the method name and do
not strip it when the method is a plain accessor for a data class,
that is, a non-static no-arg method referring to an instance field of
the same name. This supports Java records, Kotlin data classes, and
custom Java data classes alike, without relying on java.lang.Record.

As a consequence, a getter backed by a field of the exact same name
(for example isUrgent()) now resolves to the field name.

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
@junhyeong9812

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I have updated the PR as requested:

  • isRecordAccessor() is now isPlainAccessor() and no longer relies on isRecord()/RecordComponent. It treats a non-static no-arg method that refers to an instance field of the same name as a plain accessor, mirroring the core signal of CachedIntrospectionResults#isPlainAccessor, so custom Java "data classes" and Kotlin data classes are covered as well.
  • Added tests for a hand-written non-record data class (embedded prefix, leading prefix, plain, and a conventional getter without a backing field), plus static-member edge cases.
  • The setter changes have been reverted from this PR and extracted into Reject write methods not starting with "set" in Property #37139 as suggested.
  • One behavioral note flagged explicitly in the updated description ("Note on impact"): a getter backed by a field of the exact same name (for example isUrgent() with a boolean isUrgent field — the shape produced by a record component or Kotlin property named isUrgent) now resolves to isUrgent rather than urgent.
  • The branch has been rebased onto the latest main and squashed into a single commit.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Aug 14, 2026
@sbrannen sbrannen removed the status: feedback-provided Feedback has been provided label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: core Issues in core modules (aop, beans, core, context, expression) type: bug A general bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants