Skip to content

WW-3871 Derive ConversionRule prefixes for @TypeConversion keys#1812

Draft
lukaszlenart wants to merge 23 commits into
mainfrom
WW-3871-typeconversion-key-derivation
Draft

WW-3871 Derive ConversionRule prefixes for @TypeConversion keys#1812
lukaszlenart wants to merge 23 commits into
mainfrom
WW-3871-typeconversion-key-derivation

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Jul 25, 2026

Copy link
Copy Markdown
Member

Fixes WW-3871

What

@TypeConversion now derives its conversion-mapping key prefix from the declared ConversionRule, so a bare property name works as key at class, method and field level:

// both forms are now equivalent
@TypeConversion(key = "annotatedBeanMap", rule = ConversionRule.KEY_PROPERTY, value = "id")
@TypeConversion(key = "KeyProperty_annotatedBeanMap", rule = ConversionRule.KEY_PROPERTY, value = "id")

Half of the reporter's request was already implemented in 2018 (77cbafb), which taught the method-level path to derive the key from the property name. This closes the remaining gaps.

Changes

  • ConversionRule.prefix() owns the rule-to-prefix table, previously duplicated inline. The switch is exhaustive with no default, so a future rule cannot silently ship without a prefix.
  • XWorkConverter.resolveKey(type, rule, name) is the single resolver all annotation passes route through — explicit keys and derived property names alike. A key that already carries a rule prefix is returned untouched, so existing annotations keep working byte-for-byte. ConversionType.APPLICATION keys are class names and are never prefixed.
  • addConverterMapping split into four ordered passes — properties file, class-level, method, field — keeping the existing first-writer-wins rule. The previous single method was ~45 lines of nested loops.
  • @TypeConversion is now @Target({METHOD, FIELD}), which its Javadoc has always claimed. The field pass reads declared, non-static, non-synthetic fields only; buildConverterMapping already walks the hierarchy.

Three defects in the same code path are fixed as a byproduct:

  • break where continue was meant: one already-mapped key aborted the remaining @TypeConversion entries in a @Conversion array.
  • An empty class-level key registered a mapping under "". It is now skipped with a WARN naming the class.
  • An APPLICATION-scoped annotation with no explicit key derived a member name and registered it in the global default-converter map, which lookup(String, boolean) only ever reads by class name. That entry was unreachable; it is now skipped with a WARN.

Upgrade impact

Worth a release note. Applications carrying a method-level annotation with a bare key and a non-PROPERTY rule — for example:

@TypeConversion(key = "someProp", rule = ConversionRule.CREATE_IF_NULL, value = "false")
public void setSomeProp(List someProp) { ... }

registered someProp before this change, a key nothing reads. It now registers CreateIfNull_someProp, which DefaultObjectTypeDeterminer does read. Such annotations therefore go from dormant to active on upgrade, and it bites in both directions: value = "true" enables collection auto-creation that was not happening, and value = "false" actively suppresses creation that previously occurred by default through isIndexAccessed.

The same applies to KEY, ELEMENT and KEY_PROPERTY. Auto-growth stays bounded either way — XWorkListPropertyAccessor enforces struts.ognl.autoGrowthCollectionLimit.

Turning these into no-ops was never intended; the previous behaviour was a silent misfire. Related: because convertValue casts the looked-up mapping with an unchecked (TypeConverter), a property literally named someProp on that class would previously have thrown ClassCastException — the prefixing removes that latent crash.

Compatibility

Source- and binary-compatible. MyBeanAction deliberately keeps its spelled-out prefixes and its tests are untouched — that is the backward-compatibility evidence, alongside a new bare-key twin asserted to produce an identical mapping.

