Fix property name resolution for record-style accessors - #36911
Fix property name resolution for record-style accessors#36911junhyeong9812 wants to merge 1 commit into
Conversation
|
One note in case it helps: this PR bundles two independent changes, and only the first is needed for the defect.
The two are separate blocks in |
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
left a comment
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
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>
1871bcf to
6a9b789
Compare
|
Thanks for the review! I have updated the PR as requested:
|
Overview
org.springframework.core.convert.Property#resolveName()derives a propertyname from a read
Method. It located theget/isaccessor prefix withString#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
isis a verycommon bigram, this is not limited to contrived names — many ordinary component
names are affected:
budget()/widget()/gadget()""(empty)budget/widget/gadgetissue()/island()sue/landissue/islandhistory()/distance()tory/tancehistory/distancedecision()/visible()ion/ibledecision/visibleAn empty/incorrect name then makes
Property#getField()fail to locate thebacking 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/isprefix only at the start of the method name viastartsWith, and do not strip it when the method is a plain accessor for adata 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.RecordorRecordComponent.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;withpublic boolean isUrgent(), the shapeKotlin produces for
val isUrgentand that a record component namedisUrgentproduces — now resolves to the field name (isUrgent) rather thanthe stripped form (
urgent). This keeps the resolved name aligned with thebacking field (and the record component name), at the cost of diverging from
java.beans.Introspectorfor that specific shape; the two cases arestructurally identical, so they cannot be distinguished reflectively. This is
pinned by
resolveNameForBooleanGetterBackedByFieldOfSameNameand called outhere for explicit review.
Tests
PropertyTestscovers standard getters, boolean getters and setters; a getterembedding
getmid-name (isTarget(), the original defect shape); recordaccessors that embed (
budget) or start with (issue) a prefix; plain recordaccessors; a JavaBeans getter declared on a record; record components literally
named
get,is, andgetWidget; the same scenarios on a hand-writtennon-record data class; a field-backed
isUrgent()getter; and staticmethod/field edge cases.