Skip to content

Replace ASM + Gizmo with Java ClassFile API backport#4282

Open
evanchooly wants to merge 18 commits into
masterfrom
replace-asm-gizmo-with-classfile-api
Open

Replace ASM + Gizmo with Java ClassFile API backport#4282
evanchooly wants to merge 18 commits into
masterfrom
replace-asm-gizmo-with-classfile-api

Conversation

@evanchooly

Copy link
Copy Markdown
Member

Summary

Implements #4273: replace org.ow2.asm and io.quarkus.gizmo with io.github.dmlloyd:jdk-classfile-backport:25.1 in Morphia's critter bytecode generation pipeline.

  • Dependencies: Added jdk-classfile-backport; removed gizmo, asm-tree, asm-util from compile scope (asm kept as test-scope for ClassfileOutput)
  • New records: FieldInfo and MethodInfo replace ASM FieldNode/MethodNode throughout
  • ASM generators (AddFieldAccessorMethods, AddMethodAccessorMethods): rewritten using ClassFile.of().transformClass() with dropping + endHandler pattern
  • Gizmo generators (PropertyAccessorGenerator, VarHandleAccessorGenerator): rewritten using ClassFile.of().build()
  • Model generators (PropertyModelGenerator, GizmoEntityModelGenerator): rewritten using ClassFile API; use Java reflection for annotation instances and type data instead of ASM SignatureReader/AnnotationNode
  • GizmoExtensions: new utility class with emitAnnotationOnStack, emitClassRef, emitTypeData, asClass, rawTypeDesc, typeDataFromDesc
  • AnnotationNodeExtensions.kt: simplified to generate a minimal stub (no longer depends on ASM/Gizmo since annotation handling is now done via Java reflection)
  • Tests updated: TypesTest uses ClassDesc, TestGizmoGeneration removes Gizmo-specific tests and uses ClassFile API for method discovery

Test plan

  • TestGizmoGeneration — 8 tests (type data parsing, annotation building, full entity model generation, method-based accessors)
  • TypesTest — 44 tests (ClassDesc ↔ Class conversion)
  • TestVarHandleAccessor — 6 tests (VarHandle-based runtime accessors)
  • TestCritterMapper — 18 tests (full critter mapper integration)
  • MongoDB-dependent tests require Docker (not available in this environment)

https://claude.ai/code/session_01TLEDKhoUzorXDYRAqVLLYE


Generated by Claude Code

Replaces org.ow2.asm and io.quarkus.gizmo dependencies with
io.github.dmlloyd:jdk-classfile-backport:25.1 in the critter
bytecode generation pipeline.

Key changes:
- Add jdk-classfile-backport dependency; remove gizmo/asm-tree/asm-util
  from compile scope (keep asm as test-scoped for ClassfileOutput)
- New FieldInfo/MethodInfo records replacing ASM FieldNode/MethodNode
- BaseGenerator, AddFieldAccessorMethods, AddMethodAccessorMethods
  rewritten using ClassFile API transforming pattern
- PropertyAccessorGenerator, VarHandleAccessorGenerator rewritten
  with ClassFile.of().build()
- PropertyModelGenerator, GizmoEntityModelGenerator rewritten using
  ClassFile API; now use Java reflection for annotation/type data
  instead of ASM SignatureReader/AnnotationNode
- GizmoExtensions: new ClassFile API utilities (emitAnnotationOnStack,
  emitClassRef, emitTypeData, asClass, rawTypeDesc, typeDataFromDesc)
- AnnotationNodeExtensions.kt simplified to generate a stub class
  that no longer depends on ASM/Gizmo
- PropertyFinder, ExtensionFunctions, CritterParser updated to use
  ClassModel/FieldInfo/MethodInfo
- Tests updated: TypesTest uses ClassDesc, TestGizmoGeneration removes
  Gizmo-specific tests, uses ClassFile API for MethodInfo discovery

https://claude.ai/code/session_01TLEDKhoUzorXDYRAqVLLYE
@evanchooly evanchooly force-pushed the replace-asm-gizmo-with-classfile-api branch from 136782c to fc3c714 Compare June 14, 2026 20:34
- Filter ACC_BRIDGE methods in PropertyFinder.isGetter() and PropertyModelGenerator.findMethod()
  to prevent compiler-generated covariant bridge methods from overriding the real getter's return type
- Remove checkcast to non-public property types in VarHandleAccessorGenerator.set() to avoid
  IllegalAccessError when accessor classes in CritterClassLoader access inner entity classes
