DO NOT MERGE: feat(velocity): resolve record components from VTL (#34154) - #36966
Open
fabrizzio-dotCMS wants to merge 1 commit into
Open
DO NOT MERGE: feat(velocity): resolve record components from VTL (#34154)#36966fabrizzio-dotCMS wants to merge 1 commit into
fabrizzio-dotCMS wants to merge 1 commit into
Conversation
6 tasks
Contributor
|
Claude finished @fabrizzio-dotCMS's task in 3m 4s —— View job Code Review — PR #36966: resolve record components from VTL
This is a tightly-scoped, load-bearing change with strong test coverage. I traced the resolution chain, the security path, and the record-detection guard against the repo. No bugs found. Verified
Notes (non-blocking)
Housekeeping
Verdict: Clean. Additive-only, correct ordering, security preserved, and the tests assert both what changed and what must not. No changes requested. |
A record's canonical accessor is named after the component itself (foo(), not
getFoo()), so none of the strategies in UberspectImpl.getPropertyGet could reach
it: getFoo() -> getfoo() -> Map.get -> get("foo") -> isFoo(). An unresolved
Velocity reference is not an error, it renders as literal template text, so a
template reading a record printed "$rec.id" into the page, silently.
That is why SearchHit's components are named getId/getIndex/getSourceAsMap
instead of id/index/sourceAsMap: the record was deformed to satisfy the template
engine. This removes the need for that workaround on new records.
RecordComponentExecutor is deliberately narrow:
- It resolves only when the target is a record AND the identifier names one of
its declared components, never an arbitrary no-argument method. Widening
resolution to any foo() would silently change the meaning of existing
templates across the product.
- It is tried last in the chain, after every strategy that could already resolve
the reference. So it can only add a resolution where there was none: no
reference that resolves today changes meaning, without exception. Records
whose components are bean-named (SearchHit) keep resolving via PropertyExecutor.
- The accessor is looked up through Introspector.getMethod rather than
RecordComponent.getAccessor(), so the method cache and the checks of the
configured introspector (SecureIntrospectorImpl) both still apply.
SecureUberspector inherits getPropertyGet unchanged, so the fix covers the
uberspect dotCMS actually configures.
Resolution cost is amortized: ASTIdentifier caches the VelPropertyGet per
AST-node/class in the introspection cache, so getPropertyGet runs once per pair.
Also pinned by test, and unrelated to this fix: a non-public record is invisible
to VTL. ClassMap checks Modifier.isPublic on the class before collecting its
methods, so a package-private or method-local record resolves to nothing, with
the same silent literal-text outcome. A record read from a template must be
public, or nested in a public type.
Testing: 111 green.
- RecordComponentExecutorTest (14 unit) - split between what the change adds and
what it must not touch, including the guardrail that a non-record exposing a
no-arg id() still does not resolve.
- RecordComponentRenderingTest (7 integration) - the same claims end-to-end
through the real engine via VelocityUtil.eval, asserting rendered output
rather than introspection results. Registered in MainSuite1b. References are
written non-quiet on purpose; quiet notation would let a broken accessor pass.
- Regression over 10 existing VTL families (90 tests): ContentToolTest,
NavToolTest, StoryBlockMapTest, ContentSearchToolTest, ContentMapTest,
VelocityUtilTest, ASTMethodTest, DotParseTest, VelocityMacroCacheTest,
SimpleNodeTest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fabrizzio-dotCMS
force-pushed
the
issue-34154-java25-record-velocity-introspection
branch
from
August 7, 2026 21:49
2433dec to
6112276
Compare
Open
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A record's canonical accessor is named after the component itself —
foo(), notgetFoo(). Velocity'sproperty resolution never looked for that. The chain in
UberspectImpl.getPropertyGetis:An unresolved Velocity reference is not an error — it renders as literal template text. So a
template reading a record printed
$rec.idinto the page, silently.That is why the records behind
SearchHit—ContentSearchHitandSiteSearchHit— name theircomponents
getId/getIndex/getSourceAsMapinstead ofid/index/sourceAsMap: they weredeformed to satisfy the template engine. Since dotCMS
carries its own in-tree fork of Velocity (
dotCMS/src/main/java/org/apache/velocity/, no pomdeclares
velocity-engine-core), that tax was never inherent — it was ours to remove.The change
One new executor,
RecordComponentExecutor, plus four lines wiring it into the chain. It isdeliberately narrow, and each restriction is load-bearing:
clazz.isRecord()and theidentifier names one of
getRecordComponents(). It never resolves an arbitrary no-argument method— widening resolution to any
foo()would silently change the meaning of existing templates acrossthe product.
add a resolution where there was none: no reference that resolves today changes meaning, without
exception. Ordering it earlier would have left one theoretical case (a record implementing
Map)where a working reference changed target; last removes even that.
Introspector.getMethod, notRecordComponent.getAccessor(), so the methodcache and the checks of the configured introspector (
SecureIntrospectorImpl) both still apply.SecureUberspector— the uberspect dotCMS actually configures viasystem.propertiesruntime.introspector.uberspect— inheritsgetPropertyGetunchanged, so one edit covers both.Records whose components are bean-named keep resolving through
PropertyExecutor, so theSearchHitshapes and every other shipped record are untouched — verified by re-running the VTLintegration families after rebasing onto the
mainthat carries the sealedSearchHit(#36899).Cost: amortized to nothing.
ASTIdentifiercaches theVelPropertyGetper AST-node/class in theintrospection cache (
ASTIdentifier.java:132-159), sogetPropertyGetruns once per pair, not perrender.
A separate trap, pinned by test
Surfaced while writing the tests and unrelated to this fix: a non-public record is invisible to
VTL.
ClassMapchecksModifier.isPublicon the class before collecting its methods, so apackage-private or method-local record resolves to nothing — same silent literal-text outcome. This has
always been true of any class, but it bites harder with records, because the natural instinct is to
declare a small record package-private right next to its use.
Kept as an explicit assertion (
test_nonPublicRecord_doesNotResolve) rather than deleted, so theconstraint is documented where someone will hit it: a record read from a template must be
public,or nested in a public type.
Testing
111 tests green.
RecordComponentExecutorTestRecordComponentRenderingTestVelocityUtil.evalThe regression run covers
ContentToolTest(23),NavToolTest(19),StoryBlockMapTest(12),ContentSearchToolTest(11),ContentMapTest(9),VelocityUtilTest(5),ASTMethodTest(4),DotParseTest(3),VelocityMacroCacheTest(3),SimpleNodeTest(1).Two deliberate choices in the test design:
covers resolution; what matters here is what a page shows, since the failure mode being fixed is
literal text in the page.
$rec.id, never$!{rec.id}). Quiet notation renders anunresolved reference as the empty string, which would let a broken accessor pass an assertion that
only checks for absence.
Verified the ITs ran against the change rather than a stale artifact:
dotcms-corewas reinstalled to~/.m2before the run, andtest_canonicalRecordComponents_renderwould fail against the old jar.Not in this PR
Renaming
SearchHit's components back toid/index/sourceAsMapis out of scope. The JSONcontract would survive (explicit
@JsonProperty), but thegetId()accessors would disappear, whichis source-breaking for any Java consumer. This PR removes the tax for new records; retrofitting the
existing ones is a separate decision.
Breaking Changes
None. The change is strictly additive to property resolution — it only resolves references that
previously resolved to nothing.
Context
Groundwork for the Devoxx Belgium 2025 Lunch and Learn (#34154), whose dotCMS half argues about when
records are and are not a good fit in this codebase. The Velocity naming tax was the sharpest
"when not to" — so it is being removed rather than documented.
This PR fixes: #34154
🤖 Generated with Claude Code