Phase 2: VarHandle accessor generator for runtime mapper mode#4194
Phase 2: VarHandle accessor generator for runtime mapper mode#4194evanchooly merged 1 commit intomasterfrom
Conversation
Introduces VarHandleAccessorGenerator, which generates PropertyAccessor implementations using VarHandle (fields) and MethodHandle (method-based properties) instead of delegating to __readXxx/__writeXxx synthetic methods. This enables runtime-mode mapping without modifying entity bytecode on the classpath, as required by CritterMapper (Phase 4). - VarHandleAccessorGenerator: Gizmo-based generator using MethodHandles.privateLookupIn() to create VarHandle/MethodHandle fields in the constructor; entity class loaded via TCCL to avoid CritterClassLoader classloader mismatch - PropertyFinder: runtimeMode flag skips entity bytecode registration and routes to VarHandleAccessorGenerator instead of PropertyAccessorGenerator - CritterGizmoGenerator: runtimeMode param threaded through to PropertyFinder; varHandleAccessor() factory methods added - TestVarHandleAccessor: covers String, int primitive, Long boxed fields; verifies entity class is not modified in runtime mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new runtime-mode accessor generation path for critter-core that avoids modifying entity bytecode, generating PropertyAccessor<T> implementations backed by VarHandle (fields) and MethodHandle (method-based properties). This supports upcoming runtime mapping in CritterMapper.
Changes:
- Introduces
VarHandleAccessorGeneratorto generate accessor implementations usingVarHandle/MethodHandle. - Adds a
runtimeModeflag toPropertyFinderandCritterGizmoGeneratorto skip entity bytecode modification and use the new generator. - Adds
TestVarHandleAccessorto validate runtime-mode behavior (basic field accessors + “entity not modified” check).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
critter/core/src/main/kotlin/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.kt |
New Gizmo generator producing accessors backed by VarHandle/MethodHandle for runtime mode. |
critter/core/src/main/kotlin/dev/morphia/critter/parser/PropertyFinder.kt |
Adds runtimeMode to switch between bytecode-modification path vs VarHandle/MethodHandle accessors. |
critter/core/src/main/kotlin/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.kt |
Threads runtimeMode through generator entrypoint and exposes varHandleAccessor(...) helpers. |
critter/core/src/test/kotlin/dev/morphia/critter/parser/TestVarHandleAccessor.kt |
New tests for runtime-mode field accessor generation and ensuring the entity class isn’t modified. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| assert(syntheticRead.isEmpty()) { | ||
| "Entity class should not have synthetic __read methods but found: $syntheticRead" | ||
| } | ||
| assert(syntheticWrite.isEmpty()) { | ||
| "Entity class should not have synthetic __write methods but found: $syntheticWrite" | ||
| } |
There was a problem hiding this comment.
testEntityNotModified() uses Kotlin's assert(...) for its checks. JVM assertions are often disabled in test runs unless the JVM is started with -ea, so this test can become a no-op and silently pass even when the entity is modified. Use TestNG assertions (e.g., assertTrue(...) / assertEquals(...)) so the test always enforces the expectation.
| private val hasSetter: Boolean by lazy { | ||
| if (isFieldBased || setterName == null) return@lazy false | ||
| val paramClass = | ||
| if (isPrimitive) primitiveClass(propertyType) else Class.forName(propertyType) | ||
| try { | ||
| entity.getDeclaredMethod(setterName!!, paramClass) | ||
| true | ||
| } catch (_: NoSuchMethodException) { | ||
| false | ||
| } |
There was a problem hiding this comment.
hasSetter uses Class.forName(propertyType) and entity.getDeclaredMethod(...). This can (a) load the parameter type from the wrong classloader in runtime mode, and (b) incorrectly treat inherited/public setters as missing (since getDeclaredMethod doesn’t search superclasses). Consider resolving the parameter Class via the entity’s classloader/TCCL (and handling arrays), and use getMethod(...) or entity.methods-based detection to match the existing ASM path’s behavior.
| ClassLoader::class.java, | ||
| ), | ||
| tryBlock.load(entity.name), | ||
| tryBlock.load(true), |
There was a problem hiding this comment.
The generated constructor calls Class.forName(entity.name, true, tccl) with initialize=true. This can trigger entity static initialization as a side effect of merely constructing an accessor, which may be undesirable in runtime mapper mode. Consider using initialize=false unless class initialization is required for privateLookupIn/handle resolution.
| tryBlock.load(true), | |
| tryBlock.load(false), |
| return | ||
| } | ||
|
|
||
| // Do NOT checkCast to entity type — same classloader mismatch concern as in get(). | ||
| val castModel = method.getMethodParam(0) | ||
| val castValue = | ||
| if (isPrimitive) { | ||
| val boxed = method.checkCast(method.getMethodParam(1), wrapperType) | ||
| method.smartCast(boxed, propertyType) | ||
| } else { | ||
| method.checkCast(method.getMethodParam(1), propertyType) | ||
| } | ||
|
|
||
| val handleRef = | ||
| if (isFieldBased) method.readInstanceField(handleDesc, method.`this`) | ||
| else method.readInstanceField(setterHandleDesc!!, method.`this`) | ||
|
|
||
| if (isFieldBased) { | ||
| method.invokeVirtualMethod( | ||
| ofMethod( | ||
| VarHandle::class.java, | ||
| "set", | ||
| "void", | ||
| Object::class.java.name, | ||
| Object::class.java.name, | ||
| ), | ||
| handleRef, | ||
| castModel, | ||
| castValue, | ||
| ) | ||
| } else { | ||
| method.invokeVirtualMethod( | ||
| ofMethod( | ||
| MethodHandle::class.java, | ||
| "invoke", | ||
| "void", | ||
| Object::class.java.name, | ||
| Object::class.java.name, | ||
| ), | ||
| handleRef, | ||
| castModel, | ||
| castValue, | ||
| ) |
There was a problem hiding this comment.
Runtime-mode method-based accessors (using MethodHandle) and the read-only UnsupportedOperationException path aren’t covered by the new tests (current tests exercise only field-based accessors on Example). Adding a runtime-mode test using MethodExample (including the read-only getComputedValue() property) would help prevent regressions in the MethodHandle path.
| return | |
| } | |
| // Do NOT checkCast to entity type — same classloader mismatch concern as in get(). | |
| val castModel = method.getMethodParam(0) | |
| val castValue = | |
| if (isPrimitive) { | |
| val boxed = method.checkCast(method.getMethodParam(1), wrapperType) | |
| method.smartCast(boxed, propertyType) | |
| } else { | |
| method.checkCast(method.getMethodParam(1), propertyType) | |
| } | |
| val handleRef = | |
| if (isFieldBased) method.readInstanceField(handleDesc, method.`this`) | |
| else method.readInstanceField(setterHandleDesc!!, method.`this`) | |
| if (isFieldBased) { | |
| method.invokeVirtualMethod( | |
| ofMethod( | |
| VarHandle::class.java, | |
| "set", | |
| "void", | |
| Object::class.java.name, | |
| Object::class.java.name, | |
| ), | |
| handleRef, | |
| castModel, | |
| castValue, | |
| ) | |
| } else { | |
| method.invokeVirtualMethod( | |
| ofMethod( | |
| MethodHandle::class.java, | |
| "invoke", | |
| "void", | |
| Object::class.java.name, | |
| Object::class.java.name, | |
| ), | |
| handleRef, | |
| castModel, | |
| castValue, | |
| ) | |
| } else { | |
| // Do NOT checkCast to entity type — same classloader mismatch concern as in get(). | |
| val castModel = method.getMethodParam(0) | |
| val castValue = | |
| if (isPrimitive) { | |
| val boxed = method.checkCast(method.getMethodParam(1), wrapperType) | |
| method.smartCast(boxed, propertyType) | |
| } else { | |
| method.checkCast(method.getMethodParam(1), propertyType) | |
| } | |
| val handleRef = | |
| if (isFieldBased) method.readInstanceField(handleDesc, method.`this`) | |
| else method.readInstanceField(setterHandleDesc!!, method.`this`) | |
| if (isFieldBased) { | |
| method.invokeVirtualMethod( | |
| ofMethod( | |
| VarHandle::class.java, | |
| "set", | |
| "void", | |
| Object::class.java.name, | |
| Object::class.java.name, | |
| ), | |
| handleRef, | |
| castModel, | |
| castValue, | |
| ) | |
| } else { | |
| method.invokeVirtualMethod( | |
| ofMethod( | |
| MethodHandle::class.java, | |
| "invoke", | |
| "void", | |
| Object::class.java.name, | |
| Object::class.java.name, | |
| ), | |
| handleRef, | |
| castModel, | |
| castValue, | |
| ) | |
| } |
Summary
VarHandleAccessorGenerator— a Gizmo-based generator that producesPropertyAccessor<T>implementations usingVarHandle(field-based) andMethodHandle(method-based properties) instead of delegating to__readXxx/__writeXxxsynthetic methods injected into entity bytecoderuntimeModeflag toPropertyFinderandCritterGizmoGenerator: whentrue, entity bytecode is not modified and VarHandle accessors are generated insteadCritterMapper(Phase 4) will use at runtimeCloses tasks in #4185.
Key design notes
Thread.currentThread().contextClassLoaderin the generated constructor to avoid classloader mismatch withCritterClassLoader(which auto-registersdev.morphia.critter.*classes)checkCastto entity type inget()/set()—VarHandle/MethodHandleperforms its own type verificationUnsupportedOperationExceptionfromset(), matching compile-time behaviorTest plan
TestVarHandleAccessor— 5 tests: String field, int primitive, Long boxed, entity not modified, all accessors instantiatablecritter-coresuite (57 tests) passes unchanged🤖 Generated with Claude Code