- Fix VarHandleAccessorGenerator.set() final-field path to not dead-reference the entity class
- Fix GizmoExtensions.emitClassRef() for primitive types using getstatic WrapperClass.TYPE
- Merge setter annotations into PropertyModelGenerator's annotation map so annotations like
  @Version and @text on setter methods are captured for METHODS-mode property discovery
- Add CritterPropertyModel.registerFieldAnnotations/registerMethodAnnotations to register
  non-Morphia annotations (e.g. @nonnull) via reflection in generated property model constructors
- Wrap CritterParser lists in Collections.unmodifiableList and fix getter field-type check
@evanchooly evanchooly requested a review from Copilot June 15, 2026 02:01
@evanchooly evanchooly marked this pull request as ready for review June 15, 2026 02:01

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

This PR migrates Morphia’s critter bytecode generation pipeline away from ASM/Gizmo to the JDK ClassFile API backport (io.github.dmlloyd:jdk-classfile-backport), updating both the runtime generators and associated tests/build tooling to match the new model.

Changes:

  • Introduces FieldInfo/MethodInfo records and switches class parsing from ASM ClassNode/*Node to ClassModel/attributes.
  • Rewrites accessor/model generators to emit/transform bytecode using ClassFile.of().build() and transformClass(...).
  • Removes ASM/Gizmo-based build-plugin generators and updates tests to validate the new parsing/generation approach.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
pom.xml Adds backport dependency/version management (and currently still manages Gizmo).
core/pom.xml Swaps compile dependency from Gizmo to classfile-backport; moves ASM artifacts to test scope.
core/src/test/java/dev/morphia/critter/parser/TypesTest.java Updates descriptor/class conversion tests to use ClassDesc.
core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java Updates generator tests to parse methods/annotations via ClassFile API.
core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java Adds reflection-based annotation registration helpers for generated models.
core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java Replaces ASM-based property discovery with ClassModel + attributes.
core/src/main/java/dev/morphia/critter/parser/MethodInfo.java New record replacing ASM MethodNode as a carrier.
core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java Removes ASMifier utilities; keeps descriptor helpers.
core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java Rewrites VarHandle accessor generation using ClassFile API.
core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java Rewrites property model generation using ClassFile API + reflection for types/annotations.
core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java Rewrites synthetic-method delegating accessors using ClassFile API.
core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java Replaces ASM/Gizmo utilities with ClassFile-based emission helpers (annotations, TypeData, class refs).
core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java Rewrites entity model generation using ClassFile API and reflection-sourced annotations.
core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java Switches orchestration to ClassModel parsing and new generators.
core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java Removes Gizmo ClassCreator plumbing; becomes a lightweight base.
core/src/main/java/dev/morphia/critter/parser/FieldInfo.java New record replacing ASM FieldNode as a carrier.
core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java Updates getter→property naming helper to use MethodInfo + MethodTypeDesc.
core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java Replaces ASM read/filter setup with ClassFile parsing entry point.
core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java Rewrites method-based accessor injection using transformClass + drop/endHandler.
core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java Rewrites field-based accessor injection using transformClass + drop/endHandler.
core/src/main/java/dev/morphia/critter/Critter.java Replaces stored annotation types with descriptor strings.
build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt Removed (ASM/Gizmo-based annotation builder generator no longer needed).
build-plugins/src/main/java/util/KotlinAnnotationExtensions.java Removed (no longer required for old annotation emission approach).
build-plugins/src/main/java/util/AsmBuilders.java Removed (ASM builder generation removed).

Comment on lines 116 to 121
private ClassModel readClassModel(Class<?> type) {
String resourceName = "%s.class".formatted(type.getName().replace('.', '/'));
InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName);
ClassLoader cl = type.getClassLoader() != null ? type.getClassLoader()
: ClassLoader.getSystemClassLoader();
InputStream inputStream = cl.getResourceAsStream(resourceName);
if (inputStream == null) {
Comment on lines 42 to 51
public GizmoEntityModelGenerator generate(Class<?> type, CritterClassLoader critterClassLoader, boolean runtimeMode) {
ClassNode classNode = new ClassNode();
String resourceName = "%s.class".formatted(type.getName().replace('.', '/'));
java.io.InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName);
InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName);
if (inputStream == null) {
throw new IllegalArgumentException("Could not find class file for %s".formatted(type.getName()));
}
ClassModel classModel;
try {
new ClassReader(inputStream).accept(classNode, 0);
classModel = ClassFile.of().parse(inputStream.readAllBytes());
} catch (IOException e) {
Comment on lines +28 to +33
/**
* Returns the access flags used when declaring the generated class.
*
* @return the ACC_PUBLIC | ACC_SUPER access flags
* Reads the class file bytes for the given entity, excluding any existing __read/__write synthetic methods.
*/
protected int accessFlags() {
return ACC_PUBLIC | ACC_SUPER;
}

