Skip to content

Phase 2: VarHandle accessor generator for runtime mapper mode#4194

Merged
evanchooly merged 1 commit intomasterfrom
phase2-varhandle-accessor
Feb 28, 2026
Merged

Phase 2: VarHandle accessor generator for runtime mapper mode#4194
evanchooly merged 1 commit intomasterfrom
phase2-varhandle-accessor

Conversation

@evanchooly
Copy link
Member

Summary

  • Adds VarHandleAccessorGenerator — a Gizmo-based generator that produces PropertyAccessor<T> implementations using VarHandle (field-based) and MethodHandle (method-based properties) instead of delegating to __readXxx/__writeXxx synthetic methods injected into entity bytecode
  • Adds runtimeMode flag to PropertyFinder and CritterGizmoGenerator: when true, entity bytecode is not modified and VarHandle accessors are generated instead
  • This is the accessor generation path that CritterMapper (Phase 4) will use at runtime

Closes tasks in #4185.

Key design notes

  • Entity class loaded via Thread.currentThread().contextClassLoader in the generated constructor to avoid classloader mismatch with CritterClassLoader (which auto-registers dev.morphia.critter.* classes)
  • No checkCast to entity type in get()/set()VarHandle/MethodHandle performs its own type verification
  • Method-based read-only properties (no setter) throw UnsupportedOperationException from set(), matching compile-time behavior

Test plan

  • TestVarHandleAccessor — 5 tests: String field, int primitive, Long boxed, entity not modified, all accessors instantiatable
  • Full critter-core suite (57 tests) passes unchanged

🤖 Generated with Claude Code

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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 VarHandleAccessorGenerator to generate accessor implementations using VarHandle/MethodHandle.
  • Adds a runtimeMode flag to PropertyFinder and CritterGizmoGenerator to skip entity bytecode modification and use the new generator.
  • Adds TestVarHandleAccessor to 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.

Comment on lines +30 to +35
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"
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +74
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
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
ClassLoader::class.java,
),
tryBlock.load(entity.name),
tryBlock.load(true),
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
tryBlock.load(true),
tryBlock.load(false),

Copilot uses AI. Check for mistakes.
Comment on lines +369 to +411
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,
)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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,
)
}

Copilot uses AI. Check for mistakes.
@evanchooly evanchooly merged commit 285d422 into master Feb 28, 2026
28 of 30 checks passed
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