Notes for review

  • The "already prefixed" guard matches against every rule's prefix, not just the declared rule's. COLLECTION and ELEMENT are interchangeable — DefaultConversionAnnotationProcessor handles both in one branch and DefaultObjectTypeDeterminer.getElementClass reads Element_ then falls back to the deprecated Collection_ — so key = "Element_users", rule = COLLECTION must not become Collection_Element_users.
  • Precedence is class > method > field per class. Because the method pass reads getMethods() (inherited) while the field pass reads getDeclaredFields(), a superclass's annotated setter beats a subclass's field annotation; this is documented on processFieldAnnotations.
  • On fields the derived key is the field name, so a field whose name differs from its JavaBean property produces a key nothing looks up. Documented on key().
  • The dedicated field annotations @Key, @Element, @KeyProperty and @CreateIfNull still win over an equivalent @TypeConversion on the same property — DefaultObjectTypeDeterminer.getAnnotation consults them before the converter mapping. Newly reachable now that @TypeConversion targets fields, so it is documented on the annotation.
  • Out of scope, but noticed: DefaultConversionFileProcessor:69 has the identical break-instead-of-continue defect for -conversion.properties files. Happy to file a follow-up.

Testing

mvn test -DskipAssembly -pl core — 3043/3043 passing. mvn javadoc:javadoc -pl core clean.

New coverage: ConversionRuleTest; resolveKey unit tests including prefix crossover and the APPLICATION carve-out; class-level bare keys asserted equal to the spelled-out form; a -conversion.properties key collision proving later entries still register; field derivation and method-over-field precedence; inherited method annotations still registering through a subclass; and an end-to-end binding test through the action lifecycle.

Design notes and the implementation plan are included under docs/superpowers/.

lukaszlenart and others added 14 commits July 25, 2026 13:49
Specifies deriving the ConversionRule prefix for @TypeConversion keys at
class, method and field level via a single resolver, adds ElementType.FIELD
as a target, and records the break/continue and empty-key fixes in the same
code block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records that addConverterMapping runs inside the computeMappingIfAbsent
builder introduced by WW-5539, which executes outside any lock, so the new
field pass adds no deadlock risk but must stay side-effect free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven TDD tasks covering ConversionRule#prefix(), the shared resolveKey
helper, class- and field-level derivation, the break/continue and empty-key
fixes, an end-to-end binding proof and the Javadoc updates. Refines the
spec's resolveKey signature to take the two annotation attributes rather
than the annotation instance, so it can be unit tested directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on lifecycle

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e prefix

resolveKey only recognized a key as already-prefixed if it started with
its own declared rule's prefix. COLLECTION and ELEMENT are interchangeable
throughout the conversion pipeline (DefaultConversionAnnotationProcessor
handles them in the same branch, DefaultObjectTypeDeterminer.getElementClass
reads Element_ then falls back to the deprecated Collection_), so
key="Element_users" with rule=COLLECTION silently doubled to
Collection_Element_users instead of being left alone, losing the mapping.
Match against every known rule's prefix instead.

Also documents two related precedence subtleties surfaced during review:
processFieldAnnotations' Javadoc now notes that an inherited method can
claim a key before a subclass's own field annotation is considered, since
getMethods() includes inherited methods and runs first; and the
unresolvable-key WARN in processMethodAnnotations now names the method's
declaring class rather than the class being scanned, since getMethods()
can surface the same inherited method at every level of the hierarchy.

Design spec section 2 updated to match the implementation.
… and determiner package

Two pre-existing errors in the block this ticket's commits already touch:
the APPLICATION example used a non-existent "property" attribute where
"key" is the working form (see ConversionTestAction.java:97), and the
rule() Javadoc pointed at org.apache.struts2.util.DefaultObjectTypeDeterminer
instead of the actual org.apache.struts2.conversion.impl package.
…, and KeyProperty_ end-to-end binding

- testResolveKeyLeavesAnAlreadyPrefixedKeyAlone: add the COLLECTION/ELEMENT
  crossover cases that demonstrate the resolveKey guard fix (fail before,
  pass after).
- New EmptyKeyConversionAction fixture plus
  testClassLevelEmptyKeyRegistersNoMapping: a class-level @TypeConversion
  with no key must be skipped, not registered under "". This was the one
  behavioural bullet in the spec's test plan with no coverage.
- MyBeanActionTest.testBareConversionKeysBindTheSameWayAsPrefixedOnes: add
  an assertion that the bare KeyProperty_ derivation actually binds the
  list index onto the created bean's id property end to end, not just that
  a converter mapping exists.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Updates Struts’ annotation-based type conversion so @TypeConversion keys can be written as bare property names across class-, method-, and field-level declarations, with rule-specific prefixes derived consistently and without breaking already-prefixed keys.