/**
* Reads a class into the classWriter, filtering out any existing __read/__write synthetic
* methods so that accessor generation is idempotent across repeated plugin runs.
*
* @param entity the class to read
*/
protected void readClassFiltering(Class<?> entity) {
protected ClassModel readClassFiltering() {
String resourceName = "%s.class".formatted(entity.getName().replace('.', '/'));
java.io.InputStream inputStream = entity.getClassLoader().getResourceAsStream(resourceName);
InputStream inputStream = entity.getClassLoader().getResourceAsStream(resourceName);
Comment on lines +28 to +32
Field field = current.getDeclaredField(fieldName);
for (Annotation ann : field.getDeclaredAnnotations()) {
model.annotation(ann);
}
return;
Comment on lines +46 to +53
for (Method m : current.getDeclaredMethods()) {
if (m.getName().equals(getterName) && m.getParameterCount() == 0 && !m.isBridge()) {
for (Annotation ann : m.getDeclaredAnnotations()) {
model.annotation(ann);
}
return;
}
}
Comment on lines 419 to 433
private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String typeName, ClassDesc desc) {
if (isPrimitive()) {
cod.loadConstant(desc);
} else {
emitGetterHandleLookup(tryBlock, privateLookup, entityClass, handleDesc);
if (setterHandleDesc != null) {
emitSetterHandleLookup(tryBlock, privateLookup, entityClass, setterHandleDesc);
}
GizmoExtensions.emitClassRef(cod, classForName(typeName));
}

var catchBlock = tryBlock.addCatch(ReflectiveOperationException.class);
ResultHandle ex = catchBlock.getCaughtException();
ResultHandle wrapped = catchBlock.newInstance(
ofConstructor(RuntimeException.class, Throwable.class), ex);
catchBlock.throwException(wrapped);

constructor.returnVoid();
constructor.close();
}

/**
* Emits bytecode to obtain a private {@link MethodHandles.Lookup} for the entity class.
* The entity class is loaded via the thread context class loader to avoid classloader mismatches.
*
* @return a two-element array: {@code [entityClass, privateLookup]}
*/
private ResultHandle[] emitPrivateLookup(TryBlock tryBlock) {
ResultHandle callerLookup = tryBlock.invokeStaticMethod(
ofMethod(MethodHandles.class, "lookup", MethodHandles.Lookup.class));

// Load entity class via TCCL to avoid classloader mismatch
ResultHandle currentThread = tryBlock.invokeStaticMethod(
ofMethod(Thread.class, "currentThread", Thread.class));
ResultHandle tccl = tryBlock.invokeVirtualMethod(
ofMethod(Thread.class, "getContextClassLoader", ClassLoader.class),
currentThread);
ResultHandle entityClass = tryBlock.invokeStaticMethod(
ofMethod(Class.class, "forName", Class.class, String.class, boolean.class, ClassLoader.class),
tryBlock.load(entity.getName()),
tryBlock.load(true),
tccl);
ResultHandle privateLookup = tryBlock.invokeStaticMethod(
ofMethod(MethodHandles.class, "privateLookupIn", MethodHandles.Lookup.class, Class.class, MethodHandles.Lookup.class),
entityClass,
callerLookup);
return new ResultHandle[] { entityClass, privateLookup };
}

private void emitVarHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass,
FieldDescriptor handleDesc) {
ResultHandle fieldTypeClass = tryBlock.loadClass(propertyType);
ResultHandle handle = tryBlock.invokeVirtualMethod(
ofMethod(MethodHandles.Lookup.class, "findVarHandle", VarHandle.class, Class.class, String.class, Class.class),
privateLookup,
entityClass,
tryBlock.load(propertyName),
fieldTypeClass);
tryBlock.writeInstanceField(handleDesc, tryBlock.getThis(), handle);
}

private void emitGetterHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass,
FieldDescriptor handleDesc) {
ResultHandle returnTypeClass = tryBlock.loadClass(propertyType);
ResultHandle getterMethodType = tryBlock.invokeStaticMethod(
ofMethod(MethodType.class, "methodType", MethodType.class, Class.class),
returnTypeClass);
ResultHandle getterHandle = tryBlock.invokeVirtualMethod(
ofMethod(MethodHandles.Lookup.class, "findVirtual", MethodHandle.class, Class.class, String.class, MethodType.class),
privateLookup,
entityClass,
tryBlock.load(getterName),
getterMethodType);
tryBlock.writeInstanceField(handleDesc, tryBlock.getThis(), getterHandle);
}

