Various identifier fixes for not unwrapping Hibernate proxies#15841
Various identifier fixes for not unwrapping Hibernate proxies#15841jdaugherty wants to merge 6 commits into
Conversation
|
@jamesfredley & @borinquenkid I created this separate from the plugin merge. This removes the need for the groovy-proxy library from yakworks too (which is built on a very old version of groovy). |
|
Awesome work. |
bd0df2d to
4bc7f5c
Compare
|
@borinquenkid & @jamesfredley this is finally ready to review. |
|
The request to remove |
borinquenkid
left a comment
There was a problem hiding this comment.
Just a couple of quibbles
…rLogicSpec - Remove incorrect @author attribution from the new h5 GroovyProxyInterceptorLogic - Add a direct unit test for GroovyProxyInterceptorLogic (ported from the hibernate7 spec, plus isInitialized coverage)
|
I went ahead and completely removed |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #15841 +/- ##
==================================================
- Coverage 49.3373% 49.3266% -0.0107%
+ Complexity 16775 16770 -5
==================================================
Files 1985 1985
Lines 93327 93327
Branches 16336 16336
==================================================
- Hits 46045 46035 -10
- Misses 40158 40167 +9
- Partials 7124 7125 +1 🚀 New features to boost your workflow:
|
|
Removing the yakworks library revealed a change that's probably significant. Here's what AI said: I found the root cause. ProfileServiceSpec isn't the real failure — it's collateral damage from a genuine behavioral regression in the PR. The failure chain
The regression The profile list GSP renders the association directly: ${entry.user} (grails-app/views/profile/index.gsp:52), where profile.user is a lazy Hibernate proxy. The User domain has @tostring(includes='username', includeNames=true, includePackage=false), so the page used to render User(username:test_user_...). This PR's new GroovyProxyInterceptorLogic.handleUninitialized() (grails-data-hibernate5/.../GroovyProxyInterceptorLogic.java:68-70) short-circuits toString() on an uninitialized proxy and returns entityName + ":" + id — e.g. test.User:5 — without initializing the proxy or calling the entity's real toString(). And since HibernateMappingContextConfiguration now auto-registers the Groovy-aware proxy factory for every hibernate5 app, the spring-security extended app silently switched behavior: the page now renders test.User:5, the selector matches nothing, and the spec fails deterministically. The behavior is even pinned by the PR's own test (GroovyProxyInterceptorLogicSpec asserts "Book:1"). My recommendation Remove (or rethink) the toString() short-circuit. Rendering ${someEntity.association} in a GSP is an extremely common pattern in real Grails apps, and stock Hibernate semantics — initialize the proxy and delegate to the entity's real toString() — is what every existing app depends on. The lazy-toString behavior was inherited from yakworks' design, but yakworks was opt-in; this factory is now the global default. The id/metaClass/ident() interceptions are the valuable part of the fix and don't have this compatibility problem. The concrete change: drop the TO_STRING branch from handleUninitialized in the h5 GroovyProxyInterceptorLogic (and the equivalent in h7 if it does the same — worth checking GrailsBytecodeProvider/interceptor there), update GroovyProxyInterceptorLogicSpec, and add an assertion that toString() initializes and returns the real value. |
|
I'm going to make the toString behavior opt-in via config and match the historical grails behavior instead. |
✅ All tests passed ✅🏷️ Commit: 96c0aed Learn more about TestLens at testlens.app. |
Groovy-Aware Hibernate Proxies for grails-data-hibernate5 (and handler fixes for both Hibernate lines)
While working on the Spring Security branch merge to 8.x, I discovered inconsistent property behavior with
.idand.getId- the redis plugin imported the Spring version of Rollback so we never noticed this before. This PR ensures domains do not unwrap when calling either form in hibernate 5 or 7.The bug
Accessing the identifier of an uninitialized GORM/Hibernate proxy —
proxy.id,proxy.getId(),proxy['id'],proxy.ident()— initialized the proxy ongrails-data-hibernate5. With an opensession that was a silent eager fetch (defeating the point of
load()/lazy associations); on adetached proxy it threw
LazyInitializationException. Identifier access must never unwrap aproxy.
First observed as two failures in
RedisIntegrationSpec(testMemoizeDomainList,testMemoizeDomainObject) on this branch's CI. The redis plugin caches only entity IDs in redisand rehydrates with
domainClass.load(id)inside short@Transactionalmethods, so itscache-hit results are exactly this shape: uninitialized proxies whose transaction has ended.
Root cause
Hibernate's stock ByteBuddy proxy intercepts every virtual method.
BasicLazyInitializerhas a tiny no-init allowlist — essentially the identifier getter (
getId()) andgetHibernateLazyInitializer()— and initializes on anything else. Dynamic Groovy propertyaccess never reaches
getId()directly: the MOP resolves the receiver first, callinggetMetaClass()(andgetProperty("id")) on the instance. Those are ordinary virtual methodswoven in by the Groovy compiler, so the proxy intercepts them, they miss the allowlist, and the
proxy initializes before
getId()is ever called.Findings that scoped the fix:
byte-identical dispatch:
getMetaClass()×3 thengetProperty("id")thengetId(). The sameredis tests also fail on a local run of
upstream/7.2.x(Groovy 4) with a real redis — theyhad simply never executed in this repo's CI before the inverted
isRedisflag was fixed onthis branch.
GrailsBytecodeProvider→ByteBuddyGroovyProxyFactory→ByteBuddyGroovyInterceptor+GroovyProxyInterceptorLogic,installed by default. hibernate5 had no equivalent — only an opt-in third-party test dependency
(
org.yakworks:hibernate-groovy-proxy) and@PendingFeatureIf-gated tests inByteBuddyProxySpecdocumenting the gap.HibernateProxyHandler(both modules) gaineda
getProxyInstanceMetaClassprobe that callsgetMetaClass()on the object before theinstanceof HibernateProxychecks. The probe only exists to detect the simple/in-memorydatastore's metaclass-based proxies (
ProxyInstanceMetaClass), which a Hibernate proxy cannever be — so for Hibernate proxies it was pure downside:
getIdentifier()/isProxy()/unwrap()initialized the proxy (or threw when detached) on 7.2.x-and-later code that used toanswer straight from the
LazyInitializer.The fix
1. Port the hibernate7 proxy stack to grails-data-hibernate5 (new files)
Same classes, same names, same behavior — adapted only where the Hibernate 5.6 SPI differs
(
Serializableids,ByteBuddyProxyFactory/ProxyFactoryFactorysignatures):grails-data-hibernate5/core/.../org/grails/orm/hibernate/proxy/)GroovyProxyInterceptorLogic.javagetMetaClass/getStaticMetaClass/getProperty("id"|"metaClass")/ident/isDirty/hasChanged/toStringfor uninitialized proxies without initializing.ByteBuddyGroovyInterceptor.javaByteBuddyInterceptor. Returns the identifier forgetId/getIdentifier/the mapped id getter before any other handling; routes uninitialized calls through the logic above; falls back to stock interception otherwise.ByteBuddyGroovyProxyFactory.javaByteBuddyProxyFactory; builds the proxy class viaByteBuddyProxyHelperand installs the Groovy-aware interceptor viaProxyConfiguration.$$_hibernate_set_interceptor.GrailsProxyFactoryFactory.javaProxyFactoryFactoryreturning the Groovy-aware factory for entities; basic (non-entity) proxies delegate to the stock implementation.GrailsBytecodeProvider.javaBytecodeProviderImplso enhancement/reflection optimization are untouched; onlygetProxyFactoryFactory()is overridden.Wiring (
HibernateMappingContextConfiguration.buildSessionFactory()):Hibernate 5.6's
PojoEntityTuplizerresolves theProxyFactoryFactoryfrom the serviceregistry, and a provided service beats any classpath-contributed initiator — so this is
deterministic, default-on, and requires no third-party dependency. (hibernate7 wires the same
provider through
HibernateConnectionSourceFactory/hibernate.enhancer.bytecodeprovider.instance;the SPIs differ, the behavior does not.)
2.
HibernateProxyHandlerordering fix (both modules)getProxyInstanceMetaClass()returnsnullimmediately forHibernateProxyinstances, so
getIdentifier()/isProxy()/unwrap()/initialize()never dispatchgetMetaClass()through a Hibernate proxy.getIdentifier()is answered session-free fromgetHibernateLazyInitializer().getIdentifier()(the pre-branch behavior).getIdentifier()andisProxy()—instanceof HibernateProxybefore the
GroovyProxyInterceptorLogicmetaclass probe (the helper itself staysHibernate-decoupled).
This matters independently of the interceptor: the handler must be safe for any
HibernateProxyit is handed, including ones not produced by our factory.3. Tests
ByteBuddyProxySpec(h5 + h7) un-gated: the@PendingFeatureIf/runPendingmachinery andthe yakworks
testImplementationare removed from both modules — all lazy-proxy requirementsare now hard tests proving the in-repo stacks. 7 features each, identical scenarios:
getId()/.id/['id'](dynamic and@CompileStatic) don't initializeident()and id access on a detached proxy work with no session and don't initializeteam.club.id,team.clubId) don't initialize the associationisDirty()doesn't initialize (h5 expectation updated to match h7 — previouslydocumented as initializing on h5)
unwrapof a detached uninitialized proxy throwsLazyInitializationExceptionwhilegetIdentifier()/ident()on the same proxy succeed —documents the one operation that legitimately needs a session (unwrap must materialize the
entity; it exists only in the database).
HibernateProxyHandlerMetaClassProbeSpec(h5 + h7, new): DB-free unit tripwire — a fakeHibernateProxywhosegetMetaClass()throws proves the handler answersgetIdentifier/isProxy/isInitializedwithout dispatching through the proxy. (All probeinteraction is
@CompileStatic; the first version demonstrated live that even dynamic speccode touching the object trips the MOP interception.)
Verification (local)
ByteBuddyProxySpec,HibernateProxyHandler5Spec,MetaClassProbeSpec,SimpleHibernateProxyHandlerSpec,ToOneProxySpec)GrailsBytecodeProviderSpec,ByteBuddyGroovyInterceptorSpec,HibernateProxyHandler7Spec)Behavior changes to watch in CI
toString()on an uninitialized proxy rendersEntity:idinstead of initializing; a cleanisDirty()returnsfalsewithout initializing;load()results stay lazy. Any h5 test/app code that accidentally relied on eagerinitialization will surface in the full suites.
grails-test-examples/hibernate5|hibernate7/grails-hibernate-groovy-proxyexample appsstill declare
org.yakworks:hibernate-groovy-proxy; the provided service now overrides itscontributor, so the dependency is redundant there and those apps/tests should be reviewed
(likely: drop the dependency, keep the tests as extra coverage of the in-repo stack).
unwrap()/unwrapIfProxy()semantics are unchanged: they initialize by contract. Code thatonly needs the id must use
ident()/getProxyIdentifier()/proxy.id.