Changes:

  • Centralizes rule→prefix logic in ConversionRule.prefix() and introduces XWorkConverter.resolveKey(...) to normalize/derive mapping keys across annotation passes.
  • Refactors converter-mapping registration into ordered passes and adds field-level @TypeConversion support.
  • Adds targeted unit + end-to-end tests covering prefix derivation, collision handling, precedence, and action lifecycle binding.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/superpowers/specs/2026-07-25-WW-3871-typeconversion-key-derivation-design.md Design spec describing key-derivation rules, precedence, and edge cases.
docs/superpowers/plans/2026-07-25-WW-3871-typeconversion-key-derivation.md Implementation plan and verification checklist for the change set.
core/src/main/java/org/apache/struts2/conversion/annotations/ConversionRule.java Adds prefix() to own the rule-to-prefix mapping.
core/src/main/java/org/apache/struts2/conversion/annotations/TypeConversion.java Expands target to FIELD, updates Javadoc to describe derived prefixes and usage.
core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java Adds resolveKey, routes class/method/field annotation processing through it, and adds field pass.
core/src/test/java/org/apache/struts2/conversion/annotations/ConversionRuleTest.java Unit tests for ConversionRule.prefix().
core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java Unit tests for resolveKey and annotation mapping behavior (class/method/field, collisions, empty keys).
core/src/test/java/org/apache/struts2/util/BareKeyConversionAction.java Test fixture for class-level bare keys.
core/src/test/java/org/apache/struts2/util/CollidingKeyConversionAction.java Test fixture for collision behavior across sources.
core/src/test/java/org/apache/struts2/util/EmptyKeyConversionAction.java Test fixture for empty class-level key handling.
core/src/test/java/org/apache/struts2/util/ExplicitKeyConversionAction.java Test fixture for explicit, unprefixed method keys being normalized.
core/src/test/java/org/apache/struts2/util/FieldConversionAction.java Test fixture for field-level @TypeConversion + precedence.
core/src/test/java/org/apache/struts2/util/MyBeanBareKeyAction.java End-to-end fixture proving bare keys bind through the action lifecycle.
core/src/test/java/org/apache/struts2/util/MyBeanActionTest.java Adds end-to-end test asserting bare-key and prefixed-key behavior match.
core/src/test/resources/org/apache/struts2/util/CollidingKeyConversionAction-conversion.properties Properties fixture to prove collision handling doesn’t drop subsequent entries.
core/src/test/resources/xwork-sample.xml Registers the new MyBeanBareKey action for the end-to-end test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java Outdated
Comment thread core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java Outdated
lukaszlenart and others added 9 commits July 25, 2026 21:04
…plicit key

Method- and field-level @TypeConversion(type = APPLICATION) with no key
previously derived a member name (e.g. a setter's property name) and
registered it in the global default converter map via
addDefaultMapping. That map is only ever read by class name
(lookup(String, boolean) and lookup(Class)), so the entry was
permanently unreachable. Skip it before deriving a name, logging a WARN
naming the declaring class and member; the class-level pass already
handled this correctly via resolveKey returning null.

Adds a fixture and tests proving no default mapping is registered under
the derived member name in either pass.
…gn spec

TypeConversion's example class declared `users` twice (once
unannotated, once again at its annotated field), so the sample no
longer compiled as written; drop the earlier, redundant declaration.

The same example's setConvertInt showed @TypeConversion(type =
APPLICATION) with no key - exactly the case the previous commit's
XWorkConverter fix now skips. Drop the type attribute so it reads as
a class-scoped conversion, matching the corrected ConversionTestAction
fixture. The correct APPLICATION example further down (execute(), key
= "java.util.Date") is untouched.

Also records the APPLICATION no-key skip rule in the design spec's
carve-out paragraph so spec and code agree.
…eConversion

processMethodAnnotations iterates clazz.getMethods(), which includes inherited
public methods, and buildConverterMapping calls it once per class in the
hierarchy. A single misconfigured @TypeConversion on a base class method was
therefore logging its WARN once per subclass level. Gate both WARN call sites
on method.getDeclaringClass() == clazz so each fires exactly once, at the
level that owns the method; the derivation/registration logic keeps running
on every visit unchanged.