private void emitSetterHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass,
FieldDescriptor setterHandleDesc) {
ResultHandle voidClass = tryBlock.loadClass(void.class);
ResultHandle paramTypeClass = tryBlock.loadClass(propertyType);
ResultHandle setterMethodType = tryBlock.invokeStaticMethod(
ofMethod(MethodType.class, "methodType", MethodType.class, Class.class, Class.class),
voidClass,
paramTypeClass);
ResultHandle setterHandle = tryBlock.invokeVirtualMethod(
ofMethod(MethodHandles.Lookup.class, "findVirtual", MethodHandle.class, Class.class, String.class, MethodType.class),
privateLookup,
entityClass,
tryBlock.load(setterName),
setterMethodType);
tryBlock.writeInstanceField(setterHandleDesc, tryBlock.getThis(), setterHandle);
}

private void get(FieldDescriptor handleDesc) {
var method = getCreator().getMethodCreator(
ofMethod(generatedType, "get", Object.class.getName(), Object.class.getName()));
method.setSignature(
forMethod()
.addTypeParameter(typeVariable("S"))
.setReturnType(classType(propertyType))
.addParameterType(typeVariable("S"))
.build());
method.setParameterNames(new String[] { "model" });

ResultHandle model = method.getMethodParam(0);

// Guard against null model (e.g. unwrapped lazy proxy for a missing/deleted reference)
BranchResult nullCheck = method.ifNull(model);
try (BytecodeCreator nullBranch = nullCheck.trueBranch()) {
nullBranch.returnValue(nullBranch.loadNull());
}

ResultHandle handleRef = method.readInstanceField(handleDesc, method.getThis());

ResultHandle result;
if (isFieldBased) {
result = method.invokeVirtualMethod(
ofMethod(VarHandle.class, "get", Object.class.getName(), Object.class.getName()),
handleRef,
model);
} else {
result = method.invokeVirtualMethod(
ofMethod(MethodHandle.class, "invoke", Object.class.getName(), Object.class.getName()),
handleRef,
model);
private static Class<?> classForName(String name) {
try {
return Class.forName(name);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}

ResultHandle boxed = isPrimitive() ? method.smartCast(result, getWrapperType()) : result;
method.returnValue(boxed);
}
Comment on lines +364 to +372
}, catches -> catches.catching(ClassDesc.of("java.lang.Exception"), catchBody -> {
catchBody.astore(3);
catchBody.new_(rteDesc);
catchBody.dup();
catchBody.ldc("Failed to set final field '%s'".formatted(propertyName));
catchBody.invokespecial(rteDesc, "<init>",
MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"));
catchBody.athrow();
}));
Comment thread pom.xml Outdated
Comment on lines +374 to +383
<groupId>io.github.dmlloyd</groupId>
<artifactId>jdk-classfile-backport</artifactId>
<version>${classfile.backport.version}</version>
</dependency>
Comment thread pom.xml Outdated
…type resolution

hasSetter() and emitLoadClass() both called the single-argument Class.forName(),
which uses the caller's (system) classloader rather than the entity's classloader.
For property types only available in a child classloader (typical in app-server
deployments), this caused emit() to crash with ClassNotFoundException and hasSetter()
to silently return false, making the property read-only.

Fix: use entity.getClassLoader() in hasSetter(), and emit Class.forName(name, false, tccl)
in the generated constructor bytecode so property types are resolved at runtime via TCCL,
consistent with how the entity class itself is already resolved.

Regression test added in TestVarHandleAccessor that dynamically generates an entity
whose property type lives only in a child classloader and asserts get/set round-trips.
…THODS mode

findSetter() and findSetterInHierarchy() matched setter methods by name and
descriptor only, without checking ACC_STATIC. In PropertyDiscovery.METHODS mode
a static setXxx method was accepted as the property setter, causing the property
to be treated as method-based. VarHandleAccessorGenerator's hasSetter() then
correctly rejected the static method (it has a reflection-level isStatic guard),
leaving the property with no setter handle — so set() threw
UnsupportedOperationException even though the backing field was writable.

Fix: add (flags & ACC_STATIC) == 0 guard to both findSetter and
findSetterInHierarchy so static methods are never selected as property setters.
Properties with getter + static setter (no instance setter) now fall back to
field-based VarHandle discovery as expected.
CritterGenerator.generate() called type.getClassLoader() directly without
a null guard, causing NPE for bootstrap-loaded entity classes. Extracted
GenerationUtils.safeClassLoader() with the correct null fallback and
applied it at both sites (CritterGenerator and VarHandleAccessorGenerator).

Consolidated three sets of duplicated code into GenerationUtils:
PRIMITIVE_TO_WRAPPER (was in both accessor generators), typeClassName()
(same), and emitBooleanMethod() (was in both model generators). All
callers updated to use the shared versions.
…iscovery

isGetter() accepted any method starting with "is" or "get" regardless of
length. A no-arg non-void method named exactly "is" or "get" passed all
checks, then getterPropertyName() computed an empty property name and
threw StringIndexOutOfBoundsException on charAt(0), aborting property
discovery for the entire entity.

Added an early-exit guard that rejects exact matches before parsing the
property name.
emitAnnotationOnStack used value.equals(defaultValue) to skip builder
setter calls for annotation elements matching their defaults. Array-typed
elements (String[], Class[], annotation[]) return a fresh defensive copy
on every annotation proxy invocation, so two logically-identical arrays
are never the same instance and equals() always returned false.

Effect: setter calls were emitted for every array element regardless of
whether the value matched the default, inflating generated bytecode.

Fix: replaced equals() with Objects.deepEquals(), which compares array
contents recursively.
…eration

Generate a concrete annotation implementation class per annotation instance at code-generation time using the ClassFile API. This eliminates all runtime reflection from property/entity model constructors — Morphia and non-Morphia annotations alike are materialized as bytecode constants, fixing NonNull and other third-party annotations being silently dropped.

Also fix AnnotationBuilders equals() using field access instead of method calls, and add Hotel test fixture with varargs/hashCode/equals to exercise the generation pipeline.
…tDeclaredAnnotation for non-Morphia

Morphia annotations in generated <init> are emitted via AnnotationBuilder factory/setter chains,
encoding all values as bytecode constants with zero runtime reflection. Non-Morphia annotations are
embedded via RuntimeVisibleAnnotationsAttribute so getDeclaredAnnotation() works at runtime.
…ative entities

Generates readable bytecode text files under target/critter-bytecode/ with full
package/directory hierarchy for Example, MethodExample, Author, Book, and
CritterMapperTestEntity; replaces the narrower DumpBytecodeTest.

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

Copilot reviewed 49 out of 50 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java:51

  • generate() reads the classfile from an InputStream but never closes it. Wrap the stream in try-with-resources to avoid leaking jar/file handles during generation.

Comment on lines 6 to 10
import java.util.List;
import java.util.Objects;

import com.mongodb.lang.NonNullApi;

Comment on lines +29 to 33
@Override
public boolean equals(Object obj) {
return super.equals(obj);
}
}
Comment on lines +116 to 131
private ClassModel readClassModel(Class<?> type) {
String resourceName = "%s.class".formatted(type.getName().replace('.', '/'));
InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName);
ClassLoader cl = type.getClassLoader() != null ? type.getClassLoader()
: ClassLoader.getSystemClassLoader();
InputStream inputStream = cl.getResourceAsStream(resourceName);
if (inputStream == null) {
LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName());
return null;
}
ClassNode node = new ClassNode();
try {
new ClassReader(inputStream).accept(node, 0);
byte[] bytes = inputStream.readAllBytes();
return ClassFile.of().parse(bytes);
} catch (IOException e) {
LOG.warn("Failed to read bytecode for {}: {}", type.getName(), e.getMessage());
return null;
}
Comment on lines +31 to +45
protected ClassModel readClassFiltering() {
String resourceName = "%s.class".formatted(entity.getName().replace('.', '/'));
InputStream inputStream = entity.getClassLoader().getResourceAsStream(resourceName);
if (inputStream == null) {
throw new IllegalArgumentException("Could not find class file for %s".formatted(entity.getName()));
}
try {
byte[] bytes = inputStream.readAllBytes();
ClassModel model = ClassFile.of().parse(bytes);
return model;
} catch (IOException e) {
throw new RuntimeException("Failed to read class %s".formatted(entity.getName()), e);
}
}
}
Comment thread pom.xml Outdated
Comment thread pom.xml Outdated
Comment on lines 374 to 378
@@ -375,6 +376,11 @@
<artifactId>gizmo</artifactId>
<version>${gizmo.version}</version>
</dependency>
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.

3 participants