Adds a small permanent test proving the gate is logging-only: an inherited
annotated setter still resolves and registers through a subclass that
overrides nothing.
…tation precedence

Two gaps in the @TypeConversion Javadoc, both newly relevant now that the
annotation targets fields:

- The key() default on a field is the field name, not the JavaBean property
  name (processFieldAnnotations uses field.getName()). A field like _users
  backing property users would otherwise derive CreateIfNull__users, a key
  DefaultObjectTypeDeterminer never looks up.
- org.apache.struts2.util's dedicated field annotations (@key, @element,
  @keyproperty, @CreateIfNull) are consulted by DefaultObjectTypeDeterminer
  before it falls back to the converter mapping @TypeConversion populates,
  so a dedicated annotation silently wins over an equivalent @TypeConversion
  on the same property. Verified against getAnnotation/getElementClass/
  getKeyProperty in DefaultObjectTypeDeterminer before documenting it.
…_ prefix

ConversionRule.COLLECTION.prefix() intentionally returns Collection_, the
spelling DefaultObjectTypeDeterminer treats as deprecated and logs an INFO
about on every fallback hit, kept for compatibility with existing
annotations. Document that the derivation is deliberate and point readers
at ELEMENT as the current form.
processMethodAnnotations and processFieldAnnotations were the same
five-step pipeline (skip non-@TypeConversion, skip APPLICATION-scoped
without a key, derive the name, resolve the key, register unless
already mapped) written twice, driving SonarCloud S3776 cognitive
complexity to 26 and 21 respectively and triggering three S135
multiple-break/continue findings.

Extract steps 2-5 into a private registerAnnotatedMember(mapping, tc,
Member, fallbackName, logSkips) helper that both passes delegate to.
Each pass is now just its loop plus one instanceof check. The method
pass keeps its per-declaring-class log gate (getMethods() revisits
inherited methods once per hierarchy level); the field pass always
logs, since getDeclaredFields() is visited once per class. The two
WARN wordings, which differed only in a trailing clause, are merged
into one message accurate for both a method and a field.

No change to the registered mapping, pass order, or precedence for
any class - verified via the existing XWorkConverterTest,
AnnotationXWorkConverterTest, MyBeanActionTest, and ConversionRuleTest
suites (92 tests, same count and same triggering warnings before and
after) plus the full core module suite (3043 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion key Javadoc

@key, @element, @keyproperty and @CreateIfNull are @target({FIELD, METHOD}), not
field-only, and the same paragraph already says they are read from the field,
setter and getter - drop "field" from "dedicated field annotations". Fold the
field-vs-property-name correction into key()'s opening sentence instead of
stating "defaults to the property name" and rebutting it three lines later, and
align the parameters table row for key with the same rule.
…gging

Give the success DEBUG the same [declaringClass#member] shape the three skip
messages already use, instead of logging the bare member name that identifies
neither the class nor whether it was a method or a field. Reword the "already
mapped" DEBUG so it covers its commonest trigger - the same annotation seen one
hierarchy level down, not just a genuinely higher-precedence source. Note in the
logSkips comment that buildConverterMapping only visits each class' direct
interfaces, so a misconfigured annotation declared on a super-interface method
never gets logged at all, even though registration is unaffected. Also drop a
stray extra blank line.

No behavioural change: registration/derivation logic is untouched.
testInheritedMethodAnnotationStillRegistersThroughASubclass previously asserted
nothing the logSkips gate could break: the hierarchy walk always reaches
InheritedMethodConversionAction itself, where declaringClass == clazz, so the
key registers there regardless of whether registration is (wrongly) gated
alongside logging. The test passed identically with logSkips hardcoded true or
false.

Give InheritedMethodConversionSubAction a contesting field annotation for the
same property the inherited setter claims. The inherited method annotation
registers at the subclass level - before the subclass's own field pass runs -
so its value must keep winning; that is the invariant documented on
processFieldAnnotations, and it is exactly what gating registration would
break, since the subclass field would start winning over the inherited method
annotation instead.

Verified: temporarily wrapping the registerAnnotatedMember call in
processMethodAnnotations with `if (logSkips)` makes this test fail
(expected:<true> but was:<false>); reverting it passes again. Mutation was not
committed.

Corrected both Javadocs, which overclaimed what the old assertion proved.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants